diff --git a/packages/@n8n/inbound-auth/src/__tests__/inbound.test.ts b/packages/@n8n/inbound-auth/src/__tests__/inbound.test.ts index ef3ae052021..20e2e9d75c5 100644 --- a/packages/@n8n/inbound-auth/src/__tests__/inbound.test.ts +++ b/packages/@n8n/inbound-auth/src/__tests__/inbound.test.ts @@ -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', () => { diff --git a/packages/@n8n/inbound-auth/src/inbound.ts b/packages/@n8n/inbound-auth/src/inbound.ts index 196ef409b41..26622e61e3a 100644 --- a/packages/@n8n/inbound-auth/src/inbound.ts +++ b/packages/@n8n/inbound-auth/src/inbound.ts @@ -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(), }; diff --git a/packages/@n8n/inbound-auth/src/pipeline.ts b/packages/@n8n/inbound-auth/src/pipeline.ts index 47770136365..ff3ba94b090 100644 --- a/packages/@n8n/inbound-auth/src/pipeline.ts +++ b/packages/@n8n/inbound-auth/src/pipeline.ts @@ -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>; +}; + export type Reject = { ok: false; reason: RejectReason; detail?: string }; export type Ok = { ok: true; value: T }; export type Result = Ok | Reject; diff --git a/packages/cli/src/modules/inbound-auth-core/__tests__/external-identity.test.ts b/packages/cli/src/modules/inbound-auth-core/__tests__/external-identity.test.ts new file mode 100644 index 00000000000..91a8ab1d3ed --- /dev/null +++ b/packages/cli/src/modules/inbound-auth-core/__tests__/external-identity.test.ts @@ -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(); + +/** 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, mapping: ClaimMapping = DEFAULT_MAPPING) { + return translateClaims(claims, mapping, logger); +} + +function expectOk(result: Result): 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); + }); +}); diff --git a/packages/cli/src/modules/inbound-auth-core/identity/external-identity.ts b/packages/cli/src/modules/inbound-auth-core/identity/external-identity.ts new file mode 100644 index 00000000000..96d74fcac42 --- /dev/null +++ b/packages/cli/src/modules/inbound-auth-core/identity/external-identity.ts @@ -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>, + 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>, + mapping: ClaimMapping, + logger: Logger, +): Result { + 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, + }, + }; +}