mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
feat(core): Implement JWT issuance in token exchange service (#27887)
This commit is contained in:
parent
0fd9fd7155
commit
94dae154da
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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/',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user