feat(core): Add static key loading and startup validation for TrustedKeyService (no-changelog) (#27969)

This commit is contained in:
Andreas Fitzek
2026-04-03 09:40:59 +02:00
committed by GitHub
parent f96cdb17db
commit 34931a7412
5 changed files with 423 additions and 14 deletions
@@ -0,0 +1,252 @@
import type { Logger } from '@n8n/backend-common';
import { mock } from 'jest-mock-extended';
import type { TokenExchangeConfig } from '../../token-exchange.config';
import { TrustedKeyService } from '../trusted-key.service';
// ──────────────────────────────────────────────────────────────────────
// Pre-generated PEM public keys (test-only, no secrets)
// ──────────────────────────────────────────────────────────────────────
const RSA_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1A5I3JA3ylWxNFZcNqp9
qo3dhhO/7wAKUVH73Ryc/UWeHQPon5K+cVchPG2td4yg9llV6LDqurdI5wO1b1tg
XZjky3Brbh6LISZNjQJr0YvhCVW7NU6jjqgrLqNVrPeAGP51h9ozSIHUm1UyWm2J
wquhuvVhFlgaeHwA5HtBrYuwihEHJBJueIn9CiGYGwTModwT+WrhK5SxuXhtkD9w
6SJrbXZIdOnTtAFxH0bn+OYriRD7SgEn5UWiVpXyaRNkKhiFpozK2U1MqtKLrWgC
o6LNz3KqejtBEOT+/IbnbgIShhWcTuh8Ehw0EUtkOXdqykqoXuEtcoLj3c4efQ/n
dQIDAQAB
-----END PUBLIC KEY-----`;
const EC_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEpCuPN2BHQ7G0A2qD2Bd27bwwUB9M
Npzv5WS/ygt55l8y2X+Vfm5TQFRMNkqEx+/GXaPIU/hDmtnBdCxAUIRM9g==
-----END PUBLIC KEY-----`;
const ED25519_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAPBUxurC3wGyi/yXTTjNwTzgHjSioAIa4Qx6nyOqof0U=
-----END PUBLIC KEY-----`;
// ──────────────────────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────────────────────
const mockLogger = mock<Logger>({ scoped: jest.fn().mockReturnThis() });
function createService(trustedKeysJson: string): TrustedKeyService {
const tokenExchangeConfig = mock<TokenExchangeConfig>({
trustedKeys: trustedKeysJson,
enabled: true,
});
return new TrustedKeyService(mockLogger, tokenExchangeConfig);
}
function staticKeyEntry(
overrides: Partial<{
kid: string;
algorithms: string[];
key: string;
issuer: string;
expectedAudience: string;
allowedRoles: string[];
}> = {},
) {
return {
type: 'static' as const,
kid: 'test-kid',
algorithms: ['RS256'],
key: RSA_PUBLIC_KEY,
issuer: 'https://issuer.example.com',
...overrides,
};
}
async function initWithEntries(entries: unknown[]): Promise<TrustedKeyService> {
const service = createService(JSON.stringify(entries));
await service.initialize();
return service;
}
// ──────────────────────────────────────────────────────────────────────
// Tests
// ──────────────────────────────────────────────────────────────────────
describe('TrustedKeyService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('configuration loading', () => {
it('should load keys from trustedKeys config', async () => {
const service = await initWithEntries([staticKeyEntry()]);
expect(service.size).toBe(1);
});
it('should succeed with empty config', async () => {
const service = createService('');
await service.initialize();
expect(service.size).toBe(0);
});
it('should throw on invalid JSON', async () => {
const service = createService('not-json');
await expect(service.initialize()).rejects.toThrow('Failed to parse trusted keys JSON');
});
it('should throw when parsed value is not an array', async () => {
const service = createService(JSON.stringify({ type: 'static' }));
await expect(service.initialize()).rejects.toThrow();
});
});
describe('algorithm validation', () => {
it('should reject none algorithm', async () => {
// 'none' is not in JwtAlgorithmSchema, so Zod rejects it at parse time
await expect(
initWithEntries([staticKeyEntry({ algorithms: ['none' as 'RS256'] })]),
).rejects.toThrow();
});
it.each(['HS256', 'HS384', 'HS512'])('should reject HMAC algorithm %s', async (alg) => {
await expect(
initWithEntries([staticKeyEntry({ algorithms: [alg as 'RS256'] })]),
).rejects.toThrow();
});
it('should reject unknown algorithm', async () => {
await expect(
initWithEntries([staticKeyEntry({ algorithms: ['FAKE256' as 'RS256'] })]),
).rejects.toThrow();
});
it('should reject cross-family mixing (RS256 + ES256)', async () => {
await expect(
initWithEntries([staticKeyEntry({ algorithms: ['RS256', 'ES256'], key: RSA_PUBLIC_KEY })]),
).rejects.toThrow('same family');
});
it('should accept multiple same-family algorithms (RS256 + PS256)', async () => {
const service = await initWithEntries([
staticKeyEntry({ algorithms: ['RS256', 'PS256'], key: RSA_PUBLIC_KEY }),
]);
expect(service.size).toBe(1);
});
});
describe('key-algorithm compatibility', () => {
it('should accept RSA key with RS256', async () => {
const service = await initWithEntries([
staticKeyEntry({ kid: 'rsa-key', algorithms: ['RS256'], key: RSA_PUBLIC_KEY }),
]);
expect(service.size).toBe(1);
});
it('should accept EC key with ES256', async () => {
const service = await initWithEntries([
staticKeyEntry({ kid: 'ec-key', algorithms: ['ES256'], key: EC_PUBLIC_KEY }),
]);
expect(service.size).toBe(1);
});
it('should accept Ed25519 key with EdDSA', async () => {
const service = await initWithEntries([
staticKeyEntry({
kid: 'ed-key',
algorithms: ['EdDSA'],
key: ED25519_PUBLIC_KEY,
}),
]);
expect(service.size).toBe(1);
});
it('should reject EC key with RSA algorithm', async () => {
await expect(
initWithEntries([
staticKeyEntry({ kid: 'ec-rsa', algorithms: ['RS256'], key: EC_PUBLIC_KEY }),
]),
).rejects.toThrow('does not match algorithm family');
});
it('should reject RSA key with EC algorithm', async () => {
await expect(
initWithEntries([
staticKeyEntry({ kid: 'rsa-ec', algorithms: ['ES256'], key: RSA_PUBLIC_KEY }),
]),
).rejects.toThrow('does not match algorithm family');
});
it('should reject invalid PEM string', async () => {
await expect(
initWithEntries([
staticKeyEntry({ kid: 'bad-pem', algorithms: ['RS256'], key: 'not-a-pem' }),
]),
).rejects.toThrow('failed to parse public key');
});
});
describe('duplicate detection', () => {
it('should reject duplicate kid values', async () => {
await expect(
initWithEntries([
staticKeyEntry({ kid: 'same-kid', key: RSA_PUBLIC_KEY }),
staticKeyEntry({ kid: 'same-kid', key: RSA_PUBLIC_KEY }),
]),
).rejects.toThrow('duplicate kid');
});
});
describe('JWKS handling', () => {
it('should log warning and skip JWKS sources', async () => {
const service = await initWithEntries([
{
type: 'jwks',
url: 'https://example.com/.well-known/jwks.json',
issuer: 'https://example.com',
},
]);
expect(service.size).toBe(0);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('JWKS key sources are not yet supported'),
);
});
it('should load static keys alongside skipped JWKS sources', async () => {
const service = await initWithEntries([
staticKeyEntry({ kid: 'static-1' }),
{
type: 'jwks',
url: 'https://example.com/.well-known/jwks.json',
issuer: 'https://example.com',
},
]);
expect(service.size).toBe(1);
expect(await service.getByKid('static-1')).toBeDefined();
});
});
describe('getByKid', () => {
it('should return correct ResolvedTrustedKey for known kid', async () => {
const service = await initWithEntries([
staticKeyEntry({
kid: 'my-key',
algorithms: ['RS256'],
key: RSA_PUBLIC_KEY,
issuer: 'https://issuer.example.com',
}),
]);
const result = await service.getByKid('my-key');
expect(result).toBeDefined();
expect(result!.kid).toBe('my-key');
expect(result!.algorithms).toEqual(['RS256']);
expect(result!.issuer).toBe('https://issuer.example.com');
expect(result!.key).toBeDefined();
});
it('should return undefined for unknown kid', async () => {
const service = await initWithEntries([staticKeyEntry({ kid: 'known' })]);
const result = await service.getByKid('unknown');
expect(result).toBeUndefined();
});
});
});
@@ -1,23 +1,163 @@
import { Service } from '@n8n/di';
import { createPublicKey } from 'node:crypto';
import type { ResolvedTrustedKey } from '../token-exchange.schemas';
import { Logger } from '@n8n/backend-common';
import { Service } from '@n8n/di';
import type { Algorithm } from 'jsonwebtoken';
import { UnexpectedError } from 'n8n-workflow';
import { z } from 'zod';
import { TokenExchangeConfig } from '../token-exchange.config';
import type { ResolvedTrustedKey, StaticKeySource } from '../token-exchange.schemas';
import { TrustedKeySourceSchema } from '../token-exchange.schemas';
type AlgorithmFamily = 'RSA' | 'EC' | 'EdDSA';
const ALGORITHM_FAMILY: Record<string, AlgorithmFamily> = {
RS256: 'RSA',
RS384: 'RSA',
RS512: 'RSA',
PS256: 'RSA',
PS384: 'RSA',
PS512: 'RSA',
ES256: 'EC',
ES384: 'EC',
ES512: 'EC',
EdDSA: 'EdDSA',
};
/**
* Retrieves trusted public keys by Key ID (kid) from the JWT header.
* Loads and validates trusted public keys at startup, then serves
* them by Key ID (`kid`) for JWT signature verification.
*
* Returns keys in their resolved, in-memory form with key material
* already parsed and ready for `jwt.verify()`.
*
* This is a skeleton service — the real implementation will be provided
* when the trusted-key persistence layer is introduced.
* Keys are loaded from the `N8N_TOKEN_EXCHANGE_TRUSTED_KEYS` config
* (or `_FILE` variant) and stored in-process. JWKS sources are
* recognised but not yet supported — they log a warning and are skipped.
*/
@Service()
export class TrustedKeyService {
private readonly logger: Logger;
private readonly keys = new Map<string, ResolvedTrustedKey>();
constructor(
logger: Logger,
private readonly tokenExchangeConfig: TokenExchangeConfig,
) {
this.logger = logger.scoped('token-exchange');
}
/**
* Parse, validate, and store all configured trusted key sources.
* Must be called once during module initialisation — validation
* failures throw and prevent startup.
*/
async initialize(): Promise<void> {
const raw = this.tokenExchangeConfig.trustedKeys;
if (!raw) {
this.logger.info('No trusted keys configured');
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
this.logger.error('Failed to parse trusted keys JSON', { error });
throw new UnexpectedError('Failed to parse trusted keys JSON');
}
const sourcesResult = z.array(TrustedKeySourceSchema).safeParse(parsed);
if (!sourcesResult.success) {
this.logger.error('Trusted keys JSON has invalid format', { error: sourcesResult.error });
throw new UnexpectedError('Trusted keys JSON has invalid format');
}
const sources = sourcesResult.data;
for (const source of sources) {
if (source.type === 'jwks') {
this.logger.warn('JWKS key sources are not yet supported, skipping kid in source');
continue;
}
this.validateAndStoreStaticKey(source);
}
this.logger.info(`Loaded ${this.keys.size} trusted key(s)`);
}
/**
* Look up a resolved trusted key by its `kid`.
* @returns the resolved key, or `undefined` if the kid is unknown.
*/
async getByKid(_kid: string): Promise<ResolvedTrustedKey | undefined> {
return undefined;
async getByKid(kid: string): Promise<ResolvedTrustedKey | undefined> {
return this.keys.get(kid);
}
/** Number of loaded keys — useful for diagnostics and tests. */
get size(): number {
return this.keys.size;
}
private validateAndStoreStaticKey(source: StaticKeySource): void {
const { kid, algorithms, key: pemString, issuer, expectedAudience, allowedRoles } = source;
// 1. Reject duplicate kid
if (this.keys.has(kid)) {
throw new UnexpectedError(`Trusted key "${kid}": duplicate kid`);
}
// 2. Resolve and validate algorithm families
const families = new Set<AlgorithmFamily>();
for (const alg of algorithms) {
const family = ALGORITHM_FAMILY[alg];
if (!family) {
throw new UnexpectedError(`Trusted key "${kid}": unknown algorithm "${alg}"`);
}
families.add(family);
}
// 3. Reject cross-family mixing
if (families.size > 1) {
throw new UnexpectedError(
`Trusted key "${kid}": algorithms must belong to the same family, got ${[...families].join(', ')}`,
);
}
const family = [...families][0];
// 4. Parse PEM
let keyObject: ReturnType<typeof createPublicKey>;
try {
keyObject = createPublicKey(pemString);
} catch (error) {
const message = error instanceof Error ? error.message : 'unknown error';
throw new UnexpectedError(`Trusted key "${kid}": failed to parse public key — ${message}`);
}
// 5. Validate key type matches algorithm family
const keyType = keyObject.asymmetricKeyType;
const expectedTypes: Record<AlgorithmFamily, string[]> = {
RSA: ['rsa'],
EC: ['ec'],
EdDSA: ['ed25519', 'ed448'],
};
if (!expectedTypes[family].includes(keyType ?? '')) {
throw new UnexpectedError(
`Trusted key "${kid}": key type "${keyType}" does not match algorithm family "${family}"`,
);
}
// 6. Store resolved key
this.keys.set(kid, {
kid,
algorithms: algorithms as Algorithm[],
key: keyObject,
issuer,
expectedAudience,
allowedRoles,
});
}
}
@@ -9,4 +9,14 @@ export class TokenExchangeConfig {
/** Maximum lifetime in seconds for an issued token. */
@Env('N8N_TOKEN_EXCHANGE_MAX_TOKEN_TTL')
maxTokenTtl: number = 900;
/**
* JSON array of trusted key sources for JWT verification.
* Each entry is validated against `TrustedKeySourceSchema`.
*
* Can also be loaded from a file by setting `N8N_TOKEN_EXCHANGE_TRUSTED_KEYS_FILE`
* to a path — the `@Env` decorator reads the file contents automatically.
*/
@Env('N8N_TOKEN_EXCHANGE_TRUSTED_KEYS')
trustedKeys: string = '';
}
@@ -1,6 +1,7 @@
import { LICENSE_FEATURES } from '@n8n/constants';
import type { ModuleInterface } from '@n8n/decorators';
import { BackendModule } from '@n8n/decorators';
import { Container } from '@n8n/di';
function isFeatureFlagEnabled(): boolean {
return process.env.N8N_ENV_FEAT_TOKEN_EXCHANGE === 'true';
@@ -16,6 +17,10 @@ export class TokenExchangeModule implements ModuleInterface {
if (!isFeatureFlagEnabled()) {
return;
}
const { TrustedKeyService } = await import('./services/trusted-key.service');
await Container.get(TrustedKeyService).initialize();
await import('./token-exchange.controller');
await import('./controllers/embed-auth.controller');
}
@@ -4,10 +4,11 @@ import { z } from 'zod';
/** RFC 8693 grant type URN for token exchange */
export const TOKEN_EXCHANGE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:token-exchange' as const;
/**
* Asymmetric-only JWT algorithms accepted for trusted key sources.
* Symmetric (HMAC) and 'none' are excluded by design.
*/
const JwtAlgorithmSchema = z.enum([
'HS256',
'HS384',
'HS512',
'RS256',
'RS384',
'RS512',
@@ -17,7 +18,8 @@ const JwtAlgorithmSchema = z.enum([
'PS256',
'PS384',
'PS512',
]) satisfies z.ZodType<Algorithm>;
'EdDSA',
]);
/**
* Validates JWT claims originating from an external identity provider.