mirror of
https://github.com/n8n-io/n8n
synced 2026-04-21 15:47:20 +00:00
feat(core): Wire TokenExchangeService.exchange() end-to-end (no-changelog) (#28293)
This commit is contained in:
parent
872fc671bb
commit
87e3f1877e
11 changed files with 511 additions and 382 deletions
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
@ -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',
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -61,7 +61,8 @@ type ModuleName =
|
|||
| 'log-streaming'
|
||||
| 'ldap'
|
||||
| 'redaction'
|
||||
| 'source-control';
|
||||
| 'source-control'
|
||||
| 'token-exchange';
|
||||
|
||||
export interface SetupProps {
|
||||
endpointGroups?: EndpointGroup[];
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue