diff --git a/docs/generated/postgres-schema/public.deployment_key.md b/docs/generated/postgres-schema/public.deployment_key.md index a8324de3731..041c5cfa71e 100644 --- a/docs/generated/postgres-schema/public.deployment_key.md +++ b/docs/generated/postgres-schema/public.deployment_key.md @@ -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)) | diff --git a/docs/generated/sqlite-schema/deployment_key.md b/docs/generated/sqlite-schema/deployment_key.md index bb9e06c4473..95c456e0cd5 100644 --- a/docs/generated/sqlite-schema/deployment_key.md +++ b/docs/generated/sqlite-schema/deployment_key.md @@ -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' | diff --git a/packages/@n8n/db/src/entities/deployment-key.ts b/packages/@n8n/db/src/entities/deployment-key.ts index d5e0943393e..d255381951f 100644 --- a/packages/@n8n/db/src/entities/deployment-key.ts +++ b/packages/@n8n/db/src/entities/deployment-key.ts @@ -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 }) diff --git a/packages/@n8n/db/src/entities/index.ts b/packages/@n8n/db/src/entities/index.ts index 93b40ce9229..0c5e16e3072 100644 --- a/packages/@n8n/db/src/entities/index.ts +++ b/packages/@n8n/db/src/entities/index.ts @@ -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, diff --git a/packages/@n8n/db/src/migrations/common/1791199417265-AddOAuthSigningKeyIndexToDeploymentKey.ts b/packages/@n8n/db/src/migrations/common/1791199417265-AddOAuthSigningKeyIndexToDeploymentKey.ts new file mode 100644 index 00000000000..6c5abdd4ac5 --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1791199417265-AddOAuthSigningKeyIndexToDeploymentKey.ts @@ -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, + }); + } +} diff --git a/packages/@n8n/db/src/repositories/deployment-key.repository.ts b/packages/@n8n/db/src/repositories/deployment-key.repository.ts index ae29c893205..8f6ab136fb0 100644 --- a/packages/@n8n/db/src/repositories/deployment-key.repository.ts +++ b/packages/@n8n/db/src/repositories/deployment-key.repository.ts @@ -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 { + 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 { + 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 { + await this.store.insert({ + id, + type: OAUTH_SIGNING_KEY_TYPE, + value, + algorithm, + status: 'active', + }); + } } diff --git a/packages/cli/src/modules/oauth-server/__tests__/oauth-signing-jwks.provider.test.ts b/packages/cli/src/modules/oauth-server/__tests__/oauth-signing-jwks.provider.test.ts new file mode 100644 index 00000000000..2e60e8789f0 --- /dev/null +++ b/packages/cli/src/modules/oauth-server/__tests__/oauth-signing-jwks.provider.test.ts @@ -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); + }); +}); diff --git a/packages/cli/src/modules/oauth-server/__tests__/oauth-signing-key.service.integration.test.ts b/packages/cli/src/modules/oauth-server/__tests__/oauth-signing-key.service.integration.test.ts new file mode 100644 index 00000000000..3b4f60c1b5f --- /dev/null +++ b/packages/cli/src/modules/oauth-server/__tests__/oauth-signing-key.service.integration.test.ts @@ -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; + +/** 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((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'); + }); +}); diff --git a/packages/cli/src/modules/oauth-server/__tests__/oauth-signing-key.service.test.ts b/packages/cli/src/modules/oauth-server/__tests__/oauth-signing-key.service.test.ts new file mode 100644 index 00000000000..c69b217e0e8 --- /dev/null +++ b/packages/cli/src/modules/oauth-server/__tests__/oauth-signing-key.service.test.ts @@ -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 => { + 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({ + 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'); + }); +}); diff --git a/packages/cli/src/modules/oauth-server/__tests__/oauth.controller.api.test.ts b/packages/cli/src/modules/oauth-server/__tests__/oauth.controller.api.test.ts index 1afe3b588f1..97319a443cf 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/oauth.controller.api.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/oauth.controller.api.test.ts @@ -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 () => { diff --git a/packages/cli/src/modules/oauth-server/__tests__/signing-key-fixtures.ts b/packages/cli/src/modules/oauth-server/__tests__/signing-key-fixtures.ts new file mode 100644 index 00000000000..3acac834eab --- /dev/null +++ b/packages/cli/src/modules/oauth-server/__tests__/signing-key-fixtures.ts @@ -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.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(); + const cache = mock(); + 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(); + 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({ + 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({ encryptionKey: 'test-key' }), + mock(), + mock(), + ); + const service = new OAuthSigningKeyService( + keyStore.repository, + cipher, + cache, + mock(), + jwtService, + ); + return { service, keyStore, cipher, cache, cacheStore }; +}; diff --git a/packages/cli/src/modules/oauth-server/oauth-server.module.ts b/packages/cli/src/modules/oauth-server/oauth-server.module.ts index 7ac271ab4a1..66fdabee302 100644 --- a/packages/cli/src/modules/oauth-server/oauth-server.module.ts +++ b/packages/cli/src/modules/oauth-server/oauth-server.module.ts @@ -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. diff --git a/packages/cli/src/modules/oauth-server/oauth-signing-jwks.provider.ts b/packages/cli/src/modules/oauth-server/oauth-signing-jwks.provider.ts new file mode 100644 index 00000000000..a048d448ca5 --- /dev/null +++ b/packages/cli/src/modules/oauth-server/oauth-signing-jwks.provider.ts @@ -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 { + 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); + } +} diff --git a/packages/cli/src/modules/oauth-server/oauth-signing-key.constants.ts b/packages/cli/src/modules/oauth-server/oauth-signing-key.constants.ts new file mode 100644 index 00000000000..2164cd07d2f --- /dev/null +++ b/packages/cli/src/modules/oauth-server/oauth-signing-key.constants.ts @@ -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; diff --git a/packages/cli/src/modules/oauth-server/oauth-signing-key.schemas.ts b/packages/cli/src/modules/oauth-server/oauth-signing-key.schemas.ts new file mode 100644 index 00000000000..f09595a0989 --- /dev/null +++ b/packages/cli/src/modules/oauth-server/oauth-signing-key.schemas.ts @@ -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; diff --git a/packages/cli/src/modules/oauth-server/oauth-signing-key.service.ts b/packages/cli/src/modules/oauth-server/oauth-signing-key.service.ts new file mode 100644 index 00000000000..d63835f28a0 --- /dev/null +++ b/packages/cli/src/modules/oauth-server/oauth-signing-key.service.ts @@ -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 { + 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 { + const keys = await this.loadPublishedKeys(); + return keys.map((key) => key.publicJwk); + } + + private async loadPublishedKeys(): Promise { + const keys = await this.cacheService.get(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 { + 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(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 { + 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); +} diff --git a/packages/cli/src/modules/oauth-server/oauth-token.service.ts b/packages/cli/src/modules/oauth-server/oauth-token.service.ts index 45912c1a880..cb05fc5f877 100644 --- a/packages/cli/src/modules/oauth-server/oauth-token.service.ts +++ b/packages/cli/src/modules/oauth-server/oauth-token.service.ts @@ -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( diff --git a/packages/cli/src/modules/oauth-server/oauth.controller.ts b/packages/cli/src/modules/oauth-server/oauth.controller.ts index 80a31c39366..05a6d2cfbc0 100644 --- a/packages/cli/src/modules/oauth-server/oauth.controller.ts +++ b/packages/cli/src/modules/oauth-server/oauth.controller.ts @@ -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'], diff --git a/packages/cli/src/services/jwt.service.ts b/packages/cli/src/services/jwt.service.ts index c01be4121fd..1b44f578214 100644 --- a/packages/cli/src/services/jwt.service.ts +++ b/packages/cli/src/services/jwt.service.ts @@ -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. diff --git a/packages/cli/test/integration/jwks.api.test.ts b/packages/cli/test/integration/jwks.api.test.ts index 4a82479a760..14d9f35f953 100644 --- a/packages/cli/test/integration/jwks.api.test.ts +++ b/packages/cli/test/integration/jwks.api.test.ts @@ -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> }; + 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'); }); }); diff --git a/packages/cli/test/integration/shared/types.ts b/packages/cli/test/integration/shared/types.ts index c0cd97dbb9c..8aca683cd87 100644 --- a/packages/cli/test/integration/shared/types.ts +++ b/packages/cli/test/integration/shared/types.ts @@ -72,6 +72,7 @@ type ModuleName = | 'data-table' | 'instance-ai' | 'mcp' + | 'oauth-jwe' | 'oauth-server' | 'dynamic-credentials' | 'log-streaming'