diff --git a/packages/@n8n/config/src/index.ts b/packages/@n8n/config/src/index.ts index ccbdd472821..e47f323c4d4 100644 --- a/packages/@n8n/config/src/index.ts +++ b/packages/@n8n/config/src/index.ts @@ -35,7 +35,6 @@ import { SsoConfig } from './configs/sso.config'; import { SsrfProtectionConfig } from './configs/ssrf-protection.config'; import { TagsConfig } from './configs/tags.config'; import { TemplatesConfig } from './configs/templates.config'; -import { TokenExchangeConfig } from './configs/token-exchange.config'; import { UserManagementConfig } from './configs/user-management.config'; import { VersionNotificationsConfig } from './configs/version-notifications.config'; import { WorkflowHistoryCompactionConfig } from './configs/workflow-history-compaction.config'; @@ -68,7 +67,6 @@ export { NodesConfig } from './configs/nodes.config'; export { CronLoggingConfig } from './configs/logging.config'; export { WorkflowHistoryCompactionConfig } from './configs/workflow-history-compaction.config'; export { ChatHubConfig } from './configs/chat-hub.config'; -export { TokenExchangeConfig } from './configs/token-exchange.config'; export { ExpressionEngineConfig } from './configs/expression-engine.config'; export { PasswordConfig } from './configs/password.config'; @@ -197,9 +195,6 @@ export class GlobalConfig { @Nested sso: SsoConfig; - @Nested - tokenExchange: TokenExchangeConfig; - @Nested ssrfProtection: SsrfProtectionConfig; diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index 6876faf1788..7bbf1a52f2d 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -184,9 +184,6 @@ describe('GlobalConfig', () => { host: 'https://api.n8n.io/api/', dynamicTemplatesHost: 'https://dynamic-templates.n8n.io/templates', }, - tokenExchange: { - enabled: false, - }, versionNotifications: { enabled: true, endpoint: 'https://api.n8n.io/api/versions/', diff --git a/packages/cli/src/modules/token-exchange/__tests__/token-exchange.controller.test.ts b/packages/cli/src/modules/token-exchange/__tests__/token-exchange.controller.test.ts index 1ca15eaabbc..468666e8065 100644 --- a/packages/cli/src/modules/token-exchange/__tests__/token-exchange.controller.test.ts +++ b/packages/cli/src/modules/token-exchange/__tests__/token-exchange.controller.test.ts @@ -1,4 +1,3 @@ -import { GlobalConfig } from '@n8n/config'; import { mockInstance } from '@n8n/backend-test-utils'; import { Container } from '@n8n/di'; import type { Response } from 'express'; @@ -9,15 +8,17 @@ import { UnexpectedError } from 'n8n-workflow'; import { EventService } from '@/events/event.service'; import type { AuthlessRequest } from '@/requests'; +import { TokenExchangeConfig } from '../token-exchange.config'; import { TokenExchangeController } from '../token-exchange.controller'; import { TOKEN_EXCHANGE_GRANT_TYPE } from '../token-exchange.schemas'; import { TokenExchangeService } from '../token-exchange.service'; +import type { IssuedTokenResult } from '../token-exchange.types'; describe('TokenExchangeController', () => { mockInstance(ErrorReporter); mockInstance(EventService); mockInstance(TokenExchangeService); - const globalConfig = mockInstance(GlobalConfig); + const tokenExchangeConfig = mockInstance(TokenExchangeConfig); const controller = Container.get(TokenExchangeController); const errorReporter = Container.get(ErrorReporter); @@ -29,7 +30,8 @@ describe('TokenExchangeController', () => { beforeEach(() => { jest.resetAllMocks(); - globalConfig.tokenExchange = { enabled: true }; + tokenExchangeConfig.enabled = true; + tokenExchangeConfig.maxTokenTtl = 900; req = mock({ ip: '127.0.0.1' }); res = mock(); res.status.mockReturnThis(); @@ -39,7 +41,7 @@ describe('TokenExchangeController', () => { describe('POST /auth/oauth/token', () => { describe('feature flag', () => { test('returns 501 server_error when token exchange is disabled', async () => { - globalConfig.tokenExchange = { enabled: false }; + tokenExchangeConfig.enabled = false; req.body = { grant_type: TOKEN_EXCHANGE_GRANT_TYPE, subject_token: 'some-token', @@ -121,26 +123,32 @@ describe('TokenExchangeController', () => { subject_token: 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.sig', }; - const stubResponse = { - access_token: 'stub-access-token', - token_type: 'Bearer', - expires_in: 3600, - issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + const issuedResult: IssuedTokenResult = { + accessToken: 'eyJhbGciOiJIUzI1NiJ9.issued.token', + expiresIn: 900, + subject: 'user-123', + issuer: 'https://idp.example.com', + actor: undefined, }; - test('returns RFC 8693 success response', async () => { + test('returns RFC 8693 success response with issued token', async () => { req.body = validBody; - jest.mocked(tokenExchangeService.exchange).mockResolvedValue(true); + jest.mocked(tokenExchangeService.exchange).mockResolvedValue(issuedResult); await controller.exchangeToken(req, res); - expect(res.json).toHaveBeenCalledWith(stubResponse); + expect(res.json).toHaveBeenCalledWith({ + access_token: issuedResult.accessToken, + token_type: 'Bearer', + expires_in: issuedResult.expiresIn, + issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + }); expect(res.status).not.toHaveBeenCalled(); }); test('response includes all required RFC 8693 fields', async () => { req.body = { ...validBody, scope: 'openid profile' }; - jest.mocked(tokenExchangeService.exchange).mockResolvedValue(true); + jest.mocked(tokenExchangeService.exchange).mockResolvedValue(issuedResult); await controller.exchangeToken(req, res); @@ -154,16 +162,17 @@ describe('TokenExchangeController', () => { ); }); - test('emits token-exchange-succeeded event on success', async () => { + test('emits token-exchange-succeeded event with subject and issuer from result', async () => { req.body = validBody; - jest.mocked(tokenExchangeService.exchange).mockResolvedValue(true); + jest.mocked(tokenExchangeService.exchange).mockResolvedValue(issuedResult); await controller.exchangeToken(req, res); expect(eventService.emit).toHaveBeenCalledWith( 'token-exchange-succeeded', expect.objectContaining({ - subject: '', + subject: issuedResult.subject, + issuer: issuedResult.issuer, grantType: TOKEN_EXCHANGE_GRANT_TYPE, clientIp: '127.0.0.1', }), diff --git a/packages/cli/src/modules/token-exchange/__tests__/token-exchange.service.test.ts b/packages/cli/src/modules/token-exchange/__tests__/token-exchange.service.test.ts new file mode 100644 index 00000000000..47988ffd1eb --- /dev/null +++ b/packages/cli/src/modules/token-exchange/__tests__/token-exchange.service.test.ts @@ -0,0 +1,288 @@ +import { mockInstance } from '@n8n/backend-test-utils'; +import { Container } from '@n8n/di'; +import jwt from 'jsonwebtoken'; + +import { JwtService } from '@/services/jwt.service'; + +import { TokenExchangeConfig } from '../token-exchange.config'; +import { TOKEN_EXCHANGE_GRANT_TYPE } from '../token-exchange.schemas'; +import { TokenExchangeService } from '../token-exchange.service'; +import type { IssuedJwtPayload } from '../token-exchange.types'; + +/** Sign a minimal external token for use as subject_token / actor_token in tests. */ +function makeExternalToken( + claims: { + sub: string; + iss: string; + aud: string; + exp: number; + email?: string; + }, + secret = 'external-secret', +): string { + const now = Math.floor(Date.now() / 1000); + return jwt.sign( + { + sub: claims.sub, + iss: claims.iss, + aud: claims.aud, + iat: now, + exp: claims.exp, + jti: 'test-jti', + ...(claims.email && { email: claims.email }), + }, + secret, + { algorithm: 'HS256' }, + ); +} + +describe('TokenExchangeService', () => { + mockInstance(JwtService); + const tokenExchangeConfig = mockInstance(TokenExchangeConfig); + + const service = Container.get(TokenExchangeService); + const jwtService = Container.get(JwtService); + + const now = Math.floor(Date.now() / 1000); + const farFuture = now + 86400; // 24 hours from now + + const subjectToken = makeExternalToken({ + sub: 'user-123', + iss: 'https://idp.example.com', + aud: 'n8n', + exp: farFuture, + }); + + const actorToken = makeExternalToken({ + sub: 'actor-456', + iss: 'https://idp.example.com', + aud: 'n8n', + exp: farFuture, + }); + + const baseRequest = { + grant_type: TOKEN_EXCHANGE_GRANT_TYPE, + subject_token: subjectToken, + }; + + beforeEach(() => { + jest.resetAllMocks(); + tokenExchangeConfig.enabled = true; + tokenExchangeConfig.maxTokenTtl = 900; + + // Use real decode so we can read external token claims, but capture what gets signed. + jest.mocked(jwtService.decode).mockImplementation((token: string) => jwt.decode(token)); + jest + .mocked(jwtService.sign) + .mockImplementation((payload: object) => + jwt.sign(payload, 'test-secret', { algorithm: 'HS256' }), + ); + jest + .mocked(jwtService.verify) + .mockImplementation( + (token: string) => jwt.verify(token, 'test-secret') as ReturnType, + ); + }); + + describe('JWT claims', () => { + test('issued JWT contains correct sub and iss claims', async () => { + const result = await service.exchange(baseRequest); + + const decoded = jwt.decode(result.accessToken) as IssuedJwtPayload; + expect(decoded.sub).toBe('user-123'); + expect(decoded.iss).toBe('n8n'); + }); + + test('issued JWT contains iat, exp, and jti claims', async () => { + const result = await service.exchange(baseRequest); + + const decoded = jwt.decode(result.accessToken) as IssuedJwtPayload; + expect(decoded.iat).toBeCloseTo(now, -1); + expect(decoded.exp).toBeDefined(); + expect(typeof decoded.jti).toBe('string'); + expect(decoded.jti.length).toBeGreaterThan(0); + }); + + test('jti is unique across calls', async () => { + const r1 = await service.exchange(baseRequest); + const r2 = await service.exchange(baseRequest); + + const d1 = jwt.decode(r1.accessToken) as IssuedJwtPayload; + const d2 = jwt.decode(r2.accessToken) as IssuedJwtPayload; + expect(d1.jti).not.toBe(d2.jti); + }); + + test('act claim is absent when no actor_token provided', async () => { + const result = await service.exchange(baseRequest); + + const decoded = jwt.decode(result.accessToken) as IssuedJwtPayload; + expect(decoded.act).toBeUndefined(); + }); + + test('act claim is present with actor sub when actor_token provided', async () => { + const result = await service.exchange({ ...baseRequest, actor_token: actorToken }); + + const decoded = jwt.decode(result.accessToken) as IssuedJwtPayload; + expect(decoded.act).toEqual({ sub: 'actor-456' }); + }); + + test('scope claim is string array derived from space-delimited request scope', async () => { + const result = await service.exchange({ ...baseRequest, scope: 'openid profile email' }); + + const decoded = jwt.decode(result.accessToken) as IssuedJwtPayload; + expect(decoded.scope).toEqual(['openid', 'profile', 'email']); + }); + + test('scope claim is absent when no scope in request', async () => { + const result = await service.exchange(baseRequest); + + const decoded = jwt.decode(result.accessToken) as IssuedJwtPayload; + expect(decoded.scope).toBeUndefined(); + }); + + test('resource claim is present when provided in request', async () => { + const result = await service.exchange({ + ...baseRequest, + resource: 'https://api.example.com', + }); + + const decoded = jwt.decode(result.accessToken) as IssuedJwtPayload; + expect(decoded.resource).toBe('https://api.example.com'); + }); + + test('resource claim is absent when not in request', async () => { + const result = await service.exchange(baseRequest); + + const decoded = jwt.decode(result.accessToken) as IssuedJwtPayload; + expect(decoded.resource).toBeUndefined(); + }); + }); + + describe('expiry calculation', () => { + test('exp = min(subject.exp, now + maxTokenTtl) for impersonation (no actor)', async () => { + tokenExchangeConfig.maxTokenTtl = 900; + const result = await service.exchange(baseRequest); + + const decoded = jwt.decode(result.accessToken) as IssuedJwtPayload; + // subject.exp is far future, so ceiling is now + 900 + expect(decoded.exp).toBeCloseTo(now + 900, -1); + expect(result.expiresIn).toBeCloseTo(900, -1); + }); + + test('exp respects subject.exp when it is less than maxTokenTtl ceiling', async () => { + const shortLived = makeExternalToken({ + sub: 'user-123', + iss: 'https://idp.example.com', + aud: 'n8n', + exp: now + 300, // 5 minutes + }); + tokenExchangeConfig.maxTokenTtl = 900; + + const result = await service.exchange({ ...baseRequest, subject_token: shortLived }); + + const decoded = jwt.decode(result.accessToken) as IssuedJwtPayload; + expect(decoded.exp).toBeCloseTo(now + 300, -1); + }); + + test('exp = min(subject.exp, actor.exp, now + maxTokenTtl) for delegation', async () => { + const shortActor = makeExternalToken({ + sub: 'actor-456', + iss: 'https://idp.example.com', + aud: 'n8n', + exp: now + 200, // actor expires soonest + }); + tokenExchangeConfig.maxTokenTtl = 900; + + const result = await service.exchange({ ...baseRequest, actor_token: shortActor }); + + const decoded = jwt.decode(result.accessToken) as IssuedJwtPayload; + expect(decoded.exp).toBeCloseTo(now + 200, -1); + }); + + test('maxTokenTtl ceiling is enforced even when both tokens have far-future exp', async () => { + tokenExchangeConfig.maxTokenTtl = 60; + + const result = await service.exchange({ ...baseRequest, actor_token: actorToken }); + + const decoded = jwt.decode(result.accessToken) as IssuedJwtPayload; + expect(decoded.exp).toBeCloseTo(now + 60, -1); + }); + + test('expiresIn in result matches exp - now', async () => { + tokenExchangeConfig.maxTokenTtl = 900; + const result = await service.exchange(baseRequest); + + const decoded = jwt.decode(result.accessToken) as IssuedJwtPayload; + expect(result.expiresIn).toBeCloseTo(decoded.exp - now, -1); + }); + }); + + describe('result metadata', () => { + test('subject in result is sub from subject_token', async () => { + const result = await service.exchange(baseRequest); + expect(result.subject).toBe('user-123'); + }); + + test('issuer in result is iss from subject_token', async () => { + const result = await service.exchange(baseRequest); + expect(result.issuer).toBe('https://idp.example.com'); + }); + + test('actor in result is sub from actor_token when present', async () => { + const result = await service.exchange({ ...baseRequest, actor_token: actorToken }); + expect(result.actor).toBe('actor-456'); + }); + + test('actor in result is undefined when no actor_token', async () => { + const result = await service.exchange(baseRequest); + expect(result.actor).toBeUndefined(); + }); + }); + + describe('JWT verifiability', () => { + test('issued JWT is verifiable with jwtService.verify()', async () => { + const result = await service.exchange(baseRequest); + expect(() => jwtService.verify(result.accessToken)).not.toThrow(); + }); + }); + + describe('invalid input', () => { + test('throws when subject_token has invalid claims structure', async () => { + const badToken = jwt.sign({ sub: 'user' }, 'secret'); // missing required iss, aud, exp, jti + + await expect(service.exchange({ ...baseRequest, subject_token: badToken })).rejects.toThrow(); + }); + + test('throws when subject_token is not a JWT', async () => { + await expect( + service.exchange({ ...baseRequest, subject_token: 'not-a-jwt' }), + ).rejects.toThrow(); + }); + + test('throws when subject_token is expired', async () => { + const expiredSubject = makeExternalToken({ + sub: 'user-123', + iss: 'https://idp.example.com', + aud: 'n8n', + exp: now - 60, // expired 1 minute ago + }); + + await expect( + service.exchange({ ...baseRequest, subject_token: expiredSubject }), + ).rejects.toThrow('subject_token is expired'); + }); + + test('throws when actor_token is expired', async () => { + const expiredActor = makeExternalToken({ + sub: 'actor-456', + iss: 'https://idp.example.com', + aud: 'n8n', + exp: now - 60, // expired 1 minute ago + }); + + await expect(service.exchange({ ...baseRequest, actor_token: expiredActor })).rejects.toThrow( + 'actor_token is expired', + ); + }); + }); +}); diff --git a/packages/@n8n/config/src/configs/token-exchange.config.ts b/packages/cli/src/modules/token-exchange/token-exchange.config.ts similarity index 52% rename from packages/@n8n/config/src/configs/token-exchange.config.ts rename to packages/cli/src/modules/token-exchange/token-exchange.config.ts index 4d37534b4f7..9d24a21f5ba 100644 --- a/packages/@n8n/config/src/configs/token-exchange.config.ts +++ b/packages/cli/src/modules/token-exchange/token-exchange.config.ts @@ -1,8 +1,12 @@ -import { Config, Env } from '../decorators'; +import { Config, Env } from '@n8n/config'; @Config export class TokenExchangeConfig { /** Whether the token exchange endpoint (POST /auth/oauth/token) is enabled. */ @Env('N8N_TOKEN_EXCHANGE_ENABLED') enabled: boolean = false; + + /** Maximum lifetime in seconds for an issued token. */ + @Env('N8N_TOKEN_EXCHANGE_MAX_TOKEN_TTL') + maxTokenTtl: number = 900; } diff --git a/packages/cli/src/modules/token-exchange/token-exchange.controller.ts b/packages/cli/src/modules/token-exchange/token-exchange.controller.ts index a4e91c31f1d..c528b3a7b6c 100644 --- a/packages/cli/src/modules/token-exchange/token-exchange.controller.ts +++ b/packages/cli/src/modules/token-exchange/token-exchange.controller.ts @@ -1,4 +1,3 @@ -import { GlobalConfig } from '@n8n/config'; import { Time } from '@n8n/constants'; import { Post, RestController } from '@n8n/decorators'; import { Container } from '@n8n/di'; @@ -10,12 +9,13 @@ import { AuthlessRequest } from '@/requests'; import { z } from 'zod'; +import { TokenExchangeConfig } from './token-exchange.config'; import { TOKEN_EXCHANGE_GRANT_TYPE, TokenExchangeRequestSchema } from './token-exchange.schemas'; import { TokenExchangeService } from './token-exchange.service'; @RestController('/auth/oauth') export class TokenExchangeController { - private readonly globalConfig = Container.get(GlobalConfig); + private readonly config = Container.get(TokenExchangeConfig); private readonly errorReporter = Container.get(ErrorReporter); @@ -33,7 +33,7 @@ export class TokenExchangeController { ipRateLimit: { limit: 20, windowMs: 1 * Time.minutes.toMilliseconds }, }) async exchangeToken(req: AuthlessRequest, res: Response): Promise { - if (!this.globalConfig.tokenExchange.enabled) { + if (!this.config.enabled) { res.status(501).json({ error: 'server_error', error_description: 'Token exchange is not enabled on this instance', @@ -69,29 +69,29 @@ export class TokenExchangeController { // Success path: delegate to service. try { - await this.tokenExchangeService.exchange(parsed.data); + const result = await this.tokenExchangeService.exchange(parsed.data); this.eventService.emit('token-exchange-succeeded', { - subject: '', // sub claim extracted by service in later ticket - actor: undefined, // act.sub claim extracted by service in later ticket + subject: result.subject, + actor: result.actor, scopes: parsed.data.scope, resource: parsed.data.resource, grantType: parsed.data.grant_type, clientIp, - issuer: '', // populated by service in later ticket + issuer: result.issuer, }); res.json({ - access_token: 'stub-access-token', + access_token: result.accessToken, token_type: 'Bearer', - expires_in: 3600, + expires_in: result.expiresIn, issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', }); } catch (error) { this.errorReporter.error(error instanceof Error ? error : new Error(String(error))); this.eventService.emit('token-exchange-failed', { - subject: '', // sub claim extracted by service in later ticket + subject: '', failureReason: 'internal_error', grantType: parsed.data.grant_type, clientIp, diff --git a/packages/cli/src/modules/token-exchange/token-exchange.service.ts b/packages/cli/src/modules/token-exchange/token-exchange.service.ts index 2c16c5f6ba4..851d31da452 100644 --- a/packages/cli/src/modules/token-exchange/token-exchange.service.ts +++ b/packages/cli/src/modules/token-exchange/token-exchange.service.ts @@ -1,11 +1,69 @@ -import { Service } from '@n8n/di'; +import { Container, Service } from '@n8n/di'; +import { randomUUID } from 'crypto'; +import { OperationalError } from 'n8n-workflow'; -import type { TokenExchangeRequest } from './token-exchange.schemas'; +import { JwtService } from '@/services/jwt.service'; + +import { TokenExchangeConfig } from './token-exchange.config'; +import { + ExternalTokenClaimsSchema, + type ExternalTokenClaims, + type TokenExchangeRequest, +} from './token-exchange.schemas'; +import type { IssuedJwtPayload, IssuedTokenResult } from './token-exchange.types'; @Service() export class TokenExchangeService { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async exchange(_request: TokenExchangeRequest): Promise { - return true; + private static readonly ISSUER = 'n8n'; + + private readonly jwtService = Container.get(JwtService); + + private readonly config = Container.get(TokenExchangeConfig); + + async exchange(request: TokenExchangeRequest): Promise { + const subjectClaims = this.decodeAndValidate(request.subject_token); + const actorClaims = request.actor_token + ? this.decodeAndValidate(request.actor_token) + : undefined; + + const now = Math.floor(Date.now() / 1000); + + if (subjectClaims.exp <= now) { + throw new OperationalError('subject_token is expired'); + } + if (actorClaims && actorClaims.exp <= now) { + throw new OperationalError('actor_token is expired'); + } + + const maxTtl = this.config.maxTokenTtl; + const exp = Math.min(subjectClaims.exp, actorClaims?.exp ?? Infinity, now + maxTtl); + + const scopes = request.scope?.split(' ').filter(Boolean); + + const payload: IssuedJwtPayload = { + iss: TokenExchangeService.ISSUER, + sub: subjectClaims.sub, + ...(actorClaims && { act: { sub: actorClaims.sub } }), + ...(scopes?.length && { scope: scopes }), + ...(request.resource && { resource: request.resource }), + iat: now, + exp, + jti: randomUUID(), + }; + + const accessToken = this.jwtService.sign(payload); + + return { + accessToken, + expiresIn: exp - now, + subject: subjectClaims.sub, + issuer: subjectClaims.iss, + actor: actorClaims?.sub, + }; + } + + private decodeAndValidate(token: string): ExternalTokenClaims { + const decoded = this.jwtService.decode(token); + return ExternalTokenClaimsSchema.parse(decoded); } } diff --git a/packages/cli/src/modules/token-exchange/token-exchange.types.ts b/packages/cli/src/modules/token-exchange/token-exchange.types.ts index 54dc7c1f39c..5f8c1eba58d 100644 --- a/packages/cli/src/modules/token-exchange/token-exchange.types.ts +++ b/packages/cli/src/modules/token-exchange/token-exchange.types.ts @@ -1,5 +1,24 @@ import type { TOKEN_EXCHANGE_GRANT_TYPE } from './token-exchange.schemas'; +export interface IssuedTokenResult { + accessToken: string; + expiresIn: number; + subject: string; + issuer: string; + actor?: string; +} + +export interface IssuedJwtPayload { + iss: string; + sub: string; + act?: { sub: string }; + scope?: string[]; + resource?: string; + iat: number; + exp: number; + jti: string; +} + export type TokenExchangeAuditEvent = | { event: 'token_exchange_success';