NetSuite API returns 'server_error' when Creating an Access Token

We've had NetSuite API integrations for some time, but a couple of weeks ago everything just stopped working. After having spent hours at StackOverflow, asking every single LLM I could think of, turning on deep research and God knows what not - We were finally able to find the solution on NetSuite's community forums.
The problem? NetSuite is deprecating support for RSA PKCSw1.5 scheme for signing tokens used in their OAuth 2.0 client credential flow. This resulted in that as our integration is trying to generate an access token by cryptographically signing its JWT payload towards NetSuite, it's getting "server_error" returned from NetSuite. Yes I know, very informative error message, right ...?
The Solution
TL;TR - You'll need to modify how you sign your JWT token as you retrieve an access token, and your private key needs to be at least 3,072 bit size. Below is how our code looks like after we've wrapped it into a Hyperlambda slot taking [private-key], [account-id], [certificate-id], and [consumer-key] as input from the Hyperlambda code. If you can somehow retrieve these from your configuration instead of as Hyperlambda arguments, you'd probably figure out how to change your existing access token requests. I'm too lazy to do that for you, so I'll just glue in the entire code required to create a Hyperlambda slot that returns an access token using Hyperlambda input arguments.
using System;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.IdentityModel.Tokens.Jwt;
using Microsoft.IdentityModel.Tokens;
using Newtonsoft.Json.Linq;
using magic.node;
using magic.node.extensions;
using magic.signals.contracts;
namespace magic.netsuite
{
/*
* [netsuite.get-access-token] slot returning an access token
* for the specified NetSuite account.
*/
[Slot(Name = "netsuite.get-access-token")]
public class GenerateToken : ISlotAsync
{
// Needed to synchronise invocations.
static readonly SemaphoreSlim _semaphore = new(1, 1);
/*
* Yes, this looks dubious, but dynamically compiled slots cannot
* use dependency injected arguments, so we're kind of left with this
* way of dealing with things.
*
* However, the only thing that can in theory make this stop working
* is DNS updates, at which point it will stop working. It's a small
* risk, easily fixed by recycling the application, and probably
* happens less than once per year, if ever.
*/
readonly static HttpClient _httpClient = new();
// Buffers for cache.
static DateTime _tokenExpiration;
static string _accessToken;
// Implementation of interface.
public async Task SignalAsync(ISignaler signaler, Node input)
{
// Retrieving arguments to invocation.
var privateKey = input.Children.FirstOrDefault(x => x.Name == "private-key")?.GetEx() ??
throw new HyperlambdaException("No [private-key] supplied to [netsuite.get-access-token]");
var accountId = input.Children.FirstOrDefault(x => x.Name == "account-id")?.GetEx() ??
throw new HyperlambdaException("No [account-id] supplied to [netsuite.get-access-token]");
var certificateId = input.Children.FirstOrDefault(x => x.Name == "certificate-id")?.GetEx() ??
throw new HyperlambdaException("No [certificate-id] supplied to [netsuite.get-access-token]");
var consumerKey = input.Children.FirstOrDefault(x => x.Name == "consumer-key")?.GetEx() ??
throw new HyperlambdaException("No [consumer-key] supplied to [netsuite.get-access-token]");
// House cleaning.
input.Clear();
// Retrieving a valid token and returning to caller.
input.Value = await GetAccessToken(
accountId,
privateKey,
certificateId,
consumerKey);
}
#region [ -- Private helper methods -- ]
/*
* Returns an access token to caller.
*
* This is the method you'd copy and paste into your own
* code to get your own access token.
*/
async Task GetAccessToken(
string accountId,
string privateKey,
string certificateId,
string consumerKey)
{
// Thread synchronisation.
await _semaphore.WaitAsync();
try
{
// Checking if we already have a valid token, at which point we return it as is.
if (!string.IsNullOrEmpty(_accessToken) && _tokenExpiration > DateTime.UtcNow)
return _accessToken;
// Build the token endpoint URL.
var restApiRoot = $"https://{accountId}.suitetalk.api.netsuite.com/services/rest";
var oauth2ApiRoot = $"{restApiRoot}/auth/oauth2/v1";
var tokenEndPointUrl = $"{oauth2ApiRoot}/token";
// Clean up the private key PEM.
var cleanedPrivateKey = privateKey
.Replace("-----BEGIN PRIVATE KEY-----", "")
.Replace("-----END PRIVATE KEY-----", "")
.Replace("\r", "")
.Replace("\n", "")
.Trim();
var privateKeyRaw = Convert.FromBase64String(cleanedPrivateKey);
using RSA rsa = RSA.Create();
rsa.ImportPkcs8PrivateKey(new ReadOnlySpan(privateKeyRaw), out _);
// Ensure the RSA key meets NetSuite's minimum requirement (3072 bits).
if (rsa.KeySize < 3072)
throw new HyperlambdaException("RSA key must be at least 3072 bits long for RSA-PSS signatures.");
// Create the RSA security key and specify the key ID.
var rsaSecurityKey = new RsaSecurityKey(rsa)
{
KeyId = certificateId
};
// Use RSA-PSS with SHA-256 for signing.
var signingCredentials = new SigningCredentials(rsaSecurityKey, SecurityAlgorithms.RsaSsaPssSha256);
var now = DateTime.UtcNow;
// Build the JWT token descriptor.
var tokenDescriptor = new SecurityTokenDescriptor
{
Issuer = consumerKey,
Audience = tokenEndPointUrl,
Expires = now.AddMinutes(5),
IssuedAt = now,
Claims = new Dictionary
{
{ "scope", new[] { "rest_webservices" } },
},
SigningCredentials = signingCredentials
};
var tokenHandler = new JwtSecurityTokenHandler();
var token = tokenHandler.CreateToken(tokenDescriptor);
var jwtToken = tokenHandler.WriteToken(token);
// Prepare the form data required for the token request.
var formData = new List>
{
new("grant_type", "client_credentials"),
new("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"),
new("client_assertion", jwtToken)
};
// Execute the HTTP request without using Polly.
var httpResponse = await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Post, tokenEndPointUrl)
{
Content = new FormUrlEncodedContent(formData)
});
if (!httpResponse.IsSuccessStatusCode)
{
var errorContent = await httpResponse.Content.ReadAsStringAsync();
throw new Exception($"Error retrieving access token from NetSuite: {httpResponse.StatusCode} - {errorContent}");
}
string responseJson = await httpResponse.Content.ReadAsStringAsync();
JObject responseToken = JObject.Parse(responseJson);
_accessToken = responseToken["access_token"]?.ToString();
if (string.IsNullOrEmpty(_accessToken))
throw new Exception("Access token was not found in the response.");
string expiresInStr = responseToken["expires_in"]?.ToString();
if (!int.TryParse(expiresInStr, out int expiresIn))
throw new Exception("Invalid expires_in value in token response.");
_tokenExpiration = DateTime.UtcNow.AddMinutes(expiresIn);
// Returning token to caller.
return _accessToken;
}
finally
{
_semaphore.Release();
}
}
#endregion
}
}
To use it from Hyperlambda you could execute something such as the following from your Hyperlambda Playground.
// Loading private key.
.key
set-value:x:@.key
io.file.load:/etc/netsuite/private-key.txt
// Account id
.account-id
set-value:x:@.account-id
config.get:"magic:netsuite:account-id"
validators.mandatory:x:@.account-id
// Certificate id
.certificate-id
set-value:x:@.certificate-id
config.get:"magic:netsuite:certificate-id"
validators.mandatory:x:@.certificate-id
// Consumer key
.consumer-key
set-value:x:@.consumer-key
config.get:"magic:netsuite:consumer-key"
validators.mandatory:x:@.consumer-key
// Invoking slot doing the heavy lifting.
netsuite.get-access-token
private-key:x:@.key
account-id:x:@.account-id
certificate-id:x:@.certificate-id
consumer-key:x:@.consumer-key
With the above C# slot correctly wired together, the above Hyperlambda code will return a valid access token you can use during invocations towards NetSuite's APIs.
Wrapping Up
NetSuite is a plugin for our Magic platform. We've got clients using NetSuite indirectly through generative AI agents. As NetSuite deprecated the old authentication scheme, paradoxically 2 months before their scheduled change - This resulted in our integration no longer working, in addition to I must assume thousands of additional integrations no longer working. If your only interest is getting your existing integrations to work, you can probably modify the above code to match your requirements.
If your interest is in having a secure generic NetSuite integration, or use NetSuite in AI agents, you can contact us below and have us deal with it.
Have a Custom AI Solution
At AINIRO we specialise in delivering custom AI solutions and AI chatbots with AI agent features. If you want to talk to us about how we can help you implement your next custom AI solution, you can reach out to us below.