n8n/packages/@n8n/utils/src/client-assertion.ts

77 lines
2.5 KiB
TypeScript

import {
createPrivateKey,
createSign,
randomUUID,
X509Certificate,
type KeyObject,
} from 'node:crypto';
import { formatPemBlock } from './format-pem-block';
// private_key_jwt (RFC 7521/7523): the client proves its identity with a JWT
// signed by its private key instead of a shared secret. The `x5t` header (SHA-1
// thumbprint of the certificate) tells the server which public key verifies it.
export const CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
const ASSERTION_TTL_SECONDS = 300;
function base64url(input: Buffer | string): string {
return Buffer.from(input).toString('base64url');
}
function certificateThumbprint(certificate: string): string {
let parsed: X509Certificate;
try {
parsed = new X509Certificate(formatPemBlock(certificate));
} catch (error) {
throw new Error(
'The Certificate field must contain a PEM certificate (-----BEGIN CERTIFICATE-----).',
{ cause: error },
);
}
return Buffer.from(parsed.fingerprint.replace(/:/g, ''), 'hex').toString('base64url');
}
export interface BuildClientAssertionOptions {
clientId: string;
/** Token endpoint; used as the JWT `aud`. */
accessTokenUri: string;
/** RSA private key (PEM). Signing is RS256-only; EC/Ed25519 keys are not supported. */
privateKey: string;
certificate: string;
}
export function buildClientAssertion(options: BuildClientAssertionOptions): string {
const now = Math.floor(Date.now() / 1000);
const header = { alg: 'RS256', typ: 'JWT', x5t: certificateThumbprint(options.certificate) };
const payload = {
aud: options.accessTokenUri,
iss: options.clientId,
sub: options.clientId,
jti: randomUUID(),
iat: now,
nbf: now,
exp: now + ASSERTION_TTL_SECONDS,
};
let privateKey: KeyObject;
try {
privateKey = createPrivateKey(formatPemBlock(options.privateKey));
} catch (error) {
throw new Error(
'The Private Key field must contain a PEM private key (-----BEGIN PRIVATE KEY-----).',
{ cause: error },
);
}
// `createSign('RSA-SHA256')` also signs EC/Ed25519 keys, producing a signature
// that contradicts the pinned `alg: RS256` header. Reject non-RSA keys up front.
if (privateKey.asymmetricKeyType !== 'rsa') {
throw new Error('Certificate authentication requires an RSA private key');
}
const signingInput = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(payload))}`;
const signature = createSign('RSA-SHA256').update(signingInput).sign(privateKey);
return `${signingInput}.${base64url(signature)}`;
}