Credential Test button returns 401 Unauthorized, while node & Postman succeed

Credential Test button returns 401 Unauthorized, while node & Postman succeed

Describe the problem/error/question

I’m implementing a custom credential for NetSuite OAuth 1.0 (HMAC-SHA256).

  • Custom node executions succeed using the same signer and credentials.

  • Postman requests succeed against the same endpoint and creds.

  • The Credentials “Test” button in n8n always fails with 401 Unauthorized.

It looks like the signature produced for the Credential Test request (via preAuthentication) isn’t being accepted by NetSuite, even though I sign the exact same URL and method that the Test request uses.

I want to confirm whether the Credential Test request path/encoding/headers are altered in a way that changes the OAuth 1.0 signature base string, or if there’s a known issue with passing a precomputed Authorization header from preAuthentication to test.request.headers.

What is the error message (if any)?

Couldn't connect with these settings
Unauthorized

Please share your workflow

Not directly applicable (the issue is isolated to the Credential Test), but the same credentials work inside my custom node.

Share the output returned by the last node

Example successful response (from the custom node calling SuiteQL)

Code

credentials/NetSuiteOAuth1Api.credentials.ts

/******************************************************************
 * credentials/NetSuiteOAuth1Api.credentials.ts
 ******************************************************************/
import {
  ICredentialTestRequest,
  ICredentialType,
  INodeProperties,
  IHttpRequestHelper,
  ICredentialDataDecryptedObject,
  LoggerProxy as Logger,
} from 'n8n-workflow';

import { generateNetSuiteAuth } from '../nodes/Netsuite/NetsuiteAuth';

export class NetSuiteOAuth1Api implements ICredentialType {
  name = 'netSuiteOAuth1Api';
  displayName = 'NetSuite OAuth-1 API';
  documentationUrl = 'https://docs.oracle.com';
  description = { icon: 'file:netsuite.svg' };

  properties: INodeProperties[] = [
    { displayName: 'Consumer Key', name: 'consumerKey', type: 'string', typeOptions: { password: true }, required: true, default: '' },
    { displayName: 'Consumer Secret', name: 'consumerSecret', type: 'string', typeOptions: { password: true }, required: true, default: '' },
    { displayName: 'Access Token', name: 'accessToken', type: 'string', typeOptions: { password: true }, required: true, default: '' },
    { displayName: 'Token Secret', name: 'tokenSecret', type: 'string', typeOptions: { password: true }, required: true, default: '' },
    { displayName: 'Account ID', name: 'accountId', type: 'string', required: true, default: '', description: 'e.g. 30000-SB1' },
    { displayName: 'Realm', name: 'realm', type: 'string', required: true, default: '', description: 'e.g. 30000_SB1' },
    {
      displayName: 'Signature Method',
      name: 'signatureMethod',
      type: 'options',
      options: [{ name: 'HMAC-SHA256', value: 'HMAC-SHA256' }],
      required: true,
      default: 'HMAC-SHA256',
    },
    {
      displayName: 'Licence Key',
      name: 'licenseKey',
      type: 'string',
      typeOptions: { password: true },
      required: true,
      default: '',
      description: 'Licence key provided by MRK',
    },
  ];

  // Credential Test – calls metadata-catalog (same endpoint that works in Postman)
  test: ICredentialTestRequest = {
    request: {
      baseURL: '={{`https://${$credentials.accountId}.suitetalk.api.netsuite.com`}}',
      url: '/services/rest/record/v1/metadata-catalog',
      method: 'GET',
      headers: {
        'Content-Type': 'application/json',
        Accept: 'application/json',
        Authorization: '={{$credentials.authHeader}}',
      },
    },
  };

  // preAuthentication signs the exact URL/method that the Test request will send
  async preAuthentication(
    this: IHttpRequestHelper,
    credentials: ICredentialDataDecryptedObject,
  ) {
    const method = 'GET';
    const baseURL = `https://${String(credentials.accountId)}.suitetalk.api.netsuite.com`;
    const path = '/services/rest/record/v1/metadata-catalog';
    const url = `${baseURL}${path}`;

    const { Authorization } = generateNetSuiteAuth(
      method,
      url,
      String(credentials.consumerKey),
      String(credentials.consumerSecret),
      String(credentials.accessToken),
      String(credentials.tokenSecret),
      String(credentials.realm),
      {}, // no query params
    );

    Logger.debug('Generated OAuth1 header for Test request');
    return { authHeader: Authorization }; // plain string used by Test header
  }
}

nodes/Netsuite/NetsuiteAuth.ts (helper)

import crypto from 'crypto';

export function generateNetSuiteAuth(
  method: string,
  url: string,
  consumerKey: string,
  consumerSecret: string,
  token: string,
  tokenSecret: string,
  realm: string,
  additionalParams: Record<string, string> = {},
): { Authorization: string } {
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const nonce = crypto.randomBytes(16).toString('hex');

  const oauthParams: Record<string, string> = {
    oauth_consumer_key: consumerKey,
    oauth_token: token,
    oauth_signature_method: 'HMAC-SHA256',
    oauth_timestamp: timestamp,
    oauth_nonce: nonce,
    oauth_version: '1.0',
    ...additionalParams,
  };

  const sortedParams = Object.keys(oauthParams)
    .sort()
    .reduce((acc: Record<string, string>, key) => {
      acc[key] = oauthParams[key];
      return acc;
    }, {});

  let signatureBase = method.toUpperCase() + '&' + encodeURIComponent(url) + '&';

  const paramString = Object.entries(sortedParams)
    .map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
    .join('&');

  signatureBase += encodeURIComponent(paramString);

  const signingKey = `${encodeURIComponent(consumerSecret)}&${encodeURIComponent(tokenSecret)}`;
  const signature = crypto.createHmac('sha256', signingKey).update(signatureBase).digest('base64');

  const authHeader =
    `OAuth realm="${realm}",` +
    `oauth_consumer_key="${oauthParams.oauth_consumer_key}",` +
    `oauth_token="${oauthParams.oauth_token}",` +
    `oauth_signature_method="HMAC-SHA256",` +
    `oauth_timestamp="${oauthParams.oauth_timestamp}",` +
    `oauth_nonce="${oauthParams.oauth_nonce}",` +
    `oauth_version="1.0",` +
    `oauth_signature="${encodeURIComponent(signature)}"`;

  return { Authorization: authHeader };
}

Information on your n8n setup

  • n8n version: 1.93.0

  • Database: postgres

  • n8n EXECUTIONS_PROCESS: own

  • Running n8n via: Self-hosted Docker (n8nio/n8n:1.93.0)

  • Operating system (host): Ubuntu 22.04 LTS

Questions for the n8n team

  1. In credential tests, is any transformation applied to Authorization (or the request URL) after preAuthentication returns it, which could alter the OAuth base string?

  2. Are there known issues in 1.93.0 where the Credential Test flow handles preAuthentication outputs differently from node execution?

  3. Is there a recommended pattern to ensure OAuth1 HMAC-SHA256 signatures generated in preAuthentication are sent verbatim by the Test request?

Thanks!

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.