chore(core): Implements JWKS service to provide key pair for signing tokens (no-changelog) (#39718)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Martin 2026-10-05 12:22:32 +00:00 committed by GitHub
parent 7779f47a7d
commit 073239e6d5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 921 additions and 8 deletions

View File

@ -31,6 +31,7 @@
| IDX_deployment_key_data_encryption_active | CREATE UNIQUE INDEX "IDX_deployment_key_data_encryption_active" ON public.deployment_key USING btree (type) WHERE (((status)::text = 'active'::text) AND ((type)::text = 'data_encryption'::text)) |
| IDX_deployment_key_instance_id_active | CREATE UNIQUE INDEX "IDX_deployment_key_instance_id_active" ON public.deployment_key USING btree (type) WHERE (((status)::text = 'active'::text) AND ((type)::text = 'instance.id'::text)) |
| IDX_deployment_key_jwe_private_key_active | CREATE UNIQUE INDEX "IDX_deployment_key_jwe_private_key_active" ON public.deployment_key USING btree (type, algorithm) WHERE (((status)::text = 'active'::text) AND ((type)::text = 'jwe.private-key'::text)) |
| IDX_deployment_key_oauth_signing_key_active | CREATE UNIQUE INDEX "IDX_deployment_key_oauth_signing_key_active" ON public.deployment_key USING btree (type, algorithm) WHERE (((status)::text = 'active'::text) AND ((type)::text = 'oauth-server.signing-key'::text)) |
| IDX_deployment_key_signing_binary_data_active | CREATE UNIQUE INDEX "IDX_deployment_key_signing_binary_data_active" ON public.deployment_key USING btree (type) WHERE (((status)::text = 'active'::text) AND ((type)::text = 'signing.binary_data'::text)) |
| IDX_deployment_key_signing_hmac_active | CREATE UNIQUE INDEX "IDX_deployment_key_signing_hmac_active" ON public.deployment_key USING btree (type) WHERE (((status)::text = 'active'::text) AND ((type)::text = 'signing.hmac'::text)) |
| IDX_deployment_key_signing_jwt_active | CREATE UNIQUE INDEX "IDX_deployment_key_signing_jwt_active" ON public.deployment_key USING btree (type) WHERE (((status)::text = 'active'::text) AND ((type)::text = 'signing.jwt'::text)) |

View File

@ -37,6 +37,7 @@ CREATE TABLE "deployment_key" ("id" varchar(36) PRIMARY KEY NOT NULL, "type" var
| IDX_deployment_key_data_encryption_active | CREATE UNIQUE INDEX "IDX_deployment_key_data_encryption_active" ON "deployment_key" ("type") WHERE status = 'active' AND type = 'data_encryption' |
| IDX_deployment_key_instance_id_active | CREATE UNIQUE INDEX "IDX_deployment_key_instance_id_active" ON "deployment_key" ("type") WHERE status = 'active' AND type = 'instance.id' |
| IDX_deployment_key_jwe_private_key_active | CREATE UNIQUE INDEX "IDX_deployment_key_jwe_private_key_active" ON "deployment_key" ("type", "algorithm") WHERE status = 'active' AND type = 'jwe.private-key' |
| IDX_deployment_key_oauth_signing_key_active | CREATE UNIQUE INDEX "IDX_deployment_key_oauth_signing_key_active" ON "deployment_key" ("type", "algorithm") WHERE "status" = 'active' AND "type" = 'oauth-server.signing-key' |
| IDX_deployment_key_signing_binary_data_active | CREATE UNIQUE INDEX "IDX_deployment_key_signing_binary_data_active" ON "deployment_key" ("type") WHERE status = 'active' AND type = 'signing.binary_data' |
| IDX_deployment_key_signing_hmac_active | CREATE UNIQUE INDEX "IDX_deployment_key_signing_hmac_active" ON "deployment_key" ("type") WHERE status = 'active' AND type = 'signing.hmac' |
| IDX_deployment_key_signing_jwt_active | CREATE UNIQUE INDEX "IDX_deployment_key_signing_jwt_active" ON "deployment_key" ("type") WHERE status = 'active' AND type = 'signing.jwt' |

View File

@ -4,6 +4,12 @@ import { WithTimestampsAndStringId } from './abstract-entity';
export const OAUTH_JWE_PRIVATE_KEY_TYPE = 'jwe.private-key';
/**
* Private keys that sign OAuth access tokens. Not a `signing.*` name: that
* prefix holds HMAC secrets, which have their own unique index.
*/
export const OAUTH_SIGNING_KEY_TYPE = 'oauth-server.signing-key';
@Entity()
export class DeploymentKey extends WithTimestampsAndStringId {
@Column({ type: 'varchar', length: 64 })

View File

@ -23,7 +23,11 @@ import {
type CredentialDependencyType,
} from './credential-dependency-entity';
import { CredentialsEntity, type CredentialUsageScope } from './credentials-entity';
import { DeploymentKey, OAUTH_JWE_PRIVATE_KEY_TYPE } from './deployment-key';
import {
DeploymentKey,
OAUTH_JWE_PRIVATE_KEY_TYPE,
OAUTH_SIGNING_KEY_TYPE,
} from './deployment-key';
import { EvaluationCollection } from './evaluation-collection.ee';
import { EvaluationConfig } from './evaluation-config.ee';
import { ExecutionAnnotation } from './execution-annotation.ee';
@ -142,6 +146,7 @@ export {
type CredentialDependencyType,
DeploymentKey,
OAUTH_JWE_PRIVATE_KEY_TYPE,
OAUTH_SIGNING_KEY_TYPE,
EvaluationCollection,
EvaluationConfig,
Folder,

View File

@ -0,0 +1,28 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const indexName = (tablePrefix: string) =>
`IDX_${tablePrefix}deployment_key_oauth_signing_key_active`;
export class AddOAuthSigningKeyIndexToDeploymentKey1791199417265 implements ReversibleMigration {
async up({ schemaBuilder: { createIndex }, escape, tablePrefix }: MigrationContext) {
// At most one active OAuth access-token signing key per algorithm, so
// mains and webhook processes that boot at the same time cannot both
// insert one. The JWE index does not cover this type.
const status = escape.columnName('status');
const type = escape.columnName('type');
await createIndex(
'deployment_key',
['type', 'algorithm'],
true,
indexName(tablePrefix),
`${status} = 'active' AND ${type} = 'oauth-server.signing-key'`,
);
}
async down({ schemaBuilder: { dropIndex }, tablePrefix }: MigrationContext) {
await dropIndex('deployment_key', ['type', 'algorithm'], {
customIndexName: indexName(tablePrefix),
skipIfMissing: true,
});
}
}

View File

@ -4,7 +4,11 @@ import { Cipher } from 'n8n-core';
import { UnexpectedError } from 'n8n-workflow';
import { BaseRepository } from './base-repository';
import { DeploymentKey, OAUTH_JWE_PRIVATE_KEY_TYPE } from '../entities/deployment-key';
import {
DeploymentKey,
OAUTH_JWE_PRIVATE_KEY_TYPE,
OAUTH_SIGNING_KEY_TYPE,
} from '../entities/deployment-key';
import { DbLock, DbLockService } from '../services/db-lock.service';
import type { OperationContext } from '../services/transaction';
import { TransactionRunner } from '../services/transaction';
@ -297,4 +301,29 @@ export class DeploymentKeyRepository {
status: 'active',
});
}
async findActiveOAuthSigningKey(algorithm: string): Promise<DeploymentKey | null> {
return await this.store.findOne({
where: { type: OAUTH_SIGNING_KEY_TYPE, algorithm, status: 'active' },
});
}
/** Every OAuth signing key, active and inactive, so retired keys stay verifiable. */
async findOAuthSigningKeys(): Promise<DeploymentKey[]> {
return await this.store.find({ where: { type: OAUTH_SIGNING_KEY_TYPE } });
}
/**
* Throws a unique-constraint error when another process inserted the
* active key for this algorithm first. The caller re-reads the winner.
*/
async insertActiveOAuthSigningKey(id: string, value: string, algorithm: string): Promise<void> {
await this.store.insert({
id,
type: OAUTH_SIGNING_KEY_TYPE,
value,
algorithm,
status: 'active',
});
}
}

View File

@ -0,0 +1,74 @@
import { Logger } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils';
import { Container } from '@n8n/di';
import type { JWK } from 'jose';
import { OAuthSigningJwksProvider } from '../oauth-signing-jwks.provider';
import { OAuthSigningKeyService } from '../oauth-signing-key.service';
const validSigningJwk: JWK = {
kty: 'EC',
kid: 'row-sig',
use: 'sig',
alg: 'ES256',
crv: 'P-256',
x: 'x-coordinate',
y: 'y-coordinate',
};
describe('OAuthSigningJwksProvider', () => {
const signingKeyService = mockInstance(OAuthSigningKeyService);
const logger = mockInstance(Logger);
const provider = Container.get(OAuthSigningJwksProvider);
beforeEach(() => {
vi.resetAllMocks();
});
test('returns an ES256 signing key', async () => {
signingKeyService.getPublicJwks.mockResolvedValue([validSigningJwk]);
await expect(provider.getPublicJwks()).resolves.toEqual([validSigningJwk]);
expect(logger.warn).not.toHaveBeenCalled();
});
test('drops an encryption key', async () => {
const encryptionJwk: JWK = { ...validSigningJwk, kid: 'row-enc', use: 'enc' };
signingKeyService.getPublicJwks.mockResolvedValue([validSigningJwk, encryptionJwk]);
await expect(provider.getPublicJwks()).resolves.toEqual([validSigningJwk]);
expect(logger.warn).toHaveBeenCalledTimes(1);
});
test('drops a key with another algorithm', async () => {
const es384Jwk: JWK = { ...validSigningJwk, kid: 'row-es384', alg: 'ES384' };
signingKeyService.getPublicJwks.mockResolvedValue([es384Jwk]);
await expect(provider.getPublicJwks()).resolves.toEqual([]);
expect(logger.warn).toHaveBeenCalledWith(
'Failed to parse public signing JWK',
expect.objectContaining({ error: expect.anything() }),
);
});
test('drops a key on another curve', async () => {
const p384Jwk: JWK = { ...validSigningJwk, kid: 'row-p384', crv: 'P-384' };
signingKeyService.getPublicJwks.mockResolvedValue([validSigningJwk, p384Jwk]);
await expect(provider.getPublicJwks()).resolves.toEqual([validSigningJwk]);
expect(logger.warn).toHaveBeenCalledTimes(1);
});
test('drops a key with private members', async () => {
const leakyJwk: JWK = {
...validSigningJwk,
kid: 'row-leaky',
d: 'this-must-never-be-exposed',
};
signingKeyService.getPublicJwks.mockResolvedValue([validSigningJwk, leakyJwk]);
await expect(provider.getPublicJwks()).resolves.toEqual([validSigningJwk]);
expect(logger.warn).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,180 @@
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';
import { createLocalJWKSet, jwtVerify } from 'jose';
import { Cipher, InstanceSettings } from 'n8n-core';
import { generateKeyPairSync } from 'node:crypto';
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';
const ISSUER = 'https://n8n.example.com';
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. */
const createProcess = () =>
new OAuthSigningKeyService(
Container.get(DeploymentKeyRepository),
Container.get(Cipher),
new CacheService(Container.get(GlobalConfig)),
Container.get(Logger),
Container.get(JwtService),
);
/** 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 };
};
const activeRows = async () =>
await keyStore.find({ where: { type: OAUTH_SIGNING_KEY_TYPE, status: 'active' } });
beforeAll(async () => {
mockInstance(InstanceSettings, {
encryptionKey: 'oauth-signing-test-encryption-key',
n8nFolder: '/tmp/n8n-test',
});
await testDb.init();
keyStore = Container.get(DataSource).getRepository(DeploymentKey);
});
beforeEach(async () => {
await testDb.resetDeploymentKeys();
await Container.get(CacheService).delete(OAUTH_SIGNING_KEYS_CACHE_KEY);
});
afterAll(async () => {
await testDb.terminate();
});
describe('OAuthSigningKeyService (integration)', () => {
it('persists one wrapped active key whose kid is the row id', async () => {
await createProcess().initialize();
const rows = await activeRows();
expect(rows).toHaveLength(1);
expect(rows[0].algorithm).toBe(OAUTH_SIGNING_ALGORITHM);
const jwk = JSON.parse(Container.get(Cipher).decryptDEKWithInstanceKey(rows[0].value)) as {
kid?: string;
use?: string;
};
expect(jwk).toMatchObject({ kid: rows[0].id, use: 'sig' });
});
it('keeps the key across a restart', async () => {
const before = createProcess();
await before.initialize();
const after = createProcess();
await after.initialize();
expect(await activeRows()).toHaveLength(1);
expect(await after.getPublicJwks()).toEqual(await before.getPublicJwks());
});
it('keeps one active key when two processes initialize at the same time', async () => {
// Hold both inserts until both processes reach them, so both read "no key"
// and the unique index must decide the race.
const repository = Container.get(DeploymentKeyRepository);
const insert = repository.insertActiveOAuthSigningKey.bind(repository);
let arrived = 0;
let releaseAll = () => {};
const released = new Promise<void>((resolve) => {
releaseAll = resolve;
});
const insertSpy = vi
.spyOn(repository, 'insertActiveOAuthSigningKey')
.mockImplementation(async (...args) => {
if (++arrived === 2) releaseAll();
await released;
await insert(...args);
});
const processes = [createProcess(), createProcess()];
let insertAttempts = 0;
try {
await Promise.all(processes.map(async (p) => await p.initialize()));
insertAttempts = insertSpy.mock.calls.length;
} finally {
insertSpy.mockRestore();
}
expect(insertAttempts).toBe(2);
const rows = await activeRows();
expect(rows).toHaveLength(1);
for (const p of processes) {
const keys = await p.getPublicJwks();
expect(keys.map((k) => k.kid)).toEqual([rows[0].id]);
// Both processes sign with the winner's private key.
await expect(
jwtVerify(p.signAccessToken(claims(), AUDIENCE), createLocalJWKSet({ keys }), {
issuer: ISSUER,
audience: AUDIENCE,
}),
).resolves.toBeDefined();
}
});
it('publishes no private members, and jose verifies its tokens against the JWKS', async () => {
const service = createProcess();
await service.initialize();
const keys = await service.getPublicJwks();
expect(Object.keys(keys[0]).sort()).toEqual(['alg', 'crv', 'kid', 'kty', 'use', 'x', 'y']);
const { payload } = await jwtVerify(
service.signAccessToken(claims(), AUDIENCE),
createLocalJWKSet({ keys }),
{
issuer: ISSUER,
audience: AUDIENCE,
typ: 'at+jwt',
algorithms: ['ES256'],
},
);
expect(payload.sub).toBe('user-1');
});
it('publishes a retired key only within the grace window', async () => {
const cipher = Container.get(Cipher);
const insertRetired = async (id: string, retiredAt: Date) => {
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
const jwk = { ...privateKey.export({ format: 'jwk' }), kid: id, alg: 'ES256', use: 'sig' };
await keyStore.insert({
id,
type: OAUTH_SIGNING_KEY_TYPE,
value: cipher.encryptDEKWithInstanceKey(JSON.stringify(jwk)),
algorithm: OAUTH_SIGNING_ALGORITHM,
status: 'inactive',
updatedAt: retiredAt,
});
};
await insertRetired('recently-retired', new Date(Date.now() - 60_000));
await insertRetired(
'long-retired',
new Date(Date.now() - RETIRED_SIGNING_KEY_GRACE_MS - 60_000),
);
const service = createProcess();
await service.initialize();
const kids = (await service.getPublicJwks()).map((k) => k.kid);
expect(kids).toHaveLength(2);
expect(kids).toContain('recently-retired');
expect(kids).not.toContain('long-retired');
});
});

View File

@ -0,0 +1,190 @@
import { mock } from 'vitest-mock-extended';
import type { DeploymentKey } from '@n8n/db';
import { QueryFailedError } from '@n8n/typeorm';
import { createLocalJWKSet, jwtVerify } from 'jose';
import jwt from 'jsonwebtoken';
import { generateKeyPairSync } from 'node:crypto';
import {
OAUTH_SIGNING_ALGORITHM,
OAUTH_SIGNING_KEY_TYPE,
OAUTH_SIGNING_KEYS_CACHE_KEY,
RETIRED_SIGNING_KEY_GRACE_MS,
} from '../oauth-signing-key.constants';
import type { OAuthSigningKeyService } from '../oauth-signing-key.service';
import { createSigningKeyService } from './signing-key-fixtures';
const ISSUER = 'https://n8n.example.com';
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 };
};
const makeUniqueViolation = (code: string): QueryFailedError => {
const err = new QueryFailedError('insert', [], new Error('duplicate'));
(err as unknown as { driverError: { code: string } }).driverError = { code };
return err;
};
/** A stored row for a fresh EC key, wrapped the way the fixture cipher unwraps it. */
const makeKeyRow = (id: string, overrides: Partial<DeploymentKey> = {}): DeploymentKey => {
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
const privateJwk = {
...privateKey.export({ format: 'jwk' }),
kid: id,
alg: OAUTH_SIGNING_ALGORITHM,
use: 'sig',
};
const now = new Date();
return mock<DeploymentKey>({
id,
type: OAUTH_SIGNING_KEY_TYPE,
value: `wrapped:${JSON.stringify(privateJwk)}`,
algorithm: OAUTH_SIGNING_ALGORITHM,
status: 'active',
createdAt: now,
updatedAt: now,
...overrides,
});
};
describe('OAuthSigningKeyService', () => {
afterEach(() => {
vi.useRealTimers();
});
describe('initialize', () => {
it('reuses an existing active key without inserting', async () => {
const { service, keyStore } = createSigningKeyService();
keyStore.rows.push(makeKeyRow('existing-key'));
await service.initialize();
expect(keyStore.repository.insertActiveOAuthSigningKey).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();
await service.initialize();
expect(keyStore.repository.insertActiveOAuthSigningKey).toHaveBeenCalledTimes(1);
const [id, value, algorithm] = keyStore.repository.insertActiveOAuthSigningKey.mock.calls[0];
expect(algorithm).toBe(OAUTH_SIGNING_ALGORITHM);
expect(cipher.encryptDEKWithInstanceKey).toHaveBeenCalledTimes(1);
expect(value).toBe(cipher.encryptDEKWithInstanceKey.mock.results[0].value);
expect(JSON.parse(cipher.decryptDEKWithInstanceKey(value))).toMatchObject({
kid: id,
alg: 'ES256',
use: 'sig',
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);
});
it.each([
['postgres', '23505'],
['sqlite', 'SQLITE_CONSTRAINT_UNIQUE'],
])('uses the winning key when a %s insert races with another process', async (_, code) => {
const { service, keyStore } = createSigningKeyService();
keyStore.repository.insertActiveOAuthSigningKey.mockImplementation(async () => {
keyStore.rows.push(makeKeyRow('winner'));
throw makeUniqueViolation(code);
});
await service.initialize();
expect(keyStore.rows).toHaveLength(1);
expect(
jwt.decode(service.signAccessToken(claims(), AUDIENCE), { complete: true })?.header.kid,
).toBe('winner');
});
it('rejects a stored key whose kid does not match its row id', async () => {
const { service, keyStore } = createSigningKeyService();
const row = makeKeyRow('row-id');
row.id = 'another-row-id';
keyStore.rows.push(row);
await expect(service.initialize()).rejects.toThrow(
'OAuth signing key has a kid that does not match its row id',
);
});
});
it('throws when signing before initialize', () => {
const { service } = createSigningKeyService();
expect(() => service.signAccessToken(claims(), AUDIENCE)).toThrow(
'OAuth signing key is not initialized',
);
});
it('publishes only the public members of the key', async () => {
const { service } = createSigningKeyService();
await service.initialize();
const jwks = await service.getPublicJwks();
expect(jwks).toHaveLength(1);
expect(Object.keys(jwks[0]).sort()).toEqual(['alg', 'crv', 'kid', 'kty', 'use', 'x', 'y']);
expect(jwks[0]).toMatchObject({ kty: 'EC', crv: 'P-256', alg: 'ES256', use: 'sig' });
});
describe('retired keys', () => {
const setUp = async () => {
vi.useFakeTimers({ toFake: ['Date'] });
const { service, keyStore } = createSigningKeyService();
keyStore.rows.push(makeKeyRow('retired', { status: 'inactive', updatedAt: new Date() }));
await service.initialize();
return { service };
};
const publishedKids = async (service: OAuthSigningKeyService) =>
(await service.getPublicJwks()).map((k) => k.kid);
it('stay published within the grace window', async () => {
const { service } = await setUp();
// One second before the window closes.
vi.advanceTimersByTime(RETIRED_SIGNING_KEY_GRACE_MS - 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.
await service.getPublicJwks();
vi.advanceTimersByTime(RETIRED_SIGNING_KEY_GRACE_MS + 1000);
expect(await publishedKids(service)).not.toContain('retired');
});
});
it('signs tokens that jose verifies against the published keys', async () => {
const { service } = createSigningKeyService();
await service.initialize();
const token = service.signAccessToken(claims(), AUDIENCE);
const jwks = createLocalJWKSet({ keys: await service.getPublicJwks() });
const { payload, protectedHeader } = await jwtVerify(token, jwks, {
issuer: ISSUER,
audience: AUDIENCE,
typ: 'at+jwt',
algorithms: ['ES256'],
});
expect(payload.sub).toBe('user-1');
expect(protectedHeader.alg).toBe('ES256');
});
});

View File

@ -40,6 +40,7 @@ describe('GET /.well-known/oauth-authorization-server', () => {
token_endpoint: expect.stringContaining('/mcp-oauth/token'),
registration_endpoint: expect.stringContaining('/mcp-oauth/register'),
revocation_endpoint: expect.stringContaining('/mcp-oauth/revoke'),
jwks_uri: expect.stringMatching(/\/rest\/\.well-known\/jwks\.json$/),
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
token_endpoint_auth_methods_supported: ['none', 'client_secret_post', 'client_secret_basic'],
@ -68,6 +69,7 @@ describe('GET /.well-known/oauth-authorization-server', () => {
token_endpoint,
registration_endpoint,
revocation_endpoint,
jwks_uri,
} = response.body;
expect(issuer).toMatch(/^https?:\/\//);
@ -75,6 +77,7 @@ describe('GET /.well-known/oauth-authorization-server', () => {
expect(token_endpoint).toBe(`${issuer}/mcp-oauth/token`);
expect(registration_endpoint).toBe(`${issuer}/mcp-oauth/register`);
expect(revocation_endpoint).toBe(`${issuer}/mcp-oauth/revoke`);
expect(jwks_uri).toBe(`${issuer}/rest/.well-known/jwks.json`);
});
test('should include all required OAuth 2.1 fields', async () => {

View File

@ -0,0 +1,89 @@
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 { mock } from 'vitest-mock-extended';
import { JwtService } from '@/services/jwt.service';
import { OAUTH_SIGNING_KEY_TYPE } from '../oauth-signing-key.constants';
import { OAuthSigningKeyService } from '../oauth-signing-key.service';
const WRAP_PREFIX = 'wrapped:';
/** A cipher that tags values, so tests can read stored private JWKs back. */
export const createTaggingCipher = () => {
const cipher = mock<Cipher>();
cipher.encryptDEKWithInstanceKey.mockImplementation((value: string) => `${WRAP_PREFIX}${value}`);
cipher.decryptDEKWithInstanceKey.mockImplementation((value: string) =>
value.slice(WRAP_PREFIX.length),
);
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[] = [];
const repository = mock<DeploymentKeyRepository>();
repository.findActiveOAuthSigningKey.mockImplementation(
async (algorithm) =>
rows.find(
(r) =>
r.type === OAUTH_SIGNING_KEY_TYPE && r.algorithm === algorithm && r.status === 'active',
) ?? null,
);
repository.findOAuthSigningKeys.mockImplementation(async () =>
rows.filter((r) => r.type === OAUTH_SIGNING_KEY_TYPE),
);
repository.insertActiveOAuthSigningKey.mockImplementation(async (id, value, algorithm) => {
const now = new Date();
rows.push(
mock<DeploymentKey>({
id,
type: OAUTH_SIGNING_KEY_TYPE,
value,
algorithm,
status: 'active',
createdAt: now,
updatedAt: now,
}),
);
});
return { rows, repository };
};
/** A real signing-key service over in-memory storage. */
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 };
};

View File

@ -18,13 +18,30 @@ import { InstanceSettings } from 'n8n-core';
@BackendModule({ name: 'oauth-server', instanceTypes: ['main', 'webhook', 'worker'] })
export class OAuthServerModule implements ModuleInterface {
async init() {
const { instanceType } = Container.get(InstanceSettings);
// Only import controllers in the main process, since the webhook/worker processes don't run an HTTP server and don't need them.
if (Container.get(InstanceSettings).instanceType === 'main') {
if (instanceType === 'main') {
await import('./oauth.controller.js');
await import('./oauth-consent.controller.js');
await import('./oauth-clients.controller.js');
}
// 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.
if (instanceType === 'main' || instanceType === 'webhook') {
const { OAuthSigningKeyService } = await import('./oauth-signing-key.service.js');
await Container.get(OAuthSigningKeyService).initialize();
}
// The JWKS endpoint is served by main only.
if (instanceType === 'main') {
const { JwksRegistry } = await import('@/jwks/jwks.registry.js');
const { OAuthSigningJwksProvider } = await import('./oauth-signing-jwks.provider.js');
Container.get(JwksRegistry).register(Container.get(OAuthSigningJwksProvider));
}
// Register the token service as the OAuth token verifier provider, so
// protected-resource modules verify bearer tokens through the core
// `OAuthTokenVerifierProxy` instead of importing this module.

View File

@ -0,0 +1,33 @@
import { Logger } from '@n8n/backend-common';
import { Service } from '@n8n/di';
import type { JWK } from 'jose';
import type { JwksProvider } from '@/jwks/jwks.registry';
import { OAuthSigningKeyService } from './oauth-signing-key.service';
import { PublicSigningJwkSchema } from './oauth-signing-key.schemas';
/** Publishes the OAuth access-token signing keys in the instance JWKS. */
@Service()
export class OAuthSigningJwksProvider implements JwksProvider {
readonly id = 'oauth-server-signing';
constructor(
private readonly signingKeyService: OAuthSigningKeyService,
private readonly logger: Logger,
) {}
async getPublicJwks(): Promise<JWK[]> {
const jwks = await this.signingKeyService.getPublicJwks();
return jwks
.map((key) => PublicSigningJwkSchema.safeParse(key))
.filter((result) => {
if (!result.success) {
this.logger.warn('Failed to parse public signing JWK', { error: result.error });
}
return result.success;
})
.map((result) => result.data);
}
}

View File

@ -0,0 +1,20 @@
import { Time } from '@n8n/constants';
export { OAUTH_SIGNING_KEY_TYPE } from '@n8n/db';
/** ECDSA with the P-256 curve and SHA-256 (RFC 7518 §3.4). */
export const OAUTH_SIGNING_ALGORITHM = 'ES256';
export const OAUTH_SIGNING_KEY_USE = 'sig';
export const OAUTH_SIGNING_CURVE = 'P-256';
/**
* Public signing keys only. The private key stays in process memory.
* Delete this entry whenever a signing key is added or changes status.
*/
export const OAUTH_SIGNING_KEYS_CACHE_KEY = 'oauth-server:signing-keys';
export const OAUTH_ACCESS_TOKEN_TTL_SECONDS = 1 * Time.hours.toSeconds;
/** A retired key must verify every token it signed, with margin for clock skew. */
export const RETIRED_SIGNING_KEY_GRACE_MS =
OAUTH_ACCESS_TOKEN_TTL_SECONDS * Time.seconds.toMilliseconds + 5 * Time.minutes.toMilliseconds;

View File

@ -0,0 +1,21 @@
import { z } from 'zod';
import { EcPublicJwkSchema } from '@/jwks/jwks.schemas';
import {
OAUTH_SIGNING_ALGORITHM,
OAUTH_SIGNING_CURVE,
OAUTH_SIGNING_KEY_USE,
} from './oauth-signing-key.constants';
/**
* A public access-token signing key in the instance JWKS. Narrows the generic
* EC shape to `use: 'sig'`, ES256 and P-256. The base shape is `.strict()`,
* so the private parameter `d` still fails parsing.
*/
export const PublicSigningJwkSchema = EcPublicJwkSchema.extend({
use: z.literal(OAUTH_SIGNING_KEY_USE),
alg: z.literal(OAUTH_SIGNING_ALGORITHM),
crv: z.literal(OAUTH_SIGNING_CURVE),
});
export type PublicSigningJwk = z.infer<typeof PublicSigningJwkSchema>;

View File

@ -0,0 +1,191 @@
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 { generateNanoId } from '@n8n/utils/generate-nano-id';
import type { JWK } from 'jose';
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 { promisify } from 'node:util';
import { JwtService } from '@/services/jwt.service';
import {
OAUTH_SIGNING_ALGORITHM,
OAUTH_SIGNING_CURVE,
OAUTH_SIGNING_KEY_USE,
OAUTH_SIGNING_KEYS_CACHE_KEY,
RETIRED_SIGNING_KEY_GRACE_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. */
type PublishedSigningKey = {
kid: string;
publicJwk: JWK;
active: boolean;
/** Epoch ms of the last row update. For an inactive key, this is when it was retired. */
updatedAt: number;
};
type ActiveSigningKey = {
kid: string;
privateKey: KeyObject;
};
/** 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;
/**
* Manages the ES256 key pair that signs OAuth access tokens. One active
* private JWK is stored in `deployment_key` per algorithm, enforced by a
* partial unique index on `(type, algorithm)`. The JWK `kid` and the
* `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.
*/
@Service()
export class OAuthSigningKeyService {
private activeKey: ActiveSigningKey | null = null;
constructor(
private readonly deploymentKeyRepository: DeploymentKeyRepository,
private readonly cipher: Cipher,
private readonly cacheService: CacheService,
private readonly logger: Logger,
private readonly jwtService: JwtService,
) {}
/**
* Loads the active signing key, and generates it on first boot. Safe to
* call concurrently across processes: the partial unique index lets one
* insert win, and the others read the winner's row.
*/
async initialize(): Promise<void> {
let row = await this.deploymentKeyRepository.findActiveOAuthSigningKey(OAUTH_SIGNING_ALGORITHM);
if (!row) {
await this.generateAndPersist();
row = await this.deploymentKeyRepository.findActiveOAuthSigningKey(OAUTH_SIGNING_ALGORITHM);
}
if (!row) {
throw new UnexpectedError('OAuth signing key not found after generation');
}
const privateJwk = this.readPrivateJwk(row);
this.activeKey = {
kid: row.id,
privateKey: createPrivateKey({ key: privateJwk, format: 'jwk' }),
};
}
/** Signs an RFC 9068 access token for `audience` with the active key. */
signAccessToken(payload: object, audience: string): string {
if (!this.activeKey) {
throw new UnexpectedError('OAuth signing key is not initialized');
}
const { kid, privateKey } = this.activeKey;
return this.jwtService.signForResourceWithKey(payload, audience, privateKey, {
algorithm: OAUTH_SIGNING_ALGORITHM,
header: { alg: OAUTH_SIGNING_ALGORITHM, typ: 'at+jwt', kid },
});
}
/**
* 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.
*/
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(),
});
const now = Date.now();
return (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> {
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(),
}));
return keys.length > 0 ? keys : undefined;
}
private readPrivateJwk(row: DeploymentKey): JsonWebKey {
const privateJwk = jsonParse<JsonWebKey>(this.cipher.decryptDEKWithInstanceKey(row.value), {
errorMessage: 'Failed to parse OAuth signing key',
});
if (privateJwk.kid !== row.id) {
throw new UnexpectedError('OAuth signing key has a kid that does not match its row id');
}
return privateJwk;
}
private async generateAndPersist(): Promise<void> {
const { privateKey } = await generateKeyPairAsync('ec', {
namedCurve: OAUTH_SIGNING_CURVE,
});
// The JWK kid is the deployment_key row's primary key.
const id = generateNanoId();
const privateJwk: JsonWebKey = {
...privateKey.export({ format: 'jwk' }),
kid: id,
alg: OAUTH_SIGNING_ALGORITHM,
use: OAUTH_SIGNING_KEY_USE,
};
// Same wrapping as data-encryption keys: instance-key wrapped, GCM.
const encryptedPrivate = this.cipher.encryptDEKWithInstanceKey(JSON.stringify(privateJwk));
try {
await this.deploymentKeyRepository.insertActiveOAuthSigningKey(
id,
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;
this.logger.debug('OAuth signing key insert raced with another process; re-reading winner');
}
}
}
/** Picks only the allow-listed public members. Any other member is dropped. */
function toPublicJwk(privateJwk: JsonWebKey): JWK {
const entries = PUBLIC_JWK_FIELDS.filter((field) => privateJwk[field] !== undefined).map(
(field) => [field, privateJwk[field]] as const,
);
return Object.fromEntries(entries);
}

View File

@ -25,6 +25,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 { AccessTokenNotFoundError, JWTVerificationError } from './oauth.errors';
import { authorizeAgainstGrant } from './resource-gate';
import { isSameProtectedResource } from './resource-identity';
@ -39,7 +40,7 @@ import { isSameProtectedResource } from './resource-identity';
*/
@Service()
export class OAuthTokenService implements OAuthTokenVerifier {
private readonly ACCESS_TOKEN_EXPIRY_SECONDS = 1 * Time.hours.toSeconds;
private readonly ACCESS_TOKEN_EXPIRY_SECONDS = OAUTH_ACCESS_TOKEN_TTL_SECONDS;
private readonly REFRESH_TOKEN_EXPIRY_MS = 30 * Time.days.toMilliseconds;
constructor(

View File

@ -190,6 +190,8 @@ export class OAuthController {
token_endpoint: `${baseUrl}/mcp-oauth/token`,
registration_endpoint: `${baseUrl}/mcp-oauth/register`,
revocation_endpoint: `${baseUrl}/mcp-oauth/revoke`,
// RFC 8414 §2: public keys that verify the access tokens this server signs.
jwks_uri: this.urlService.getInstanceJwksUri(),
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
token_endpoint_auth_methods_supported: ['none', 'client_secret_post', 'client_secret_basic'],

View File

@ -2,7 +2,7 @@ import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import { isRecord } from '@n8n/utils/is-record';
import { createHash } from 'crypto';
import { createHash, type KeyObject } from 'crypto';
import jwt from 'jsonwebtoken';
import { InstanceSettings, type DeploymentStateRepo } from 'n8n-core';
@ -84,6 +84,19 @@ export class JwtService {
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(
payload: object,
audience: string,
privateKey: KeyObject,
options: PurposedSignOptions = {},
): string {
return jwt.sign(payload, privateKey, { ...options, audience });
}
/**
* Reads the claims without checking the signature. Use only to route a
* token to the code that then verifies it — never to make a decision.

View File

@ -1,12 +1,20 @@
import { setupTestServer } from './shared/utils';
describe('GET /.well-known/jwks.json', () => {
const testServer = setupTestServer({ endpointGroups: ['jwks'] });
const testServer = setupTestServer({
endpointGroups: ['jwks'],
modules: ['oauth-jwe', 'oauth-server'],
});
test('returns an empty key set without authentication when no provider is registered', async () => {
test('returns the JWE key and the access-token signing key without authentication', async () => {
const response = await testServer.authlessAgent.get('/.well-known/jwks.json').expect(200);
expect(response.body).toEqual({ keys: [] });
const { keys } = response.body as { keys: Array<Record<string, string>> };
const encKeys = keys.filter((key) => key.use === 'enc');
const sigKeys = keys.filter((key) => key.use === 'sig');
expect(encKeys).toEqual([expect.objectContaining({ kty: 'RSA', alg: 'RSA-OAEP-256' })]);
expect(sigKeys).toEqual([expect.objectContaining({ kty: 'EC', crv: 'P-256', alg: 'ES256' })]);
expect(new Set(keys.map((key) => key.kid)).size).toBe(keys.length);
expect(response.headers['cache-control']).toBe('public, max-age=3600, must-revalidate');
});
});

View File

@ -72,6 +72,7 @@ type ModuleName =
| 'data-table'
| 'instance-ai'
| 'mcp'
| 'oauth-jwe'
| 'oauth-server'
| 'dynamic-credentials'
| 'log-streaming'