feat(core): Translate trusted-source claims into an external identity (no-changelog) (#40681)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andreas Fitzek 2026-10-09 13:06:31 +00:00 committed by GitHub
parent 55200605dd
commit 0d7df52a45
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 356 additions and 1 deletions

View File

@ -56,6 +56,22 @@ describe('inboundFrom', () => {
expect(inbound.surface).toBe('trigger');
expect(inbound.acceptedSourceIds).toEqual(['source-a']);
});
it('leaves grant unset when the resource issues no grant', () => {
expect(inboundFrom(resource, request)).not.toHaveProperty('grant');
});
it('takes grant from the resource when it issues one', () => {
const granting: ProtectedResource = {
...resource,
surface: 'trigger',
getGrant: () => ({ audiences: [resourceUrl], executeAccessWorkflowId: 'wf-1' }),
};
const inbound = inboundFrom(granting, request);
expect(inbound.grant).toEqual({ audiences: [resourceUrl], executeAccessWorkflowId: 'wf-1' });
});
});
describe('extractBearer', () => {

View File

@ -4,10 +4,12 @@ import type { Extracted, Inbound, Result } from './pipeline';
/** Derives `Inbound` from the resource, so a surface integration cannot stamp it wrong. */
export function inboundFrom(resource: ProtectedResource, request: Inbound['request']): Inbound {
const acceptedSourceIds = resource.getAcceptedSourceIds?.();
const grant = resource.getGrant?.();
return {
surface: resource.surface,
resource: { url: resource.getResourceUrl(), acceptedAudiences: resource.getAudiences() },
...(acceptedSourceIds !== undefined && { acceptedSourceIds }),
...(grant !== undefined && { grant }),
request,
receivedAt: new Date(),
};

View File

@ -1,4 +1,4 @@
import type { ProtectedResourceRef } from '@n8n/permissions';
import type { ProtectedResourceRef, ResourceGrant } from '@n8n/permissions';
import type { TrustedSource } from './trusted-source';
import type { SurfaceId } from './trusted-source-config';
@ -9,6 +9,8 @@ export type Inbound = {
resource: ProtectedResourceRef;
/** A trigger's selected source narrows the set of sources accepted on the surface. */
acceptedSourceIds?: string[];
/** What the resource hands out to a caller. Absent when the resource issues no grant. */
grant?: ResourceGrant;
request: {
method: string;
url: string;
@ -47,11 +49,24 @@ export type RejectReason =
| 'audience-mismatch'
| 'not-an-access-token'
| 'unknown-subject'
| 'user-disabled'
| 'binding-inactive'
| 'link-refused'
| 'provision-refused'
| 'no-role';
/** The caller as the trusted source describes it, before n8n looks for a matching user. */
export type ExternalIdentity = {
subject: string;
email?: string;
emailVerified?: boolean;
displayName?: string;
clientId?: string;
scopes: string[];
assurance?: { acr?: string; amr?: string[]; authTime?: Date };
raw: Readonly<Record<string, unknown>>;
};
export type Reject = { ok: false; reason: RejectReason; detail?: string };
export type Ok<T> = { ok: true; value: T };
export type Result<T> = Ok<T> | Reject;

View File

@ -0,0 +1,218 @@
import type { Logger } from '@n8n/backend-common';
import {
ClaimMappingSchema,
type ClaimMapping,
type ExternalIdentity,
type Result,
} from '@n8n/inbound-auth';
import { mock } from 'vitest-mock-extended';
import { translateClaims } from '../identity/external-identity';
const logger = mock<Logger>();
/** RFC 9068 claim names: sub, email, email_verified, name, client_id, scope. */
const DEFAULT_MAPPING = ClaimMappingSchema.parse({});
/** Microsoft Entra: stable id in `oid`, email in one of two claims, scopes as an array in `scp`. */
const ENTRA_MAPPING = ClaimMappingSchema.parse({
subject: 'oid',
email: '={{ $claims.email ?? $claims.preferred_username }}',
emailVerified: 'xms_edov',
clientId: 'azp',
scopes: 'scp',
});
function translate(claims: Record<string, unknown>, mapping: ClaimMapping = DEFAULT_MAPPING) {
return translateClaims(claims, mapping, logger);
}
function expectOk(result: Result<ExternalIdentity>): ExternalIdentity {
expect(result.ok).toBe(true);
if (!result.ok) throw new Error(`rejected: ${result.reason}`);
return result.value;
}
describe('translateClaims', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('mapping', () => {
it('reads the RFC 9068 claims with the default mapping', () => {
const claims = {
sub: 'user-1',
email: 'Ada@Example.com',
email_verified: true,
name: 'Ada Lovelace',
client_id: 'client-a',
scope: 'read write read',
};
const identity = expectOk(translate(claims));
expect(identity).toEqual({
subject: 'user-1',
email: 'ada@example.com',
emailVerified: true,
displayName: 'Ada Lovelace',
clientId: 'client-a',
scopes: ['read', 'write'],
raw: claims,
});
expect(identity.assurance).toBeUndefined();
});
it('reads Entra claims with an array scope and the preferred_username fallback', () => {
const claims = {
oid: 'oid-1',
preferred_username: 'Ada@Contoso.com',
xms_edov: 'true',
azp: 'app-1',
scp: ['Mail.Read', 'Mail.Read', 'User.Read'],
};
const identity = expectOk(translate(claims, ENTRA_MAPPING));
expect(identity).toEqual({
subject: 'oid-1',
email: 'ada@contoso.com',
emailVerified: true,
clientId: 'app-1',
scopes: ['Mail.Read', 'User.Read'],
raw: claims,
});
});
it('prefers the first operand of the ?? fallback when that claim is present', () => {
const claims = { oid: 'oid-1', email: 'Ada@Example.com', preferred_username: 'other' };
expect(expectOk(translate(claims, ENTRA_MAPPING)).email).toBe('ada@example.com');
});
it('joins several claims in a displayName expression', () => {
const mapping = ClaimMappingSchema.parse({
displayName: '={{ $claims.given_name }} {{ $claims.family_name }}',
});
const claims = { sub: 'user-1', given_name: 'Ada', family_name: 'Lovelace' };
expect(expectOk(translate(claims, mapping)).displayName).toBe('Ada Lovelace');
});
it.each([
['undefined', '={{ $claims.missing }}', {}],
['an empty string', '={{ $claims.empty }}', { empty: '' }],
['a number', '={{ $claims.n }}', { n: 42 }],
['a thrown error', '={{ $claims.x.y.z }}', {}],
])(
'leaves the attribute absent and logs at debug when an expression yields %s',
(_label, displayName, extraClaims) => {
const mapping = ClaimMappingSchema.parse({ displayName });
const result = translate({ sub: 'user-1', ...extraClaims }, mapping);
expect(result.ok).toBe(true);
expect(expectOk(result).displayName).toBeUndefined();
expect(logger.debug).toHaveBeenCalled();
},
);
it('reads a plain claim name with own-property semantics, not the prototype chain', () => {
const mapping = ClaimMappingSchema.parse({ displayName: 'constructor' });
expect(expectOk(translate({ sub: 'user-1' }, mapping)).displayName).toBeUndefined();
});
it('leaves the attribute absent when the plain claim is not a string', () => {
expect(expectOk(translate({ sub: 'user-1', name: 7 })).displayName).toBeUndefined();
expect(expectOk(translate({ sub: 'user-1', name: '' })).displayName).toBeUndefined();
});
});
describe('subject', () => {
it.each([
['the claim is missing', { email: 'a@example.com' }],
['the claim is not a string', { sub: 42 }],
['the claim is an empty string', { sub: '' }],
])('rejects with unknown-subject when %s', (_label, claims) => {
expect(translate(claims)).toEqual({ ok: false, reason: 'unknown-subject' });
});
});
describe('email', () => {
it('lowercases the email', () => {
expect(expectOk(translate({ sub: 'u', email: 'Ada.L@EXAMPLE.org' })).email).toBe(
'ada.l@example.org',
);
});
it.each([
[true, true],
['true', true],
[false, false],
['false', false],
['yes', undefined],
['TRUE', undefined],
[1, undefined],
[undefined, undefined],
])('maps email_verified %j to %j', (email_verified, expected) => {
expect(expectOk(translate({ sub: 'u', email_verified })).emailVerified).toBe(expected);
});
});
describe('scopes', () => {
it.each([
['a space-separated string with a duplicate', 'a b a', ['a', 'b']],
['an array with a duplicate', ['a', 'a', 'b'], ['a', 'b']],
['an array with a non-string entry', ['a', 3], []],
['an array with an empty string', ['a', ''], []],
['an absent claim', undefined, []],
['an empty string', '', []],
])('yields %s as %j', (_label, scope, expected) => {
expect(expectOk(translate({ sub: 'u', scope })).scopes).toEqual(expected);
});
});
describe('assurance', () => {
it('is undefined when no assurance claim is present', () => {
expect(expectOk(translate({ sub: 'u' })).assurance).toBeUndefined();
});
it('reads acr as a non-empty string', () => {
const identity = expectOk(translate({ sub: 'u', acr: 'urn:mace:incommon:iap:silver' }));
expect(identity.assurance).toEqual({ acr: 'urn:mace:incommon:iap:silver' });
});
it('keeps only the string entries of amr', () => {
const identity = expectOk(translate({ sub: 'u', amr: ['pwd', 7, 'mfa'] }));
expect(identity.assurance).toEqual({ amr: ['pwd', 'mfa'] });
});
it.each([
['a number of seconds', 1700000000],
['a numeric string of seconds', '1700000000'],
])('reads auth_time given as %s into a Date', (_label, auth_time) => {
const identity = expectOk(translate({ sub: 'u', auth_time }));
expect(identity.assurance?.authTime).toEqual(new Date(1700000000 * 1000));
});
it.each([
['negative', -5],
['zero', 0],
['not numeric', 'yesterday'],
['an empty string', ''],
['beyond the Date range', 1e300],
])('leaves authTime absent when auth_time is %s', (_label, auth_time) => {
expect(expectOk(translate({ sub: 'u', auth_time })).assurance?.authTime).toBeUndefined();
});
});
it('keeps the input claims object as raw', () => {
const claims = { sub: 'u', extra: { nested: true } };
expect(expectOk(translate(claims)).raw).toBe(claims);
});
});

View File

@ -0,0 +1,104 @@
import type { Logger } from '@n8n/backend-common';
import type { ClaimMapping, ExternalIdentity, Result } from '@n8n/inbound-auth';
import { Expression } from 'n8n-workflow';
function readClaim(
claims: Readonly<Record<string, unknown>>,
source: string,
logger: Logger,
): unknown {
if (!source.startsWith('=')) {
return Object.hasOwn(claims, source) ? claims[source] : undefined;
}
const expression = source.slice(1);
try {
const value = Expression.resolveWithoutWorkflow(expression, {
$claims: claims,
});
const usable = typeof value === 'string' ? value.trim() !== '' : Array.isArray(value);
if (!usable) logger.debug('Claim mapping expression yielded no usable value', { source });
return value;
} catch (error) {
logger.debug('Claim mapping expression failed', { source, error });
return undefined;
}
}
function nonEmptyStringExact(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() !== '' ? value : undefined;
}
function nonEmptyString(value: unknown): string | undefined {
return nonEmptyStringExact(value)?.trim();
}
function toScopes(value: unknown): string[] {
const parts =
typeof value === 'string'
? value.split(/\s+/).map((scope) => scope.trim())
: Array.isArray(value) && value.every((v) => typeof v === 'string' && v.trim() !== '')
? value
: [];
return [...new Set(parts.filter((scope) => scope !== ''))];
}
function toVerified(value: unknown): boolean | undefined {
if (value === true || value === 'true') return true;
if (value === false || value === 'false') return false;
return undefined;
}
function toAuthTime(value: unknown): Date | undefined {
const seconds =
typeof value === 'number'
? value
: typeof value === 'string' && value.trim() !== ''
? Number(value)
: NaN;
if (!Number.isFinite(seconds) || seconds <= 0) return undefined;
// A large finite value overflows the Date range and yields an Invalid Date.
const date = new Date(seconds * 1000);
return Number.isNaN(date.getTime()) ? undefined : date;
}
export function translateClaims(
claims: Readonly<Record<string, unknown>>,
mapping: ClaimMapping,
logger: Logger,
): Result<ExternalIdentity> {
const subject = nonEmptyStringExact(
Object.hasOwn(claims, mapping.subject) ? claims[mapping.subject] : undefined,
);
if (subject === undefined) {
return {
ok: false,
reason: 'unknown-subject',
};
}
const read = (source: string) => readClaim(claims, source, logger);
const email = nonEmptyString(read(mapping.email))?.toLowerCase();
const acr = nonEmptyString(claims['acr']);
const amr = Array.isArray(claims['amr'])
? claims['amr'].filter((v): v is string => typeof v === 'string')
: undefined;
const authTime = toAuthTime(claims['auth_time']);
const assurance = acr || amr || authTime ? { acr, amr, authTime } : undefined;
return {
ok: true,
value: {
subject,
email,
emailVerified: toVerified(read(mapping.emailVerified)),
displayName: nonEmptyString(read(mapping.displayName)),
clientId: nonEmptyString(read(mapping.clientId)),
scopes: toScopes(read(mapping.scopes)),
assurance,
raw: claims,
},
};
}