mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-01 05:38:33 +08:00
feat(core): Add static key loading and startup validation for TrustedKeyService (no-changelog) (#27969)
This commit is contained in:
+252
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user