feat(core): Implement JWT issuance in token exchange service (#27887)

This commit is contained in:
Stephen Wright 2026-04-01 18:33:31 +01:00 committed by GitHub
parent 0fd9fd7155
commit 94dae154da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 410 additions and 40 deletions

View File

@ -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;

View File

@ -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/',

View File

@ -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<AuthlessRequest>({ ip: '127.0.0.1' });
res = mock<Response>();
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',
}),

View File

@ -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<typeof jwtService.verify>,
);
});
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',
);
});
});
});

View File

@ -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;
}

View File

@ -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<void> {
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,

View File

@ -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<true> {
return true;
private static readonly ISSUER = 'n8n';
private readonly jwtService = Container.get(JwtService);
private readonly config = Container.get(TokenExchangeConfig);
async exchange(request: TokenExchangeRequest): Promise<IssuedTokenResult> {
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<unknown>(token);
return ExternalTokenClaimsSchema.parse(decoded);
}
}

View File

@ -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';