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