feat(core): Add trusted source config schema in @n8n/inbound-auth (no-changelog) (#39533)

This commit is contained in:
Andreas Fitzek 2026-09-29 08:33:06 +00:00 committed by GitHub
parent 0f77ba5e7e
commit 599df45795
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 864 additions and 27 deletions

1
OWNERS
View File

@ -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

View File

@ -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/**

View File

@ -0,0 +1,4 @@
import { defineConfig } from 'eslint/config';
import { baseConfig } from '@n8n/eslint-config/base';
export default defineConfig(baseConfig);

View File

@ -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"
}

View File

@ -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;

View File

@ -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<string, unknown>) => ({
...fullyDefaultedDocument,
identity: { ...fullyDefaultedDocument.identity, ...identity },
});
const withAuthentication = (authentication: Record<string, unknown>) => ({
...fullyDefaultedDocument,
authentication: { ...fullyDefaultedDocument.authentication, ...authentication },
});
const withRoleMapping = (roleMapping: Record<string, unknown>) =>
withIdentity({ roleMapping: { ...fullyDefaultedDocument.identity.roleMapping, ...roleMapping } });
const issuesOf = (result: ReturnType<typeof TrustedSourceConfigSchema.safeParse>) =>
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<string, unknown>) =>
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);
},
);
});

View File

@ -0,0 +1 @@
export * from './trusted-source-config';

View File

@ -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<typeof JwtAlgorithmSchema>;
/** 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<typeof RoleMappingRuleSchema>;
export const SurfaceIdSchema = z.enum(['public-api', 'instance-mcp', 'trigger']);
export type SurfaceId = z.infer<typeof SurfaceIdSchema>;
export const ManagedBySchema = z.enum(['system', 'admin']);
export type ManagedBy = z.infer<typeof ManagedBySchema>;
// 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<typeof Oauth2AuthenticationSchema>;
/** 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<typeof AuthenticationSchema>;
/**
* `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<typeof SurfacesSchema>;
/**
* 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<typeof ClaimMappingSchema>;
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<typeof RoleMappingSchema>;
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<typeof IdentitySchema>;
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<typeof TrustedSourceConfigV1Schema>;
export type TrustedSourceConfigV1Input = z.input<typeof TrustedSourceConfigV1Schema>;
const ownerRoleIssue = (path: Array<string | number>): 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<typeof TrustedSourceConfigSchema>;
/**
* 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<typeof TrustedSourceSecretsSchema>;
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<number, ConfigMigration> = {};
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<Record<number, ConfigMigration>> = 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;
}

View File

@ -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__/**"]
}

View File

@ -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" }
]
}

View File

@ -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,
);

View File

@ -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:*",

View File

@ -1,3 +1,7 @@
import type { RoleMappingRule } from '@n8n/inbound-auth';
export type { RoleMappingRule };
export interface RoleResolverContext {
/** Protocol-agnostic merged claims */
$claims: Record<string, unknown>;
@ -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[];

View File

@ -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<typeof TrustedKeySourceSchema>;
export type StaticKeySource = Extract<TrustedKeySource, { type: 'static' }>;
export type JwksKeySource = Extract<TrustedKeySource, { type: 'jwks' }>;
export type JwtAlgorithm = z.infer<typeof JwtAlgorithmSchema>;
export type TrustedKeySourceType = 'static' | 'jwks';
export type TrustedKeySourceStatus = 'pending' | 'healthy' | 'error';

View File

@ -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" },

View File

@ -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

View File

@ -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",