feat: Add Prometheus counters for token exchange (#28453)

This commit is contained in:
Stephen Wright
2026-04-16 13:20:38 +01:00
committed by GitHub
parent bb9bec3ba4
commit c6534fa0b3
13 changed files with 446 additions and 48 deletions

View File

@@ -109,6 +109,7 @@ export const eventNamesAudit = [
'n8n.audit.token-exchange.succeeded',
'n8n.audit.token-exchange.failed',
'n8n.audit.token-exchange.embed-login',
'n8n.audit.token-exchange.embed-login-failed',
'n8n.audit.token-exchange.identity-linked',
'n8n.audit.token-exchange.user-provisioned',
'n8n.audit.token-exchange.role-updated',

View File

@@ -12,6 +12,7 @@ import type {
} from 'n8n-workflow';
import type { ConcurrencyQueueType } from '@/concurrency/concurrency-control.service';
import type { TokenExchangeFailureReason } from '@/modules/token-exchange/token-exchange.types';
import type { AiEventMap } from './ai.event-map';
@@ -776,7 +777,7 @@ export type RelayEventMap = {
'token-exchange-failed': {
subject?: string;
failureReason: string;
failureReason: TokenExchangeFailureReason;
grantType: string;
clientIp: string;
};
@@ -788,6 +789,11 @@ export type RelayEventMap = {
clientIp: string;
};
'embed-login-failed': {
failureReason: TokenExchangeFailureReason;
clientIp: string;
};
'token-exchange-identity-linked': {
userId: string;
sub: string;

View File

@@ -110,6 +110,7 @@ export class LogStreamingEventRelay extends EventRelay {
'token-exchange-user-provisioned': (event) => this.tokenExchangeUserProvisioned(event),
'token-exchange-role-updated': (event) => this.tokenExchangeRoleUpdated(event),
'embed-login': (event) => this.embedLogin(event),
'embed-login-failed': (event) => this.embedLoginFailed(event),
'expression-mapping-roles-resolved': (event) => this.expressionMappingRolesResolved(event),
'role-mapping-rule-created': (event) => this.roleMappingRuleCreated(event),
'role-mapping-rule-updated': (event) => this.roleMappingRuleUpdated(event),
@@ -1039,6 +1040,13 @@ export class LogStreamingEventRelay extends EventRelay {
});
}
private embedLoginFailed(event: RelayEventMap['embed-login-failed']) {
void this.eventBus.sendAuditEvent({
eventName: 'n8n.audit.token-exchange.embed-login-failed',
payload: event,
});
}
// #endregion
// #region Role Mapping

View File

@@ -258,8 +258,8 @@ describe('PrometheusMetricsService', () => {
await prometheusMetricsService.init(app);
expect(promClient.Gauge).toHaveBeenCalledTimes(3); // version metric + active workflow count metric + instance role metric
expect(promClient.Counter).toHaveBeenCalledTimes(0); // cache metrics
expect(eventService.on).not.toHaveBeenCalled();
expect(promClient.Counter).toHaveBeenCalledTimes(6); // token exchange metrics (always registered)
expect(eventService.on).toHaveBeenCalledTimes(6); // token exchange event listeners
});
it('should not set up queue metrics if enabled and on scaling mode but instance is not main', async () => {
@@ -271,8 +271,8 @@ describe('PrometheusMetricsService', () => {
await prometheusMetricsService.init(app);
expect(promClient.Gauge).toHaveBeenCalledTimes(2); // version metric + active workflow count metric
expect(promClient.Counter).toHaveBeenCalledTimes(0); // cache metrics
expect(eventService.on).not.toHaveBeenCalled();
expect(promClient.Counter).toHaveBeenCalledTimes(6); // token exchange metrics (always registered)
expect(eventService.on).toHaveBeenCalledTimes(6); // token exchange event listeners
});
it('should setup active workflow count metric', async () => {
@@ -824,4 +824,152 @@ describe('PrometheusMetricsService', () => {
expect(mockSet).not.toHaveBeenCalled();
});
});
describe('token exchange metrics', () => {
// Helper to capture an eventService.on handler by event name
const getEventServiceHandler = (eventName: string) => {
const call = (eventService.on as jest.Mock).mock.calls.find((c) => c[0] === eventName);
return call ? call[1] : undefined;
};
it('should register all 6 token exchange counters on init', async () => {
await prometheusMetricsService.init(app);
expect(promClient.Counter).toHaveBeenCalledWith({
name: 'n8n_token_exchange_requests_total',
help: 'Total number of token exchange requests.',
labelNames: ['result'],
});
expect(promClient.Counter).toHaveBeenCalledWith({
name: 'n8n_token_exchange_failures_total',
help: 'Total number of token exchange failures broken down by reason.',
labelNames: ['reason'],
});
expect(promClient.Counter).toHaveBeenCalledWith({
name: 'n8n_embed_login_requests_total',
help: 'Total number of embed login requests.',
labelNames: ['result'],
});
expect(promClient.Counter).toHaveBeenCalledWith({
name: 'n8n_embed_login_failures_total',
help: 'Total number of embed login failures broken down by reason.',
labelNames: ['reason'],
});
expect(promClient.Counter).toHaveBeenCalledWith({
name: 'n8n_token_exchange_jit_provisioning_total',
help: 'Total number of users JIT-provisioned via token exchange.',
});
expect(promClient.Counter).toHaveBeenCalledWith({
name: 'n8n_token_exchange_identity_linked_total',
help: 'Total number of external identities linked to existing users via token exchange.',
});
});
it('should pre-seed result label combos on request counters', async () => {
await prometheusMetricsService.init(app);
expect(promClient.Counter.prototype.inc).toHaveBeenCalledWith({ result: 'success' }, 0);
expect(promClient.Counter.prototype.inc).toHaveBeenCalledWith({ result: 'failure' }, 0);
});
it('should increment token exchange success counter on token-exchange-succeeded', async () => {
await prometheusMetricsService.init(app);
const handler = getEventServiceHandler('token-exchange-succeeded');
handler({});
// @ts-expect-error private field
const succeedReqCounter = prometheusMetricsService.counters.tokenExchangeRequestsTotal;
expect(succeedReqCounter?.inc).toHaveBeenCalledWith({ result: 'success' }, 1);
});
it('should increment token exchange failure counter on token-exchange-failed', async () => {
await prometheusMetricsService.init(app);
const handler = getEventServiceHandler('token-exchange-failed');
handler({ failureReason: 'unknown_key' });
// @ts-expect-error private field
const failReqCounter = prometheusMetricsService.counters.tokenExchangeRequestsTotal;
expect(failReqCounter?.inc).toHaveBeenCalledWith({ result: 'failure' }, 1);
// @ts-expect-error private field
const failuresCounter = prometheusMetricsService.counters.tokenExchangeFailuresTotal;
expect(failuresCounter?.inc).toHaveBeenCalledWith({ reason: 'unknown_key' }, 1);
});
it('should pass through "other" failure reason', async () => {
await prometheusMetricsService.init(app);
const handler = getEventServiceHandler('token-exchange-failed');
handler({ failureReason: 'other' });
// @ts-expect-error private field
const otherCounter = prometheusMetricsService.counters.tokenExchangeFailuresTotal;
expect(otherCounter?.inc).toHaveBeenCalledWith({ reason: 'other' }, 1);
});
it('should pass through "role_not_allowed" failure reason', async () => {
await prometheusMetricsService.init(app);
const handler = getEventServiceHandler('token-exchange-failed');
// @ts-expect-error private field
const roleCounter = prometheusMetricsService.counters.tokenExchangeFailuresTotal;
handler({ failureReason: 'role_not_allowed' });
expect(roleCounter?.inc).toHaveBeenCalledWith({ reason: 'role_not_allowed' }, 1);
});
it('should increment embed login success counter on embed-login', async () => {
await prometheusMetricsService.init(app);
const handler = getEventServiceHandler('embed-login');
handler({});
// @ts-expect-error private field
expect(prometheusMetricsService.counters.embedLoginRequestsTotal?.inc).toHaveBeenCalledWith(
{ result: 'success' },
1,
);
});
it('should increment embed login failure counter on embed-login-failed', async () => {
await prometheusMetricsService.init(app);
const handler = getEventServiceHandler('embed-login-failed');
handler({ failureReason: 'invalid_signature' });
// @ts-expect-error private field
expect(prometheusMetricsService.counters.embedLoginRequestsTotal?.inc).toHaveBeenCalledWith(
{ result: 'failure' },
1,
);
// @ts-expect-error private field
expect(prometheusMetricsService.counters.embedLoginFailuresTotal?.inc).toHaveBeenCalledWith(
{ reason: 'invalid_signature' },
1,
);
});
it('should increment JIT provisioning counter on token-exchange-user-provisioned', async () => {
await prometheusMetricsService.init(app);
const handler = getEventServiceHandler('token-exchange-user-provisioned');
handler({});
// @ts-expect-error private field
const jitCounter = prometheusMetricsService.counters.tokenExchangeJitProvisioningTotal;
expect(jitCounter?.inc).toHaveBeenCalledWith(1);
});
it('should increment identity linked counter on token-exchange-identity-linked', async () => {
await prometheusMetricsService.init(app);
const handler = getEventServiceHandler('token-exchange-identity-linked');
handler({});
// @ts-expect-error private field
const linkedCounter = prometheusMetricsService.counters.tokenExchangeIdentityLinkedTotal;
expect(linkedCounter?.inc).toHaveBeenCalledWith(1);
});
});
});

View File

@@ -35,6 +35,8 @@ export class PrometheusMetricsService {
private readonly counters: { [key: string]: Counter<string> | null } = {};
private tokenExchangeListenersRegistered = false;
private readonly gauges: Record<string, Gauge<string>> = {};
private readonly histograms: Record<string, Histogram<string>> = {};
@@ -76,6 +78,7 @@ export class PrometheusMetricsService {
this.initWorkflowExecutionDurationMetric();
this.initActiveWorkflowCountMetric();
this.initWorkflowStatisticsMetrics();
this.initTokenExchangeMetrics();
this.mountMetricsEndpoint(app);
}
@@ -618,4 +621,95 @@ export class PrometheusMetricsService {
);
});
}
/**
* Set up counters for token exchange and embed login flows.
*
* These metrics are always registered when the `/metrics` endpoint is active
* and require no additional configuration flag.
*
* Counters:
* - `n8n_token_exchange_requests_total{result}` - success/failure rate
* - `n8n_token_exchange_failures_total{reason}` - failure breakdown by reason
* - `n8n_embed_login_requests_total{result}` - embed login success/failure rate
* - `n8n_embed_login_failures_total{reason}` - embed login failure breakdown
* - `n8n_token_exchange_jit_provisioning_total` - JIT-provisioned users
* - `n8n_token_exchange_identity_linked_total` - identities linked to existing users
*/
private initTokenExchangeMetrics() {
// Token exchange (RFC 8693 flow)
this.counters.tokenExchangeRequestsTotal = new promClient.Counter({
name: this.prefix + 'token_exchange_requests_total',
help: 'Total number of token exchange requests.',
labelNames: ['result'],
});
this.counters.tokenExchangeRequestsTotal.inc({ result: 'success' }, 0);
this.counters.tokenExchangeRequestsTotal.inc({ result: 'failure' }, 0);
this.counters.tokenExchangeFailuresTotal = new promClient.Counter({
name: this.prefix + 'token_exchange_failures_total',
help: 'Total number of token exchange failures broken down by reason.',
labelNames: ['reason'],
});
// Embed login flow
this.counters.embedLoginRequestsTotal = new promClient.Counter({
name: this.prefix + 'embed_login_requests_total',
help: 'Total number of embed login requests.',
labelNames: ['result'],
});
this.counters.embedLoginRequestsTotal.inc({ result: 'success' }, 0);
this.counters.embedLoginRequestsTotal.inc({ result: 'failure' }, 0);
this.counters.embedLoginFailuresTotal = new promClient.Counter({
name: this.prefix + 'embed_login_failures_total',
help: 'Total number of embed login failures broken down by reason.',
labelNames: ['reason'],
});
// JIT provisioning and identity linking
this.counters.tokenExchangeJitProvisioningTotal = new promClient.Counter({
name: this.prefix + 'token_exchange_jit_provisioning_total',
help: 'Total number of users JIT-provisioned via token exchange.',
});
this.counters.tokenExchangeJitProvisioningTotal.inc(0);
this.counters.tokenExchangeIdentityLinkedTotal = new promClient.Counter({
name: this.prefix + 'token_exchange_identity_linked_total',
help: 'Total number of external identities linked to existing users via token exchange.',
});
this.counters.tokenExchangeIdentityLinkedTotal.inc(0);
// Listeners reference `this.counters.*` via `this`, so they automatically
// pick up newly created counter objects after a re-init. Register them only
// once to prevent double-counting if `init()` is called more than once.
if (this.tokenExchangeListenersRegistered) return;
this.tokenExchangeListenersRegistered = true;
this.eventService.on('token-exchange-succeeded', () => {
this.counters.tokenExchangeRequestsTotal?.inc({ result: 'success' }, 1);
});
this.eventService.on('token-exchange-failed', ({ failureReason }) => {
this.counters.tokenExchangeRequestsTotal?.inc({ result: 'failure' }, 1);
this.counters.tokenExchangeFailuresTotal?.inc({ reason: failureReason }, 1);
});
this.eventService.on('embed-login', () => {
this.counters.embedLoginRequestsTotal?.inc({ result: 'success' }, 1);
});
this.eventService.on('embed-login-failed', ({ failureReason }) => {
this.counters.embedLoginRequestsTotal?.inc({ result: 'failure' }, 1);
this.counters.embedLoginFailuresTotal?.inc({ reason: failureReason }, 1);
});
this.eventService.on('token-exchange-user-provisioned', () => {
this.counters.tokenExchangeJitProvisioningTotal?.inc(1);
});
this.eventService.on('token-exchange-identity-linked', () => {
this.counters.tokenExchangeIdentityLinkedTotal?.inc(1);
});
}
}

