From 37ed34866571f9a3e4f3f4f55578ea44b1ebc50e Mon Sep 17 00:00:00 2001 From: Yuliia Pominchuk <31064937+yuliia-pominchuk@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:59:50 +0000 Subject: [PATCH] refactor(core): Bind signed tokens to a declared purpose (#39467) Co-authored-by: Claude Opus 5 (1M context) --- packages/cli/eslint.config.mjs | 87 +++++++++++- packages/cli/oxlint.config.mts | 84 ++++++++++- .../src/auth/__tests__/auth.service.test.ts | 17 +-- packages/cli/src/auth/auth.service.ts | 22 ++- .../__tests__/users.controller.test.ts | 2 + .../cli/src/controllers/users.controller.ts | 1 + .../teams-arm-template.service.test.ts | 2 + .../__tests__/teams-setup.service.test.ts | 1 + .../teams/teams-arm-template.service.ts | 8 +- .../mcp-api-key.service.integration.test.ts | 5 +- .../mcp-server-middleware.service.test.ts | 76 ++++------ .../src/modules/mcp/mcp-api-key.service.ts | 3 +- .../mcp/mcp-server-middleware.service.ts | 2 +- .../oauth-consent.controller.api.test.ts | 2 +- .../__tests__/oauth-session.service.test.ts | 23 +++- .../__tests__/oauth-token.service.test.ts | 130 ++++++++---------- ...flow-mcp-trigger-authorization.api.test.ts | 6 +- .../oauth-server/oauth-session.service.ts | 4 +- .../oauth-server/oauth-token.service.ts | 10 +- .../__tests__/oidc.service.ee.test.ts | 4 +- .../src/modules/sso-oidc/oidc.service.ee.ts | 8 +- .../scoped-jwt.strategy.integration.test.ts | 8 +- .../__tests__/scoped-jwt.strategy.test.ts | 21 ++- .../services/scoped-jwt.strategy.ts | 4 +- .../services/token-exchange.service.ts | 2 +- .../api-key-auth.strategy.integration.test.ts | 10 +- ...oyment-signing-secrets.integration.test.ts | 5 +- .../services/__tests__/jwt.service.test.ts | 102 ++++++++++++-- ...public-api-key.service.integration.test.ts | 2 +- .../__tests__/public-api-key.service.test.ts | 5 +- .../services/__tests__/user.service.test.ts | 2 +- .../cli/src/services/api-key-auth.strategy.ts | 10 +- packages/cli/src/services/jwt.service.ts | 99 ++++++++++++- .../src/services/public-api-key.service.ts | 5 +- packages/cli/src/services/token-purposes.ts | 78 +++++++++++ packages/cli/src/services/user.service.ts | 6 +- .../cli/test/integration/auth.api.test.ts | 4 + .../invitation.controller.integration.test.ts | 2 +- .../integration/oidc/oidc.service.ee.test.ts | 12 +- .../integration/password-reset.api.test.ts | 2 +- .../endpoints-with-scopes-enabled.test.ts | 2 +- .../token-exchange.controller.test.ts | 6 +- 42 files changed, 650 insertions(+), 234 deletions(-) create mode 100644 packages/cli/src/services/token-purposes.ts diff --git a/packages/cli/eslint.config.mjs b/packages/cli/eslint.config.mjs index 5e6152825a9..38eb5ce025e 100644 --- a/packages/cli/eslint.config.mjs +++ b/packages/cli/eslint.config.mjs @@ -31,6 +31,30 @@ const instanceAiLazyRuntimeImports = [ message: INSTANCE_AI_LAZY_IMPORT_MESSAGE, })); +// Only JwtService may reach the raw signing API: it derives the `aud` claim from +// the token's purpose, which is what keeps a token for one purpose from being +// presented for another. The error classes and types stay importable. +const jsonwebtokenSigningRestriction = { + name: 'jsonwebtoken', + // An allowlist, not a denylist: the module's whole runtime surface is off + // limits except the error classes, so a member added upstream is restricted + // from the start. `allowTypeImports` keeps `Secret`, `Algorithm` and friends + // importable. A namespace import is restricted too — the linter cannot see + // which members it reaches for. + allowImportNames: ['JsonWebTokenError', 'TokenExpiredError', 'NotBeforeError'], + allowTypeImports: true, + message: + 'Sign and verify through JwtService, so the token is bound to a purpose in token-purposes.ts.', +}; + +// `jsonwebtoken` declares no `exports`, so `jsonwebtoken/sign` and its siblings +// resolve straight to the same functions and would slip past a name-only rule. +const jsonwebtokenSubpathRestriction = { + group: ['jsonwebtoken/*'], + message: + 'Sign and verify through JwtService, so the token is bound to a purpose in token-purposes.ts.', +}; + const engineV2ModuleOnlyImport = { name: '@n8n/engine', allowTypeImports: true, @@ -154,7 +178,14 @@ export default defineConfig( // wholesale rather than merging them. '@typescript-eslint/no-restricted-imports': [ 'error', - { paths: [POLICY_INTERNAL_RESTRICTION, engineV2ModuleOnlyImport] }, + { + paths: [ + POLICY_INTERNAL_RESTRICTION, + engineV2ModuleOnlyImport, + jsonwebtokenSigningRestriction, + ], + patterns: [jsonwebtokenSubpathRestriction], + }, ], }, }, @@ -171,11 +202,65 @@ export default defineConfig( POLICY_INTERNAL_RESTRICTION, ...instanceAiLazyRuntimeImports, engineV2ModuleOnlyImport, + jsonwebtokenSigningRestriction, ], + patterns: [jsonwebtokenSubpathRestriction], }, ], }, }, + { + // engine-v2 owns `@n8n/engine`, so the block above skips it wholesale — which + // would drop the JWT restriction too. Reinstate it here, without the engine + // restriction these files are exempt from. + files: ['./src/modules/engine-v2/**/*.ts'], + rules: { + '@typescript-eslint/no-restricted-imports': [ + 'error', + { + paths: [POLICY_INTERNAL_RESTRICTION, jsonwebtokenSigningRestriction], + patterns: [jsonwebtokenSubpathRestriction], + }, + ], + }, + }, + { + // The two places that hold the raw signing API. NEVER add to this list. + files: [ + // Owns the signing key and derives every audience from a purpose. + './src/services/jwt.service.ts', + // Verifies subject tokens with a foreign key from the trusted-key store, + // against the audience that key is registered for. + './src/modules/token-exchange/services/token-exchange.service.ts', + ], + rules: { + '@typescript-eslint/no-restricted-imports': [ + 'error', + { paths: [POLICY_INTERNAL_RESTRICTION, engineV2ModuleOnlyImport] }, + ], + }, + }, + { + // Tests mint tokens as fixtures, including malformed ones a purpose cannot express. + files: ['./src/**/__tests__/**/*.ts'], + rules: { + '@typescript-eslint/no-restricted-imports': [ + 'error', + { paths: [POLICY_INTERNAL_RESTRICTION, engineV2ModuleOnlyImport] }, + ], + }, + }, + { + // engine-v2 tests reach for `@n8n/engine` the same way the module does, and + // the tests block above would reinstate the restriction they are exempt from. + files: ['./src/modules/engine-v2/**/__tests__/**/*.ts'], + rules: { + '@typescript-eslint/no-restricted-imports': [ + 'error', + { paths: [POLICY_INTERNAL_RESTRICTION] }, + ], + }, + }, { // Only the PEP may import the clearance minter. files: ['./src/policy/policy-enforcement.service.ts'], diff --git a/packages/cli/oxlint.config.mts b/packages/cli/oxlint.config.mts index a6a752eb2d1..0906bcd0ca5 100644 --- a/packages/cli/oxlint.config.mts +++ b/packages/cli/oxlint.config.mts @@ -31,6 +31,30 @@ const instanceAiLazyRuntimeImports = [ message: INSTANCE_AI_LAZY_IMPORT_MESSAGE, })); +// Only JwtService may reach the raw signing API: it derives the `aud` claim from +// the token's purpose, which is what keeps a token for one purpose from being +// presented for another. The error classes and types stay importable. +const jsonwebtokenSigningRestriction = { + name: 'jsonwebtoken', + // An allowlist, not a denylist: the module's whole runtime surface is off + // limits except the error classes, so a member added upstream is restricted + // from the start. `allowTypeImports` keeps `Secret`, `Algorithm` and friends + // importable. A namespace import is restricted too — the linter cannot see + // which members it reaches for. + allowImportNames: ['JsonWebTokenError', 'TokenExpiredError', 'NotBeforeError'], + allowTypeImports: true, + message: + 'Sign and verify through JwtService, so the token is bound to a purpose in token-purposes.ts.', +}; + +// `jsonwebtoken` declares no `exports`, so `jsonwebtoken/sign` and its siblings +// resolve straight to the same functions and would slip past a name-only rule. +const jsonwebtokenSubpathRestriction = { + group: ['jsonwebtoken/*'], + message: + 'Sign and verify through JwtService, so the token is bound to a purpose in token-purposes.ts.', +}; + const engineV2ModuleOnlyImport = { name: '@n8n/engine', allowTypeImports: true, @@ -148,7 +172,14 @@ export default defineConfig({ // wholesale rather than merging them. 'no-restricted-imports': [ 'error', - { paths: [POLICY_INTERNAL_RESTRICTION, engineV2ModuleOnlyImport] }, + { + paths: [ + POLICY_INTERNAL_RESTRICTION, + engineV2ModuleOnlyImport, + jsonwebtokenSigningRestriction, + ], + patterns: [jsonwebtokenSubpathRestriction], + }, ], }, }, @@ -165,7 +196,9 @@ export default defineConfig({ POLICY_INTERNAL_RESTRICTION, ...instanceAiLazyRuntimeImports, engineV2ModuleOnlyImport, + jsonwebtokenSigningRestriction, ], + patterns: [jsonwebtokenSubpathRestriction], }, ], }, @@ -179,6 +212,55 @@ export default defineConfig({ files: ['./src/modules/agents/runtime/agent-isolate-pool.ts'], rules: { 'prefer-const': 'off' }, }, + { + // engine-v2 owns `@n8n/engine`, so the block above skips it wholesale — which + // would drop the JWT restriction too. Reinstate it here, without the engine + // restriction these files are exempt from. + files: ['./src/modules/engine-v2/**/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [POLICY_INTERNAL_RESTRICTION, jsonwebtokenSigningRestriction], + patterns: [jsonwebtokenSubpathRestriction], + }, + ], + }, + }, + { + // The two places that hold the raw signing API. NEVER add to this list. + files: [ + // Owns the signing key and derives every audience from a purpose. + './src/services/jwt.service.ts', + // Verifies subject tokens with a foreign key from the trusted-key store, + // against the audience that key is registered for. + './src/modules/token-exchange/services/token-exchange.service.ts', + ], + rules: { + 'no-restricted-imports': [ + 'error', + { paths: [POLICY_INTERNAL_RESTRICTION, engineV2ModuleOnlyImport] }, + ], + }, + }, + { + // Tests mint tokens as fixtures, including malformed ones a purpose cannot express. + files: ['./src/**/__tests__/**/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { paths: [POLICY_INTERNAL_RESTRICTION, engineV2ModuleOnlyImport] }, + ], + }, + }, + { + // engine-v2 tests reach for `@n8n/engine` the same way the module does, and + // the tests block above would reinstate the restriction they are exempt from. + files: ['./src/modules/engine-v2/**/__tests__/**/*.ts'], + rules: { + 'no-restricted-imports': ['error', { paths: [POLICY_INTERNAL_RESTRICTION] }], + }, + }, { // Only the PEP may import the clearance minter. files: ['./src/policy/policy-enforcement.service.ts'], diff --git a/packages/cli/src/auth/__tests__/auth.service.test.ts b/packages/cli/src/auth/__tests__/auth.service.test.ts index b7adea08ec3..63d6a4ac4b5 100644 --- a/packages/cli/src/auth/__tests__/auth.service.test.ts +++ b/packages/cli/src/auth/__tests__/auth.service.test.ts @@ -41,7 +41,7 @@ describe('AuthService', () => { userManagement: { jwtSecret: 'random-secret' }, endpoints: { rest: 'rest' }, }); - const jwtService = new JwtService(mock(), globalConfig); + const jwtService = new JwtService(mock(), globalConfig, mock()); const urlService = mock(); const userRepository = mock(); const invalidAuthTokenRepository = mock(); @@ -63,10 +63,10 @@ describe('AuthService', () => { vi.useFakeTimers({ now }); const validToken = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEyMyIsImhhc2giOiJtSkFZeDRXYjdrIiwiYnJvd3NlcklkIjoiOFpDVXE1YU1uSFhnMFZvcURLcm9hMHNaZ0NwdWlPQ1AzLzB2UmZKUXU0MD0iLCJ1c2VkTWZhIjpmYWxzZSwiaWF0IjoxNzA2NzUwNjI1LCJleHAiOjE3MDczNTU0MjV9.N7JgwETmO41o4FUDVb4pA1HM3Clj4jyjDK-lE8Fa1Zw'; // Generated using `authService.issueJWT(user, false, browserId)` + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEyMyIsImhhc2giOiJtSkFZeDRXYjdrIiwiYnJvd3NlcklkIjoiOFpDVXE1YU1uSFhnMFZvcURLcm9hMHNaZ0NwdWlPQ1AzLzB2UmZKUXU0MD0iLCJ1c2VkTWZhIjpmYWxzZSwiaWF0IjoxNzA2NzUwNjI1LCJleHAiOjE3MDczNTU0MjUsImF1ZCI6Im44bjpzZXNzaW9uIn0.Non8MLCyq2HdJMu4G2EMKsooOSSZV09f3SJ0vGk3_O8'; // Generated using `authService.issueJWT(user, false, browserId)` const validTokenWithMfa = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEyMyIsImhhc2giOiJtSkFZeDRXYjdrIiwiYnJvd3NlcklkIjoiOFpDVXE1YU1uSFhnMFZvcURLcm9hMHNaZ0NwdWlPQ1AzLzB2UmZKUXU0MD0iLCJ1c2VkTWZhIjp0cnVlLCJpYXQiOjE3MDY3NTA2MjUsImV4cCI6MTcwNzM1NTQyNX0.9kTTue-ZdBQ0CblH0IrqW9K-k0WWfxfsWTglyPB10ko'; // Generated using `authService.issueJWT(user, true, browserId)` + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEyMyIsImhhc2giOiJtSkFZeDRXYjdrIiwiYnJvd3NlcklkIjoiOFpDVXE1YU1uSFhnMFZvcURLcm9hMHNaZ0NwdWlPQ1AzLzB2UmZKUXU0MD0iLCJ1c2VkTWZhIjp0cnVlLCJpYXQiOjE3MDY3NTA2MjUsImV4cCI6MTcwNzM1NTQyNSwiYXVkIjoibjhuOnNlc3Npb24ifQ.54_9gexM1Y39cMkmp7Rr2cVFxSx8R0xtgEdPAbagomE'; // Generated using `authService.issueJWT(user, true, browserId)` beforeEach(() => { vi.resetAllMocks(); @@ -530,7 +530,7 @@ describe('AuthService', () => { const next = vi.fn() as NextFunction; const tokenWithPayload = (payload: object, options: jwt.SignOptions = { expiresIn: '1h' }) => - jwtService.sign(payload, options); + jwtService.sign('session', payload, options); it('should 404 if no cookie is set', () => { const req = mockReq(); @@ -792,7 +792,7 @@ describe('AuthService', () => { const token = authService.issueJWT(user, false, browserId); expect(authService.jwtExpiration).toBe(defaultInSeconds); - const decodedToken = jwtService.verify(token); + const decodedToken = jwtService.verify('session', token); if (decodedToken.exp === undefined || decodedToken.iat === undefined) { expect.fail('Expected exp and iat to be defined'); } @@ -809,7 +809,7 @@ describe('AuthService', () => { globalConfig.userManagement.jwtSessionDurationHours = testDurationHours; const token = authService.issueJWT(user, false, browserId); - const decodedToken = jwtService.verify(token); + const decodedToken = jwtService.verify('session', token); if (decodedToken.exp === undefined || decodedToken.iat === undefined) { expect.fail('Expected exp and iat to be defined on decodedToken'); } @@ -865,7 +865,7 @@ describe('AuthService', () => { ['the user id is empty', { id: '', hash: 'mJAYx4Wb7k' }], ['the hash is missing', { id: '123' }], ])('should throw when %s', async (_name, payload) => { - const token = jwtService.sign(payload, { expiresIn: '1h' }); + const token = jwtService.sign('session', payload, { expiresIn: '1h' }); await expect(authService.resolveJwt(token, req, res)).rejects.toThrow('Unauthorized'); expect(userRepository.findOne).not.toHaveBeenCalled(); @@ -874,7 +874,7 @@ describe('AuthService', () => { it('should throw when the payload is missing the expiry', async () => { // jwt.verify treats an absent `exp` as a token that never expires. - const token = jwtService.sign({ id: user.id, hash: 'mJAYx4Wb7k' }); + const token = jwtService.sign('session', { id: user.id, hash: 'mJAYx4Wb7k' }); await expect(authService.resolveJwt(token, req, res)).rejects.toThrow('Unauthorized'); expect(userRepository.findOne).not.toHaveBeenCalled(); @@ -889,6 +889,7 @@ describe('AuthService', () => { ['browserId', { browserId: 0 }], ])('should throw when %s is present but not its declared type', async (_name, claim) => { const token = jwtService.sign( + 'session', { id: user.id, hash: 'mJAYx4Wb7k', ...claim }, { expiresIn: '1h' }, ); diff --git a/packages/cli/src/auth/auth.service.ts b/packages/cli/src/auth/auth.service.ts index bfdfeeaf703..4e561df5834 100644 --- a/packages/cli/src/auth/auth.service.ts +++ b/packages/cli/src/auth/auth.service.ts @@ -218,7 +218,9 @@ export class AuthService { if (token) { try { - const payload = this.jwtService.verify(token, { algorithms: ['HS256'] }); + const payload = this.jwtService.verify('session', token, { + algorithms: ['HS256'], + }); if (this.isAuthJwtPayload(payload)) { next(); return; @@ -281,7 +283,7 @@ export class AuthService { const token = req.cookies[AUTH_COOKIE_NAME]; if (!token) return; try { - const { exp } = this.jwtService.decode(token); + const { exp } = this.jwtService.decodeUnverified(token); if (exp) { await this.invalidAuthTokenRepository.insert({ token, @@ -326,7 +328,7 @@ export class AuthService { usedMfa, ...(isEmbed && { isEmbed }), }; - return this.jwtService.sign(payload, { + return this.jwtService.sign('session', payload, { expiresIn: this.jwtExpiration, }); } @@ -425,7 +427,7 @@ export class AuthService { user: User; jwtPayload: IssuedJWT; }> { - const jwtPayload = this.jwtService.verify(token, { + const jwtPayload = this.jwtService.verify('session', token, { algorithms: ['HS256'], }); @@ -490,7 +492,7 @@ export class AuthService { newEmail, hash: this.createJWTHash(user), }; - const token = this.jwtService.sign(payload, { expiresIn: '20m', audience: 'n8n-email-change' }); + const token = this.jwtService.sign('emailChange', payload, { expiresIn: '20m' }); const url = new URL(`${this.urlService.getInstanceBaseUrl()}/confirm-email-change`); url.searchParams.append('token', token); return url.toString(); @@ -501,9 +503,7 @@ export class AuthService { ): Promise<{ user: User; newEmail: string } | undefined> { let decoded: EmailChangeToken; try { - decoded = this.jwtService.verify(token, { - audience: 'n8n-email-change', - }); + decoded = this.jwtService.verify('emailChange', token); } catch { return; } @@ -518,7 +518,7 @@ export class AuthService { generatePasswordResetToken(user: User, expiresIn: TimeUnitValue = '20m') { const payload: PasswordResetToken = { sub: user.id, hash: this.createJWTHash(user) }; - return this.jwtService.sign(payload, { expiresIn, audience: 'n8n-password-reset' }); + return this.jwtService.sign('passwordReset', payload, { expiresIn }); } generatePasswordResetUrl(user: User) { @@ -534,9 +534,7 @@ export class AuthService { async resolvePasswordResetToken(token: string): Promise { let decodedToken: PasswordResetToken; try { - decodedToken = this.jwtService.verify(token, { - audience: 'n8n-password-reset', - }); + decodedToken = this.jwtService.verify('passwordReset', token); } catch (e) { if (e instanceof TokenExpiredError) { this.logger.debug('Reset password token expired'); diff --git a/packages/cli/src/controllers/__tests__/users.controller.test.ts b/packages/cli/src/controllers/__tests__/users.controller.test.ts index 4c53da2a431..94ae053bd01 100644 --- a/packages/cli/src/controllers/__tests__/users.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/users.controller.test.ts @@ -85,6 +85,7 @@ describe('UsersController', () => { where: { id: inviteeId }, }); expect(jwtService.sign).toHaveBeenCalledWith( + 'invite', { inviterId, inviteeId, @@ -143,6 +144,7 @@ describe('UsersController', () => { await controller.generateInviteLink(request, mock()); expect(jwtService.sign).toHaveBeenCalledWith( + 'invite', { inviterId, inviteeId, diff --git a/packages/cli/src/controllers/users.controller.ts b/packages/cli/src/controllers/users.controller.ts index 017a3737a9e..3b4a3aab687 100644 --- a/packages/cli/src/controllers/users.controller.ts +++ b/packages/cli/src/controllers/users.controller.ts @@ -150,6 +150,7 @@ export class UsersController { } const token = this.jwtService.sign( + 'invite', { inviterId, inviteeId, diff --git a/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-arm-template.service.test.ts b/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-arm-template.service.test.ts index 7aaea681f43..c2743738739 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-arm-template.service.test.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-arm-template.service.test.ts @@ -14,6 +14,7 @@ const CREDENTIAL_ID = 'cred-1'; const jwtService = new JwtService( mock({ encryptionKey: 'test-encryption-key' }), mock({ userManagement: { jwtSecret: 'test-jwt-secret' } }), + mock(), ); const urlService = mock(); urlService.getWebhookBaseUrl.mockReturnValue('https://n8n.example.com/'); @@ -212,6 +213,7 @@ describe('TeamsArmTemplateService', () => { it('rejects an n8n token minted for anything other than this route', () => { // The instance signs tokens for other purposes with the same secret. const token = jwtService.sign( + 'session', { projectId: PROJECT_ID, agentId: AGENT_ID, credentialId: CREDENTIAL_ID }, { expiresIn: '15m' }, ); diff --git a/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-setup.service.test.ts b/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-setup.service.test.ts index e859c3f28bf..7a58844b2cb 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-setup.service.test.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-setup.service.test.ts @@ -27,6 +27,7 @@ const user = mock({ id: 'user-1' }); const jwtService = new JwtService( mock({ encryptionKey: 'test-encryption-key' }), mock({ userManagement: { jwtSecret: 'test-jwt-secret' } }), + mock(), ); describe('TeamsSetupService', () => { diff --git a/packages/cli/src/modules/agents/integrations/platforms/teams/teams-arm-template.service.ts b/packages/cli/src/modules/agents/integrations/platforms/teams/teams-arm-template.service.ts index 4b59f813a29..5a3c0ce4450 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/teams/teams-arm-template.service.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/teams/teams-arm-template.service.ts @@ -13,9 +13,6 @@ import { sanitiseAppName } from '../../integration-helpers'; */ const TOKEN_TTL = '15m'; -/** Scopes the token to this route, so no other n8n-signed token is accepted. */ -const TOKEN_SUBJECT = 'teams-arm-template'; - interface TeamsArmTokenClaims { projectId: string; agentId: string; @@ -168,7 +165,7 @@ export class TeamsArmTemplateService { */ verifyToken(projectId: string, agentId: string, credentialId: string, token: string): boolean { try { - const claims = this.jwtService.verify(token, { subject: TOKEN_SUBJECT }); + const claims = this.jwtService.verify('teamsArmTemplate', token); return ( claims.projectId === projectId && claims.agentId === agentId && @@ -181,8 +178,9 @@ export class TeamsArmTemplateService { private signToken(projectId: string, agentId: string, credentialId: string): string { return this.jwtService.sign( + 'teamsArmTemplate', { projectId, agentId, credentialId }, - { subject: TOKEN_SUBJECT, expiresIn: TOKEN_TTL }, + { expiresIn: TOKEN_TTL }, ); } diff --git a/packages/cli/src/modules/mcp/__tests__/mcp-api-key.service.integration.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp-api-key.service.integration.test.ts index 5bb79652aea..6f9aeff4fb3 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp-api-key.service.integration.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp-api-key.service.integration.test.ts @@ -21,7 +21,7 @@ function makeScopedJwt( opts: { actSub?: string; expired?: boolean } = {}, ): string { const now = Math.floor(Date.now() / 1000); - return jwtService.sign({ + return jwtService.sign('tokenExchange', { iss: TOKEN_EXCHANGE_ISSUER, sub, ...(opts.actSub && { act: { sub: opts.actSub } }), @@ -166,10 +166,9 @@ describe('McpServerApiKeyService.verifyApiKey (integration)', () => { // the JWT verify step (with the expected audience) must still reject a token signed // with a different aud claim. const owner = await createOwner(); - const jwt = jwtService.sign({ + const jwt = jwtService.sign('publicApiKey', { sub: owner.id, iss: 'n8n', - aud: 'public-api', jti: 'tampered', }); 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 b630f1d489c..30a6fd87a80 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 @@ -26,7 +26,7 @@ const mockReqWith = (authHeader: string | undefined, body?: any) => { }; const instanceSettings = mock({ encryptionKey: 'test-key' }); -const jwtService = new JwtService(instanceSettings, mock()); +const jwtService = new JwtService(instanceSettings, mock(), mock()); let mcpServerApiKeyService: Mocked; let oauthTokenVerifier: Mocked; @@ -61,11 +61,10 @@ 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.sign({ - sub: 'user-123', - aud: 'mcp-server-api', - meta: { isOAuth: true }, - }); + const oauthToken = jwtService.signForResource( + { sub: 'user-123', meta: { isOAuth: true } }, + 'mcp-server-api', + ); oauthTokenVerifier.verifyOAuthAccessToken.mockResolvedValue({ user, @@ -84,10 +83,7 @@ describe('McpServerMiddlewareService', () => { it('should return user for valid API key (no meta.isOAuth)', async () => { const user = mock({ id: 'user-123' }); - const apiKeyToken = jwtService.sign({ - sub: 'user-123', - aud: 'mcp-server-api', - }); + const apiKeyToken = jwtService.sign('mcpApiKey', { sub: 'user-123' }); mcpServerApiKeyService.verifyApiKey.mockResolvedValue({ user, @@ -103,9 +99,8 @@ describe('McpServerMiddlewareService', () => { it('should return user for valid API key (meta.isOAuth = false)', async () => { const user = mock({ id: 'user-123' }); - const apiKeyToken = jwtService.sign({ + const apiKeyToken = jwtService.sign('mcpApiKey', { sub: 'user-123', - aud: 'mcp-server-api', meta: { isOAuth: false }, }); @@ -132,11 +127,10 @@ describe('McpServerMiddlewareService', () => { }); it('should return null when OAuth token verification fails', async () => { - const oauthToken = jwtService.sign({ - sub: 'user-123', - aud: 'mcp-server-api', - meta: { isOAuth: true }, - }); + const oauthToken = jwtService.signForResource( + { sub: 'user-123', meta: { isOAuth: true } }, + 'mcp-server-api', + ); oauthTokenVerifier.verifyOAuthAccessToken.mockResolvedValue({ user: null }); @@ -146,10 +140,7 @@ describe('McpServerMiddlewareService', () => { }); it('should return null when API key verification fails', async () => { - const apiKeyToken = jwtService.sign({ - sub: 'user-123', - aud: 'mcp-server-api', - }); + const apiKeyToken = jwtService.sign('mcpApiKey', { sub: 'user-123' }); mcpServerApiKeyService.verifyApiKey.mockResolvedValue({ user: null }); @@ -310,11 +301,10 @@ describe('McpServerMiddlewareService', () => { it('should authenticate with valid OAuth token and call next', async () => { const user = mock({ id: 'user-123' }); - const oauthToken = jwtService.sign({ - sub: 'user-123', - aud: 'mcp-server-api', - meta: { isOAuth: true }, - }); + const oauthToken = jwtService.signForResource( + { sub: 'user-123', meta: { isOAuth: true } }, + 'mcp-server-api', + ); const req = mockReqWith(`Bearer ${oauthToken}`); const res = mockDeep(); @@ -338,11 +328,10 @@ describe('McpServerMiddlewareService', () => { it('should attach the OAuth token scopes to the request', async () => { const user = mock({ id: 'user-123' }); - const oauthToken = jwtService.sign({ - sub: 'user-123', - aud: 'mcp-server-api', - meta: { isOAuth: true }, - }); + const oauthToken = jwtService.signForResource( + { sub: 'user-123', meta: { isOAuth: true } }, + 'mcp-server-api', + ); const req = mockReqWith(`Bearer ${oauthToken}`); const res = mockDeep(); @@ -363,11 +352,10 @@ describe('McpServerMiddlewareService', () => { it('should attach the OAuth client the token was issued to', async () => { const user = mock({ id: 'user-123' }); - const oauthToken = jwtService.sign({ - sub: 'user-123', - aud: 'mcp-server-api', - meta: { isOAuth: true }, - }); + const oauthToken = jwtService.signForResource( + { sub: 'user-123', meta: { isOAuth: true } }, + 'mcp-server-api', + ); const req = mockReqWith(`Bearer ${oauthToken}`); const res = mockDeep(); @@ -389,10 +377,7 @@ describe('McpServerMiddlewareService', () => { it('should authenticate with valid API key and call next', async () => { const user = mock({ id: 'user-123' }); - const apiKeyToken = jwtService.sign({ - sub: 'user-123', - aud: 'mcp-server-api', - }); + const apiKeyToken = jwtService.sign('mcpApiKey', { sub: 'user-123' }); const req = mockReqWith(`Bearer ${apiKeyToken}`); const res = mockDeep(); @@ -423,7 +408,7 @@ describe('McpServerMiddlewareService', () => { it('should authenticate with a delegated scoped JWT and set req.user to the actor', async () => { const actor = mock({ id: 'actor-1' }); - const scopedJwt = jwtService.sign({ + const scopedJwt = jwtService.sign('tokenExchange', { iss: 'n8n-token-exchange', sub: 'subject-1', act: { sub: 'actor-1' }, @@ -450,11 +435,10 @@ describe('McpServerMiddlewareService', () => { }); it('should return 401 with WWW-Authenticate header when token validation fails', async () => { - const invalidToken = jwtService.sign({ - sub: 'user-123', - aud: 'mcp-server-api', - meta: { isOAuth: true }, - }); + const invalidToken = jwtService.signForResource( + { sub: 'user-123', meta: { isOAuth: true } }, + 'mcp-server-api', + ); const req = mockReqWith(`Bearer ${invalidToken}`); const res = mockDeep(); diff --git a/packages/cli/src/modules/mcp/mcp-api-key.service.ts b/packages/cli/src/modules/mcp/mcp-api-key.service.ts index 1cc30261c22..b61c208de4f 100644 --- a/packages/cli/src/modules/mcp/mcp-api-key.service.ts +++ b/packages/cli/src/modules/mcp/mcp-api-key.service.ts @@ -30,10 +30,9 @@ export class McpServerApiKeyService { async createMcpServerApiKey(user: User, trx?: EntityManager) { const manager = trx ?? this.apiKeyRepository.manager; - const apiKey = this.jwtService.sign({ + const apiKey = this.jwtService.sign('mcpApiKey', { sub: user.id, iss: API_KEY_ISSUER, - aud: API_KEY_AUDIENCE, jti: randomUUID(), }); diff --git a/packages/cli/src/modules/mcp/mcp-server-middleware.service.ts b/packages/cli/src/modules/mcp/mcp-server-middleware.service.ts index 3688e459a49..93998ce2b3a 100644 --- a/packages/cli/src/modules/mcp/mcp-server-middleware.service.ts +++ b/packages/cli/src/modules/mcp/mcp-server-middleware.service.ts @@ -44,7 +44,7 @@ export class McpServerMiddlewareService { async getUserForToken(token: string): Promise { let decoded: { meta?: { isOAuth?: boolean } }; try { - decoded = this.jwtService.decode<{ meta?: { isOAuth?: boolean } }>(token); + decoded = this.jwtService.decodeUnverified<{ meta?: { isOAuth?: boolean } }>(token); } catch (error) { return { user: null, diff --git a/packages/cli/src/modules/oauth-server/__tests__/oauth-consent.controller.api.test.ts b/packages/cli/src/modules/oauth-server/__tests__/oauth-consent.controller.api.test.ts index 875954c6869..01405e4970c 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/oauth-consent.controller.api.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/oauth-consent.controller.api.test.ts @@ -21,7 +21,7 @@ let jwtService: JwtService; let supportedScopes: string[]; const createSessionToken = (payload: OAuthSessionPayload): string => { - return jwtService.sign(payload, { expiresIn: '10m' }); + return jwtService.sign('oauthSession', payload, { expiresIn: '10m' }); }; let oauthClientRepository: OAuthClientRepository; diff --git a/packages/cli/src/modules/oauth-server/__tests__/oauth-session.service.test.ts b/packages/cli/src/modules/oauth-server/__tests__/oauth-session.service.test.ts index 840f1278303..60ea7162865 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/oauth-session.service.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/oauth-session.service.test.ts @@ -11,7 +11,7 @@ describe('OAuthSessionService', () => { const globalConfig = mock({ userManagement: { jwtSecret: 'random-secret' }, }); - const jwtService = new JwtService(mock(), globalConfig); + const jwtService = new JwtService(mock(), globalConfig, mock()); const oauthSessionService = new OAuthSessionService(jwtService); const sessionPayload: OAuthSessionPayload = { @@ -23,13 +23,13 @@ describe('OAuthSessionService', () => { describe('verifySession', () => { it('should return the payload of an authorization session token', () => { - const token = jwtService.sign(sessionPayload, { expiresIn: '10m' }); + const token = jwtService.sign('oauthSession', sessionPayload, { expiresIn: '10m' }); expect(oauthSessionService.verifySession(token)).toMatchObject(sessionPayload); }); it('should throw when the signature does not match', () => { - const token = jwtService.sign(sessionPayload, { expiresIn: '10m' }); + const token = jwtService.sign('oauthSession', sessionPayload, { expiresIn: '10m' }); const [header, payload, signature] = token.split('.'); const tampered = [header, payload, `${signature}x`].join('.'); @@ -37,20 +37,31 @@ describe('OAuthSessionService', () => { }); it('should throw when the token has expired', () => { - const token = jwtService.sign(sessionPayload, { expiresIn: -10 }); + const token = jwtService.sign('oauthSession', sessionPayload, { expiresIn: -10 }); expect(() => oauthSessionService.verifySession(token)).toThrow(TokenExpiredError); }); + // The payload is a valid authorization session, so only the audience stands + // between a token minted for another purpose and the consent flow. + it.each(['session', 'invite'] as const)( + 'should throw for a session payload signed for %s', + (purpose) => { + const token = jwtService.sign(purpose, sessionPayload, { expiresIn: '10m' }); + + expect(() => oauthSessionService.verifySession(token)).toThrow(JsonWebTokenError); + }, + ); + it('should throw when the payload does not describe an authorization session', () => { - const token = jwtService.sign({ sub: 'user-id' }, { expiresIn: '10m' }); + const token = jwtService.sign('oauthSession', { sub: 'user-id' }, { expiresIn: '10m' }); expect(() => oauthSessionService.verifySession(token)).toThrow(ZodError); }); it('should throw when a required field is missing', () => { const { codeChallenge: _, ...withoutCodeChallenge } = sessionPayload; - const token = jwtService.sign(withoutCodeChallenge, { expiresIn: '10m' }); + const token = jwtService.sign('oauthSession', withoutCodeChallenge, { expiresIn: '10m' }); expect(() => oauthSessionService.verifySession(token)).toThrow(ZodError); }); 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 98b7be7d8dc..9a3e9b84263 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 @@ -24,7 +24,7 @@ import type { UrlService } from '@n8n/backend-services'; import type { WorkflowFinderService } from '@/workflows/workflow-finder.service'; const instanceSettings = mock({ encryptionKey: 'test-key' }); -const jwtService = new JwtService(instanceSettings, mock()); +const jwtService = new JwtService(instanceSettings, mock(), mock()); let logger: Mocked; let userRepository: Mocked; @@ -97,7 +97,7 @@ describe('OAuthTokenService', () => { expect(accessToken).toMatch(/^[\w-]+\.[\w-]+\.[\w-]+$/); // JWT format - const decoded = jwtService.decode(accessToken); + const decoded = jwtService.decodeUnverified(accessToken); expect(decoded.iss).toBe(TEST_BASE_URL); expect(decoded.sub).toBe(userId); expect(decoded.aud).toBe(TEST_RESOURCE_URL); @@ -123,7 +123,7 @@ describe('OAuthTokenService', () => { [], ); - const decoded = jwtService.decode(accessToken); + const decoded = jwtService.decodeUnverified(accessToken); expect(decoded.aud).toBe('https://n8n.example.com/mcp-server/http'); }); @@ -133,14 +133,14 @@ describe('OAuthTokenService', () => { 'execution:read', ]); - const decoded = jwtService.decode(accessToken); + const decoded = jwtService.decodeUnverified(accessToken); expect(decoded.scope).toBe('workflow:read execution:read'); }); it('should mint an empty scope claim for scope-less grants', () => { const { accessToken } = service.generateTokenPair('user-123', 'client-456', undefined, []); - const decoded = jwtService.decode(accessToken); + const decoded = jwtService.decodeUnverified(accessToken); expect(decoded.scope).toBe(''); }); @@ -263,7 +263,7 @@ describe('OAuthTokenService', () => { 'https://n8n.example.com/mcp-server/http', ); - const decoded = jwtService.decode(result.access_token); + const decoded = jwtService.decodeUnverified(result.access_token); expect(decoded.aud).toBe('https://n8n.example.com/mcp-server/http'); }); @@ -287,7 +287,9 @@ describe('OAuthTokenService', () => { const result = await service.validateAndRotateRefreshToken(refreshToken, clientId); expect(result.scope).toBe('workflow:read execution:read'); - expect(jwtService.decode(result.access_token).scope).toBe('workflow:read execution:read'); + expect(jwtService.decodeUnverified(result.access_token).scope).toBe( + 'workflow:read execution:read', + ); expect(refreshTokenRepository.insertToken).toHaveBeenCalledWith( expect.objectContaining({ scope: ['workflow:read', 'execution:read'] }), expect.anything(), @@ -384,7 +386,7 @@ describe('OAuthTokenService', () => { const result = await boundService.validateAndRotateRefreshToken(REFRESH_TOKEN, CLIENT_ID); // Not the default resource, which is what a resource-less mint would fall back to. - expect(jwtService.decode(result.access_token).aud).toBe(GRANTED_URL); + expect(jwtService.decodeUnverified(result.access_token).aud).toBe(GRANTED_URL); }); it('accepts a request naming the granted resource', async () => { @@ -394,7 +396,7 @@ describe('OAuthTokenService', () => { GRANTED_URL, ); - expect(jwtService.decode(result.access_token).aud).toBe(GRANTED_URL); + expect(jwtService.decodeUnverified(result.access_token).aud).toBe(GRANTED_URL); }); it('accepts an equivalent spelling of the granted resource', async () => { @@ -405,7 +407,7 @@ describe('OAuthTokenService', () => { ); // Minted from the stored spelling, which the resource also accepts. - expect(jwtService.decode(result.access_token).aud).toBe(GRANTED_URL); + expect(jwtService.decodeUnverified(result.access_token).aud).toBe(GRANTED_URL); }); it('rejects a request naming a different resource, leaving the token usable', async () => { @@ -469,11 +471,10 @@ describe('OAuthTokenService', () => { }); it('should throw error for wrong audience', async () => { - const wrongAudienceToken = jwtService.sign({ - sub: 'user-123', - aud: 'wrong-audience', // Matches neither legacy literal nor resource URL - client_id: 'client-456', - }); + const wrongAudienceToken = jwtService.signForResource( + { sub: 'user-123', client_id: 'client-456' }, + 'wrong-audience', + ); // Matches neither legacy literal nor resource URL; await expect(service.verifyAccessToken(wrongAudienceToken)).rejects.toThrow( 'JWT Verification Failed', @@ -482,11 +483,10 @@ 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.sign({ - sub: 'user-123', - aud: audience, - client_id: 'client-456', - }); + const canonicalAudienceToken = jwtService.signForResource( + { sub: 'user-123', client_id: 'client-456' }, + audience, + ); accessTokenRepository.findOne.mockResolvedValue( mock({ @@ -508,11 +508,10 @@ 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.sign({ - sub: 'user-123', - aud: 'mcp-server-api', - client_id: 'client-456', - }); + const legacyAudienceToken = jwtService.signForResource( + { sub: 'user-123', client_id: 'client-456' }, + 'mcp-server-api', + ); accessTokenRepository.findOne.mockResolvedValue( mock({ @@ -533,11 +532,10 @@ 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.sign({ - sub: userId, - aud: 'mcp-server-api', - client_id: clientId, - }); + const legacyToken = jwtService.signForResource( + { sub: userId, client_id: clientId }, + 'mcp-server-api', + ); accessTokenRepository.findOne.mockResolvedValue( mock({ token: legacyToken, clientId, userId }), ); @@ -551,11 +549,10 @@ describe('OAuthTokenService', () => { it('should accept token whose aud is the resource URL', async () => { const userId = 'user-123'; const clientId = 'client-456'; - const urlToken = jwtService.sign({ - sub: userId, - aud: TEST_RESOURCE_URL, - client_id: clientId, - }); + const urlToken = jwtService.signForResource( + { sub: userId, client_id: clientId }, + TEST_RESOURCE_URL, + ); accessTokenRepository.findOne.mockResolvedValue( mock({ token: urlToken, clientId, userId }), ); @@ -793,11 +790,10 @@ describe('OAuthTokenService', () => { }); it('should reject a token whose aud belongs to another resource', async () => { - const tokenForResourceA = jwtService.sign({ - sub: 'user-123', - aud: RESOURCE_A_URL, - client_id: 'client-456', - }); + const tokenForResourceA = jwtService.signForResource( + { sub: 'user-123', client_id: 'client-456' }, + RESOURCE_A_URL, + ); await expect( multiResourceService.verifyAccessToken(tokenForResourceA, RESOURCE_B_URL), @@ -805,11 +801,10 @@ describe('OAuthTokenService', () => { }); it('should not accept the legacy audience at a non-default resource', async () => { - const legacyToken = jwtService.sign({ - sub: 'user-123', - aud: LEGACY_AUDIENCE, - client_id: 'client-456', - }); + const legacyToken = jwtService.signForResource( + { sub: 'user-123', client_id: 'client-456' }, + LEGACY_AUDIENCE, + ); await expect( multiResourceService.verifyAccessToken(legacyToken, RESOURCE_B_URL), @@ -817,11 +812,10 @@ describe('OAuthTokenService', () => { }); it('should accept a token at its own resource', async () => { - const tokenForResourceB = jwtService.sign({ - sub: 'user-123', - aud: RESOURCE_B_URL, - client_id: 'client-456', - }); + const tokenForResourceB = jwtService.signForResource( + { sub: 'user-123', client_id: 'client-456' }, + RESOURCE_B_URL, + ); accessTokenRepository.findOne.mockResolvedValue( mock({ token: tokenForResourceB, clientId: 'client-456', userId: 'user-123' }), ); @@ -832,11 +826,10 @@ describe('OAuthTokenService', () => { }); it('should still accept the legacy audience at the default (instance MCP) resource', async () => { - const legacyToken = jwtService.sign({ - sub: 'user-123', - aud: LEGACY_AUDIENCE, - client_id: 'client-456', - }); + const legacyToken = jwtService.signForResource( + { sub: 'user-123', client_id: 'client-456' }, + LEGACY_AUDIENCE, + ); accessTokenRepository.findOne.mockResolvedValue( mock({ token: legacyToken, clientId: 'client-456', userId: 'user-123' }), ); @@ -900,11 +893,10 @@ 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.sign({ - sub: 'user-123', - aud: TEST_RESOURCE_URL, - client_id: 'client-456', - }); + const legacyToken = jwtService.signForResource( + { sub: 'user-123', client_id: 'client-456' }, + TEST_RESOURCE_URL, + ); accessTokenRepository.findOne.mockResolvedValue( mock({ token: legacyToken, clientId: 'client-456', userId: 'user-123' }), ); @@ -1002,11 +994,10 @@ 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.sign({ - sub: 'user-123', - aud: audience, - client_id: 'client-456', - }); + const token = jwtService.signForResource( + { sub: 'user-123', client_id: 'client-456' }, + audience, + ); accessTokenRepository.findOne.mockResolvedValue( mock({ token, clientId: 'client-456', userId: 'user-123' }), ); @@ -1017,11 +1008,10 @@ describe('OAuthTokenService', () => { }); it('should reject a token whose aud is an unconfigured host', async () => { - const token = jwtService.sign({ - sub: 'user-123', - aud: 'https://other.example.com/mcp-server/http', - client_id: 'client-456', - }); + const token = jwtService.signForResource( + { sub: 'user-123', client_id: 'client-456' }, + 'https://other.example.com/mcp-server/http', + ); await expect( configuredService.verifyAccessToken(token, CONFIGURED_RESOURCE_URL), diff --git a/packages/cli/src/modules/oauth-server/__tests__/workflow-mcp-trigger-authorization.api.test.ts b/packages/cli/src/modules/oauth-server/__tests__/workflow-mcp-trigger-authorization.api.test.ts index c71d2fcef2b..61ce1261da7 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/workflow-mcp-trigger-authorization.api.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/workflow-mcp-trigger-authorization.api.test.ts @@ -116,7 +116,7 @@ const sessionTokenFor = async (resourceUrl: string) => { state: 'test-state', resource: resourceUrl, }; - return jwtService.sign(payload, { expiresIn: '10m' }); + return jwtService.sign('oauthSession', payload, { expiresIn: '10m' }); }; beforeAll(async () => { @@ -282,7 +282,7 @@ describe('token endpoint: refresh grants stay on their approved resource', () => const response = await refresh(clientId, refreshToken); expect(response.statusCode).toBe(200); - expect(jwtService.decode(response.body.access_token).aud).toBe(resourceUrl); + expect(jwtService.decodeUnverified(response.body.access_token).aud).toBe(resourceUrl); }); test('keeps the resource on the rotated refresh token', async () => { @@ -299,6 +299,6 @@ describe('token endpoint: refresh grants stay on their approved resource', () => const reused = await refresh(clientId, rotated.body.refresh_token); expect(reused.statusCode).toBe(200); - expect(jwtService.decode(reused.body.access_token).aud).toBe(resourceA); + expect(jwtService.decodeUnverified(reused.body.access_token).aud).toBe(resourceA); }); }); diff --git a/packages/cli/src/modules/oauth-server/oauth-session.service.ts b/packages/cli/src/modules/oauth-server/oauth-session.service.ts index 366d348a4a2..50a30fcf05a 100644 --- a/packages/cli/src/modules/oauth-server/oauth-session.service.ts +++ b/packages/cli/src/modules/oauth-server/oauth-session.service.ts @@ -32,7 +32,7 @@ export class OAuthSessionService { * Create OAuth session token and set it as a cookie */ createSession(res: Response, payload: OAuthSessionPayload): void { - const sessionToken = this.jwtService.sign(payload, { + const sessionToken = this.jwtService.sign('oauthSession', payload, { expiresIn: '10m', }); @@ -52,7 +52,7 @@ export class OAuthSessionService { * here and reject anything that is not an authorization session. */ verifySession(sessionToken: string): OAuthSessionPayload { - const payload = this.jwtService.verify(sessionToken); + const payload = this.jwtService.verify('oauthSession', sessionToken); return oauthSessionPayloadSchema.parse(payload); } 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 c38b5473dc1..45912c1a880 100644 --- a/packages/cli/src/modules/oauth-server/oauth-token.service.ts +++ b/packages/cli/src/modules/oauth-server/oauth-token.service.ts @@ -78,11 +78,10 @@ export class OAuthTokenService implements OAuthTokenVerifier { ); } - const accessToken = this.jwtService.sign( + const accessToken = this.jwtService.signForResource( { iss: this.urlService.getInstanceBaseUrl(), sub: userId, - aud: audience, client_id: clientId, jti: randomUUID(), iat: Math.floor(Date.now() / 1000), @@ -95,6 +94,7 @@ export class OAuthTokenService implements OAuthTokenVerifier { isOAuth: true, }, }, + audience, { header: { typ: 'at+jwt', @@ -478,14 +478,12 @@ export class OAuthTokenService implements OAuthTokenVerifier { // tokens minted before n8n v2.19 have aged out (refresh-token lifespan). private verifyJwtWithAllowedAudiences(token: string, audiences: string[]): unknown { try { - return this.jwtService.verify(token, { - audience: audiences as [string, ...string[]], - }); + return this.jwtService.verifyForResource(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.verify(token, { audience }); + return this.jwtService.verifyForResource(token, audience); } catch { continue; } diff --git a/packages/cli/src/modules/sso-oidc/__tests__/oidc.service.ee.test.ts b/packages/cli/src/modules/sso-oidc/__tests__/oidc.service.ee.test.ts index 0b38af59e38..e4150581f2b 100644 --- a/packages/cli/src/modules/sso-oidc/__tests__/oidc.service.ee.test.ts +++ b/packages/cli/src/modules/sso-oidc/__tests__/oidc.service.ee.test.ts @@ -395,8 +395,8 @@ describe('OidcService', () => { describe('state redirect target', () => { beforeEach(() => { - vi.mocked(jwtService.sign).mockImplementation((payload) => JSON.stringify(payload)); - vi.mocked(jwtService.verify).mockImplementation((token) => JSON.parse(token)); + vi.mocked(jwtService.sign).mockImplementation((_purpose, payload) => JSON.stringify(payload)); + vi.mocked(jwtService.verify).mockImplementation((_purpose, token) => JSON.parse(token)); }); it('round-trips the in-app destination through the signed state', () => { diff --git a/packages/cli/src/modules/sso-oidc/oidc.service.ee.ts b/packages/cli/src/modules/sso-oidc/oidc.service.ee.ts index ad7647bd2b3..40cd4e25d1a 100644 --- a/packages/cli/src/modules/sso-oidc/oidc.service.ee.ts +++ b/packages/cli/src/modules/sso-oidc/oidc.service.ee.ts @@ -144,7 +144,7 @@ export class OidcService { payload.redirectUrl = redirectUrl; } return { - signed: this.jwtService.sign(payload, { expiresIn: '15m' }), + signed: this.jwtService.sign('oidcState', payload, { expiresIn: '15m' }), plaintext: state, }; } @@ -154,7 +154,7 @@ export class OidcService { let testMode: boolean | undefined; let redirectUrl: unknown; try { - const decodedState = this.jwtService.verify(signedState); + const decodedState = this.jwtService.verify('oidcState', signedState); state = decodedState?.state; testMode = decodedState?.testMode; redirectUrl = decodedState?.redirectUrl; @@ -193,7 +193,7 @@ export class OidcService { generateNonce() { const nonce = `n8n_nonce:${randomUUID()}`; return { - signed: this.jwtService.sign({ nonce }, { expiresIn: '15m' }), + signed: this.jwtService.sign('oidcNonce', { nonce }, { expiresIn: '15m' }), plaintext: nonce, }; } @@ -201,7 +201,7 @@ export class OidcService { verifyNonce(signedNonce: string) { let nonce: string; try { - const decodedNonce = this.jwtService.verify(signedNonce); + const decodedNonce = this.jwtService.verify('oidcNonce', signedNonce); nonce = decodedNonce?.nonce; } catch (error) { this.logger.error('Failed to verify nonce', { error }); diff --git a/packages/cli/src/modules/token-exchange/services/__tests__/scoped-jwt.strategy.integration.test.ts b/packages/cli/src/modules/token-exchange/services/__tests__/scoped-jwt.strategy.integration.test.ts index 1543c77d3b1..80ba88a6530 100644 --- a/packages/cli/src/modules/token-exchange/services/__tests__/scoped-jwt.strategy.integration.test.ts +++ b/packages/cli/src/modules/token-exchange/services/__tests__/scoped-jwt.strategy.integration.test.ts @@ -12,7 +12,7 @@ import { TOKEN_EXCHANGE_ISSUER } from '../../token-exchange.types'; import { ScopedJwtStrategy } from '../scoped-jwt.strategy'; const instanceSettings = mock({ encryptionKey: 'test-key' }); -const jwtService = new JwtService(instanceSettings, mock()); +const jwtService = new JwtService(instanceSettings, mock(), mock()); function makeBearerReq(token: string): AuthenticatedRequest { const req = mock(); @@ -21,7 +21,7 @@ function makeBearerReq(token: string): AuthenticatedRequest { } function makeScopedJwt(sub: string, actSub?: string): string { - return jwtService.sign({ + return jwtService.sign('tokenExchange', { iss: TOKEN_EXCHANGE_ISSUER, sub, ...(actSub && { act: { sub: actSub } }), @@ -99,7 +99,9 @@ describe('ScopedJwtStrategy (integration)', () => { it('rejects tokens that fail validation', async () => { // Wrong issuer → abstain. Subject missing / disabled / disabled-actor → fail. - expect(await strategy.buildTokenGrant(jwtService.sign({ iss: 'n8n', sub: '1' }))).toBeNull(); + expect( + await strategy.buildTokenGrant(jwtService.sign('tokenExchange', { iss: 'n8n', sub: '1' })), + ).toBeNull(); expect( await strategy.buildTokenGrant(makeScopedJwt('422b72e6-2df2-47c9-8082-f8393b088fde')), ).toBe(false); diff --git a/packages/cli/src/modules/token-exchange/services/__tests__/scoped-jwt.strategy.test.ts b/packages/cli/src/modules/token-exchange/services/__tests__/scoped-jwt.strategy.test.ts index e7e8dbc9ba4..3cb0a509d7c 100644 --- a/packages/cli/src/modules/token-exchange/services/__tests__/scoped-jwt.strategy.test.ts +++ b/packages/cli/src/modules/token-exchange/services/__tests__/scoped-jwt.strategy.test.ts @@ -10,10 +10,11 @@ import { TOKEN_EXCHANGE_ISSUER, type IssuedJwtPayload } from '../../token-exchan import { ScopedJwtStrategy } from '../scoped-jwt.strategy'; const instanceSettings = mock({ encryptionKey: 'test-key' }); -const jwtService = new JwtService(instanceSettings, mock()); +const jwtService = new JwtService(instanceSettings, mock(), mock()); const otherJwtService = new JwtService( mock({ encryptionKey: 'different-key' }), mock(), + mock(), ); /** Create a mock user with role.slug and role.scopes pre-populated for scope assertions. */ @@ -36,7 +37,7 @@ function makeUser( } function makeTokenExchangeJwt(payload: Partial = {}): string { - return jwtService.sign({ + return jwtService.sign('tokenExchange', { iss: TOKEN_EXCHANGE_ISSUER, sub: 'subject-id', iat: Math.floor(Date.now() / 1000), @@ -65,21 +66,27 @@ describe('ScopedJwtStrategy', () => { it.each([ ['empty token', ''], ['non-JWT garbage', 'not-a-jwt'], - ['JWT with a non-token-exchange issuer', jwtService.sign({ iss: 'n8n', sub: '123' })], - ['JWT with a foreign issuer', jwtService.sign({ iss: 'https://idp.example.com', sub: '1' })], + [ + 'JWT with a non-token-exchange issuer', + jwtService.sign('tokenExchange', { iss: 'n8n', sub: '123' }), + ], + [ + 'JWT with a foreign issuer', + jwtService.sign('tokenExchange', { iss: 'https://idp.example.com', sub: '1' }), + ], ])('returns null (abstains) for %s', async (_name, token) => { expect(await strategy.buildTokenGrant(token)).toBeNull(); }); it('returns false when signature or expiry fail', async () => { - const expired = jwtService.sign({ + const expired = jwtService.sign('tokenExchange', { iss: TOKEN_EXCHANGE_ISSUER, sub: 'subject-id', iat: Math.floor(Date.now() / 1000) - 7200, exp: Math.floor(Date.now() / 1000) - 1, jti: 'test-jti', }); - const badSignature = otherJwtService.sign({ + const badSignature = otherJwtService.sign('tokenExchange', { iss: TOKEN_EXCHANGE_ISSUER, sub: 'subject-id', iat: Math.floor(Date.now() / 1000), @@ -169,7 +176,7 @@ describe('ScopedJwtStrategy', () => { const subject = makeUser('subject-id', ['workflow:read']); userRepository.findOne.mockResolvedValue(subject); - const token = jwtService.sign({ + const token = jwtService.sign('tokenExchange', { iss: 'custom-issuer', sub: 'subject-id', iat: Math.floor(Date.now() / 1000), diff --git a/packages/cli/src/modules/token-exchange/services/scoped-jwt.strategy.ts b/packages/cli/src/modules/token-exchange/services/scoped-jwt.strategy.ts index 75f11e4ae83..148b007b0c8 100644 --- a/packages/cli/src/modules/token-exchange/services/scoped-jwt.strategy.ts +++ b/packages/cli/src/modules/token-exchange/services/scoped-jwt.strategy.ts @@ -28,7 +28,7 @@ export class ScopedJwtStrategy implements AuthStrategy { const issuer = options?.issuer ?? TOKEN_EXCHANGE_ISSUER; // 1. Decode (unverified) — check iss before expensive signature verification - const decoded = this.jwtService.decode(token); + const decoded = this.jwtService.decodeUnverified(token); if (!decoded || decoded.iss !== issuer) { return null; // Not a token-exchange JWT — pass to next strategy } @@ -36,7 +36,7 @@ export class ScopedJwtStrategy implements AuthStrategy { // 2. Verify signature + expiry let payload: IssuedJwtPayload; try { - payload = this.jwtService.verify(token, { + payload = this.jwtService.verify('tokenExchange', token, { issuer, }); } catch (error) { diff --git a/packages/cli/src/modules/token-exchange/services/token-exchange.service.ts b/packages/cli/src/modules/token-exchange/services/token-exchange.service.ts index 38df6da569a..ac2e84815bc 100644 --- a/packages/cli/src/modules/token-exchange/services/token-exchange.service.ts +++ b/packages/cli/src/modules/token-exchange/services/token-exchange.service.ts @@ -202,7 +202,7 @@ export class TokenExchangeService { jti: randomUUID(), }; - const accessToken = this.jwtService.sign(payload); + const accessToken = this.jwtService.sign('tokenExchange', payload); return { accessToken, diff --git a/packages/cli/src/services/__tests__/api-key-auth.strategy.integration.test.ts b/packages/cli/src/services/__tests__/api-key-auth.strategy.integration.test.ts index 8c2beff2261..a082ff766fc 100644 --- a/packages/cli/src/services/__tests__/api-key-auth.strategy.integration.test.ts +++ b/packages/cli/src/services/__tests__/api-key-auth.strategy.integration.test.ts @@ -32,10 +32,9 @@ const mockReqWithoutApiKey = (): AuthenticatedRequest => { }; async function signAndStoreMcpApiKey(owner: User): Promise { - const apiKey = Container.get(JwtService).sign({ + const apiKey = Container.get(JwtService).sign('mcpApiKey', { sub: owner.id, iss: API_KEY_ISSUER, - aud: 'mcp-server-api', jti: randomUUID(), }); @@ -106,7 +105,10 @@ describe('ApiKeyAuthStrategy', () => { }); it('returns null for a JWT whose issuer does not match (abstain)', async () => { - const tokenExchangeJwt = jwtService.sign({ iss: TOKEN_EXCHANGE_ISSUER, sub: '123' }); + const tokenExchangeJwt = jwtService.sign('tokenExchange', { + iss: TOKEN_EXCHANGE_ISSUER, + sub: '123', + }); expect(await strategy.buildTokenGrant(tokenExchangeJwt)).toBeNull(); }); @@ -115,7 +117,7 @@ describe('ApiKeyAuthStrategy', () => { // Each is a short setup with the same expected outcome — folded into one. expect(await strategy.buildTokenGrant('invalid')).toBe(false); - const unknownKey = jwtService.sign({ sub: '123', iss: API_KEY_ISSUER }); + const unknownKey = jwtService.sign('publicApiKey', { sub: '123', iss: API_KEY_ISSUER }); expect(await strategy.buildTokenGrant(unknownKey)).toBe(false); const expiredOwner = await createOwnerWithApiKey({ diff --git a/packages/cli/src/services/__tests__/deployment-signing-secrets.integration.test.ts b/packages/cli/src/services/__tests__/deployment-signing-secrets.integration.test.ts index 0f65cc4ad47..b4060c7e745 100644 --- a/packages/cli/src/services/__tests__/deployment-signing-secrets.integration.test.ts +++ b/packages/cli/src/services/__tests__/deployment-signing-secrets.integration.test.ts @@ -5,6 +5,7 @@ import { Container } from '@n8n/di'; import { DataSource, type Repository } from '@n8n/typeorm'; import jsonwebtoken from 'jsonwebtoken'; import { Cipher, InstanceSettings } from 'n8n-core'; +import { mock } from 'vitest-mock-extended'; import { JwtService } from '@/services/jwt.service'; @@ -130,10 +131,10 @@ describe('deployment signing secrets (integration)', () => { const globalConfig = Container.get(GlobalConfig); globalConfig.userManagement.jwtSecret = ''; - const service = new JwtService(Container.get(InstanceSettings), globalConfig); + const service = new JwtService(Container.get(InstanceSettings), globalConfig, mock()); await service.initialize(repo); - const token = service.sign({ sub: 'roundtrip' }); + const token = service.sign('session', { sub: 'roundtrip' }); expect(jsonwebtoken.verify(token, 'preseeded-db-secret')).toMatchObject({ sub: 'roundtrip' }); }); }); diff --git a/packages/cli/src/services/__tests__/jwt.service.test.ts b/packages/cli/src/services/__tests__/jwt.service.test.ts index ee7bea74418..7b77a60ef05 100644 --- a/packages/cli/src/services/__tests__/jwt.service.test.ts +++ b/packages/cli/src/services/__tests__/jwt.service.test.ts @@ -1,3 +1,4 @@ +import type { Logger } from '@n8n/backend-common'; import type { GlobalConfig } from '@n8n/config'; import jwt from 'jsonwebtoken'; import type { InstanceSettings } from 'n8n-core'; @@ -11,8 +12,6 @@ describe('JwtService', () => { const iat = 1699984313; const jwtSecret = 'random-string'; const payload = { sub: 1 }; - const signedToken = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsImlhdCI6MTY5OTk4NDMxM30.xNZOAmcidW5ovEF_mwIOzCWkJ70FEO6MFNLK2QRDOeQ'; const instanceSettings = mock({ encryptionKey: 'test-key' }); let globalConfig: GlobalConfig; @@ -31,13 +30,13 @@ describe('JwtService', () => { describe('secret initialization', () => { it('should read the secret from config, when set', () => { globalConfig.userManagement.jwtSecret = jwtSecret; - const jwtService = new JwtService(instanceSettings, globalConfig); + const jwtService = new JwtService(instanceSettings, globalConfig, mock()); expect(getJwtSecret(jwtService)).toEqual(jwtSecret); }); it('should derive the secret from encryption key when not set in config', () => { globalConfig.userManagement.jwtSecret = ''; - const jwtService = new JwtService(instanceSettings, globalConfig); + const jwtService = new JwtService(instanceSettings, globalConfig, mock()); expect(getJwtSecret(jwtService)).toEqual( 'e9e2975005eddefbd31b2c04a0b0f2d9c37d9d718cf3676cddf76d65dec555cb', ); @@ -55,23 +54,96 @@ describe('JwtService', () => { beforeEach(() => { globalConfig.userManagement.jwtSecret = jwtSecret; - jwtService = new JwtService(instanceSettings, globalConfig); + jwtService = new JwtService(instanceSettings, globalConfig, mock()); }); - it('should sign', () => { - const token = jwtService.sign(payload); - expect(token).toEqual(signedToken); + it('should bind a signed token to the audience of its purpose', () => { + const token = jwtService.sign('session', payload); + + expect(jwt.decode(token)).toMatchObject({ sub: 1, iat, aud: 'n8n:session' }); }); it('should decode and verify payload', () => { - const decodedToken = jwtService.verify(signedToken); + const token = jwtService.sign('session', payload); + + const decodedToken = jwtService.verify('session', token); + expect(decodedToken.sub).toEqual(1); expect(decodedToken.iat).toEqual(iat); }); it('should throw an error on verify if the token is expired', () => { - const expiredToken = jwt.sign(payload, jwtSecret, { expiresIn: -10 }); - expect(() => jwtService.verify(expiredToken)).toThrow(jwt.TokenExpiredError); + const expiredToken = jwtService.sign('session', payload, { expiresIn: -10 }); + + expect(() => jwtService.verify('session', expiredToken)).toThrow(jwt.TokenExpiredError); + }); + + it('should reject a token signed for a different purpose', () => { + const token = jwtService.sign('passwordReset', payload); + + expect(() => jwtService.verify('emailChange', token)).toThrow(jwt.JsonWebTokenError); + }); + + 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); + + expect(jwtService.verifyForResource(token, resource)).toMatchObject({ sub: 1 }); + expect(() => jwtService.verifyForResource(token, 'https://other.example.com')).toThrow( + jwt.JsonWebTokenError, + ); + }); + + describe('tokens minted before audience binding', () => { + const invitePayload = { inviterId: 'inviter-id', inviteeId: 'invitee-id' }; + + it('should accept an invite that carries the invite claims, and report it', () => { + const logger = mock(); + const service = new JwtService(instanceSettings, globalConfig, logger); + const unbound = jwt.sign(invitePayload, jwtSecret); + + expect(service.verify('invite', unbound)).toMatchObject(invitePayload); + expect(logger.warn).toHaveBeenCalledWith( + 'Accepted a token minted before its purpose carried an audience', + { purpose: 'invite' }, + ); + }); + + it.each([ + ['no invite claims', payload], + ['only an inviter', { inviterId: 'inviter-id' }], + ['an empty invitee', { inviterId: 'inviter-id', inviteeId: '' }], + ['non-string ids', { inviterId: 1, inviteeId: 2 }], + ])('should reject an invite with %s', (_, claims) => { + const unbound = jwt.sign(claims, jwtSecret); + + expect(() => jwtService.verify('invite', unbound)).toThrow(jwt.JsonWebTokenError); + }); + + it.each([ + 'session', + 'tokenExchange', + 'publicApiKey', + 'oidcState', + 'oidcNonce', + 'oauthSession', + ] as const)('should reject one for %s', (purpose) => { + const unbound = jwt.sign(invitePayload, jwtSecret); + + expect(() => jwtService.verify(purpose, unbound)).toThrow(jwt.JsonWebTokenError); + }); + + it('should not let a bound token stand in for an invite', () => { + const boundElsewhere = jwtService.sign('session', invitePayload); + + expect(() => jwtService.verify('invite', boundElsewhere)).toThrow(jwt.JsonWebTokenError); + }); + + it('should still reject an unbound invite with a bad signature', () => { + const unbound = jwt.sign(invitePayload, 'a-different-secret'); + + expect(() => jwtService.verify('invite', unbound)).toThrow(jwt.JsonWebTokenError); + }); }); }); @@ -88,7 +160,7 @@ describe('JwtService', () => { it('should use jwtSecret from config and skip DB entirely when set', async () => { globalConfig.userManagement.jwtSecret = 'env-pinned-secret'; const repo = makeRepo(); - const jwtService = new JwtService(instanceSettings, globalConfig); + const jwtService = new JwtService(instanceSettings, globalConfig, mock()); await jwtService.initialize(repo); @@ -100,7 +172,7 @@ describe('JwtService', () => { it('should use the value from the active DB row when one exists', async () => { const repo = makeRepo(); repo.findActiveSigningSecret.mockResolvedValue('db-stored-secret'); - const jwtService = new JwtService(instanceSettings, globalConfig); + const jwtService = new JwtService(instanceSettings, globalConfig, mock()); await jwtService.initialize(repo); @@ -115,7 +187,7 @@ describe('JwtService', () => { it('should persist the derived jwtSecret when no active DB row exists', async () => { const repo = makeRepo(); repo.findActiveSigningSecret.mockResolvedValue(null); - const jwtService = new JwtService(instanceSettings, globalConfig); + const jwtService = new JwtService(instanceSettings, globalConfig, mock()); const derivedSecret = getJwtSecret(jwtService); await jwtService.initialize(repo); @@ -130,7 +202,7 @@ describe('JwtService', () => { .mockResolvedValueOnce(null) .mockResolvedValueOnce('winner-secret'); repo.seedSigningSecret.mockResolvedValue(undefined); - const jwtService = new JwtService(instanceSettings, globalConfig); + const jwtService = new JwtService(instanceSettings, globalConfig, mock()); await jwtService.initialize(repo); diff --git a/packages/cli/src/services/__tests__/public-api-key.service.integration.test.ts b/packages/cli/src/services/__tests__/public-api-key.service.integration.test.ts index 840d5a48013..c45943ca125 100644 --- a/packages/cli/src/services/__tests__/public-api-key.service.integration.test.ts +++ b/packages/cli/src/services/__tests__/public-api-key.service.integration.test.ts @@ -16,7 +16,7 @@ import { PublicApiKeyService } from '../public-api-key.service'; const instanceSettings = mock({ encryptionKey: 'test-key' }); -const jwtService = new JwtService(instanceSettings, mock()); +const jwtService = new JwtService(instanceSettings, mock(), mock()); let apiKeyRepository: ApiKeyRepository; let publicApiKeyService: PublicApiKeyService; diff --git a/packages/cli/src/services/__tests__/public-api-key.service.test.ts b/packages/cli/src/services/__tests__/public-api-key.service.test.ts index 3163df279fc..29aab9fb506 100644 --- a/packages/cli/src/services/__tests__/public-api-key.service.test.ts +++ b/packages/cli/src/services/__tests__/public-api-key.service.test.ts @@ -104,12 +104,13 @@ describe('PublicApiKeyService', () => { }); apiKeyRepository.findOne.mockResolvedValue(existingKey); - jwtService.decode.mockReturnValue({ exp: futureExp }); + jwtService.decodeUnverified.mockReturnValue({ exp: futureExp }); jwtService.sign.mockReturnValue('new-token'); const result = await service.rotateApiKey(owner, 'key-1'); expect(jwtService.sign).toHaveBeenCalledWith( + 'publicApiKey', expect.objectContaining({ sub: 'owner-1' }), expect.objectContaining({ expiresIn: expect.any(Number) }), ); @@ -137,7 +138,7 @@ describe('PublicApiKeyService', () => { apiKeyRepository.findOne.mockResolvedValue( mock({ id: 'key-1', userId: 'owner-1', apiKey: 'old-token' }), ); - jwtService.decode.mockReturnValue({ exp: pastExp }); + jwtService.decodeUnverified.mockReturnValue({ exp: pastExp }); await expect(service.rotateApiKey(owner, 'key-1')).rejects.toThrow(BadRequestError); expect(apiKeyRepository.update).not.toHaveBeenCalled(); diff --git a/packages/cli/src/services/__tests__/user.service.test.ts b/packages/cli/src/services/__tests__/user.service.test.ts index 58ee537bd4d..a896966db5e 100644 --- a/packages/cli/src/services/__tests__/user.service.test.ts +++ b/packages/cli/src/services/__tests__/user.service.test.ts @@ -569,7 +569,7 @@ describe('UserService', () => { const result = await userService.getInvitationIdsFromPayload(token); expect(result).toEqual({ inviterId, inviteeId }); - expect(jwtService.verify).toHaveBeenCalledWith(token); + expect(jwtService.verify).toHaveBeenCalledWith('invite', token); expect(userRepository.findOne).toHaveBeenCalledWith({ where: { role: { slug: GLOBAL_OWNER_ROLE.slug } }, }); diff --git a/packages/cli/src/services/api-key-auth.strategy.ts b/packages/cli/src/services/api-key-auth.strategy.ts index 1c71f70c976..71a9059fee1 100644 --- a/packages/cli/src/services/api-key-auth.strategy.ts +++ b/packages/cli/src/services/api-key-auth.strategy.ts @@ -9,6 +9,7 @@ import { isApiKeyAuthEnabled } from '@/public-api'; import type { AuthStrategy, AuthStrategyOptions } from './auth-strategy.types'; import { JwtService } from './jwt.service'; +import { API_KEY_PURPOSES } from './token-purposes'; import { API_KEY_AUDIENCE, API_KEY_ISSUER, PREFIX_LEGACY_API_KEY } from './public-api-key.service'; const API_KEY_HEADER = 'x-n8n-api-key'; @@ -35,8 +36,8 @@ export class ApiKeyAuthStrategy implements AuthStrategy { // Legacy keys (PREFIX_LEGACY_API_KEY prefix) are never JWTs — skip the decode for them. // A null decode means the string is not a structurally valid JWT — reject authentication. if (!token.startsWith(PREFIX_LEGACY_API_KEY)) { - const decoded = this.jwtService.decode<{ iss?: string }>(token); - // Note: JwtService.decode casts its return as T, but jwt.decode() can still return null at runtime. + const decoded = this.jwtService.decodeUnverified<{ iss?: string }>(token); + // Note: decodeUnverified casts its return as T, but jwt.decode() can still return null at runtime. if (decoded === null) return false; if (decoded.iss !== issuer) return null; } @@ -52,10 +53,7 @@ export class ApiKeyAuthStrategy implements AuthStrategy { if (!token.startsWith(PREFIX_LEGACY_API_KEY)) { try { - this.jwtService.verify(token, { - issuer, - audience, - }); + this.jwtService.verify(API_KEY_PURPOSES[audience], token, { issuer }); } catch (e) { if (e instanceof TokenExpiredError) return false; throw e; diff --git a/packages/cli/src/services/jwt.service.ts b/packages/cli/src/services/jwt.service.ts index 04d3e773cd5..c01be4121fd 100644 --- a/packages/cli/src/services/jwt.service.ts +++ b/packages/cli/src/services/jwt.service.ts @@ -1,15 +1,34 @@ +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 jwt from 'jsonwebtoken'; import { InstanceSettings, type DeploymentStateRepo } from 'n8n-core'; +import { + LEGACY_UNBOUND_PURPOSES, + TOKEN_PURPOSES, + type TokenPurpose, +} from '@/services/token-purposes'; + +/** + * `audience` is omitted on purpose: it is derived from the token's purpose, so + * a caller cannot bind a token to the wrong audience or forget to bind it. + */ +export type PurposedSignOptions = Omit; +export type PurposedVerifyOptions = Omit; + @Service() export class JwtService { private jwtSecret: string = ''; private readonly jwtSecretFromEnv: boolean; - constructor({ encryptionKey }: InstanceSettings, globalConfig: GlobalConfig) { + constructor( + { encryptionKey }: InstanceSettings, + globalConfig: GlobalConfig, + private readonly logger: Logger, + ) { this.jwtSecretFromEnv = Boolean(globalConfig.userManagement.jwtSecret); this.jwtSecret = globalConfig.userManagement.jwtSecret; if (!this.jwtSecret) { @@ -48,16 +67,84 @@ export class JwtService { if (winner !== null) this.jwtSecret = winner; } - sign(payload: object, options: jwt.SignOptions = {}): string { - return jwt.sign(payload, this.jwtSecret, options); + /** Signs a token bound to the audience of `purpose`. */ + sign(purpose: TokenPurpose, payload: object, options: PurposedSignOptions = {}): string { + return jwt.sign(payload, this.jwtSecret, { + ...options, + audience: TOKEN_PURPOSES[purpose], + }); } - decode(token: string) { + /** + * 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. + */ + signForResource(payload: object, audience: string, options: PurposedSignOptions = {}): string { + return jwt.sign(payload, this.jwtSecret, { ...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. + */ + decodeUnverified(token: string) { return jwt.decode(token) as T; } - verify(token: string, options: jwt.VerifyOptions = {}) { - return jwt.verify(token, this.jwtSecret, options) as T; + /** Verifies a token and requires it to carry the audience of `purpose`. */ + verify( + purpose: TokenPurpose, + token: string, + options: PurposedVerifyOptions = {}, + ) { + try { + return jwt.verify(token, this.jwtSecret, { + ...options, + audience: TOKEN_PURPOSES[purpose], + }) as T; + } catch (error) { + return this.verifyUnbound(purpose, token, options, error); + } + } + + /** Verifies an OAuth access token against the resource(s) it may be used for. */ + verifyForResource( + token: string, + audience: string | [string, ...string[]], + options: PurposedVerifyOptions = {}, + ) { + return jwt.verify(token, this.jwtSecret, { ...options, audience }) as T; + } + + /** + * Accepts a token minted before its purpose carried an `aud` claim, so that + * invite links sent before an upgrade keep working. + * + * This only admits a token that carries no audience at all and carries the + * claims its purpose requires. A token bound to a different audience still + * fails, so the allowance cannot be used to present one kind of bound token + * as another. + * + * DEPRECATED: see `LEGACY_UNBOUND_PURPOSES`. + */ + private verifyUnbound( + purpose: TokenPurpose, + token: string, + options: PurposedVerifyOptions, + verifyError: unknown, + ): T { + const hasRequiredClaims = LEGACY_UNBOUND_PURPOSES[purpose]; + if (!hasRequiredClaims) throw verifyError; + + const claims = jwt.decode(token); + if (!isRecord(claims) || 'aud' in claims || !hasRequiredClaims(claims)) throw verifyError; + + const payload = jwt.verify(token, this.jwtSecret, options) as T; + this.logger.warn('Accepted a token minted before its purpose carried an audience', { + purpose, + }); + return payload; } } diff --git a/packages/cli/src/services/public-api-key.service.ts b/packages/cli/src/services/public-api-key.service.ts index 8fc36453038..aca95f01be6 100644 --- a/packages/cli/src/services/public-api-key.service.ts +++ b/packages/cli/src/services/public-api-key.service.ts @@ -338,13 +338,14 @@ export class PublicApiKeyService { const nowInSeconds = Math.floor(Date.now() / 1000); return this.jwtService.sign( - { sub: user.id, iss: API_KEY_ISSUER, aud: API_KEY_AUDIENCE, jti: randomUUID() }, + 'publicApiKey', + { sub: user.id, iss: API_KEY_ISSUER, jti: randomUUID() }, { ...(expiresAt && { expiresIn: expiresAt - nowInSeconds }) }, ); } getApiKeyExpiration = (apiKey: string) => { - const decoded = this.jwtService.decode(apiKey); + const decoded = this.jwtService.decodeUnverified(apiKey); return decoded?.exp ?? null; }; diff --git a/packages/cli/src/services/token-purposes.ts b/packages/cli/src/services/token-purposes.ts new file mode 100644 index 00000000000..50c4d695ac9 --- /dev/null +++ b/packages/cli/src/services/token-purposes.ts @@ -0,0 +1,78 @@ +import type { ApiKeyAudience } from '@n8n/api-types'; + +/** + * Every JWT that n8n signs with its own secret, keyed by what the token is for. + * The value becomes the token's `aud` claim, and `JwtService` derives it from + * the purpose on both sign and verify — so a token minted for one purpose + * cannot be presented for another. + * + * Add a purpose here to mint a new kind of token. + * + * The values that predate this registry are kept verbatim: they appear in + * tokens that are already issued, and `public-api` / `mcp-server-api` are also + * persisted in the `api_key.audience` column. + */ +export const TOKEN_PURPOSES = { + session: 'n8n:session', + invite: 'n8n:invite', + passwordReset: 'n8n-password-reset', + emailChange: 'n8n-email-change', + oidcState: 'n8n:oidc-state', + oidcNonce: 'n8n:oidc-nonce', + oauthSession: 'n8n:oauth-session', + tokenExchange: 'n8n:token-exchange', + teamsArmTemplate: 'n8n:teams-arm-template', + publicApiKey: 'public-api', + mcpApiKey: 'mcp-server-api', +} as const satisfies Record & { + // Keep these two in step with the persisted `api_key.audience` values. + publicApiKey: ApiKeyAudience; + mcpApiKey: ApiKeyAudience; +}; + +export type TokenPurpose = keyof typeof TOKEN_PURPOSES; + +/** + * Known overlap: `mcp-server-api` is also the MCP protected resource's + * pre-RFC-8707 audience (`LEGACY_MCP_AUDIENCE` in `mcp-protected-resource.ts`), + * so the audience alone does not tell an MCP API key from an OAuth access token + * minted for that resource. What separates them is the signed `meta.isOAuth` + * claim the MCP middleware routes on, and the row each kind must have — in + * `api_key` for one, `oauth_access_token` for the other. + * + * Giving API keys an audience of their own would invalidate every key in the + * field: they are persisted, handed to clients, and minted without an expiry, + * and the allowance below admits an absent audience, never a different one. + * Close this together with the legacy audience itself, on the v3 line — see the + * TODO on `verifyJwtWithAllowedAudiences` in `oauth-token.service.ts`. + */ + +/** Resolves the audience an API key is stored under to the purpose it was minted for. */ +export const API_KEY_PURPOSES = { + 'public-api': 'publicApiKey', + 'mcp-server-api': 'mcpApiKey', +} as const satisfies Record; + +const isNonEmptyString = (value: unknown): value is string => + typeof value === 'string' && value.length > 0; + +/** + * Purposes whose tokens used to be minted without an `aud` claim, each with + * the claims an audience-less token must carry to be accepted for it. + * + * Only invites are listed. A pending invite link lasts three months, and a + * lost one needs an admin to send it again. Every other purpose is left out: + * a user whose session or token exchange is rejected after an upgrade signs in + * or exchanges again on their own. + * + * The claim check keeps any other audience-less token signed with the same + * secret from being presented as an invite. + * + * DEPRECATED: remove this allowance once pre-upgrade invite links have expired. + * Do not add entries — a new purpose has carried an audience from its first token. + */ +export const LEGACY_UNBOUND_PURPOSES: Partial< + Record) => boolean> +> = { + invite: (claims) => isNonEmptyString(claims.inviterId) && isNonEmptyString(claims.inviteeId), +}; diff --git a/packages/cli/src/services/user.service.ts b/packages/cli/src/services/user.service.ts index 8aa51547dc6..cb28d2ab5a0 100644 --- a/packages/cli/src/services/user.service.ts +++ b/packages/cli/src/services/user.service.ts @@ -210,6 +210,7 @@ export class UserService { Object.entries(toInviteUsers).map(async ([email, id]) => { // Always use JWT-based tamper-proof invite links const token = this.jwtService.sign( + 'invite', { inviterId: owner.id, inviteeId: id, @@ -452,7 +453,10 @@ export class UserService { token: string, ): Promise<{ inviterId: string; inviteeId: string }> { try { - const decoded = this.jwtService.verify<{ inviterId: string; inviteeId: string }>(token); + const decoded = this.jwtService.verify<{ inviterId: string; inviteeId: string }>( + 'invite', + token, + ); if (!decoded.inviterId || !decoded.inviteeId) { this.logger.debug('Invalid JWT token payload - missing inviterId or inviteeId'); throw new BadRequestError('Invalid invite URL'); diff --git a/packages/cli/test/integration/auth.api.test.ts b/packages/cli/test/integration/auth.api.test.ts index ee76455231c..1ba6653b330 100644 --- a/packages/cli/test/integration/auth.api.test.ts +++ b/packages/cli/test/integration/auth.api.test.ts @@ -336,6 +336,7 @@ describe('GET /resolve-signup-token', () => { test('should validate invite token', async () => { const memberShell = await createUserShell(GLOBAL_MEMBER_ROLE); const token = Container.get(JwtService).sign( + 'invite', { inviterId: owner.id, inviteeId: memberShell.id }, { expiresIn: '90d' }, ); @@ -357,6 +358,7 @@ describe('GET /resolve-signup-token', () => { license.setQuota('quota:users', 0); const memberShell = await createUserShell(GLOBAL_MEMBER_ROLE); const token = Container.get(JwtService).sign( + 'invite', { inviterId: owner.id, inviteeId: memberShell.id }, { expiresIn: '90d' }, ); @@ -369,6 +371,7 @@ describe('GET /resolve-signup-token', () => { test('should fail with invalid inputs', async () => { const { id: inviteeId } = await createUser({ role: { slug: 'global:member' } }); const validToken = Container.get(JwtService).sign( + 'invite', { inviterId: owner.id, inviteeId }, { expiresIn: '90d' }, ); @@ -397,6 +400,7 @@ describe('GET /resolve-signup-token', () => { test('should send roles for user-invite-email-click event', async () => { const memberShell = await createUserShell(GLOBAL_MEMBER_ROLE); const token = Container.get(JwtService).sign( + 'invite', { inviterId: owner.id, inviteeId: memberShell.id }, { expiresIn: '90d' }, ); diff --git a/packages/cli/test/integration/controllers/invitation/invitation.controller.integration.test.ts b/packages/cli/test/integration/controllers/invitation/invitation.controller.integration.test.ts index a02626a9a94..fb9d10fbcdc 100644 --- a/packages/cli/test/integration/controllers/invitation/invitation.controller.integration.test.ts +++ b/packages/cli/test/integration/controllers/invitation/invitation.controller.integration.test.ts @@ -58,7 +58,7 @@ describe('InvitationController', () => { }); function invitationToken(inviterId: string, inviteeId: string): string { - return Container.get(JwtService).sign({ inviterId, inviteeId }, { expiresIn: '90d' }); + return Container.get(JwtService).sign('invite', { inviterId, inviteeId }, { expiresIn: '90d' }); } describe('POST /invitations/accept', () => { diff --git a/packages/cli/test/integration/oidc/oidc.service.ee.test.ts b/packages/cli/test/integration/oidc/oidc.service.ee.test.ts index 447ef1ba300..7a0ba93da83 100644 --- a/packages/cli/test/integration/oidc/oidc.service.ee.test.ts +++ b/packages/cli/test/integration/oidc/oidc.service.ee.test.ts @@ -1390,12 +1390,14 @@ describe('OIDC service', () => { }); it('should throw an error for an invalid formatted state', () => { - const invalid = Container.get(JwtService).sign({ state: 'invalid_state' }); + const invalid = Container.get(JwtService).sign('oidcState', { state: 'invalid_state' }); expect(() => oidcService.verifyState(invalid)).toThrow(BadRequestError); }); it('should throw an error for an invalid random part of the state', () => { - const invalid = Container.get(JwtService).sign({ state: 'n8n_state:invalid-state' }); + const invalid = Container.get(JwtService).sign('oidcState', { + state: 'n8n_state:invalid-state', + }); expect(() => oidcService.verifyState(invalid)).toThrow(BadRequestError); }); @@ -1404,12 +1406,14 @@ describe('OIDC service', () => { }); it('should throw an error for an invalid formatted nonce', () => { - const invalid = Container.get(JwtService).sign({ nonce: 'invalid_nonce' }); + const invalid = Container.get(JwtService).sign('oidcNonce', { nonce: 'invalid_nonce' }); expect(() => oidcService.verifyNonce(invalid)).toThrow(BadRequestError); }); it('should throw an error for an invalid random part of the nonce', () => { - const invalid = Container.get(JwtService).sign({ nonce: 'n8n_nonce:invalid-nonce' }); + const invalid = Container.get(JwtService).sign('oidcNonce', { + nonce: 'n8n_nonce:invalid-nonce', + }); expect(() => oidcService.verifyNonce(invalid)).toThrow(BadRequestError); }); }); diff --git a/packages/cli/test/integration/password-reset.api.test.ts b/packages/cli/test/integration/password-reset.api.test.ts index 2915fccf5b9..1788398513e 100644 --- a/packages/cli/test/integration/password-reset.api.test.ts +++ b/packages/cli/test/integration/password-reset.api.test.ts @@ -149,7 +149,7 @@ describe('GET /resolve-password-token', () => { }); test('should fail if user is not found', async () => { - const token = jwtService.sign({ sub: uuid() }); + const token = jwtService.sign('passwordReset', { sub: uuid() }); const response = await testServer.authlessAgent .get('/resolve-password-token') diff --git a/packages/cli/test/integration/public-api/endpoints-with-scopes-enabled.test.ts b/packages/cli/test/integration/public-api/endpoints-with-scopes-enabled.test.ts index f7e9c822645..81b6c8b5d9d 100644 --- a/packages/cli/test/integration/public-api/endpoints-with-scopes-enabled.test.ts +++ b/packages/cli/test/integration/public-api/endpoints-with-scopes-enabled.test.ts @@ -1709,7 +1709,7 @@ describe('Public API endpoints with API key scopes', () => { const ownerWorkflow = await createWorkflow({}, owner); const jwtService = Container.get(JwtService); - const token = jwtService.sign({ + const token = jwtService.sign('tokenExchange', { iss: TOKEN_EXCHANGE_ISSUER, sub: member.id, iat: Math.floor(Date.now() / 1000), diff --git a/packages/cli/test/integration/token-exchange/token-exchange.controller.test.ts b/packages/cli/test/integration/token-exchange/token-exchange.controller.test.ts index 727c8ed57f1..23aaaa9a3ca 100644 --- a/packages/cli/test/integration/token-exchange/token-exchange.controller.test.ts +++ b/packages/cli/test/integration/token-exchange/token-exchange.controller.test.ts @@ -158,7 +158,7 @@ describe('POST /auth/oauth/token', () => { ); // Issued token is valid and has correct claims - const decoded = jwtService.verify(body.access_token); + const decoded = jwtService.verify('tokenExchange', body.access_token); expect(decoded.iss).toBe(TOKEN_EXCHANGE_ISSUER); expect(decoded.sub).toEqual(expect.any(String)); expect(decoded.act).toBeUndefined(); @@ -211,9 +211,11 @@ describe('POST /auth/oauth/token', () => { }).expect(200); const decodedA = jwtService.verify( + 'tokenExchange', (responseA.body as TokenExchangeSuccessResponse).access_token, ); const decodedB = jwtService.verify( + 'tokenExchange', (responseB.body as TokenExchangeSuccessResponse).access_token, ); @@ -256,6 +258,7 @@ describe('POST /auth/oauth/token', () => { }).expect(200); const decoded = jwtService.verify( + 'tokenExchange', (response.body as TokenExchangeSuccessResponse).access_token, ); @@ -288,6 +291,7 @@ describe('POST /auth/oauth/token', () => { }).expect(200); const decoded = jwtService.verify( + 'tokenExchange', (response.body as TokenExchangeSuccessResponse).access_token, ); expect(decoded.scope).toBe('workflow:read');