feat(core): Wire TokenExchangeService.exchange() end-to-end (no-changelog) (#28293)

This commit is contained in:
Andreas Fitzek 2026-04-10 13:49:17 +02:00 committed by GitHub
parent 872fc671bb
commit 87e3f1877e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 511 additions and 382 deletions

View file

@ -1,288 +0,0 @@
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

@ -5,14 +5,16 @@ import { mock } from 'jest-mock-extended';
import { ErrorReporter } from 'n8n-core';
import { UnexpectedError } from 'n8n-workflow';
import { AuthError } from '@/errors/response-errors/auth.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { EventService } from '@/events/event.service';
import type { AuthlessRequest } from '@/requests';
import { TokenExchangeConfig } from '../token-exchange.config';
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';
import { TOKEN_EXCHANGE_GRANT_TYPE } from '../../token-exchange.schemas';
import { TokenExchangeService } from '../../services/token-exchange.service';
import type { IssuedTokenResult } from '../../token-exchange.types';
describe('TokenExchangeController', () => {
mockInstance(ErrorReporter);
@ -129,6 +131,7 @@ describe('TokenExchangeController', () => {
accessToken: 'eyJhbGciOiJIUzI1NiJ9.issued.token',
expiresIn: 900,
subject: 'user-123',
subjectUserId: 'user-id-123',
issuer: 'https://idp.example.com',
actor: undefined,
};
@ -188,11 +191,39 @@ describe('TokenExchangeController', () => {
subject_token: 'some-subject-token',
};
test('returns 500 server_error when service throws', async () => {
test('returns 400 invalid_grant when service throws AuthError', async () => {
req.body = validBody;
jest
.mocked(tokenExchangeService.exchange)
.mockRejectedValue(new UnexpectedError('Token exchange not yet implemented'));
.mockRejectedValue(new AuthError('Token verification failed'));
await controller.exchangeToken(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'invalid_grant' }));
expect(errorReporter.error).not.toHaveBeenCalled();
});
test('returns 400 invalid_request when service throws BadRequestError', async () => {
req.body = validBody;
jest
.mocked(tokenExchangeService.exchange)
.mockRejectedValue(new BadRequestError('Invalid token format'));
await controller.exchangeToken(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: 'invalid_request' }),
);
expect(errorReporter.error).not.toHaveBeenCalled();
});
test('returns 500 server_error when service throws unexpected error', async () => {
req.body = validBody;
jest
.mocked(tokenExchangeService.exchange)
.mockRejectedValue(new UnexpectedError('Something broke'));
await controller.exchangeToken(req, res);
@ -200,11 +231,11 @@ describe('TokenExchangeController', () => {
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'server_error' }));
});
test('emits token-exchange-failed event when service throws', async () => {
test('emits failure reason from AuthError in token-exchange-failed event', async () => {
req.body = validBody;
jest
.mocked(tokenExchangeService.exchange)
.mockRejectedValue(new UnexpectedError('Token exchange not yet implemented'));
.mockRejectedValue(new AuthError('Token has already been used'));
await controller.exchangeToken(req, res);
@ -212,14 +243,15 @@ describe('TokenExchangeController', () => {
'token-exchange-failed',
expect.objectContaining({
subject: '',
failureReason: 'Token has already been used',
grantType: TOKEN_EXCHANGE_GRANT_TYPE,
clientIp: '127.0.0.1',
}),
);
});
test('reports error to ErrorReporter when service throws', async () => {
const error = new UnexpectedError('Token exchange not yet implemented');
test('reports only unexpected errors to ErrorReporter', async () => {
const error = new UnexpectedError('Something broke');
req.body = validBody;
jest.mocked(tokenExchangeService.exchange).mockRejectedValue(error);

View file

@ -3,15 +3,16 @@ import { Post, RestController } from '@n8n/decorators';
import { Container } from '@n8n/di';
import type { Response } from 'express';
import { ErrorReporter } from 'n8n-core';
import { z, ZodError } from 'zod';
import { AuthError } from '@/errors/response-errors/auth.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { EventService } from '@/events/event.service';
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';
import { TokenExchangeService } from '../services/token-exchange.service';
import { TokenExchangeConfig } from '../token-exchange.config';
import { TOKEN_EXCHANGE_GRANT_TYPE, TokenExchangeRequestSchema } from '../token-exchange.schemas';
@RestController('/auth/oauth')
export class TokenExchangeController {
@ -88,15 +89,55 @@ export class TokenExchangeController {
issued_token_type: 'urn:ietf:params:oauth:token-type:access_token',
});
} catch (error) {
this.errorReporter.error(error instanceof Error ? error : new Error(String(error)));
if (error instanceof AuthError) {
this.eventService.emit('token-exchange-failed', {
subject: '',
failureReason: error.message,
grantType: parsed.data.grant_type,
clientIp,
});
res.status(400).json({
error: 'invalid_grant',
error_description: 'Token exchange failed',
});
return;
}
if (error instanceof BadRequestError) {
this.eventService.emit('token-exchange-failed', {
subject: '',
failureReason: error.message,
grantType: parsed.data.grant_type,
clientIp,
});
res.status(400).json({
error: 'invalid_request',
error_description: error.message,
});
return;
}
if (error instanceof ZodError) {
this.eventService.emit('token-exchange-failed', {
subject: '',
failureReason: 'invalid_claims',
grantType: parsed.data.grant_type,
clientIp,
});
res.status(400).json({
error: 'invalid_request',
error_description: 'Token claims validation failed',
});
return;
}
this.errorReporter.error(error instanceof Error ? error : new Error(String(error)));
this.eventService.emit('token-exchange-failed', {
subject: '',
failureReason: 'internal_error',
grantType: parsed.data.grant_type,
clientIp,
});
res.status(500).json({
error: 'server_error',
error_description: 'An unexpected error occurred during token exchange',

View file

@ -6,7 +6,10 @@ import { mock } from 'jest-mock-extended';
import { AuthError } from '@/errors/response-errors/auth.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import type { JwtService } from '@/services/jwt.service';
import type { ResolvedTrustedKey } from '../../token-exchange.schemas';
import type { TokenExchangeConfig } from '../../token-exchange.config';
import type { IdentityResolutionService } from '../identity-resolution.service';
import type { JtiStoreService } from '../jti-store.service';
import { TokenExchangeService } from '../token-exchange.service';
@ -16,12 +19,16 @@ const logger = mock<Logger>({ scoped: jest.fn().mockReturnThis() });
const trustedKeyStore = mock<TrustedKeyService>();
const jtiStore = mock<JtiStoreService>();
const identityResolutionService = mock<IdentityResolutionService>();
const config = mock<TokenExchangeConfig>();
const jwtService = mock<JwtService>();
const service = new TokenExchangeService(
logger,
trustedKeyStore,
jtiStore,
identityResolutionService,
config,
jwtService,
);
const resolvedKey: ResolvedTrustedKey = {

View file

@ -1,18 +1,28 @@
import { Logger } from '@n8n/backend-common';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import { randomUUID } from 'crypto';
import jwt from 'jsonwebtoken';
import { AuthError } from '@/errors/response-errors/auth.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { JwtService } from '@/services/jwt.service';
import type { ExternalTokenClaims, ResolvedTrustedKey } from '../token-exchange.schemas';
import { TokenExchangeConfig } from '../token-exchange.config';
import type {
ExternalTokenClaims,
ResolvedTrustedKey,
TokenExchangeRequest,
} from '../token-exchange.schemas';
import { ExternalTokenClaimsSchema } from '../token-exchange.schemas';
import type { IssuedJwtPayload, IssuedTokenResult } from '../token-exchange.types';
import { IdentityResolutionService } from './identity-resolution.service';
import { JtiStoreService } from './jti-store.service';
import { TrustedKeyService } from './trusted-key.service';
const MAX_TOKEN_LIFETIME_SECONDS = 60;
const MIN_REMAINING_LIFETIME_SECONDS = 5;
const ISSUER = 'n8n';
@Service()
export class TokenExchangeService {
@ -23,6 +33,8 @@ export class TokenExchangeService {
private readonly trustedKeyStore: TrustedKeyService,
private readonly jtiStore: JtiStoreService,
private readonly identityResolutionService: IdentityResolutionService,
private readonly config: TokenExchangeConfig,
private readonly jwtService: JwtService,
) {
this.logger = logger.scoped('token-exchange');
}
@ -73,6 +85,8 @@ export class TokenExchangeService {
algorithms: resolvedKey.algorithms as jwt.Algorithm[],
issuer: resolvedKey.issuer,
audience: resolvedKey.expectedAudience,
ignoreExpiration: false,
ignoreNotBefore: false,
});
if (typeof result === 'string' || !('iat' in result)) {
throw new AuthError('Unexpected token format');
@ -111,4 +125,62 @@ export class TokenExchangeService {
issuer: resolvedKey.issuer,
});
}
async exchange(request: TokenExchangeRequest): Promise<IssuedTokenResult> {
const subjectClaims = await this.verifyToken(request.subject_token);
const actorClaims = request.actor_token
? await this.verifyToken(request.actor_token)
: undefined;
const actor = actorClaims
? await this.identityResolutionService.resolve(
actorClaims.claims,
actorClaims.resolvedKey.allowedRoles,
actorClaims.resolvedKey,
)
: undefined;
const subject = await this.identityResolutionService.resolve(
subjectClaims.claims,
subjectClaims.resolvedKey.allowedRoles,
subjectClaims.resolvedKey,
);
const now = Math.floor(Date.now() / 1000);
const maxTtl = this.config.maxTokenTtl;
const exp = Math.min(
subjectClaims.claims.exp,
actorClaims?.claims.exp ?? Infinity,
now + maxTtl,
);
if (exp <= now + MIN_REMAINING_LIFETIME_SECONDS) {
throw new AuthError('Subject token too close to expiry to issue a new token');
}
const resources = request.resource?.split(' ').filter(Boolean);
const payload: IssuedJwtPayload = {
iss: ISSUER,
sub: subject.id,
...(actor && { act: { sub: actor.id } }),
...(request.scope && { scope: request.scope }),
...(resources?.length && { resource: resources }),
iat: now,
exp,
jti: randomUUID(),
};
const accessToken = this.jwtService.sign(payload);
return {
accessToken,
expiresIn: exp - now,
subjectUserId: subject.id,
subject: subjectClaims.claims.sub,
issuer: subjectClaims.claims.iss,
actor: actorClaims?.claims.sub,
actorUserId: actor?.id,
};
}
}

View file

@ -30,7 +30,7 @@ export class TokenExchangeModule implements ModuleInterface {
const { TrustedKeyService } = await import('./services/trusted-key.service');
await Container.get(TrustedKeyService).initialize();
await import('./token-exchange.controller');
await import('./controllers/token-exchange.controller');
await import('./controllers/embed-auth.controller');
const { JtiCleanupService } = await import('./services/jti-cleanup.service');

View file

@ -33,6 +33,7 @@ export const ExternalTokenClaimsSchema = z.object({
iat: z.number().int(),
exp: z.number().int(),
jti: z.string().min(1),
nbf: z.number().int().optional(),
email: z.string().email().optional(),
given_name: z.string().optional(),
family_name: z.string().optional(),
@ -128,9 +129,9 @@ export const TokenExchangeRequestSchema = z.object({
actor_token: z.string().optional(),
actor_token_type: z.string().optional(),
requested_token_type: z.string().optional(),
scope: z.string().optional(),
audience: z.string().optional(),
resource: z.string().optional(),
scope: z.string().max(1024).optional(),
audience: z.string().max(1024).optional(),
resource: z.string().max(2048).optional(),
});
export type TokenExchangeRequest = z.infer<typeof TokenExchangeRequestSchema>;

View file

@ -1,69 +0,0 @@
import { Container, Service } from '@n8n/di';
import { randomUUID } from 'crypto';
import { OperationalError } from 'n8n-workflow';
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 {
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

@ -4,16 +4,18 @@ export interface IssuedTokenResult {
accessToken: string;
expiresIn: number;
subject: string;
subjectUserId: string;
issuer: string;
actor?: string;
actorUserId?: string;
}
export interface IssuedJwtPayload {
iss: string;
sub: string;
act?: { sub: string };
scope?: string[];
resource?: string;
scope?: string;
resource?: string[];
iat: number;
exp: number;
jti: string;

View file

@ -61,7 +61,8 @@ type ModuleName =
| 'log-streaming'
| 'ldap'
| 'redaction'
| 'source-control';
| 'source-control'
| 'token-exchange';
export interface SetupProps {
endpointGroups?: EndpointGroup[];

View file

@ -0,0 +1,330 @@
import { testDb } from '@n8n/backend-test-utils';
import { AuthIdentityRepository, ProjectRepository, UserRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { generateKeyPairSync, randomUUID } from 'crypto';
import jwt from 'jsonwebtoken';
import { TrustedKeyService } from '@/modules/token-exchange/services/trusted-key.service';
import { TokenExchangeConfig } from '@/modules/token-exchange/token-exchange.config';
import { TOKEN_EXCHANGE_GRANT_TYPE } from '@/modules/token-exchange/token-exchange.schemas';
import type {
IssuedJwtPayload,
TokenExchangeSuccessResponse,
} from '@/modules/token-exchange/token-exchange.types';
import { JwtService } from '@/services/jwt.service';
import { InstanceSettings } from 'n8n-core';
import { createUser } from '../shared/db/users';
import * as utils from '../shared/utils';
// Must be set before module init reads the env var.
process.env.N8N_ENV_FEAT_TOKEN_EXCHANGE = 'true';
const { privateKey, publicKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
const ISSUER = 'https://issuer.test';
const KID = 'test-kid';
function makeExternalJwt(
overrides: Partial<{
sub: string;
iss: string;
aud: string;
exp: number;
jti: string;
email: string;
given_name: string;
family_name: string;
role: string;
}> = {},
): string {
const now = Math.floor(Date.now() / 1000);
return jwt.sign(
{
sub: `ext-${randomUUID().slice(0, 8)}`,
iss: ISSUER,
aud: 'n8n',
iat: now,
exp: now + 300,
jti: randomUUID(),
...overrides,
},
privateKey,
{ algorithm: 'RS256', keyid: KID },
);
}
const testServer = utils.setupTestServer({
endpointGroups: ['auth'],
enabledFeatures: ['feat:tokenExchange'],
modules: ['token-exchange'],
});
let config: TokenExchangeConfig;
let jwtService: JwtService;
beforeAll(async () => {
// TrustedKeyService.initialize() only runs on the leader instance.
const instanceSettings = Container.get(InstanceSettings);
Object.defineProperty(instanceSettings, 'isLeader', { value: true, configurable: true });
config = Container.get(TokenExchangeConfig);
config.enabled = true;
config.trustedKeys = JSON.stringify([
{
type: 'static',
kid: KID,
algorithms: ['RS256'],
key: publicKey,
issuer: ISSUER,
expectedAudience: 'n8n',
allowedRoles: ['global:member', 'global:admin'],
},
]);
await Container.get(TrustedKeyService).initialize();
jwtService = Container.get(JwtService);
});
beforeEach(async () => {
await testDb.truncate([
'TokenExchangeJti',
'TrustedKeyEntity',
'TrustedKeySourceEntity',
'AuthIdentity',
'ProjectRelation',
'Project',
'User',
]);
config.enabled = true;
config.maxTokenTtl = 900;
// Re-initialize keys after truncation clears the trusted key tables.
await Container.get(TrustedKeyService).initialize();
});
afterEach(() => {
Container.get(TrustedKeyService).stopRefresh();
});
const postToken = (body: Record<string, string>) =>
testServer.authlessAgent.post('/auth/oauth/token').send(body);
describe('POST /auth/oauth/token', () => {
it('should exchange a subject token, provision the user, and return a valid access token', async () => {
const email = 'jit-user@example.com';
const token = makeExternalJwt({
sub: 'ext-jit-1',
email,
given_name: 'Jane',
family_name: 'Doe',
role: 'global:admin',
});
const response = await postToken({
grant_type: TOKEN_EXCHANGE_GRANT_TYPE,
subject_token: token,
}).expect(200);
const body = response.body as TokenExchangeSuccessResponse;
// RFC 8693 response format
expect(body).toEqual(
expect.objectContaining({
access_token: expect.any(String),
token_type: 'Bearer',
expires_in: expect.any(Number),
issued_token_type: 'urn:ietf:params:oauth:token-type:access_token',
}),
);
// Issued token is valid and has correct claims
const decoded = jwtService.verify<IssuedJwtPayload>(body.access_token);
expect(decoded.iss).toBe('n8n');
expect(decoded.sub).toEqual(expect.any(String));
expect(decoded.act).toBeUndefined();
expect(decoded.scope).toBeUndefined();
expect(decoded.resource).toBeUndefined();
// User provisioned in DB with correct fields
const userRepo = Container.get(UserRepository);
const user = await userRepo.findOne({
where: { email },
relations: ['role'],
});
expect(user).not.toBeNull();
expect(user!.firstName).toBe('Jane');
expect(user!.lastName).toBe('Doe');
expect(user!.role.slug).toBe('global:admin');
expect(decoded.sub).toBe(user!.id);
// AuthIdentity linked
const identity = await Container.get(AuthIdentityRepository).findOne({
where: { providerId: 'ext-jit-1', providerType: 'token-exchange' },
});
expect(identity).not.toBeNull();
expect(identity!.userId).toBe(user!.id);
// Personal project created
const project = await Container.get(ProjectRepository).getPersonalProjectForUser(user!.id);
expect(project).toBeDefined();
});
it('should exchange subject + actor tokens and include act claim in issued token', async () => {
// Pre-create subject user to test the existing-user path
const subjectUser = await createUser({ email: 'subject@example.com' });
const subjectToken = makeExternalJwt({
sub: 'ext-subject',
email: 'subject@example.com',
});
const actorToken = makeExternalJwt({
sub: 'ext-actor',
email: 'actor@example.com',
});
const response = await postToken({
grant_type: TOKEN_EXCHANGE_GRANT_TYPE,
subject_token: subjectToken,
actor_token: actorToken,
}).expect(200);
const decoded = jwtService.verify<IssuedJwtPayload>(
(response.body as TokenExchangeSuccessResponse).access_token,
);
// Subject is the existing user
expect(decoded.sub).toBe(subjectUser.id);
// Act claim present with JIT-provisioned actor user ID
expect(decoded.act).toBeDefined();
expect(decoded.act!.sub).toEqual(expect.any(String));
expect(decoded.act!.sub).not.toBe(decoded.sub);
// Both users exist in DB
const userRepo = Container.get(UserRepository);
const actorUser = await userRepo.findOneBy({ id: decoded.act!.sub });
expect(actorUser).not.toBeNull();
expect(actorUser!.email).toBe('actor@example.com');
});
it('should pass through scope and split resource into array in issued token', async () => {
const token = makeExternalJwt({
sub: 'ext-scope-test',
email: 'scope-test@example.com',
});
const response = await postToken({
grant_type: TOKEN_EXCHANGE_GRANT_TYPE,
subject_token: token,
scope: 'workflow:read',
resource: 'https://api.a.com https://api.b.com',
}).expect(200);
const decoded = jwtService.verify<IssuedJwtPayload>(
(response.body as TokenExchangeSuccessResponse).access_token,
);
expect(decoded.scope).toBe('workflow:read');
expect(decoded.resource).toEqual(['https://api.a.com', 'https://api.b.com']);
});
it('should enforce expiry as min of subject.exp and maxTokenTtl', async () => {
config.maxTokenTtl = 60;
const token = makeExternalJwt({
sub: 'ext-expiry',
email: 'expiry@example.com',
exp: Math.floor(Date.now() / 1000) + 86400, // far future
});
const response = await postToken({
grant_type: TOKEN_EXCHANGE_GRANT_TYPE,
subject_token: token,
}).expect(200);
expect((response.body as TokenExchangeSuccessResponse).expires_in).toBeLessThanOrEqual(60);
});
it('should reject a replayed token (same jti)', async () => {
const token = makeExternalJwt({
sub: 'ext-replay',
email: 'replay@example.com',
});
// First exchange succeeds
await postToken({
grant_type: TOKEN_EXCHANGE_GRANT_TYPE,
subject_token: token,
}).expect(200);
// Second exchange with identical token (same jti) fails
const response = await postToken({
grant_type: TOKEN_EXCHANGE_GRANT_TYPE,
subject_token: token,
}).expect(400);
expect((response.body as { error: string }).error).toBe('invalid_grant');
});
it('should return 400 unsupported_grant_type for wrong or missing grant_type', async () => {
const response = await postToken({
subject_token: 'some-token',
}).expect(400);
expect((response.body as { error: string }).error).toBe('unsupported_grant_type');
});
it('should return 400 invalid_request when subject_token is missing', async () => {
const response = await postToken({
grant_type: TOKEN_EXCHANGE_GRANT_TYPE,
}).expect(400);
expect((response.body as { error: string }).error).toBe('invalid_request');
});
it('should reject a token too close to expiry', async () => {
const token = makeExternalJwt({
sub: 'ext-near-expiry',
email: 'near-expiry@example.com',
exp: Math.floor(Date.now() / 1000) + 3,
});
const response = await postToken({
grant_type: TOKEN_EXCHANGE_GRANT_TYPE,
subject_token: token,
}).expect(400);
expect((response.body as { error: string }).error).toBe('invalid_grant');
});
it('should return 400 invalid_request when scope exceeds max length', async () => {
const token = makeExternalJwt({
sub: 'ext-scope-limit',
email: 'scope-limit@example.com',
});
const response = await postToken({
grant_type: TOKEN_EXCHANGE_GRANT_TYPE,
subject_token: token,
scope: 'a'.repeat(1025),
}).expect(400);
expect((response.body as { error: string }).error).toBe('invalid_request');
});
it('should return 501 when token exchange is disabled', async () => {
config.enabled = false;
const response = await postToken({
grant_type: TOKEN_EXCHANGE_GRANT_TYPE,
subject_token: 'any',
}).expect(501);
expect((response.body as { error: string }).error).toBe('server_error');
});
});