mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
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:
parent
55200605dd
commit
0d7df52a45
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user