View File

@@ -184,18 +184,43 @@ describe('EmbedAuthController', () => {
});
describe('error propagation', () => {
it('should not emit audit event or issue cookie on failure', async () => {
const req = mock<AuthlessRequest>({ browserId: 'browser-id-789' });
it('should emit embed-login-failed with typed reason and rethrow on TokenExchangeAuthError', async () => {
const req = mock<AuthlessRequest>({ browserId: 'browser-id-789', ip: '10.0.0.1' });
const res = mock<Response>();
const query = new EmbedLoginQueryDto({ token: 'bad-token' });
tokenExchangeService.embedLogin.mockRejectedValue(new Error('Token verification failed'));
const { TokenExchangeAuthError } = await import('../../token-exchange.errors');
const { TokenExchangeFailureReason } = await import('../../token-exchange.types');
tokenExchangeService.embedLogin.mockRejectedValue(
new TokenExchangeAuthError(
TokenExchangeFailureReason.InvalidSignature,
'Token verification failed',
),
);
await expect(controller.getLogin(req, res, query)).rejects.toThrow(
'Token verification failed',
);
expect(authService.issueCookie).not.toHaveBeenCalled();
expect(eventService.emit).not.toHaveBeenCalled();
expect(res.redirect).not.toHaveBeenCalled();
expect(eventService.emit).not.toHaveBeenCalledWith('embed-login', expect.anything());
expect(eventService.emit).toHaveBeenCalledWith('embed-login-failed', {
failureReason: TokenExchangeFailureReason.InvalidSignature,
clientIp: '10.0.0.1',
});
});
it('should emit embed-login-failed with internal_error and rethrow on unknown error', async () => {
const req = mock<AuthlessRequest>({ browserId: 'browser-id-789', ip: '10.0.0.1' });
const res = mock<Response>();
const query = new EmbedLoginQueryDto({ token: 'bad-token' });
const { TokenExchangeFailureReason } = await import('../../token-exchange.types');
tokenExchangeService.embedLogin.mockRejectedValue(new Error('Some unexpected error'));
await expect(controller.getLogin(req, res, query)).rejects.toThrow('Some unexpected error');
expect(eventService.emit).toHaveBeenCalledWith('embed-login-failed', {
failureReason: TokenExchangeFailureReason.InternalError,
clientIp: '10.0.0.1',
});
});
});
});

