From 599df45795bc8e4093c75bd7b2a218b3455c00a1 Mon Sep 17 00:00:00 2001 From: Andreas Fitzek Date: Tue, 29 Sep 2026 08:33:06 +0000 Subject: [PATCH] feat(core): Add trusted source config schema in @n8n/inbound-auth (no-changelog) (#39533) --- OWNERS | 1 + codecov.yml | 1 + packages/@n8n/inbound-auth/eslint.config.mjs | 4 + packages/@n8n/inbound-auth/package.json | 44 ++ .../fixtures/trusted-source-config.v1.ts | 30 ++ .../__tests__/trusted-source-config.test.ts | 376 ++++++++++++++++++ packages/@n8n/inbound-auth/src/index.ts | 1 + .../inbound-auth/src/trusted-source-config.ts | 320 +++++++++++++++ .../@n8n/inbound-auth/tsconfig.build.json | 10 + packages/@n8n/inbound-auth/tsconfig.json | 14 + packages/@n8n/inbound-auth/vite.config.ts | 14 + packages/cli/package.json | 1 + .../provisioning.ee/role-resolver-types.ts | 14 +- .../token-exchange/token-exchange.schemas.ts | 19 +- packages/cli/tsconfig.json | 1 + pnpm-lock.yaml | 40 ++ scripts/lint-parity/layers.json | 1 + 17 files changed, 864 insertions(+), 27 deletions(-) create mode 100644 packages/@n8n/inbound-auth/eslint.config.mjs create mode 100644 packages/@n8n/inbound-auth/package.json create mode 100644 packages/@n8n/inbound-auth/src/__tests__/fixtures/trusted-source-config.v1.ts create mode 100644 packages/@n8n/inbound-auth/src/__tests__/trusted-source-config.test.ts create mode 100644 packages/@n8n/inbound-auth/src/index.ts create mode 100644 packages/@n8n/inbound-auth/src/trusted-source-config.ts create mode 100644 packages/@n8n/inbound-auth/tsconfig.build.json create mode 100644 packages/@n8n/inbound-auth/tsconfig.json create mode 100644 packages/@n8n/inbound-auth/vite.config.ts diff --git a/OWNERS b/OWNERS index 9f6606b0f7b..eb12fec83a2 100644 --- a/OWNERS +++ b/OWNERS @@ -231,6 +231,7 @@ packages/@n8n/mcp-browser-extension/ @n8n-io/nodes packages/@n8n/permissions/ @n8n-io/iam packages/@n8n/client-oauth2/ @n8n-io/iam +packages/@n8n/inbound-auth/ @n8n-io/iam # LiGo diff --git a/codecov.yml b/codecov.yml index 69bad3858ad..ce45fc5a93c 100644 --- a/codecov.yml +++ b/codecov.yml @@ -89,6 +89,7 @@ component_management: - packages/@n8n/expression-runtime/** - packages/@n8n/extension-sdk/** - packages/@n8n/imap/** + - packages/@n8n/inbound-auth/** - packages/@n8n/local-gateway/** - packages/@n8n/permissions/** - packages/@n8n/scan-community-package/** diff --git a/packages/@n8n/inbound-auth/eslint.config.mjs b/packages/@n8n/inbound-auth/eslint.config.mjs new file mode 100644 index 00000000000..beff10615e5 --- /dev/null +++ b/packages/@n8n/inbound-auth/eslint.config.mjs @@ -0,0 +1,4 @@ +import { defineConfig } from 'eslint/config'; +import { baseConfig } from '@n8n/eslint-config/base'; + +export default defineConfig(baseConfig); diff --git a/packages/@n8n/inbound-auth/package.json b/packages/@n8n/inbound-auth/package.json new file mode 100644 index 00000000000..e0a6df71033 --- /dev/null +++ b/packages/@n8n/inbound-auth/package.json @@ -0,0 +1,44 @@ +{ + "name": "@n8n/inbound-auth", + "version": "0.1.0", + "scripts": { + "clean": "rimraf dist .turbo", + "dev": "pnpm watch", + "typecheck": "tsc --noEmit", + "build": "tsc -p tsconfig.build.json", + "build:unchecked": "tsc -p tsconfig.build.json --noCheck", + "format": "biome format --write .", + "format:check": "biome ci .", + "lint": "eslint . --quiet", + "lint:fix": "eslint . --fix", + "watch": "tsc -p tsconfig.build.json --watch", + "test": "vitest run", + "test:unit": "vitest run", + "test:dev": "vitest --silent=false" + }, + "main": "dist/index.js", + "module": "src/index.ts", + "types": "dist/index.d.ts", + "files": [ + "dist/**/*" + ], + "dependencies": { + "@n8n/permissions": "workspace:*", + "n8n-workflow": "workspace:*" + }, + "peerDependencies": { + "zod": "catalog:" + }, + "devDependencies": { + "@n8n/eslint-config": "workspace:*", + "@n8n/typescript-config": "workspace:*", + "@n8n/vitest-config": "workspace:*", + "@vitest/coverage-v8": "catalog:", + "eslint": "catalog:", + "typescript": "catalog:typescript", + "vite": "catalog:", + "vitest": "catalog:", + "zod": "catalog:" + }, + "license": "LicenseRef-n8n-sustainable-use" +} diff --git a/packages/@n8n/inbound-auth/src/__tests__/fixtures/trusted-source-config.v1.ts b/packages/@n8n/inbound-auth/src/__tests__/fixtures/trusted-source-config.v1.ts new file mode 100644 index 00000000000..803a271eea8 --- /dev/null +++ b/packages/@n8n/inbound-auth/src/__tests__/fixtures/trusted-source-config.v1.ts @@ -0,0 +1,30 @@ +import type { TrustedSourceConfigV1 } from '../../trusted-source-config'; + +/** A fully-specified v1 document, used by the migration-chain test harness. */ +export const trustedSourceConfigV1Fixture = { + version: 1, + authentication: { + type: 'oauth2', + discovery: { mode: 'auto' }, + keys: { kind: 'jwks-uri' }, + verification: { mode: 'jwt' }, + algorithms: ['RS256', 'ES256'], + maxTokenLifetimeSeconds: 3600, + clockSkewSeconds: 30, + }, + surfaces: { 'public-api': { audiences: ['n8n'] } }, + identity: { + subject: 'binding', + claimMapping: { + subject: 'sub', + email: 'email', + emailVerified: 'email_verified', + displayName: 'name', + clientId: 'client_id', + scopes: 'scope', + }, + linkByEmail: 'verified-only', + provision: { human: 'off' }, + roleMapping: { mode: 'off', instanceRoleRules: [], projectRoleRules: [] }, + }, +} satisfies TrustedSourceConfigV1; diff --git a/packages/@n8n/inbound-auth/src/__tests__/trusted-source-config.test.ts b/packages/@n8n/inbound-auth/src/__tests__/trusted-source-config.test.ts new file mode 100644 index 00000000000..007cdda0a57 --- /dev/null +++ b/packages/@n8n/inbound-auth/src/__tests__/trusted-source-config.test.ts @@ -0,0 +1,376 @@ +import { UnexpectedError } from 'n8n-workflow'; + +import { + configMigrations, + migrateToLatest, + TrustedSourceConfigSchema, + trustedSourceConfigSchemaFor, + TrustedSourceSecretsSchema, + type TrustedSourceConfig, +} from '../trusted-source-config'; +import { trustedSourceConfigV1Fixture } from './fixtures/trusted-source-config.v1'; + +const minimalDocument = { + version: 1, + authentication: { type: 'oauth2' }, + surfaces: {}, +}; + +const fullyDefaultedDocument = { + version: 1, + authentication: { + type: 'oauth2', + discovery: { mode: 'auto' }, + keys: { kind: 'jwks-uri' }, + verification: { mode: 'jwt' }, + algorithms: [ + 'RS256', + 'RS384', + 'RS512', + 'ES256', + 'ES384', + 'ES512', + 'PS256', + 'PS384', + 'PS512', + 'EdDSA', + ], + maxTokenLifetimeSeconds: 86400, + clockSkewSeconds: 60, + }, + surfaces: {}, + identity: { + subject: 'binding', + claimMapping: { + subject: 'sub', + email: 'email', + emailVerified: 'email_verified', + displayName: 'name', + clientId: 'client_id', + scopes: 'scope', + }, + linkByEmail: 'verified-only', + provision: { human: 'off' }, + roleMapping: { mode: 'off', instanceRoleRules: [], projectRoleRules: [] }, + }, +}; + +const rule = { id: 'r1', expression: '={{ true }}', role: 'global:member', enabled: true }; + +const withIdentity = (identity: Record) => ({ + ...fullyDefaultedDocument, + identity: { ...fullyDefaultedDocument.identity, ...identity }, +}); +const withAuthentication = (authentication: Record) => ({ + ...fullyDefaultedDocument, + authentication: { ...fullyDefaultedDocument.authentication, ...authentication }, +}); +const withRoleMapping = (roleMapping: Record) => + withIdentity({ roleMapping: { ...fullyDefaultedDocument.identity.roleMapping, ...roleMapping } }); + +const issuesOf = (result: ReturnType) => + result.success ? [] : result.error.issues; + +describe('TrustedSourceConfigSchema defaults', () => { + test('a minimal document with only version, authentication type and surfaces gets every default applied', () => { + expect(TrustedSourceConfigSchema.parse(minimalDocument)).toEqual(fullyDefaultedDocument); + }); +}); + +describe('TrustedSourceConfigSchema shape', () => { + test.each([ + { name: 'an unknown version', document: { ...minimalDocument, version: 2 }, path: ['version'] }, + { + name: 'a missing authentication block', + document: { version: 1, surfaces: {} }, + path: ['authentication'], + }, + { + name: 'an unknown authentication type', + document: { ...minimalDocument, authentication: { type: 'saml' } }, + path: ['authentication', 'type'], + }, + { + name: 'a surface key that is not a registered surface id', + document: { ...fullyDefaultedDocument, surfaces: { 'not-a-real-surface': {} } }, + path: ['surfaces', 'not-a-real-surface'], + }, + { + name: 'an empty audiences list', + document: { ...fullyDefaultedDocument, surfaces: { 'public-api': { audiences: [] } } }, + path: ['surfaces', 'public-api', 'audiences'], + }, + { + name: 'a registered client without clientId', + document: withAuthentication({ client: { kind: 'registered', clientId: '' } }), + path: ['authentication', 'client', 'clientId'], + }, + { + name: 'an empty algorithms list', + document: withAuthentication({ algorithms: [] }), + path: ['authentication', 'algorithms'], + }, + { + name: 'a token lifetime above one day', + document: withAuthentication({ maxTokenLifetimeSeconds: 86401 }), + path: ['authentication', 'maxTokenLifetimeSeconds'], + }, + { + name: 'a clock skew above five minutes', + document: withAuthentication({ clockSkewSeconds: 301 }), + path: ['authentication', 'clockSkewSeconds'], + }, + ])('rejects $name', ({ document, path }) => { + const result = TrustedSourceConfigSchema.safeParse(document); + + expect(result.success).toBe(false); + expect(issuesOf(result)).toContainEqual(expect.objectContaining({ path })); + }); + + test('a registered client carries no secret in the document', () => { + const document = withAuthentication({ + client: { kind: 'registered', clientId: 'n8n', clientSecret: 'leaked' }, + }); + + const parsed = TrustedSourceConfigSchema.parse(document); + + expect(parsed.authentication.client).toEqual({ kind: 'registered', clientId: 'n8n' }); + }); +}); + +describe('TrustedSourceConfigSchema refinement rules', () => { + test.each([ + { + rule: 'jit provisioning requires a role-mapping mode', + document: withIdentity({ provision: { human: 'jit' } }), + path: ['identity', 'roleMapping', 'mode'], + }, + { + rule: 'a role-mapping mode requires a fallback role', + document: withRoleMapping({ mode: 'on-provision' }), + path: ['identity', 'roleMapping', 'fallbackInstanceRole'], + }, + { + rule: 'manual discovery with jwks-uri keys requires jwksUri or metadataUrl', + document: withAuthentication({ discovery: { mode: 'manual' } }), + path: ['authentication', 'discovery'], + }, + { + rule: 'provisioning never assigns the instance owner role', + document: withRoleMapping({ mode: 'on-provision', fallbackInstanceRole: 'global:owner' }), + path: ['identity', 'roleMapping', 'fallbackInstanceRole'], + }, + { + rule: 'provisioning never assigns the instance owner role', + document: withRoleMapping({ instanceRoleRules: [{ ...rule, role: 'global:owner' }] }), + path: ['identity', 'roleMapping', 'instanceRoleRules', 0, 'role'], + }, + { + rule: 'an instance role rule must not set projectId', + document: withRoleMapping({ instanceRoleRules: [{ ...rule, projectId: 'p1' }] }), + path: ['identity', 'roleMapping', 'instanceRoleRules', 0, 'projectId'], + }, + { + rule: 'a project role rule requires projectId', + document: withRoleMapping({ projectRoleRules: [{ ...rule, role: 'project:editor' }] }), + path: ['identity', 'roleMapping', 'projectRoleRules', 0, 'projectId'], + }, + ])('fails validation and names the rule: $rule', ({ rule: message, document, path }) => { + const result = TrustedSourceConfigSchema.safeParse(document); + + expect(result.success).toBe(false); + expect(issuesOf(result)).toContainEqual(expect.objectContaining({ path, message })); + }); + + test.each([ + { + name: 'a role-mapping mode with a fallback role', + document: withRoleMapping({ mode: 'on-provision', fallbackInstanceRole: 'global:member' }), + }, + { + name: 'jit provisioning with a role-mapping mode and a fallback role', + document: withIdentity({ + provision: { human: 'jit' }, + roleMapping: { + mode: 'continuous', + fallbackInstanceRole: 'global:member', + instanceRoleRules: [rule], + projectRoleRules: [{ ...rule, id: 'r2', role: 'project:editor', projectId: 'p1' }], + }, + }), + }, + { + name: 'manual discovery with a metadata URL', + document: withAuthentication({ + discovery: { mode: 'manual', metadataUrl: 'https://idp.example.com/.well-known' }, + }), + }, + { + name: 'manual discovery without endpoints when keys come from the local keystore', + document: withAuthentication({ + discovery: { mode: 'manual' }, + keys: { kind: 'local-keystore' }, + }), + }, + ])('accepts $name', ({ document }) => { + expect(TrustedSourceConfigSchema.safeParse(document).success).toBe(true); + }); +}); + +describe('managedBy-gated fields', () => { + const systemOnlyDocuments = [ + { + field: 'identity.subject: n8n-user-id', + document: withIdentity({ subject: 'n8n-user-id' }), + path: ['identity', 'subject'], + }, + { + field: 'authentication.keys.kind: local-keystore', + document: withAuthentication({ keys: { kind: 'local-keystore' } }), + path: ['authentication', 'keys', 'kind'], + }, + { + field: 'authentication.client.kind: virtual', + document: withAuthentication({ client: { kind: 'virtual' } }), + path: ['authentication', 'client', 'kind'], + }, + ]; + + test.each(systemOnlyDocuments)('$field is accepted for a system row', ({ document }) => { + expect(trustedSourceConfigSchemaFor('system').safeParse(document).success).toBe(true); + }); + + test.each(systemOnlyDocuments)('$field is refused for an admin row', ({ document, path }) => { + const result = trustedSourceConfigSchemaFor('admin').safeParse(document); + + expect(result.success).toBe(false); + expect(issuesOf(result)).toContainEqual( + expect.objectContaining({ + path, + message: expect.stringContaining('only allowed on system-managed sources'), + }), + ); + }); + + test('an admin row accepts a document without system-only fields', () => { + const document = withAuthentication({ client: { kind: 'registered', clientId: 'n8n' } }); + + expect(trustedSourceConfigSchemaFor('admin').safeParse(document).success).toBe(true); + }); +}); + +describe('claim mapping sources', () => { + test('accepts an expression for a derived attribute', () => { + const document = withIdentity({ + claimMapping: { + ...fullyDefaultedDocument.identity.claimMapping, + displayName: '={{ $claims.given_name }} {{ $claims.family_name }}', + }, + }); + + expect(TrustedSourceConfigSchema.safeParse(document).success).toBe(true); + }); + + test('refuses an expression for the subject', () => { + const document = withIdentity({ + claimMapping: { + ...fullyDefaultedDocument.identity.claimMapping, + subject: '={{ $claims.oid }}', + }, + }); + + const result = TrustedSourceConfigSchema.safeParse(document); + + expect(result.success).toBe(false); + expect(issuesOf(result)).toContainEqual( + expect.objectContaining({ path: ['identity', 'claimMapping', 'subject'] }), + ); + }); +}); + +describe('algorithm allow-list inside authentication.algorithms', () => { + test.each([ + { name: 'none', algorithms: ['none'] }, + { name: 'a single HMAC algorithm', algorithms: ['HS256'] }, + { name: 'one valid and one HMAC algorithm', algorithms: ['RS256', 'HS512'] }, + ])('rejects $name', ({ algorithms }) => { + expect(TrustedSourceConfigSchema.safeParse(withAuthentication({ algorithms })).success).toBe( + false, + ); + }); +}); + +describe('manual discovery endpoints must be https', () => { + test.each(['metadataUrl', 'jwksUri', 'authorizationEndpoint', 'tokenEndpoint'])( + 'rejects a plain http %s', + (field) => { + const document = withAuthentication({ + discovery: { + mode: 'manual', + jwksUri: 'https://idp.example.com/jwks.json', + [field]: 'http://idp.example.com/x', + }, + }); + + const result = TrustedSourceConfigSchema.safeParse(document); + + expect(result.success).toBe(false); + expect(issuesOf(result)).toContainEqual( + expect.objectContaining({ path: ['authentication', 'discovery', field] }), + ); + }, + ); +}); + +describe('TrustedSourceSecretsSchema', () => { + test('accepts a client secret and refuses unknown keys', () => { + expect( + TrustedSourceSecretsSchema.safeParse({ version: 1, clientSecret: 's3cret' }).success, + ).toBe(true); + expect(TrustedSourceSecretsSchema.safeParse({ version: 1, clientId: 'n8n' }).success).toBe( + false, + ); + }); +}); + +describe('migrateToLatest', () => { + const asConfig = (document: Record) => + document as unknown as TrustedSourceConfig; + + test('returns a v1 document unchanged', () => { + const parsed = TrustedSourceConfigSchema.parse(trustedSourceConfigV1Fixture); + + expect(migrateToLatest(parsed)).toEqual(trustedSourceConfigV1Fixture); + }); + + test('throws on a version without a migration', () => { + const v0 = asConfig({ ...trustedSourceConfigV1Fixture, version: 0 }); + + expect(() => migrateToLatest(v0, {})).toThrow(UnexpectedError); + expect(() => migrateToLatest(v0, {})).toThrow( + 'No migration from trusted source config version 0', + ); + }); + + test('throws when a migration step does not advance the version', () => { + const v0 = asConfig({ ...trustedSourceConfigV1Fixture, version: 0 }); + const stuck = { 0: (config: TrustedSourceConfig) => config }; + + expect(() => migrateToLatest(v0, stuck)).toThrow(UnexpectedError); + expect(() => migrateToLatest(v0, stuck)).toThrow('did not advance the version'); + }); + + test('the shipped migration map is empty while v1 is the only version', () => { + expect(configMigrations).toEqual({}); + }); + + test.each([{ name: 'v1', fixture: trustedSourceConfigV1Fixture }])( + 'migrates the $name fixture to a document the latest schema accepts', + ({ fixture }) => { + const parsed = TrustedSourceConfigSchema.parse(fixture); + const migrated = migrateToLatest(parsed); + + expect(TrustedSourceConfigSchema.safeParse(migrated).success).toBe(true); + }, + ); +}); diff --git a/packages/@n8n/inbound-auth/src/index.ts b/packages/@n8n/inbound-auth/src/index.ts new file mode 100644 index 00000000000..072fc3d9cfc --- /dev/null +++ b/packages/@n8n/inbound-auth/src/index.ts @@ -0,0 +1 @@ +export * from './trusted-source-config'; diff --git a/packages/@n8n/inbound-auth/src/trusted-source-config.ts b/packages/@n8n/inbound-auth/src/trusted-source-config.ts new file mode 100644 index 00000000000..f16262789d6 --- /dev/null +++ b/packages/@n8n/inbound-auth/src/trusted-source-config.ts @@ -0,0 +1,320 @@ +import { GLOBAL_OWNER_ROLE_SLUG } from '@n8n/permissions'; +import { UnexpectedError } from 'n8n-workflow'; +import { z } from 'zod'; + +/** + * Asymmetric-only JWT algorithms accepted for trusted sources. + * Symmetric (HMAC) and 'none' are excluded by design. + */ +export const JwtAlgorithmSchema = z.enum([ + 'RS256', + 'RS384', + 'RS512', + 'ES256', + 'ES384', + 'ES512', + 'PS256', + 'PS384', + 'PS512', + 'EdDSA', +]); +export type JwtAlgorithm = z.infer; + +/** A single rule mapping claims to an instance or project role. Bounds match the public API DTO. */ +export const RoleMappingRuleSchema = z.object({ + id: z.string().min(1), + expression: z.string().min(1), + role: z.string().min(1).max(128), + /** undefined = instance role rule; set = project role rule */ + projectId: z.string().min(1).optional(), + enabled: z.boolean(), + description: z.string().optional(), +}); +export type RoleMappingRule = z.infer; + +export const SurfaceIdSchema = z.enum(['public-api', 'instance-mcp', 'trigger']); +export type SurfaceId = z.infer; + +export const ManagedBySchema = z.enum(['system', 'admin']); +export type ManagedBy = z.infer; + +// An admin-supplied endpoint is fetched server-side, so it must be https. +const HttpsUrl = z + .string() + .url() + .refine((url) => url.startsWith('https://'), { message: 'must be an https URL' }); + +export const Oauth2AuthenticationSchema = z.object({ + type: z.literal('oauth2'), + discovery: z + .discriminatedUnion('mode', [ + z.object({ mode: z.literal('auto') }), + z.object({ + mode: z.literal('manual'), + metadataUrl: HttpsUrl.optional(), + jwksUri: HttpsUrl.optional(), + authorizationEndpoint: HttpsUrl.optional(), + tokenEndpoint: HttpsUrl.optional(), + }), + ]) + .default({ mode: 'auto' }), + keys: z + .discriminatedUnion('kind', [ + z.object({ kind: z.literal('jwks-uri') }), + z.object({ kind: z.literal('local-keystore') }), + ]) + .default({ kind: 'jwks-uri' }), + // A union with one member, so introspection can be added later without a version bump. + verification: z + .discriminatedUnion('mode', [z.object({ mode: z.literal('jwt') })]) + .default({ mode: 'jwt' }), + algorithms: z + .array(JwtAlgorithmSchema) + .min(1) + .default([...JwtAlgorithmSchema.options]), + // Upper bounds keep both windows from disabling `exp`/`nbf` checks. Relaxing later is additive. + maxTokenLifetimeSeconds: z.number().int().positive().max(86400).default(86400), + clockSkewSeconds: z.number().int().nonnegative().max(300).default(60), + // The client secret is not part of the document: see `TrustedSourceSecretsSchema`. + client: z + .discriminatedUnion('kind', [ + z.object({ + kind: z.literal('registered'), + clientId: z.string().min(1), + scopes: z.array(z.string()).optional(), + }), + z.object({ kind: z.literal('virtual') }), + ]) + .optional(), +}); +export type Oauth2Authentication = z.infer; + +/** Discriminated on `type`. A new authentication method is a new member here, not a new config version. */ +export const AuthenticationSchema = z.discriminatedUnion('type', [Oauth2AuthenticationSchema]); +export type TrustedSourceAuthentication = z.infer; + +/** + * `audiences` absent means the surface's canonical audience is required. It never means the `aud` + * check is skipped, and an empty list is refused so it cannot be read that way either. + */ +export const SurfaceSettingsSchema = z.object({ + audiences: z.array(z.string().min(1)).min(1).optional(), +}); +/** A key present means the source is accepted on that surface. Unregistered keys are rejected by zod. */ +export const SurfacesSchema = z.record(SurfaceIdSchema, SurfaceSettingsSchema); +export type TrustedSourceSurfaces = z.infer; + +/** + * A claim name, or an n8n expression over `$claims` when prefixed with `=`, the same convention + * node parameters use. `={{ $claims.email ?? $claims.upn }}` covers IdPs whose claims differ. + */ +const claimSource = z.string().min(1); +/** The binding key stays a plain claim name, so re-keying a source is a one-token, auditable change. */ +const claimName = claimSource.refine((value) => !value.startsWith('='), { + message: 'subject must be a claim name, not an expression', +}); + +export const ClaimMappingSchema = z.object({ + subject: claimName.default('sub'), + email: claimSource.default('email'), + emailVerified: claimSource.default('email_verified'), + displayName: claimSource.default('name'), + clientId: claimSource.default('client_id'), + scopes: claimSource.default('scope'), +}); +export type ClaimMapping = z.infer; + +export const RoleMappingSchema = z.object({ + mode: z.enum(['off', 'on-provision', 'continuous']).default('off'), + instanceRoleRules: z.array(RoleMappingRuleSchema).default([]), + projectRoleRules: z.array(RoleMappingRuleSchema).default([]), + fallbackInstanceRole: z.string().min(1).optional(), +}); +export type RoleMapping = z.infer; + +export const IdentitySchema = z.object({ + subject: z.enum(['n8n-user-id', 'binding']).default('binding'), + claimMapping: ClaimMappingSchema.default({}), + linkByEmail: z.enum(['off', 'verified-only', 'any']).default('verified-only'), + provision: z.object({ human: z.enum(['off', 'jit']).default('off') }).default({}), + roleMapping: RoleMappingSchema.default({}), +}); +export type TrustedSourceIdentity = z.infer; + +export const TrustedSourceConfigV1Schema = z.object({ + version: z.literal(1), + // No default: the authentication method is an explicit choice. + authentication: AuthenticationSchema, + surfaces: SurfacesSchema, + identity: IdentitySchema.default({}), +}); +export type TrustedSourceConfigV1 = z.infer; +export type TrustedSourceConfigV1Input = z.input; + +const ownerRoleIssue = (path: Array): z.IssueData => ({ + code: z.ZodIssueCode.custom, + message: 'provisioning never assigns the instance owner role', + path, +}); + +/** + * The config document of one trusted source. Unknown keys are stripped, not rejected: an added + * optional field keeps the version, so an older process in a rolling deploy must tolerate it. The + * write path (admin DTO) must therefore reject unknown keys itself, and the store must persist the + * parsed output, not the raw input, so a later default change does not alter stored rows. + * + * Cross-field rules live here on the union, not on the block schemas: a discriminated-union member + * has to stay a plain object, and the rules hold for every version anyway. + */ +export const TrustedSourceConfigSchema = z + .discriminatedUnion('version', [TrustedSourceConfigV1Schema]) + .superRefine((config, ctx) => { + const { authentication, identity } = config; + if ( + authentication.discovery.mode === 'manual' && + authentication.keys.kind === 'jwks-uri' && + !authentication.discovery.jwksUri && + !authentication.discovery.metadataUrl + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'manual discovery with jwks-uri keys requires jwksUri or metadataUrl', + path: ['authentication', 'discovery'], + }); + } + if (identity.provision.human === 'jit' && identity.roleMapping.mode === 'off') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'jit provisioning requires a role-mapping mode', + path: ['identity', 'roleMapping', 'mode'], + }); + } + if (identity.roleMapping.mode !== 'off' && !identity.roleMapping.fallbackInstanceRole) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'a role-mapping mode requires a fallback role', + path: ['identity', 'roleMapping', 'fallbackInstanceRole'], + }); + } + if (identity.roleMapping.fallbackInstanceRole === GLOBAL_OWNER_ROLE_SLUG) { + ctx.addIssue(ownerRoleIssue(['identity', 'roleMapping', 'fallbackInstanceRole'])); + } + identity.roleMapping.instanceRoleRules.forEach((rule, index) => { + const path = ['identity', 'roleMapping', 'instanceRoleRules', index]; + if (rule.role === GLOBAL_OWNER_ROLE_SLUG) ctx.addIssue(ownerRoleIssue([...path, 'role'])); + if (rule.projectId !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'an instance role rule must not set projectId', + path: [...path, 'projectId'], + }); + } + }); + identity.roleMapping.projectRoleRules.forEach((rule, index) => { + if (rule.projectId === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'a project role rule requires projectId', + path: ['identity', 'roleMapping', 'projectRoleRules', index, 'projectId'], + }); + } + }); + }); +export type TrustedSourceConfig = z.infer; + +/** + * The config schema for one row. `managedBy` is a column, not part of the document, so the rules + * that depend on it are added per row on top of the shared schema. + */ +export const trustedSourceConfigSchemaFor = (managedBy: ManagedBy) => + TrustedSourceConfigSchema.superRefine((config, ctx) => { + if (managedBy === 'system') return; + if (config.identity.subject === 'n8n-user-id') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "identity.subject 'n8n-user-id' is only allowed on system-managed sources", + path: ['identity', 'subject'], + }); + } + // Narrow on the method first: these two rules are oauth2-specific. + if (config.authentication.type === 'oauth2') { + if (config.authentication.keys.kind === 'local-keystore') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "authentication.keys.kind 'local-keystore' is only allowed on system-managed sources", + path: ['authentication', 'keys', 'kind'], + }); + } + if (config.authentication.client?.kind === 'virtual') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "authentication.client.kind 'virtual' is only allowed on system-managed sources", + path: ['authentication', 'client', 'kind'], + }); + } + } + }); + +/** + * Secret material of a trusted source. Stored apart from the config document in its own encrypted + * column, so the document stays safe to return to an admin UI unchanged. Strict: a secret payload + * with an unknown key is a bug, not a forward-compatible read. + */ +export const TrustedSourceSecretsSchema = z + .object({ + version: z.literal(1), + clientSecret: z.string().min(1).optional(), + }) + .strict(); +export type TrustedSourceSecrets = z.infer; + +type ConfigMigration = (config: TrustedSourceConfig) => TrustedSourceConfig; + +/** One step per version, keyed by the version it migrates from. Empty until a v2 exists. */ +export const configMigrations: Record = {}; + +const LATEST_VERSION = 1; + +export type TrustedSourceConfigLatest = Extract< + TrustedSourceConfig, + { version: typeof LATEST_VERSION } +>; + +const isLatest = (config: TrustedSourceConfig): config is TrustedSourceConfigLatest => + config.version === LATEST_VERSION; + +/** + * Every throw here is a programming error, not bad input: the union parse already rejected + * unknown versions, so a missing or non-advancing step means the chain itself is wrong. + * `migrations` is injectable so tests seed a local map instead of mutating the module one. + */ +export function migrateToLatest( + config: TrustedSourceConfig, + migrations: Readonly> = configMigrations, +): TrustedSourceConfigLatest { + let current: TrustedSourceConfig = config; + // A plain number, not `current.version`: comparing the discriminant would narrow `current` to + // `never` while the union has one member. + let version: number = current.version; + while (version !== LATEST_VERSION) { + const migrate: ConfigMigration | undefined = migrations[version]; + if (!migrate) { + throw new UnexpectedError(`No migration from trusted source config version ${version}`); + } + current = migrate(current); + // A step must advance the version, or the chain would never end. + if (current.version <= version) { + throw new UnexpectedError( + `Migration from trusted source config version ${version} did not advance the version`, + ); + } + version = current.version; + } + if (!isLatest(current)) { + throw new UnexpectedError( + 'Migration chain did not reach the latest trusted source config version', + ); + } + return current; +} diff --git a/packages/@n8n/inbound-auth/tsconfig.build.json b/packages/@n8n/inbound-auth/tsconfig.build.json new file mode 100644 index 00000000000..1e88f16fb0e --- /dev/null +++ b/packages/@n8n/inbound-auth/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.go.json"], + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/__tests__/**"] +} diff --git a/packages/@n8n/inbound-auth/tsconfig.json b/packages/@n8n/inbound-auth/tsconfig.json new file mode 100644 index 00000000000..33ccd974365 --- /dev/null +++ b/packages/@n8n/inbound-auth/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "@n8n/typescript-config/tsconfig.common.go.json", + "compilerOptions": { + "types": ["node", "vitest/globals", "vite/client"], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../permissions/tsconfig.build.json" }, + { "path": "../../workflow/tsconfig.build.esm.json" } + ] +} diff --git a/packages/@n8n/inbound-auth/vite.config.ts b/packages/@n8n/inbound-auth/vite.config.ts new file mode 100644 index 00000000000..bcfcc92b9f6 --- /dev/null +++ b/packages/@n8n/inbound-auth/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig, mergeConfig } from 'vite'; +import { vitestConfig } from '@n8n/vitest-config/node'; +import path from 'node:path'; + +export default mergeConfig( + defineConfig({ + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + }), + vitestConfig, +); diff --git a/packages/cli/package.json b/packages/cli/package.json index 47c4091453c..8ba91f998ea 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -172,6 +172,7 @@ "@n8n/engine": "workspace:*", "@n8n/errors": "workspace:*", "@n8n/expression-runtime": "workspace:*", + "@n8n/inbound-auth": "workspace:*", "@n8n/instance-ai": "workspace:*", "@n8n/mcp-apps": "workspace:*", "@n8n/mcp-browser": "workspace:*", diff --git a/packages/cli/src/modules/provisioning.ee/role-resolver-types.ts b/packages/cli/src/modules/provisioning.ee/role-resolver-types.ts index edee3d60397..95b038e24f0 100644 --- a/packages/cli/src/modules/provisioning.ee/role-resolver-types.ts +++ b/packages/cli/src/modules/provisioning.ee/role-resolver-types.ts @@ -1,3 +1,7 @@ +import type { RoleMappingRule } from '@n8n/inbound-auth'; + +export type { RoleMappingRule }; + export interface RoleResolverContext { /** Protocol-agnostic merged claims */ $claims: Record; @@ -21,16 +25,6 @@ export interface RoleResolverContext { $project?: ProjectInfo; } -export interface RoleMappingRule { - id: string; - expression: string; - role: string; - /** undefined = instance role rule; set = project role rule */ - projectId?: string; - enabled: boolean; - description?: string; -} - export interface RoleMappingConfig { instanceRoleRules: RoleMappingRule[]; projectRoleRules: RoleMappingRule[]; diff --git a/packages/cli/src/modules/token-exchange/token-exchange.schemas.ts b/packages/cli/src/modules/token-exchange/token-exchange.schemas.ts index 5743af9654b..c9aa18134f8 100644 --- a/packages/cli/src/modules/token-exchange/token-exchange.schemas.ts +++ b/packages/cli/src/modules/token-exchange/token-exchange.schemas.ts @@ -1,25 +1,11 @@ import type { Secret } from 'jsonwebtoken'; +import { JwtAlgorithmSchema, type JwtAlgorithm } from '@n8n/inbound-auth'; import { z } from 'zod'; /** RFC 8693 grant type URN for token exchange */ export const TOKEN_EXCHANGE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:token-exchange' as const; -/** - * Asymmetric-only JWT algorithms accepted for trusted key sources. - * Symmetric (HMAC) and 'none' are excluded by design. - */ -export const JwtAlgorithmSchema = z.enum([ - 'RS256', - 'RS384', - 'RS512', - 'ES256', - 'ES384', - 'ES512', - 'PS256', - 'PS384', - 'PS512', - 'EdDSA', -]); +export { JwtAlgorithmSchema, type JwtAlgorithm }; /** * Validates JWT claims originating from an external identity provider. @@ -78,7 +64,6 @@ export type TrustedKeySource = z.infer; export type StaticKeySource = Extract; export type JwksKeySource = Extract; -export type JwtAlgorithm = z.infer; export type TrustedKeySourceType = 'static' | 'jwks'; export type TrustedKeySourceStatus = 'pending' | 'healthy' | 'error'; diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index e662c44fb55..802b53491d2 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -37,6 +37,7 @@ { "path": "../@n8n/decorators/tsconfig.build.json" }, { "path": "../@n8n/db/tsconfig.build.json" }, { "path": "../@n8n/errors/tsconfig.build.json" }, + { "path": "../@n8n/inbound-auth/tsconfig.build.json" }, { "path": "../@n8n/backend-common/tsconfig.build.json" }, { "path": "../@n8n/backend-test-utils/tsconfig.build.json" }, { "path": "../@n8n/blob-storage/tsconfig.build.json" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 184fa8145d9..2741ba2dda8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2921,6 +2921,43 @@ importers: specifier: 'catalog:' version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/browser-playwright@5.0.1)(@vitest/coverage-istanbul@5.0.1)(@vitest/coverage-v8@5.0.1)(@vitest/ui@5.0.1)(jsdom@23.0.1(bufferutil@4.0.9)(supports-color@5.5.0)(utf-8-validate@5.0.10))(vite@8.0.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@20.19.41)(esbuild@0.28.1)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)) + packages/@n8n/inbound-auth: + dependencies: + '@n8n/permissions': + specifier: workspace:* + version: link:../permissions + n8n-workflow: + specifier: workspace:* + version: link:../../workflow + devDependencies: + '@n8n/eslint-config': + specifier: workspace:* + version: link:../eslint-config + '@n8n/typescript-config': + specifier: workspace:* + version: link:../typescript-config + '@n8n/vitest-config': + specifier: workspace:* + version: link:../vitest-config + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 5.0.1(@vitest/browser@5.0.1)(vitest@5.0.1) + eslint: + specifier: 'catalog:' + version: 9.29.0(jiti@2.6.1)(supports-color@8.1.1) + typescript: + specifier: catalog:typescript + version: 7.0.2 + vite: + specifier: 'catalog:' + version: 8.0.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@20.19.41)(esbuild@0.28.1)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3) + vitest: + specifier: 'catalog:' + version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/browser-playwright@5.0.1)(@vitest/coverage-istanbul@5.0.1)(@vitest/coverage-v8@5.0.1)(@vitest/ui@5.0.1)(jsdom@23.0.1(bufferutil@4.0.9)(supports-color@8.1.1)(utf-8-validate@5.0.10))(vite@8.0.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@20.19.41)(esbuild@0.28.1)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)) + zod: + specifier: 3.25.76 + version: 3.25.76 + packages/@n8n/instance-ai: dependencies: '@daytona/sdk': @@ -4509,6 +4546,9 @@ importers: '@n8n/expression-runtime': specifier: workspace:* version: link:../@n8n/expression-runtime + '@n8n/inbound-auth': + specifier: workspace:* + version: link:../@n8n/inbound-auth '@n8n/instance-ai': specifier: workspace:* version: link:../@n8n/instance-ai diff --git a/scripts/lint-parity/layers.json b/scripts/lint-parity/layers.json index 2c998cca6b4..0cb4c161680 100644 --- a/scripts/lint-parity/layers.json +++ b/scripts/lint-parity/layers.json @@ -10,6 +10,7 @@ "packages/@n8n/di", "packages/@n8n/errors", "packages/@n8n/extension-sdk", + "packages/@n8n/inbound-auth", "packages/@n8n/json-schema-to-zod", "packages/@n8n/mcp-browser-extension", "packages/@n8n/permissions",