mirror of
https://github.com/n8n-io/n8n
synced 2026-04-19 13:05:54 +02:00
feat: Add Prometheus counters for token exchange (#28453)
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user