View File

@@ -11,10 +11,11 @@ import { EventService } from '@/events/event.service';
import type { AuthlessRequest } from '@/requests';
import { TokenExchangeConfig } from '../../token-exchange.config';
import { TokenExchangeAuthError } from '../../token-exchange.errors';
import { TokenExchangeController } from '../token-exchange.controller';
import { TOKEN_EXCHANGE_GRANT_TYPE } from '../../token-exchange.schemas';
import { TokenExchangeService } from '../../services/token-exchange.service';
import type { IssuedTokenResult } from '../../token-exchange.types';
import { TokenExchangeFailureReason, type IssuedTokenResult } from '../../token-exchange.types';
describe('TokenExchangeController', () => {
mockInstance(ErrorReporter);
@@ -231,11 +232,16 @@ describe('TokenExchangeController', () => {
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'server_error' }));
});
test('emits failure reason from AuthError in token-exchange-failed event', async () => {
test('emits typed failure reason from TokenExchangeAuthError in token-exchange-failed event', async () => {
req.body = validBody;
jest
.mocked(tokenExchangeService.exchange)
.mockRejectedValue(new AuthError('Token has already been used'));
.mockRejectedValue(
new TokenExchangeAuthError(
TokenExchangeFailureReason.TokenReplay,
'Token has already been used',
),
);
await controller.exchangeToken(req, res);
@@ -243,7 +249,7 @@ describe('TokenExchangeController', () => {
'token-exchange-failed',
expect.objectContaining({
subject: '',
failureReason: 'Token has already been used',
failureReason: TokenExchangeFailureReason.TokenReplay,
grantType: TOKEN_EXCHANGE_GRANT_TYPE,
clientIp: '127.0.0.1',
}),

View File

@@ -11,6 +11,8 @@ import { validateRedirectUrl } from '@/utils/validate-redirect-url';
import { TokenExchangeService } from '../services/token-exchange.service';
import { TokenExchangeConfig } from '../token-exchange.config';
import { TokenExchangeAuthError, TokenExchangeRequestError } from '../token-exchange.errors';
import { TokenExchangeFailureReason } from '../token-exchange.types';
import { Container } from '@n8n/di';
const configService = Container.get(TokenExchangeConfig);
@@ -67,21 +69,33 @@ export class EmbedAuthController {
res: Response,
redirect?: string,
) {
const { user, subject, issuer, kid } = await this.tokenExchangeService.embedLogin(subjectToken);
try {
const { user, subject, issuer, kid } =
await this.tokenExchangeService.embedLogin(subjectToken);
this.authService.issueCookie(res, user, true, req.browserId, true, {
sameSite: 'none',
secure: true,
});
this.authService.issueCookie(res, user, true, req.browserId, true, {
sameSite: 'none',
secure: true,
});
this.eventService.emit('embed-login', {
subject,
issuer,
kid,
clientIp: req.ip ?? 'unknown',
});
this.eventService.emit('embed-login', {
subject,
issuer,
kid,
clientIp: req.ip ?? 'unknown',
});
const safePath = validateRedirectUrl(redirect ?? '');
res.redirect(this.urlService.getInstanceBaseUrl() + safePath);
const safePath = validateRedirectUrl(redirect ?? '');
res.redirect(this.urlService.getInstanceBaseUrl() + safePath);
} catch (error) {
this.eventService.emit('embed-login-failed', {
failureReason:
error instanceof TokenExchangeAuthError || error instanceof TokenExchangeRequestError
? error.reason
: TokenExchangeFailureReason.InternalError,
clientIp: req.ip ?? 'unknown',
});
throw error;
}
}
}

View File

@@ -12,7 +12,9 @@ import { AuthlessRequest } from '@/requests';
import { TokenExchangeService } from '../services/token-exchange.service';
import { TokenExchangeConfig } from '../token-exchange.config';
import { TokenExchangeAuthError, TokenExchangeRequestError } from '../token-exchange.errors';
import { TOKEN_EXCHANGE_GRANT_TYPE, TokenExchangeRequestSchema } from '../token-exchange.schemas';
import { TokenExchangeFailureReason } from '../token-exchange.types';
const configService = Container.get(TokenExchangeConfig);
@@ -97,7 +99,10 @@ export class TokenExchangeController {
if (error instanceof AuthError) {
this.eventService.emit('token-exchange-failed', {
subject: '',
failureReason: error.message,
failureReason:
error instanceof TokenExchangeAuthError
? error.reason
: TokenExchangeFailureReason.Other,
grantType: parsed.data.grant_type,
clientIp,
});
@@ -111,7 +116,10 @@ export class TokenExchangeController {
if (error instanceof BadRequestError) {
this.eventService.emit('token-exchange-failed', {
subject: '',
failureReason: error.message,
failureReason:
error instanceof TokenExchangeRequestError
? error.reason
: TokenExchangeFailureReason.InvalidFormat,
grantType: parsed.data.grant_type,
clientIp,
});
@@ -125,7 +133,7 @@ export class TokenExchangeController {
if (error instanceof ZodError) {
this.eventService.emit('token-exchange-failed', {
subject: '',
failureReason: 'invalid_claims',
failureReason: TokenExchangeFailureReason.InvalidClaims,
grantType: parsed.data.grant_type,
clientIp,
});
@@ -139,7 +147,7 @@ export class TokenExchangeController {
this.errorReporter.error(error instanceof Error ? error : new Error(String(error)));
this.eventService.emit('token-exchange-failed', {
subject: '',
failureReason: 'internal_error',
failureReason: TokenExchangeFailureReason.InternalError,
grantType: parsed.data.grant_type,
clientIp,
});

View File

@@ -9,11 +9,12 @@ import {
} from '@n8n/db';
import { Service } from '@n8n/di';
import { AuthError } from '@/errors/response-errors/auth.error';
import { EventService } from '@/events/event.service';
import { UserService } from '@/services/user.service';
import { TokenExchangeAuthError } from '../token-exchange.errors';
import type { ExternalTokenClaims } from '../token-exchange.schemas';
import { TokenExchangeFailureReason } from '../token-exchange.types';
/**
* Password placeholder for JIT-provisioned users. This is not a valid bcrypt
@@ -92,7 +93,10 @@ export class IdentityResolutionService {
// Path 3: JIT provisioning
if (!email) {
throw new AuthError('Email claim is required for user provisioning');
throw new TokenExchangeAuthError(
TokenExchangeFailureReason.InvalidClaims,
'Email claim is required for user provisioning',
);
}
return await this.provisionUser(claims, email, allowedRoles, tokenContext);
@@ -226,7 +230,10 @@ export class IdentityResolutionService {
}
if (allowedRoles && allowedRoles.length > 0 && !allowedRoles.includes(role)) {
throw new AuthError(`Role '${role}' is not allowed for this token exchange key`);
throw new TokenExchangeAuthError(
TokenExchangeFailureReason.RoleNotAllowed,
`Role '${role}' is not allowed for this token exchange key`,
);
}
return role;
@@ -248,15 +255,24 @@ export class IdentityResolutionService {
const role = roleClaim;
if (role === 'global:owner') {
throw new AuthError('Cannot provision global:owner role via token exchange');
throw new TokenExchangeAuthError(
TokenExchangeFailureReason.RoleNotAllowed,
'Cannot provision global:owner role via token exchange',
);
}
if (!isGlobalRole(role)) {
throw new AuthError(`Unrecognized role '${role}' cannot be assigned to new user`);
throw new TokenExchangeAuthError(
TokenExchangeFailureReason.RoleNotAllowed,
`Unrecognized role '${role}' cannot be assigned to new user`,
);
}
if (allowedRoles && allowedRoles.length > 0 && !allowedRoles.includes(role)) {
throw new AuthError(`Role '${role}' is not allowed for this token exchange key`);
throw new TokenExchangeAuthError(
TokenExchangeFailureReason.RoleNotAllowed,
`Role '${role}' is not allowed for this token exchange key`,
);
}
return role;

View File

@@ -4,11 +4,10 @@ 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 { TokenExchangeConfig } from '../token-exchange.config';
import { TokenExchangeAuthError, TokenExchangeRequestError } from '../token-exchange.errors';
import type {
ExternalTokenClaims,
ResolvedTrustedKey,
@@ -17,6 +16,7 @@ import type {
import { ExternalTokenClaimsSchema } from '../token-exchange.schemas';
import {
TOKEN_EXCHANGE_ISSUER,
TokenExchangeFailureReason,
type IssuedJwtPayload,
type IssuedTokenResult,
} from '../token-exchange.types';
@@ -59,12 +59,18 @@ export class TokenExchangeService {
): Promise<{ claims: ExternalTokenClaims; resolvedKey: ResolvedTrustedKey }> {
const decoded = jwt.decode(subjectToken, { complete: true });
if (!decoded || typeof decoded === 'string') {
throw new BadRequestError('Invalid token format');
throw new TokenExchangeRequestError(
TokenExchangeFailureReason.InvalidFormat,
'Invalid token format',
);
}
const { kid } = decoded.header;
if (!kid) {
throw new BadRequestError('Token header missing kid');
throw new TokenExchangeRequestError(
TokenExchangeFailureReason.MissingKid,
'Token header missing kid',
);
}
const decodedPayload = decoded.payload;
@@ -73,12 +79,15 @@ export class TokenExchangeService {
? decodedPayload.iss
: undefined;
if (typeof iss !== 'string' || !iss) {
throw new BadRequestError('Token payload missing iss');
throw new TokenExchangeRequestError(
TokenExchangeFailureReason.MissingIss,
'Token payload missing iss',
);
}
const resolvedKey = await this.trustedKeyStore.getByKidAndIss(kid, iss);
if (!resolvedKey) {
throw new AuthError('Unknown key id');
throw new TokenExchangeAuthError(TokenExchangeFailureReason.UnknownKey, 'Unknown key id');
}
let payload: jwt.JwtPayload;
@@ -92,14 +101,20 @@ export class TokenExchangeService {
ignoreNotBefore: false,
});
if (typeof result === 'string' || !('iat' in result)) {
throw new AuthError('Unexpected token format');
throw new TokenExchangeAuthError(
TokenExchangeFailureReason.InvalidFormat,
'Unexpected token format',
);
}
payload = result;
} catch (error) {
if (error instanceof AuthError) throw error;
if (error instanceof TokenExchangeAuthError) throw error;
const message = error instanceof Error ? error.message : 'unknown error';
this.logger.warn('JWT verification failed', { error: message });
throw new AuthError('Token verification failed');
throw new TokenExchangeAuthError(
TokenExchangeFailureReason.InvalidSignature,
'Token verification failed',
);
}
const claims = ExternalTokenClaimsSchema.parse(payload);
@@ -107,13 +122,19 @@ export class TokenExchangeService {
if (maxLifetimeSeconds !== undefined) {
const tokenLifetime = claims.exp - claims.iat;
if (tokenLifetime > maxLifetimeSeconds) {
throw new AuthError('Token lifetime exceeds maximum allowed');
throw new TokenExchangeAuthError(
TokenExchangeFailureReason.TokenTooLong,
'Token lifetime exceeds maximum allowed',
);
}
}
const consumed = await this.jtiStore.consume(claims.jti, new Date(claims.exp * 1000));
if (!consumed) {
throw new AuthError('Token has already been used');
throw new TokenExchangeAuthError(
TokenExchangeFailureReason.TokenReplay,
'Token has already been used',
);
}
return { claims, resolvedKey };
@@ -161,7 +182,10 @@ export class TokenExchangeService {
);
if (exp <= now + MIN_REMAINING_LIFETIME_SECONDS) {
throw new AuthError('Subject token too close to expiry to issue a new token');
throw new TokenExchangeAuthError(
TokenExchangeFailureReason.TokenNearExpiry,
'Subject token too close to expiry to issue a new token',
);
}
const resources = request.resource?.split(' ').filter(Boolean);

View File

@@ -0,0 +1,30 @@
import { AuthError } from '@/errors/response-errors/auth.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import type { TokenExchangeFailureReason } from './token-exchange.types';
/**
* AuthError subclass that carries a typed failure reason for Prometheus metrics.
* Extends AuthError so existing `instanceof AuthError` checks in controllers continue to work.
*/
export class TokenExchangeAuthError extends AuthError {
constructor(
readonly reason: TokenExchangeFailureReason,
message: string,
) {
super(message);
}
}
/**
* BadRequestError subclass that carries a typed failure reason for Prometheus metrics.
* Extends BadRequestError so existing `instanceof BadRequestError` checks in controllers continue to work.
*/
export class TokenExchangeRequestError extends BadRequestError {
constructor(
readonly reason: TokenExchangeFailureReason,
message: string,
) {
super(message);
}
}

View File

@@ -1,5 +1,23 @@
import type { TOKEN_EXCHANGE_GRANT_TYPE } from './token-exchange.schemas';
export const TokenExchangeFailureReason = {
InvalidSignature: 'invalid_signature',
UnknownKey: 'unknown_key',
TokenReplay: 'token_replay',
TokenTooLong: 'token_too_long',
TokenNearExpiry: 'token_near_expiry',
InvalidFormat: 'invalid_format',
MissingKid: 'missing_kid',
MissingIss: 'missing_iss',
InvalidClaims: 'invalid_claims',
InternalError: 'internal_error',
RoleNotAllowed: 'role_not_allowed',
Other: 'other',
} as const;
export type TokenExchangeFailureReason =
(typeof TokenExchangeFailureReason)[keyof typeof TokenExchangeFailureReason];
export interface IssuedTokenResult {
accessToken: string;
expiresIn: number;