chore(core): Begins minting asymmetric signed access tokens (#40286)

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
James Martin
2026-10-07 07:23:43 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 66b382de52
commit e882e8a483
17 changed files with 986 additions and 163 deletions
@@ -21,6 +21,7 @@ export type PubSubEventName =
| 'reload-license'
| 'reload-oidc-config'
| 'reload-saml-config'
| 'reload-oauth-signing-keys'
| 'reload-overwrite-credentials'
| 'response-to-get-worker-status'
| 'restart-event-bus'
@@ -111,7 +111,8 @@ describe('Webhook', () => {
baseInitSpy.mockRestore();
});
it('should call executionContextHookRegistry.init before LoadNodesAndCredentials.postProcessLoaders', async () => {
/** A webhook command whose init steps are all stubbed. */
const createWebhook = () => {
const webhook = new Webhook();
// @ts-expect-error - Accessing protected property for testing
@@ -138,6 +139,11 @@ describe('Webhook', () => {
webhook.executionContextHookRegistry = {
init: vi.fn().mockResolvedValue(undefined),
};
return webhook;
};
it('should call executionContextHookRegistry.init before LoadNodesAndCredentials.postProcessLoaders', async () => {
const webhook = createWebhook();
await webhook.init();
@@ -151,6 +157,21 @@ describe('Webhook', () => {
postProcessMock.mock.invocationCallOrder[0],
);
});
it('should register pubsub event handlers again after the modules initialize', async () => {
const webhook = createWebhook();
await webhook.init();
// @ts-expect-error - Accessing protected property for testing
const initModulesMock = webhook.moduleRegistry.initModules as Mock;
const registryInitMock = Container.get(PubSubRegistry).init as Mock;
expect(registryInitMock).toHaveBeenCalledTimes(1);
expect(initModulesMock.mock.invocationCallOrder[0]).toBeLessThan(
registryInitMock.mock.invocationCallOrder[0],
);
});
});
});
+4
View File
@@ -96,6 +96,10 @@ export class Webhook extends BaseCommand {
await this.moduleRegistry.initModules(this.instanceSettings.instanceType);
// Re-register pubsub event handlers after modules have been initialized
// As modules can add new event handlers we need to make sure they are registered
Container.get(PubSubRegistry).init();
await this.executionContextHookRegistry.init();
await Container.get(LoadNodesAndCredentials).postProcessLoaders();
}
@@ -2,6 +2,8 @@ import type { Mocked } from 'vitest';
import { mockInstance } from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import type { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { generateKeyPairSync } from 'node:crypto';
import { mock, mockDeep } from 'vitest-mock-extended';
import type { InstanceSettings } from 'n8n-core';
@@ -27,6 +29,15 @@ const mockReqWith = (authHeader: string | undefined, body?: any) => {
const instanceSettings = mock<InstanceSettings>({ encryptionKey: 'test-key' });
const jwtService = new JwtService(instanceSettings, mock(), mock());
const signingKey = generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey;
/** An access token in the shape n8n mints. The verifier is mocked, so only its claims matter. */
const signOAuthToken = () =>
jwt.sign({ sub: 'user-123', meta: { isOAuth: true } }, signingKey, {
algorithm: 'ES256',
audience: 'https://n8n.example.com/mcp-server/http',
header: { alg: 'ES256', typ: 'at+jwt', kid: 'signing-kid' },
});
let mcpServerApiKeyService: Mocked<McpServerApiKeyService>;
let oauthTokenVerifier: Mocked<OAuthTokenVerifierProxy>;
@@ -61,10 +72,7 @@ describe('McpServerMiddlewareService', () => {
describe('getUserForToken', () => {
it('should return user for valid OAuth token (meta.isOAuth = true)', async () => {
const user = mock<User>({ id: 'user-123' });
const oauthToken = jwtService.signForResource(
{ sub: 'user-123', meta: { isOAuth: true } },
'mcp-server-api',
);
const oauthToken = signOAuthToken();
oauthTokenVerifier.verifyOAuthAccessToken.mockResolvedValue({
user,
@@ -127,10 +135,7 @@ describe('McpServerMiddlewareService', () => {
});
it('should return null when OAuth token verification fails', async () => {
const oauthToken = jwtService.signForResource(
{ sub: 'user-123', meta: { isOAuth: true } },
'mcp-server-api',
);
const oauthToken = signOAuthToken();
oauthTokenVerifier.verifyOAuthAccessToken.mockResolvedValue({ user: null });
@@ -301,10 +306,7 @@ describe('McpServerMiddlewareService', () => {
it('should authenticate with valid OAuth token and call next', async () => {
const user = mock<User>({ id: 'user-123' });
const oauthToken = jwtService.signForResource(
{ sub: 'user-123', meta: { isOAuth: true } },
'mcp-server-api',
);
const oauthToken = signOAuthToken();
const req = mockReqWith(`Bearer ${oauthToken}`);
const res = mockDeep<Response>();
@@ -328,10 +330,7 @@ describe('McpServerMiddlewareService', () => {
it('should attach the OAuth token scopes to the request', async () => {
const user = mock<User>({ id: 'user-123' });
const oauthToken = jwtService.signForResource(
{ sub: 'user-123', meta: { isOAuth: true } },
'mcp-server-api',
);
const oauthToken = signOAuthToken();
const req = mockReqWith(`Bearer ${oauthToken}`);
const res = mockDeep<Response>();
@@ -352,10 +351,7 @@ describe('McpServerMiddlewareService', () => {
it('should attach the OAuth client the token was issued to', async () => {
const user = mock<User>({ id: 'user-123' });
const oauthToken = jwtService.signForResource(
{ sub: 'user-123', meta: { isOAuth: true } },
'mcp-server-api',
);
const oauthToken = signOAuthToken();
const req = mockReqWith(`Bearer ${oauthToken}`);
const res = mockDeep<Response>();
@@ -435,10 +431,7 @@ describe('McpServerMiddlewareService', () => {
});
it('should return 401 with WWW-Authenticate header when token validation fails', async () => {
const invalidToken = jwtService.signForResource(
{ sub: 'user-123', meta: { isOAuth: true } },
'mcp-server-api',
);
const invalidToken = signOAuthToken();
const req = mockReqWith(`Bearer ${invalidToken}`);
const res = mockDeep<Response>();
@@ -0,0 +1,118 @@
import type { User } from '@n8n/db';
import { DeploymentKey, OAUTH_JWE_PRIVATE_KEY_TYPE } from '@n8n/db';
import { Container } from '@n8n/di';
import { DataSource } from '@n8n/typeorm';
import type { JSONWebKeySet } from 'jose';
import { createLocalJWKSet, decodeProtectedHeader, jwtVerify } from 'jose';
import jwt from 'jsonwebtoken';
import { Cipher } from 'n8n-core';
import type { JsonWebKey } from 'node:crypto';
import { createPrivateKey } from 'node:crypto';
import { createOwner } from '@test-integration/db/users';
import { setupTestServer } from '@test-integration/utils';
import { OAUTH_ACCESS_TOKEN_TTL_SECONDS } from '../oauth-signing-key.constants';
import { OAuthSigningKeyService } from '../oauth-signing-key.service';
import { OAuthTokenService } from '../oauth-token.service';
import { AccessTokenNotFoundError, JWTVerificationError } from '../oauth.errors';
const testServer = setupTestServer({
modules: ['oauth-jwe', 'oauth-server', 'mcp'],
endpointGroups: ['mcp', 'jwks'],
});
let owner: User;
let issuer: string;
let jwks: JSONWebKeySet;
beforeAll(async () => {
owner = await createOwner();
const discovery = await testServer.restlessAgent
.get('/.well-known/oauth-authorization-server')
.expect(200);
issuer = discovery.body.issuer as string;
const jwksPath = new URL(discovery.body.jwks_uri as string).pathname;
jwks = (await testServer.restlessAgent.get(jwksPath).expect(200)).body as JSONWebKeySet;
});
describe('OAuth access-token signing', () => {
const claims = () => {
const now = Math.floor(Date.now() / 1000);
return {
iss: issuer,
sub: owner.id,
aud: `${issuer}/mcp-server/http`,
client_id: 'client-1',
iat: now,
exp: now + 60,
scope: '',
meta: { isOAuth: true },
};
};
it('mints tokens that verify with only the published JWKS, next to the JWE key', async () => {
expect(jwks.keys.map((key) => key.use).sort()).toEqual(['enc', 'sig']);
const resource = `${issuer}/mcp-server/http`;
const { accessToken } = Container.get(OAuthTokenService).generateTokenPair(
owner.id,
'client-1',
resource,
['tool:read'],
);
const { payload, protectedHeader } = await jwtVerify(accessToken, createLocalJWKSet(jwks), {
issuer,
audience: resource,
typ: 'at+jwt',
algorithms: ['ES256'],
});
const signingKey = jwks.keys.find((key) => key.use === 'sig');
expect(protectedHeader).toEqual({ alg: 'ES256', typ: 'at+jwt', kid: signingKey?.kid });
expect(payload).toEqual({
iss: issuer,
sub: owner.id,
aud: resource,
client_id: 'client-1',
jti: expect.any(String),
iat: expect.any(Number),
exp: payload.iat! + OAUTH_ACCESS_TOKEN_TTL_SECONDS,
scope: 'tool:read',
meta: { isOAuth: true },
});
});
it('rejects a token signed with the JWE key under its own kid', async () => {
const jweRow = await Container.get(DataSource)
.getRepository(DeploymentKey)
.findOneByOrFail({ type: OAUTH_JWE_PRIVATE_KEY_TYPE, status: 'active' });
const jweJwk = JSON.parse(
Container.get(Cipher).decryptDEKWithInstanceKey(jweRow.value),
) as JsonWebKey;
const jweKid = jwks.keys.find((key) => key.use === 'enc')?.kid;
expect(jweKid).toBe(jweRow.id);
const token = jwt.sign(claims(), createPrivateKey({ key: jweJwk, format: 'jwk' }), {
algorithm: 'RS256',
header: { alg: 'RS256', typ: 'at+jwt', kid: jweRow.id },
});
expect(decodeProtectedHeader(token).kid).toBe(jweKid);
await expect(
Container.get(OAuthTokenService).verifyAccessToken(token, `${issuer}/mcp-server/http`),
).rejects.toThrow(JWTVerificationError);
});
it('accepts the same claims signed with the signing key', async () => {
const { aud, ...rest } = claims();
const token = Container.get(OAuthSigningKeyService).signAccessToken(rest, aud);
// The token is not in the database, so it passes JWT verification and
// fails only on the lookup.
await expect(
Container.get(OAuthTokenService).verifyAccessToken(token, `${issuer}/mcp-server/http`),
).rejects.toThrow(AccessTokenNotFoundError);
});
});
@@ -1,7 +1,5 @@
import { Logger } from '@n8n/backend-common';
import { CacheService } from '@n8n/backend-services';
import { mockInstance, testDb } from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import { DeploymentKey, DeploymentKeyRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { DataSource, type Repository } from '@n8n/typeorm';
@@ -14,7 +12,6 @@ import { JwtService } from '@/services/jwt.service';
import {
OAUTH_SIGNING_ALGORITHM,
OAUTH_SIGNING_KEY_TYPE,
OAUTH_SIGNING_KEYS_CACHE_KEY,
RETIRED_SIGNING_KEY_GRACE_MS,
} from '../oauth-signing-key.constants';
import { OAuthSigningKeyService } from '../oauth-signing-key.service';
@@ -24,12 +21,11 @@ const AUDIENCE = `${ISSUER}/mcp-server/http`;
let keyStore: Repository<DeploymentKey>;
/** A process of its own: its own memory cache and its own in-memory private key. */
/** A process of its own: its own in-memory public keys and private key. */
const createProcess = () =>
new OAuthSigningKeyService(
Container.get(DeploymentKeyRepository),
Container.get(Cipher),
new CacheService(Container.get(GlobalConfig)),
Container.get(Logger),
Container.get(JwtService),
);
@@ -54,7 +50,6 @@ beforeAll(async () => {
beforeEach(async () => {
await testDb.resetDeploymentKeys();
await Container.get(CacheService).delete(OAUTH_SIGNING_KEYS_CACHE_KEY);
});
afterAll(async () => {
@@ -75,15 +70,20 @@ describe('OAuthSigningKeyService (integration)', () => {
expect(jwk).toMatchObject({ kid: rows[0].id, use: 'sig' });
});
it('keeps the key across a restart', async () => {
it('keeps the key across a restart, and the restarted process verifies older tokens', async () => {
const before = createProcess();
await before.initialize();
const token = before.signAccessToken(claims(), AUDIENCE);
const after = createProcess();
await after.initialize();
expect(await activeRows()).toHaveLength(1);
expect(await after.getPublicJwks()).toEqual(await before.getPublicJwks());
const [{ kid }] = await after.getPublicJwks();
await expect(
after.verifyAccessToken(token, { kid, audiences: [AUDIENCE], issuer: ISSUER }),
).resolves.toMatchObject({ sub: 'user-1' });
});
it('keeps one active key when two processes initialize at the same time', async () => {
@@ -1,18 +1,24 @@
import { mock } from 'vitest-mock-extended';
import { mockInstance } from '@n8n/backend-test-utils';
import type { DeploymentKey } from '@n8n/db';
import { QueryFailedError } from '@n8n/typeorm';
import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
import { createLocalJWKSet, jwtVerify } from 'jose';
import jwt from 'jsonwebtoken';
import { generateKeyPairSync } from 'node:crypto';
import { Publisher } from '@/scaling/pubsub/publisher.service';
import {
OAUTH_ACCESS_TOKEN_TTL_SECONDS,
OAUTH_SIGNING_ALGORITHM,
OAUTH_SIGNING_KEY_TYPE,
OAUTH_SIGNING_KEYS_CACHE_KEY,
RETIRED_SIGNING_KEY_GRACE_MS,
SIGNING_KEYS_REFRESH_MS,
UNKNOWN_KID_REFRESH_MS,
} from '../oauth-signing-key.constants';
import type { OAuthSigningKeyService } from '../oauth-signing-key.service';
import { createSigningKeyService } from './signing-key-fixtures';
import { createSigningKeyService, readStoredPrivateKey } from './signing-key-fixtures';
const ISSUER = 'https://n8n.example.com';
const AUDIENCE = `${ISSUER}/mcp-server/http`;
@@ -20,7 +26,7 @@ const AUDIENCE = `${ISSUER}/mcp-server/http`;
/** The audience is passed to `signAccessToken` on its own. */
const claims = () => {
const now = Math.floor(Date.now() / 1000);
return { iss: ISSUER, sub: 'user-1', iat: now, exp: now + 3600 };
return { iss: ISSUER, sub: 'user-1', iat: now, exp: now + OAUTH_ACCESS_TOKEN_TTL_SECONDS };
};
const makeUniqueViolation = (code: string): QueryFailedError => {
@@ -51,7 +57,24 @@ const makeKeyRow = (id: string, overrides: Partial<DeploymentKey> = {}): Deploym
});
};
/** Signs with a stored key that the service under test did not create. */
const signWithRow = (row: DeploymentKey) =>
jwt.sign(claims(), readStoredPrivateKey(row), {
algorithm: 'ES256',
audience: AUDIENCE,
header: { alg: 'ES256', typ: 'at+jwt', kid: row.id },
});
const verifyToken = async (service: OAuthSigningKeyService, token: string, kid: string) =>
await service.verifyAccessToken(token, { kid, audiences: [AUDIENCE], issuer: ISSUER });
describe('OAuthSigningKeyService', () => {
let publisher: Publisher;
beforeEach(() => {
publisher = mockInstance(Publisher, { publishCommand: vi.fn() });
});
afterEach(() => {
vi.useRealTimers();
});
@@ -64,13 +87,14 @@ describe('OAuthSigningKeyService', () => {
await service.initialize();
expect(keyStore.repository.insertActiveOAuthSigningKey).not.toHaveBeenCalled();
expect(publisher.publishCommand).not.toHaveBeenCalled();
expect(
jwt.decode(service.signAccessToken(claims(), AUDIENCE), { complete: true })?.header.kid,
).toBe('existing-key');
});
it('generates a key whose kid is the row id, wraps it, and drops the cached key list', async () => {
const { service, keyStore, cipher, cache } = createSigningKeyService();
it('generates a key whose kid is the row id, and wraps it', async () => {
const { service, keyStore, cipher } = createSigningKeyService();
await service.initialize();
@@ -86,7 +110,6 @@ describe('OAuthSigningKeyService', () => {
kty: 'EC',
crv: 'P-256',
});
expect(cache.delete).toHaveBeenCalledWith(OAUTH_SIGNING_KEYS_CACHE_KEY);
expect(
jwt.decode(service.signAccessToken(claims(), AUDIENCE), { complete: true })?.header.kid,
).toBe(id);
@@ -141,33 +164,238 @@ describe('OAuthSigningKeyService', () => {
expect(jwks[0]).toMatchObject({ kty: 'EC', crv: 'P-256', alg: 'ES256', use: 'sig' });
});
it('neither publishes nor verifies with a signing-key row that is not ES256', async () => {
const { service, keyStore } = createSigningKeyService();
const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const privateJwk = {
...privateKey.export({ format: 'jwk' }),
kid: 'rs256-key',
alg: 'RS256',
use: 'sig',
};
keyStore.rows.push(
makeKeyRow('rs256-key', {
algorithm: 'RS256',
value: `wrapped:${JSON.stringify(privateJwk)}`,
}),
);
await service.initialize();
const token = jwt.sign(claims(), privateKey, {
algorithm: 'RS256',
audience: AUDIENCE,
header: { alg: 'RS256', typ: 'at+jwt', kid: 'rs256-key' },
});
expect((await service.getPublicJwks()).map((k) => k.kid)).not.toContain('rs256-key');
await expect(
service.verifyAccessToken(token, { kid: 'rs256-key', audiences: [AUDIENCE], issuer: ISSUER }),
).rejects.toThrow('kid is unknown');
});
describe('retired keys', () => {
const setUp = async () => {
vi.useFakeTimers({ toFake: ['Date'] });
const { service, keyStore } = createSigningKeyService();
keyStore.rows.push(makeKeyRow('retired', { status: 'inactive', updatedAt: new Date() }));
const retired = makeKeyRow('retired', { status: 'inactive', updatedAt: new Date() });
keyStore.rows.push(retired);
await service.initialize();
return { service };
const token = jwt.sign(claims(), readStoredPrivateKey(retired), {
algorithm: 'ES256',
audience: AUDIENCE,
header: { alg: 'ES256', typ: 'at+jwt', kid: 'retired' },
});
return { service, token };
};
const publishedKids = async (service: OAuthSigningKeyService) =>
(await service.getPublicJwks()).map((k) => k.kid);
it('stay published within the grace window', async () => {
const { service } = await setUp();
const verify = async (service: OAuthSigningKeyService, token: string) =>
await service.verifyAccessToken(token, {
kid: 'retired',
audiences: [AUDIENCE],
issuer: ISSUER,
});
it('stay published and verifiable within the grace window', async () => {
const { service, token } = await setUp();
// A token signed at retirement verifies until it expires.
vi.advanceTimersByTime(OAUTH_ACCESS_TOKEN_TTL_SECONDS * 1000 - 1000);
await expect(verify(service, token)).resolves.toMatchObject({ sub: 'user-1' });
// One second before the window closes.
vi.advanceTimersByTime(RETIRED_SIGNING_KEY_GRACE_MS - 1000);
vi.advanceTimersByTime(RETIRED_SIGNING_KEY_GRACE_MS - OAUTH_ACCESS_TOKEN_TTL_SECONDS * 1000);
expect(await publishedKids(service)).toContain('retired');
});
it('drop out after the grace window, even from a cached key list', async () => {
const { service } = await setUp();
// Fill the cache while the retired key is still in its window.
it('drop out after the grace window', async () => {
const { service, token } = await setUp();
// Read the list while the retired key is still in its window.
await service.getPublicJwks();
vi.advanceTimersByTime(RETIRED_SIGNING_KEY_GRACE_MS + 1000);
expect(await publishedKids(service)).not.toContain('retired');
await expect(verify(service, token)).rejects.toThrow('kid is unknown');
});
});
describe('in-memory key list', () => {
const setUp = async () => {
vi.useFakeTimers({ toFake: ['Date'] });
const { service, keyStore } = createSigningKeyService();
await service.initialize();
return {
service,
keyStore,
kid: keyStore.rows[0].id,
token: service.signAccessToken(claims(), AUDIENCE),
reads: keyStore.repository.findOAuthSigningKeys,
};
};
it('reads the rows once for all verifications within the refresh window', async () => {
const { service, kid, token, reads } = await setUp();
await verifyToken(service, token, kid);
vi.advanceTimersByTime(SIGNING_KEYS_REFRESH_MS - 1);
await verifyToken(service, token, kid);
await service.getPublicJwks();
expect(reads).toHaveBeenCalledTimes(1);
});
it('reads the rows again after the refresh window', async () => {
const { service, kid, token, reads } = await setUp();
await verifyToken(service, token, kid);
vi.advanceTimersByTime(SIGNING_KEYS_REFRESH_MS);
await verifyToken(service, token, kid);
expect(reads).toHaveBeenCalledTimes(2);
});
it('reads the rows again for an unknown kid at most once per cooldown', async () => {
const { service, kid, token, reads } = await setUp();
await verifyToken(service, token, kid);
vi.advanceTimersByTime(UNKNOWN_KID_REFRESH_MS);
await expect(verifyToken(service, token, 'unknown-1')).rejects.toThrow('kid is unknown');
expect(reads).toHaveBeenCalledTimes(2);
vi.advanceTimersByTime(UNKNOWN_KID_REFRESH_MS - 1);
await expect(verifyToken(service, token, 'unknown-2')).rejects.toThrow('kid is unknown');
expect(reads).toHaveBeenCalledTimes(2);
});
it('shares one read between concurrent verifications', async () => {
const { service, kid, token, reads } = await setUp();
await Promise.all(
Array.from({ length: 5 }, async () => await verifyToken(service, token, kid)),
);
expect(reads).toHaveBeenCalledTimes(1);
});
});
describe('reload across processes', () => {
it('tells the other processes when it generates a key', async () => {
const { service } = createSigningKeyService();
await service.initialize();
expect(publisher.publishCommand).toHaveBeenCalledTimes(1);
expect(publisher.publishCommand).toHaveBeenCalledWith({
command: 'reload-oauth-signing-keys',
});
});
it('does not tell the other processes when it loses the insert race', async () => {
const { service, keyStore } = createSigningKeyService();
keyStore.repository.insertActiveOAuthSigningKey.mockImplementation(async () => {
keyStore.rows.push(makeKeyRow('winner'));
throw makeUniqueViolation('23505');
});
await service.initialize();
expect(publisher.publishCommand).not.toHaveBeenCalled();
});
it('logs a failed publish and still initializes', async () => {
const { service, logger } = createSigningKeyService();
const error = new Error('Connection is closed.');
vi.mocked(publisher.publishCommand).mockRejectedValue(error);
await expect(service.initialize()).resolves.toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(expect.any(String), { error });
expect(() => service.signAccessToken(claims(), AUDIENCE)).not.toThrow();
});
it('reads the rows again after a reload, within both refresh windows', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
const { service, keyStore } = createSigningKeyService();
await service.initialize();
await service.getPublicJwks();
// Another process creates a key after this process read the list.
const newKey = makeKeyRow('new-key');
keyStore.rows.push(newKey);
const token = signWithRow(newKey);
await expect(verifyToken(service, token, newKey.id)).rejects.toThrow('kid is unknown');
service.reloadPublishedKeys();
await expect(verifyToken(service, token, newKey.id)).resolves.toMatchObject({
sub: 'user-1',
});
expect(keyStore.repository.findOAuthSigningKeys).toHaveBeenCalledTimes(2);
});
it('does not keep a read that started before a reload', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
const { service, keyStore } = createSigningKeyService();
await service.initialize();
const rowsBeforeInsert = [...keyStore.rows];
const staleRead = createDeferredPromise<DeploymentKey[]>();
keyStore.repository.findOAuthSigningKeys.mockReturnValueOnce(staleRead.promise);
const inFlight = service.getPublicJwks();
// Another process creates a key and broadcasts while the read is in flight.
const newKey = makeKeyRow('new-key');
keyStore.rows.push(newKey);
service.reloadPublishedKeys();
staleRead.resolve(rowsBeforeInsert);
await inFlight;
await expect(verifyToken(service, signWithRow(newKey), newKey.id)).resolves.toMatchObject({
sub: 'user-1',
});
expect(keyStore.repository.findOAuthSigningKeys).toHaveBeenCalledTimes(2);
});
it('keeps sharing the read that started after a reload', async () => {
const { service, keyStore } = createSigningKeyService();
await service.initialize();
const reads = keyStore.repository.findOAuthSigningKeys;
const readBeforeReload = createDeferredPromise<DeploymentKey[]>();
const readAfterReload = createDeferredPromise<DeploymentKey[]>();
reads
.mockReturnValueOnce(readBeforeReload.promise)
.mockReturnValueOnce(readAfterReload.promise);
const first = service.getPublicJwks();
service.reloadPublishedKeys();
const second = service.getPublicJwks();
readBeforeReload.resolve([...keyStore.rows]);
await first;
const third = service.getPublicJwks();
readAfterReload.resolve([...keyStore.rows]);
await Promise.all([second, third]);
expect(reads).toHaveBeenCalledTimes(2);
});
});
@@ -1,6 +1,6 @@
import { InvalidTargetError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
import type { Mocked } from 'vitest';
import jwt from 'jsonwebtoken';
import jwt, { type SignOptions } from 'jsonwebtoken';
import { Logger, type LicenseState, type ModuleRegistry } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils';
import type { GlobalConfig } from '@n8n/config';
@@ -8,6 +8,8 @@ import type { OperationContext, TransactionRunner, User } from '@n8n/db';
import { UserRepository } from '@n8n/db';
import { mock, type MockProxy } from 'vitest-mock-extended';
import type { InstanceSettings } from 'n8n-core';
import type { KeyObject } from 'node:crypto';
import { createHmac, createPublicKey, generateKeyPairSync } from 'node:crypto';
import { JwtService } from '@/services/jwt.service';
@@ -15,7 +17,16 @@ import type { AccessToken } from '../database/entities/oauth-access-token.entity
import type { RefreshToken } from '../database/entities/oauth-refresh-token.entity';
import { AccessTokenRepository } from '../database/repositories/oauth-access-token.repository';
import { RefreshTokenRepository } from '../database/repositories/oauth-refresh-token.repository';
import { OAUTH_ACCESS_TOKEN_TTL_SECONDS } from '../oauth-signing-key.constants';
import { OAuthTokenService } from '../oauth-token.service';
import { JWTVerificationError } from '../oauth.errors';
import {
createSigningKeyService,
es256Signer,
forgeJwt,
readStoredPrivateKey,
rs256Signer,
} from './signing-key-fixtures';
import { McpProtectedResource } from '@/modules/mcp/mcp-protected-resource';
import type { McpConfig } from '@/modules/mcp/mcp.config';
import type { McpSettingsService } from '@/modules/mcp/mcp.settings.service';
@@ -23,8 +34,17 @@ import { ProtectedResourceRegistry } from '@/services/protected-resource.registr
import type { UrlService } from '@n8n/backend-services';
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
const LEGACY_HMAC_SECRET = 'legacy-hmac-secret';
const instanceSettings = mock<InstanceSettings>({ encryptionKey: 'test-key' });
const jwtService = new JwtService(instanceSettings, mock(), mock());
const jwtService = new JwtService(
instanceSettings,
mock<GlobalConfig>({ userManagement: { jwtSecret: LEGACY_HMAC_SECRET } }),
mock(),
);
/** Signs an access token the way n8n did before ES256: HS256 with the HMAC secret. */
const signLegacyHmac = (payload: object, audience: string, options: SignOptions = {}) =>
jwt.sign(payload, LEGACY_HMAC_SECRET, { algorithm: 'HS256', ...options, audience });
let logger: Mocked<Logger>;
let userRepository: Mocked<UserRepository>;
@@ -34,6 +54,8 @@ let urlService: MockProxy<UrlService>;
let service: OAuthTokenService;
let txRunner: MockProxy<TransactionRunner>;
const workflowFinderService = mock<WorkflowFinderService>();
const signingKeys = createSigningKeyService();
const signingKeyService = signingKeys.service;
const TEST_BASE_URL = 'https://n8n.example.com';
const TEST_RESOURCE_URL = `${TEST_BASE_URL}/mcp-server/http`;
@@ -52,7 +74,9 @@ registry.register({
});
describe('OAuthTokenService', () => {
beforeAll(() => {
beforeAll(async () => {
await signingKeyService.initialize();
logger = mockInstance(Logger);
userRepository = mockInstance(UserRepository);
accessTokenRepository = mockInstance(AccessTokenRepository) as Mocked<AccessTokenRepository>;
@@ -77,6 +101,7 @@ describe('OAuthTokenService', () => {
txRunner,
workflowFinderService,
urlService,
signingKeyService,
);
});
@@ -109,8 +134,11 @@ describe('OAuthTokenService', () => {
expect(decoded.exp).toBeDefined();
const fullToken = jwt.decode(accessToken, { complete: true });
expect(fullToken?.header.typ).toBe('at+jwt');
expect(fullToken?.header.alg).toBe('HS256');
expect(fullToken?.header).toEqual({
alg: 'ES256',
typ: 'at+jwt',
kid: signingKeys.keyStore.rows[0].id,
});
expect(refreshToken).toHaveLength(64); // 32 bytes hex = 64 characters
expect(refreshToken).toMatch(/^[a-f0-9]{64}$/);
@@ -371,6 +399,7 @@ describe('OAuthTokenService', () => {
txRunner,
workflowFinderService,
urlService,
signingKeyService,
);
});
@@ -475,7 +504,7 @@ describe('OAuthTokenService', () => {
});
it('should throw error for wrong audience', async () => {
const wrongAudienceToken = jwtService.signForResource(
const wrongAudienceToken = signLegacyHmac(
{ sub: 'user-123', client_id: 'client-456' },
'wrong-audience',
); // Matches neither legacy literal nor resource URL;
@@ -487,7 +516,7 @@ describe('OAuthTokenService', () => {
it('should accept tokens with canonical audience when expected audience is provided', async () => {
const audience = 'https://n8n.example.com/mcp-server/http';
const canonicalAudienceToken = jwtService.signForResource(
const canonicalAudienceToken = signLegacyHmac(
{ sub: 'user-123', client_id: 'client-456' },
audience,
);
@@ -512,7 +541,7 @@ describe('OAuthTokenService', () => {
it('should accept legacy audience when expected audience is provided', async () => {
const audience = 'https://n8n.example.com/mcp-server/http';
const legacyAudienceToken = jwtService.signForResource(
const legacyAudienceToken = signLegacyHmac(
{ sub: 'user-123', client_id: 'client-456' },
'mcp-server-api',
);
@@ -536,10 +565,7 @@ describe('OAuthTokenService', () => {
it('should accept token whose aud is the legacy literal (backward compat)', async () => {
const userId = 'user-123';
const clientId = 'client-456';
const legacyToken = jwtService.signForResource(
{ sub: userId, client_id: clientId },
'mcp-server-api',
);
const legacyToken = signLegacyHmac({ sub: userId, client_id: clientId }, 'mcp-server-api');
accessTokenRepository.findOne.mockResolvedValue(
mock<AccessToken>({ token: legacyToken, clientId, userId }),
);
@@ -553,10 +579,7 @@ describe('OAuthTokenService', () => {
it('should accept token whose aud is the resource URL', async () => {
const userId = 'user-123';
const clientId = 'client-456';
const urlToken = jwtService.signForResource(
{ sub: userId, client_id: clientId },
TEST_RESOURCE_URL,
);
const urlToken = signLegacyHmac({ sub: userId, client_id: clientId }, TEST_RESOURCE_URL);
accessTokenRepository.findOne.mockResolvedValue(
mock<AccessToken>({ token: urlToken, clientId, userId }),
);
@@ -792,11 +815,12 @@ describe('OAuthTokenService', () => {
txRunner,
workflowFinderService,
urlService,
signingKeyService,
);
});
it('should reject a token whose aud belongs to another resource', async () => {
const tokenForResourceA = jwtService.signForResource(
const tokenForResourceA = signLegacyHmac(
{ sub: 'user-123', client_id: 'client-456' },
RESOURCE_A_URL,
);
@@ -807,7 +831,7 @@ describe('OAuthTokenService', () => {
});
it('should not accept the legacy audience at a non-default resource', async () => {
const legacyToken = jwtService.signForResource(
const legacyToken = signLegacyHmac(
{ sub: 'user-123', client_id: 'client-456' },
LEGACY_AUDIENCE,
);
@@ -818,7 +842,7 @@ describe('OAuthTokenService', () => {
});
it('should accept a token at its own resource', async () => {
const tokenForResourceB = jwtService.signForResource(
const tokenForResourceB = signLegacyHmac(
{ sub: 'user-123', client_id: 'client-456' },
RESOURCE_B_URL,
);
@@ -832,7 +856,7 @@ describe('OAuthTokenService', () => {
});
it('should still accept the legacy audience at the default (instance MCP) resource', async () => {
const legacyToken = jwtService.signForResource(
const legacyToken = signLegacyHmac(
{ sub: 'user-123', client_id: 'client-456' },
LEGACY_AUDIENCE,
);
@@ -873,6 +897,7 @@ describe('OAuthTokenService', () => {
txRunner,
workflowFinderService,
urlService,
signingKeyService,
);
});
@@ -900,7 +925,7 @@ describe('OAuthTokenService', () => {
it('treats a token without a scope claim as having no scopes', async () => {
// cannot occur legitimately: migration 1784000000047 deleted every
// access token minted before scoping shipped
const legacyToken = jwtService.signForResource(
const legacyToken = signLegacyHmac(
{ sub: 'user-123', client_id: 'client-456' },
TEST_RESOURCE_URL,
);
@@ -993,6 +1018,7 @@ describe('OAuthTokenService', () => {
txRunner,
workflowFinderService,
urlService,
signingKeyService,
);
});
@@ -1001,10 +1027,7 @@ describe('OAuthTokenService', () => {
['the instance-base-URL-derived resource URL', TEST_RESOURCE_URL],
['the legacy audience', LEGACY_AUDIENCE],
])('should accept a token whose aud is %s', async (_, audience) => {
const token = jwtService.signForResource(
{ sub: 'user-123', client_id: 'client-456' },
audience,
);
const token = signLegacyHmac({ sub: 'user-123', client_id: 'client-456' }, audience);
accessTokenRepository.findOne.mockResolvedValue(
mock<AccessToken>({ token, clientId: 'client-456', userId: 'user-123' }),
);
@@ -1015,7 +1038,7 @@ describe('OAuthTokenService', () => {
});
it('should reject a token whose aud is an unconfigured host', async () => {
const token = jwtService.signForResource(
const token = signLegacyHmac(
{ sub: 'user-123', client_id: 'client-456' },
'https://other.example.com/mcp-server/http',
);
@@ -1025,6 +1048,237 @@ describe('OAuthTokenService', () => {
).rejects.toThrow('JWT Verification Failed');
});
});
describe('ES256 access tokens', () => {
const USER_ID = 'user-123';
const CLIENT_ID = 'client-456';
const ATTACKER_KEY = generateKeyPairSync('ec', { namedCurve: 'P-256' });
let kid: string;
let privateKey: KeyObject;
const validClaims = () => {
const now = Math.floor(Date.now() / 1000);
return {
iss: TEST_BASE_URL,
sub: USER_ID,
aud: TEST_RESOURCE_URL,
client_id: CLIENT_ID,
jti: 'jti-1',
iat: now,
exp: now + OAUTH_ACCESS_TOKEN_TTL_SECONDS,
scope: '',
meta: { isOAuth: true },
};
};
const signEs256 = (claims: Record<string, unknown>, header: Record<string, unknown> = {}) =>
forgeJwt({ alg: 'ES256', typ: 'at+jwt', kid, ...header }, claims, es256Signer(privateKey));
const hmacSigner = (secret: string | Buffer) => (signingInput: string) =>
createHmac('sha256', secret).update(signingInput).digest();
/** Signs with the HMAC secret, the way n8n minted access tokens before ES256. */
const signHmac = (options: SignOptions) => {
const { aud, ...claims } = validClaims();
return signLegacyHmac(claims, aud, options);
};
const storeToken = (token: string) => {
accessTokenRepository.findOne.mockResolvedValue(
mock<AccessToken>({ token, clientId: CLIENT_ID, userId: USER_ID }),
);
};
/** The token is in the database, so only its signature and claims can fail it. */
const expectRejected = async (token: string) => {
storeToken(token);
await expect(service.verifyAccessToken(token, TEST_RESOURCE_URL)).rejects.toThrow(
JWTVerificationError,
);
};
beforeAll(() => {
const [row] = signingKeys.keyStore.rows;
kid = row.id;
privateKey = readStoredPrivateKey(row);
});
describe('with a kid', () => {
it('verifies against the key the kid names and never against the HMAC secret', async () => {
const { accessToken } = service.generateTokenPair(USER_ID, CLIENT_ID, undefined, []);
storeToken(accessToken);
const signingVerify = vi.spyOn(signingKeyService, 'verifyAccessToken');
const hmacVerify = vi.spyOn(jwtService, 'verifyLegacyHmacAccessToken');
try {
await expect(
service.verifyAccessToken(accessToken, TEST_RESOURCE_URL),
).resolves.toMatchObject({ clientId: CLIENT_ID, extra: { userId: USER_ID } });
expect(signingVerify).toHaveBeenCalledWith(
accessToken,
expect.objectContaining({ kid, issuer: TEST_BASE_URL }),
);
expect(hmacVerify).not.toHaveBeenCalled();
} finally {
signingVerify.mockRestore();
hmacVerify.mockRestore();
}
});
it.each<[string, Record<string, unknown>, Record<string, unknown>]>([
['a wrong aud', { aud: 'https://other.example.com/mcp' }, {}],
['a wrong iss', { iss: 'https://attacker.example.com' }, {}],
['a wrong typ', {}, { typ: 'JWT' }],
['an expired exp', { exp: 1 }, {}],
])('rejects a token with %s', async (_, claimOverrides, headerOverrides) => {
await expectRejected(signEs256({ ...validClaims(), ...claimOverrides }, headerOverrides));
});
});
describe('algorithm confusion', () => {
it('rejects HS256 signed with the public key PEM as the secret', async () => {
const pem = createPublicKey(privateKey).export({ type: 'spki', format: 'pem' });
await expectRejected(
forgeJwt({ alg: 'HS256', typ: 'at+jwt', kid }, validClaims(), hmacSigner(pem)),
);
});
it('rejects HS256 signed with the public JWK JSON as the secret', async () => {
const [publicJwk] = await signingKeyService.getPublicJwks();
await expectRejected(
forgeJwt(
{ alg: 'HS256', typ: 'at+jwt', kid },
validClaims(),
hmacSigner(JSON.stringify(publicJwk)),
),
);
});
it('rejects a valid HMAC token with a kid added', async () => {
const token = signHmac({
keyid: kid,
header: { alg: 'HS256', typ: 'at+jwt' },
});
await expectRejected(token);
});
it('rejects alg none', async () => {
await expectRejected(forgeJwt({ alg: 'none', typ: 'at+jwt', kid }, validClaims(), null));
});
it('rejects ES384 in the header over a signature from the real key', async () => {
await expectRejected(
forgeJwt({ alg: 'ES384', typ: 'at+jwt', kid }, validClaims(), es256Signer(privateKey)),
);
});
it('rejects RS256 signed with an RSA key under the real kid', async () => {
const { privateKey: rsaKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
await expectRejected(
forgeJwt({ alg: 'RS256', typ: 'at+jwt', kid }, validClaims(), rs256Signer(rsaKey)),
);
});
it('rejects another P-256 key under the real kid', async () => {
await expectRejected(
forgeJwt(
{ alg: 'ES256', typ: 'at+jwt', kid },
validClaims(),
es256Signer(ATTACKER_KEY.privateKey),
),
);
});
it('rejects a P-384 key under the real kid', async () => {
const { privateKey: p384Key } = generateKeyPairSync('ec', { namedCurve: 'P-384' });
await expectRejected(
forgeJwt({ alg: 'ES256', typ: 'at+jwt', kid }, validClaims(), es256Signer(p384Key)),
);
});
it('rejects an all-zero signature', async () => {
await expectRejected(
forgeJwt({ alg: 'ES256', typ: 'at+jwt', kid }, validClaims(), () => Buffer.alloc(64)),
);
});
it.each<[string, unknown]>([
['an unknown kid', 'unknown-kid'],
['an empty kid', ''],
['a numeric kid', 123],
['an object kid', { id: 'x' }],
])('rejects %s', async (_, badKid) => {
await expectRejected(signEs256(validClaims(), { kid: badKid }));
});
it('ignores an embedded jwk header', async () => {
const jwk = ATTACKER_KEY.publicKey.export({ format: 'jwk' });
await expectRejected(
forgeJwt(
{ alg: 'ES256', typ: 'at+jwt', kid, jwk },
validClaims(),
es256Signer(ATTACKER_KEY.privateKey),
),
);
});
it('ignores a jku header', async () => {
await expectRejected(
forgeJwt(
{ alg: 'ES256', typ: 'at+jwt', kid, jku: 'https://attacker.example.com/jwks.json' },
validClaims(),
es256Signer(ATTACKER_KEY.privateKey),
),
);
});
});
describe('without a kid', () => {
it('rejects an ES256 token whose kid was removed', async () => {
await expectRejected(
forgeJwt({ alg: 'ES256', typ: 'at+jwt' }, validClaims(), es256Signer(privateKey)),
);
});
it('accepts a legacy HS256 token until it expires', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
try {
// Minted the way n8n did before ES256.
const accessToken = signHmac({
header: { typ: 'at+jwt', alg: 'HS256' },
});
storeToken(accessToken);
await expect(
service.verifyAccessToken(accessToken, TEST_RESOURCE_URL),
).resolves.toMatchObject({ clientId: CLIENT_ID });
vi.advanceTimersByTime((OAUTH_ACCESS_TOKEN_TTL_SECONDS + 1) * 1000);
await expect(service.verifyAccessToken(accessToken, TEST_RESOURCE_URL)).rejects.toThrow(
JWTVerificationError,
);
} finally {
vi.useRealTimers();
}
});
it('rejects HS512', async () => {
const token = signHmac({
algorithm: 'HS512',
header: { alg: 'HS512', typ: 'at+jwt' },
});
await expectRejected(token);
});
});
});
});
import type { PostHogClient } from '@/posthog';
@@ -1,7 +1,8 @@
import type { Logger } from '@n8n/backend-common';
import type { CacheService } from '@n8n/backend-services';
import type { DeploymentKey, DeploymentKeyRepository } from '@n8n/db';
import type { Cipher, InstanceSettings } from 'n8n-core';
import type { JsonWebKey, KeyObject } from 'node:crypto';
import { createPrivateKey, sign as cryptoSign } from 'node:crypto';
import { mock } from 'vitest-mock-extended';
import { JwtService } from '@/services/jwt.service';
@@ -21,22 +22,6 @@ export const createTaggingCipher = () => {
return cipher;
};
/** A cache backed by a map, with the `refreshFn` semantics of `CacheService.get`. */
export const createMapCache = () => {
const store = new Map<string, unknown>();
const cache = mock<CacheService>();
cache.get.mockImplementation(async (key, options) => {
if (store.has(key)) return store.get(key);
const value = await options?.refreshFn?.(key);
if (value !== undefined) store.set(key, value);
return value;
});
cache.delete.mockImplementation(async (key) => {
store.delete(key);
});
return { cache, store };
};
/** `deployment_key` rows in memory, behind the repository methods the service calls. */
export const createDeploymentKeyStore = () => {
const rows: DeploymentKey[] = [];
@@ -72,18 +57,41 @@ export const createDeploymentKeyStore = () => {
export const createSigningKeyService = () => {
const keyStore = createDeploymentKeyStore();
const cipher = createTaggingCipher();
const { cache, store: cacheStore } = createMapCache();
const jwtService = new JwtService(
mock<InstanceSettings>({ encryptionKey: 'test-key' }),
mock(),
mock(),
);
const service = new OAuthSigningKeyService(
keyStore.repository,
cipher,
cache,
mock<Logger>(),
jwtService,
);
return { service, keyStore, cipher, cache, cacheStore };
const logger = mock<Logger>();
const service = new OAuthSigningKeyService(keyStore.repository, cipher, logger, jwtService);
return { service, keyStore, cipher, logger };
};
/** Reads a stored private key back, e.g. to sign tokens the service would not sign. */
export const readStoredPrivateKey = (row: DeploymentKey): KeyObject => {
const jwk = JSON.parse(row.value.slice(WRAP_PREFIX.length)) as JsonWebKey;
return createPrivateKey({ key: jwk, format: 'jwk' });
};
const base64url = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url');
/**
* Builds a compact JWS by hand, so a test can set any header and signature
* that a JWT library would refuse to produce.
*/
export const forgeJwt = (
header: Record<string, unknown>,
payload: Record<string, unknown>,
signWith: ((signingInput: string) => Buffer) | null,
): string => {
const signingInput = `${base64url(header)}.${base64url(payload)}`;
const signature = signWith ? signWith(signingInput).toString('base64url') : '';
return `${signingInput}.${signature}`;
};
export const rs256Signer = (privateKey: KeyObject) => (signingInput: string) =>
cryptoSign('sha256', Buffer.from(signingInput), privateKey);
/** JWS carries an ECDSA signature as raw `r || s`, not DER (RFC 7518 §3.4). */
export const es256Signer = (privateKey: KeyObject) => (signingInput: string) =>
cryptoSign('sha256', Buffer.from(signingInput), { key: privateKey, dsaEncoding: 'ieee-p1363' });
@@ -28,8 +28,8 @@ export class OAuthServerModule implements ModuleInterface {
}
// Main and webhook processes mint access tokens (Form and Chat flows mint
// on webhook processes), so they load the private signing key. Workers never
// mint.
// on webhook processes), so they load the private signing key. Workers only
// verify, and load the public keys on first use.
if (instanceType === 'main' || instanceType === 'webhook') {
const { OAuthSigningKeyService } = await import('./oauth-signing-key.service.js');
await Container.get(OAuthSigningKeyService).initialize();
@@ -7,11 +7,17 @@ export const OAUTH_SIGNING_ALGORITHM = 'ES256';
export const OAUTH_SIGNING_KEY_USE = 'sig';
export const OAUTH_SIGNING_CURVE = 'P-256';
/** How long a process uses its in-memory list of public keys before it reads the list again. */
export const SIGNING_KEYS_REFRESH_MS = 5 * Time.minutes.toMilliseconds;
/**
* Public signing keys only. The private key stays in process memory.
* Delete this entry whenever a signing key is added or changes status.
* An unknown `kid` reads the list again only when the list is older than
* this, so tokens with made-up kids cannot load the database.
*/
export const OAUTH_SIGNING_KEYS_CACHE_KEY = 'oauth-server:signing-keys';
export const UNKNOWN_KID_REFRESH_MS = 30 * Time.seconds.toMilliseconds;
/** RFC 9068 §2.1 `typ` values. */
export const ACCESS_TOKEN_TYPES = ['at+jwt', 'application/at+jwt'] as const;
export const OAUTH_ACCESS_TOKEN_TTL_SECONDS = 1 * Time.hours.toSeconds;
@@ -1,42 +1,59 @@
import { Logger } from '@n8n/backend-common';
import { CacheService } from '@n8n/backend-services';
import { DeploymentKeyRepository, isUniqueConstraintError } from '@n8n/db';
import type { DeploymentKey } from '@n8n/db';
import { Service } from '@n8n/di';
import { OnPubSubEvent } from '@n8n/decorators';
import { Container, Service } from '@n8n/di';
import { generateNanoId } from '@n8n/utils/generate-nano-id';
import type { JWK } from 'jose';
import { JsonWebTokenError } from 'jsonwebtoken';
import { Cipher } from 'n8n-core';
import { jsonParse, UnexpectedError } from 'n8n-workflow';
import type { JsonWebKey, KeyObject } from 'node:crypto';
import { createPrivateKey, generateKeyPair } from 'node:crypto';
import { createPrivateKey, createPublicKey, generateKeyPair } from 'node:crypto';
import { promisify } from 'node:util';
import { JwtService } from '@/services/jwt.service';
import { JwtService, type JwtPayload } from '@/services/jwt.service';
import {
ACCESS_TOKEN_TYPES,
OAUTH_SIGNING_ALGORITHM,
OAUTH_SIGNING_CURVE,
OAUTH_SIGNING_KEY_USE,
OAUTH_SIGNING_KEYS_CACHE_KEY,
RETIRED_SIGNING_KEY_GRACE_MS,
SIGNING_KEYS_REFRESH_MS,
UNKNOWN_KID_REFRESH_MS,
} from './oauth-signing-key.constants';
const generateKeyPairAsync = promisify(generateKeyPair);
/** A signing key as the shared cache holds it. It never holds the private key. */
/** A signing key as process memory holds it. It never holds the private key. */
type PublishedSigningKey = {
kid: string;
publicJwk: JWK;
publicKey: KeyObject;
active: boolean;
/** Epoch ms of the last row update. For an inactive key, this is when it was retired. */
updatedAt: number;
};
type PublishedKeyList = {
keys: PublishedSigningKey[];
/** Epoch ms of the database read. */
readAt: number;
};
type ActiveSigningKey = {
kid: string;
privateKey: KeyObject;
};
type VerifyAccessTokenOptions = {
/** The `kid` header value. Its type is not checked yet. */
kid: unknown;
audiences: string[];
issuer: string;
};
/** The only JWK members safe to publish for an EC signing key. */
const PUBLIC_JWK_FIELDS = ['kty', 'crv', 'x', 'y', 'kid', 'alg', 'use'] as const;
@@ -47,17 +64,25 @@ const PUBLIC_JWK_FIELDS = ['kty', 'crv', 'x', 'y', 'kid', 'alg', 'use'] as const
* `deployment_key.id` are the same nanoid, as for the JWE keys.
*
* Signing is synchronous: {@link initialize} loads the private key into
* memory once. The public keys are read through the shared cache, so any
* process can publish them without the private key.
* memory once. Each process also keeps the public keys in memory, so
* verification does not call Redis or the database for each token. Workers
* read the public keys on first use and never keep a private key. A process
* that creates a key tells the others to drop their list.
*/
@Service()
export class OAuthSigningKeyService {
private activeKey: ActiveSigningKey | null = null;
private publishedKeys: PublishedKeyList | null = null;
private pendingRead: Promise<PublishedKeyList> | null = null;
/** Goes up on each reload, so a read that started before it is not kept. */
private keysGeneration = 0;
constructor(
private readonly deploymentKeyRepository: DeploymentKeyRepository,
private readonly cipher: Cipher,
private readonly cacheService: CacheService,
private readonly logger: Logger,
private readonly jwtService: JwtService,
) {}
@@ -93,7 +118,7 @@ export class OAuthSigningKeyService {
}
const { kid, privateKey } = this.activeKey;
return this.jwtService.signForResourceWithKey(payload, audience, privateKey, {
return this.jwtService.signForResource(payload, audience, privateKey, {
algorithm: OAUTH_SIGNING_ALGORITHM,
header: { alg: OAUTH_SIGNING_ALGORITHM, typ: 'at+jwt', kid },
});
@@ -102,37 +127,112 @@ export class OAuthSigningKeyService {
/**
* Public JWKs of the active key and of the keys retired within
* {@link RETIRED_SIGNING_KEY_GRACE_MS}. The window is checked on every
* call, so a cached list does not keep a retired key alive.
* call, so the in-memory list does not keep a retired key alive.
*/
async getPublicJwks(): Promise<JWK[]> {
const keys = await this.loadPublishedKeys();
return keys.map((key) => key.publicJwk);
}
private async loadPublishedKeys(): Promise<PublishedSigningKey[]> {
const keys = await this.cacheService.get<PublishedSigningKey[]>(OAUTH_SIGNING_KEYS_CACHE_KEY, {
refreshFn: async () => await this.readPublishedKeys(),
});
/**
* Verifies a token that carries a `kid` header. The key comes from this
* service's own list, and the algorithm comes from the key, never from
* the token header. There is no fallback to the HMAC secret.
*/
async verifyAccessToken(
token: string,
{ kid, audiences, issuer }: VerifyAccessTokenOptions,
): Promise<JwtPayload> {
if (typeof kid !== 'string' || kid.length === 0) {
throw new JsonWebTokenError('kid is invalid');
}
const key = await this.findPublishedKey(kid);
if (!key) {
throw new JsonWebTokenError('kid is unknown');
}
const verified = this.jwtService.verifyForResource(
token,
audiences as [string, ...string[]],
key.publicKey,
{ algorithms: [OAUTH_SIGNING_ALGORITHM], issuer },
);
const { typ } = verified.header;
if (typeof typ !== 'string' || !ACCESS_TOKEN_TYPES.some((t) => t === typ.toLowerCase())) {
throw new JsonWebTokenError('typ is invalid');
}
const { payload } = verified;
if (typeof payload !== 'object' || typeof payload.exp !== 'number') {
throw new JsonWebTokenError('exp is missing');
}
return payload;
}
/**
* A kid that is not in the list reads the list again, because another
* process may have created the key since this process read it.
*/
private async findPublishedKey(kid: string): Promise<PublishedSigningKey | undefined> {
const keys = await this.loadPublishedKeys(SIGNING_KEYS_REFRESH_MS);
const key = keys.find((k) => k.kid === kid);
if (key) return key;
const refreshed = await this.loadPublishedKeys(UNKNOWN_KID_REFRESH_MS);
return refreshed.find((k) => k.kid === kid);
}
/** Reads the list again only when it is older than `maxAgeMs`. */
private async loadPublishedKeys(
maxAgeMs = SIGNING_KEYS_REFRESH_MS,
): Promise<PublishedSigningKey[]> {
let list = this.publishedKeys;
if (!list || Date.now() - list.readAt >= maxAgeMs) {
list = await this.readPublishedKeys();
}
const now = Date.now();
return (keys ?? []).filter(
return list.keys.filter(
(key) => key.active || now - key.updatedAt <= RETIRED_SIGNING_KEY_GRACE_MS,
);
}
/** Returns `undefined` when there are no keys, so an empty list is never cached. */
private async readPublishedKeys(): Promise<PublishedSigningKey[] | undefined> {
/** Concurrent callers share one database read. */
private async readPublishedKeys(): Promise<PublishedKeyList> {
if (!this.pendingRead) {
const generation = this.keysGeneration;
this.pendingRead = this.readPublishedKeyRows()
.then((list) => {
if (generation === this.keysGeneration) this.publishedKeys = list;
return list;
})
.finally(() => {
if (generation === this.keysGeneration) this.pendingRead = null;
});
}
return await this.pendingRead;
}
private async readPublishedKeyRows(): Promise<PublishedKeyList> {
const rows = await this.deploymentKeyRepository.findOAuthSigningKeys();
const keys = rows
.filter((row) => row.algorithm === OAUTH_SIGNING_ALGORITHM)
.map((row) => ({
kid: row.id,
publicJwk: toPublicJwk(this.readPrivateJwk(row)),
active: row.status === 'active',
updatedAt: new Date(row.updatedAt).getTime(),
}));
.map((row) => {
const publicJwk = toPublicJwk(this.readPrivateJwk(row));
const { kty, crv, x, y } = publicJwk;
return {
kid: row.id,
publicJwk,
publicKey: createPublicKey({ key: { kty, crv, x, y }, format: 'jwk' }),
active: row.status === 'active',
updatedAt: new Date(row.updatedAt).getTime(),
};
});
return keys.length > 0 ? keys : undefined;
return { keys, readAt: Date.now() };
}
private readPrivateJwk(row: DeploymentKey): JsonWebKey {
@@ -170,15 +270,50 @@ export class OAuthSigningKeyService {
encryptedPrivate,
OAUTH_SIGNING_ALGORITHM,
);
// Another process may hold a list read before this key existed.
await this.cacheService.delete(OAUTH_SIGNING_KEYS_CACHE_KEY);
this.logger.info('Generated new OAuth access-token signing key', { kid: id });
} catch (error) {
if (!isUniqueConstraintError(error)) throw error;
// The winner already told the other processes.
this.logger.debug('OAuth signing key insert raced with another process; re-reading winner');
return;
}
this.logger.info('Generated new OAuth access-token signing key', { kid: id });
this.reloadPublishedKeys();
await this.broadcastReloadSigningKeysCommand();
}
/**
* Not limited to multi-main: workers and webhook processes verify tokens
* too. Outside queue mode the publisher does nothing.
*/
private async broadcastReloadSigningKeysCommand(): Promise<void> {
try {
const { Publisher } = await import('@/scaling/pubsub/publisher.service.js');
await Container.get(Publisher).publishCommand({ command: 'reload-oauth-signing-keys' });
} catch (error) {
// The key is already stored. The other processes read it when their list expires.
this.logger.warn('Failed to tell other processes about the new OAuth signing key', {
error,
});
}
}
/**
* Drops the list. The next call reads it from the database.
*
* This does not reload the private key, which {@link initialize} loads
* once. That is safe only while a key is created only when no key is
* active. Key rotation must also reload the private key here. If it does
* not, a process signs with the retired key, and its tokens fail after
* {@link RETIRED_SIGNING_KEY_GRACE_MS}.
*/
@OnPubSubEvent('reload-oauth-signing-keys')
reloadPublishedKeys(): void {
this.keysGeneration++;
this.publishedKeys = null;
this.pendingRead = null;
}
}
@@ -9,6 +9,7 @@ import { Time } from '@n8n/constants';
import { TransactionRunner, User, UserRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { ensureError } from '@n8n/utils/errors/ensure-error';
import { JsonWebTokenError } from 'jsonwebtoken';
import type { OAuthResourceGrant } from 'n8n-workflow';
import { UnexpectedError } from 'n8n-workflow';
import { randomBytes, randomUUID } from 'node:crypto';
@@ -26,6 +27,7 @@ import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { AccessTokenRepository } from './database/repositories/oauth-access-token.repository';
import { RefreshTokenRepository } from './database/repositories/oauth-refresh-token.repository';
import { OAUTH_ACCESS_TOKEN_TTL_SECONDS } from './oauth-signing-key.constants';
import { OAuthSigningKeyService } from './oauth-signing-key.service';
import { AccessTokenNotFoundError, JWTVerificationError } from './oauth.errors';
import { authorizeAgainstGrant } from './resource-gate';
import { isSameProtectedResource } from './resource-identity';
@@ -53,6 +55,7 @@ export class OAuthTokenService implements OAuthTokenVerifier {
private readonly txRunner: TransactionRunner,
private readonly workflowFinderService: WorkflowFinderService,
private readonly urlService: UrlService,
private readonly signingKeyService: OAuthSigningKeyService,
) {}
getAccessTokenExpirySeconds(): number {
@@ -79,7 +82,7 @@ export class OAuthTokenService implements OAuthTokenVerifier {
);
}
const accessToken = this.jwtService.signForResource(
const accessToken = this.signingKeyService.signAccessToken(
{
iss: this.urlService.getInstanceBaseUrl(),
sub: userId,
@@ -96,12 +99,6 @@ export class OAuthTokenService implements OAuthTokenVerifier {
},
},
audience,
{
header: {
typ: 'at+jwt',
alg: 'HS256',
},
},
);
const refreshToken = randomBytes(32).toString('hex');
@@ -242,8 +239,11 @@ export class OAuthTokenService implements OAuthTokenVerifier {
let decoded: unknown;
try {
decoded = this.verifyJwtWithAllowedAudiences(token, allowedAudiences);
decoded = await this.verifyJwt(token, allowedAudiences);
} catch (error) {
this.logger.debug('OAuth access token failed verification', {
error: ensureError(error).message,
});
throw new JWTVerificationError();
}
@@ -475,16 +475,39 @@ export class OAuthTokenService implements OAuthTokenVerifier {
return await this.resourceRegistry.getByResourceUrl(expectedAudience);
}
// TODO: drop legacy audiences and the per-audience fallback once all legacy
// tokens minted before n8n v2.19 have aged out (refresh-token lifespan).
private verifyJwtWithAllowedAudiences(token: string, audiences: string[]): unknown {
/**
* A token with a `kid` header is ES256 and verifies against the signing
* keys only. A token without one is a legacy HMAC token. The `alg` header
* never picks the path or the key.
*/
private async verifyJwt(token: string, audiences: string[]): Promise<unknown> {
const decoded = this.jwtService.decodeUnverifiedWithHeader(token);
if (!decoded) {
throw new JsonWebTokenError('jwt malformed');
}
if (Object.hasOwn(decoded.header, 'kid')) {
return await this.signingKeyService.verifyAccessToken(token, {
kid: decoded.header.kid,
audiences,
issuer: this.urlService.getInstanceBaseUrl(),
});
}
return this.verifyLegacyHmacJwt(token, audiences);
}
// TODO: remove once every process mints ES256 and the last HS256 tokens
// have expired. Drops the legacy audiences and the per-audience fallback
// for tokens minted before n8n v2.19 at the same time.
private verifyLegacyHmacJwt(token: string, audiences: string[]): unknown {
try {
return this.jwtService.verifyForResource(token, audiences as [string, ...string[]]);
return this.jwtService.verifyLegacyHmacAccessToken(token, audiences as [string, ...string[]]);
} catch (error) {
// Some jsonwebtoken builds reject the array form for tokens signed with a single-string aud.
for (const audience of audiences) {
try {
return this.jwtService.verifyForResource(token, audience);
return this.jwtService.verifyLegacyHmacAccessToken(token, audience);
} catch {
continue;
}
@@ -41,6 +41,10 @@ export type PubSubCommandMap = {
// #endregion
// # region OAuth server
'reload-oauth-signing-keys': never;
// #endregion
'reload-source-control-config': never;
'reload-mcp-registry': never;
@@ -42,6 +42,7 @@ export namespace PubSub {
export type ReloadLicense = ToCommand<'reload-license'>;
export type ReloadOIDCConfiguration = ToCommand<'reload-oidc-config'>;
export type ReloadSamlConfiguration = ToCommand<'reload-saml-config'>;
export type ReloadOAuthSigningKeys = ToCommand<'reload-oauth-signing-keys'>;
export type ReloadCredentialsOverwrites = ToCommand<'reload-overwrite-credentials'>;
export type RestartEventBus = ToCommand<'restart-event-bus'>;
export type ReloadExternalSecretsProviders = ToCommand<'reload-external-secrets-providers'>;
@@ -128,6 +129,7 @@ export namespace PubSub {
| Commands.ClearTestWebhooks
| Commands.ReloadOIDCConfiguration
| Commands.ReloadSamlConfiguration
| Commands.ReloadOAuthSigningKeys
| Commands.ReloadCredentialsOverwrites
| Commands.ReloadSsoProvisioningConfiguration
| Commands.ReloadSourceControlConfiguration
@@ -2,6 +2,7 @@ import type { Logger } from '@n8n/backend-common';
import type { GlobalConfig } from '@n8n/config';
import jwt from 'jsonwebtoken';
import type { InstanceSettings } from 'n8n-core';
import { generateKeyPairSync } from 'node:crypto';
import { mock } from 'vitest-mock-extended';
import { JwtService } from '@/services/jwt.service';
@@ -86,12 +87,17 @@ describe('JwtService', () => {
it('should bind a resource token to the audience it is given', () => {
const resource = 'https://n8n.example.com/mcp-server/http';
const token = jwtService.signForResource(payload, resource);
const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
const token = jwtService.signForResource(payload, resource, privateKey, {
algorithm: 'ES256',
});
expect(jwtService.verifyForResource(token, resource)).toMatchObject({ sub: 1 });
expect(() => jwtService.verifyForResource(token, 'https://other.example.com')).toThrow(
jwt.JsonWebTokenError,
);
expect(jwtService.verifyForResource(token, resource, publicKey).payload).toMatchObject({
sub: 1,
});
expect(() =>
jwtService.verifyForResource(token, 'https://other.example.com', publicKey),
).toThrow(jwt.JsonWebTokenError);
});
describe('tokens minted before audience binding', () => {
+33 -13
View File
@@ -78,17 +78,10 @@ export class JwtService {
/**
* Signs a token bound to a protected resource rather than to a fixed
* purpose. Only OAuth access tokens need this: their audience is the
* requested resource indicator (RFC 8707), known at runtime.
* requested resource indicator (RFC 8707), known at runtime. The caller
* owns the asymmetric key, such as the OAuth access-token signing key.
*/
signForResource(payload: object, audience: string, options: PurposedSignOptions = {}): string {
return jwt.sign(payload, this.jwtSecret, { ...options, audience });
}
/**
* Like {@link signForResource}, but signs with an asymmetric key that the
* caller owns, such as the OAuth access-token signing key.
*/
signForResourceWithKey(
signForResource(
payload: object,
audience: string,
privateKey: KeyObject,
@@ -105,6 +98,14 @@ export class JwtService {
return jwt.decode(token) as T;
}
/**
* Like {@link decodeUnverified}, but also returns the header, e.g. to route
* a token by its `kid`. The same rule applies: never make a decision on it.
*/
decodeUnverifiedWithHeader(token: string) {
return jwt.decode(token, { complete: true });
}
/** Verifies a token and requires it to carry the audience of `purpose`. */
verify<T = JwtPayload>(
purpose: TokenPurpose,
@@ -121,13 +122,32 @@ export class JwtService {
}
}
/** Verifies an OAuth access token against the resource(s) it may be used for. */
verifyForResource<T = JwtPayload>(
/**
* Verifies an OAuth access token against the resource(s) it may be used
* for, with the public key of the key that signed it. Returns the header
* and the payload.
*/
verifyForResource(
token: string,
audience: string | [string, ...string[]],
publicKey: KeyObject,
options: PurposedVerifyOptions = {},
) {
return jwt.verify(token, this.jwtSecret, { ...options, audience }) as T;
return jwt.verify(token, publicKey, { ...options, audience, complete: true });
}
/**
* Verifies an OAuth access token that n8n signed with the HMAC secret,
* before it signed access tokens with ES256. Accepts HS256 only.
*
* DEPRECATED: remove with `OAuthTokenService.verifyLegacyHmacJwt`, after the
* last HS256 access tokens have expired.
*/
verifyLegacyHmacAccessToken<T = JwtPayload>(
token: string,
audience: string | [string, ...string[]],
) {
return jwt.verify(token, this.jwtSecret, { audience, algorithms: ['HS256'] }) as T;
}
/**