mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(core): Add trusted source config schema in @n8n/inbound-auth (no-changelog) (#39533)
This commit is contained in:
parent
0f77ba5e7e
commit
599df45795
1
OWNERS
1
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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/**
|
||||
|
|
|
|||
4
packages/@n8n/inbound-auth/eslint.config.mjs
Normal file
4
packages/@n8n/inbound-auth/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { defineConfig } from 'eslint/config';
|
||||
import { baseConfig } from '@n8n/eslint-config/base';
|
||||
|
||||
export default defineConfig(baseConfig);
|
||||
44
packages/@n8n/inbound-auth/package.json
Normal file
44
packages/@n8n/inbound-auth/package.json
Normal 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"
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
1
packages/@n8n/inbound-auth/src/index.ts
Normal file
1
packages/@n8n/inbound-auth/src/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './trusted-source-config';
|
||||
320
packages/@n8n/inbound-auth/src/trusted-source-config.ts
Normal file
320
packages/@n8n/inbound-auth/src/trusted-source-config.ts
Normal 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;
|
||||
}
|
||||
10
packages/@n8n/inbound-auth/tsconfig.build.json
Normal file
10
packages/@n8n/inbound-auth/tsconfig.build.json
Normal 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__/**"]
|
||||
}
|
||||
14
packages/@n8n/inbound-auth/tsconfig.json
Normal file
14
packages/@n8n/inbound-auth/tsconfig.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
14
packages/@n8n/inbound-auth/vite.config.ts
Normal file
14
packages/@n8n/inbound-auth/vite.config.ts
Normal 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,
|
||||
);
|
||||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user