mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
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:
parent
7779f47a7d
commit
073239e6d5
|
|
@ -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)) |
|
||||
|
|
|
|||
1
docs/generated/sqlite-schema/deployment_key.md
generated
1
docs/generated/sqlite-schema/deployment_key.md
generated
|
|
@ -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' |
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ type ModuleName =
|
|||
| 'data-table'
|
||||
| 'instance-ai'
|
||||
| 'mcp'
|
||||
| 'oauth-jwe'
|
||||
| 'oauth-server'
|
||||
| 'dynamic-credentials'
|
||||
| 'log-streaming'
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user