From 32d7c8ebf4ffffdacaf471a5092b3c398d6bfa2a Mon Sep 17 00:00:00 2001 From: Andreas Fitzek Date: Mon, 5 Oct 2026 09:30:49 +0000 Subject: [PATCH] feat(core): Add inbound-auth contracts and fail-closed defaults (no-changelog) (#40004) Co-authored-by: Claude Fable 5.1 --- .../modules/__tests__/module-registry.test.ts | 15 +- .../src/modules/module-registry.ts | 5 +- packages/@n8n/inbound-auth/package.json | 1 + .../src/__tests__/inbound.test.ts | 118 +++++++++ .../src/__tests__/trusted-source.test.ts | 232 ++++++++++++++++++ packages/@n8n/inbound-auth/src/contracts.ts | 124 ++++++++++ packages/@n8n/inbound-auth/src/inbound.ts | 31 +++ packages/@n8n/inbound-auth/src/index.ts | 4 + packages/@n8n/inbound-auth/src/pipeline.ts | 57 +++++ .../@n8n/inbound-auth/src/trusted-source.ts | 127 ++++++++++ packages/@n8n/inbound-auth/tsconfig.json | 1 + .../inbound-auth-core.module.test.ts | 79 ++++++ .../inbound-auth-core.module.ts | 52 +++- .../__tests__/mcp-protected-resource.test.ts | 4 + .../src/modules/mcp/mcp-protected-resource.ts | 11 +- .../src/modules/mcp/mcp-tool-availability.ts | 5 +- .../oauth-consent.controller.api.test.ts | 1 + .../__tests__/oauth-server.service.test.ts | 11 + .../__tests__/oauth-token.service.test.ts | 7 + .../__tests__/oauth.controller.test.ts | 1 + .../chat-trigger-resource.base.ts | 1 + .../form-trigger-resource.resolver.ts | 1 + .../form-trigger-test-resource.resolver.ts | 1 + ...flow-mcp-test-trigger-resource.resolver.ts | 1 + .../workflow-mcp-trigger-resource.resolver.ts | 1 + ...-webhook-test-trigger-resource.resolver.ts | 1 + ...kflow-webhook-trigger-resource.resolver.ts | 1 + .../src/modules/oauth-server/resource-gate.ts | 6 +- .../protected-resource.registry.test.ts | 3 + .../services/protected-resource.registry.ts | 113 +-------- .../__tests__/webhook-helpers.test.ts | 1 + .../src/workflows/workflow-finder.service.ts | 11 +- pnpm-lock.yaml | 3 + 33 files changed, 900 insertions(+), 130 deletions(-) create mode 100644 packages/@n8n/inbound-auth/src/__tests__/inbound.test.ts create mode 100644 packages/@n8n/inbound-auth/src/__tests__/trusted-source.test.ts create mode 100644 packages/@n8n/inbound-auth/src/contracts.ts create mode 100644 packages/@n8n/inbound-auth/src/inbound.ts create mode 100644 packages/@n8n/inbound-auth/src/pipeline.ts create mode 100644 packages/@n8n/inbound-auth/src/trusted-source.ts diff --git a/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts b/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts index 1f12d265817..00c0a9ab986 100644 --- a/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts +++ b/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts @@ -41,6 +41,17 @@ describe('eligibleModules', () => { expect(Container.get(ModuleRegistry).eligibleModules).toContain('policy-infrastructure'); }); + it('should list inbound-auth-core before the modules that inject its contracts', () => { + const eligible = Container.get(ModuleRegistry).eligibleModules; + const core = eligible.indexOf('inbound-auth-core'); + + expect(core).toBeGreaterThanOrEqual(0); + for (const consumer of ['oauth-server', 'mcp', 'token-exchange'] as const) { + expect(eligible).toContain(consumer); + expect(eligible.indexOf(consumer)).toBeGreaterThan(core); + } + }); + it('should allow opting out of policy-infrastructure via env var', () => { process.env.N8N_DISABLED_MODULES = 'policy-infrastructure'; expect(Container.get(ModuleRegistry).eligibleModules).not.toContain('policy-infrastructure'); @@ -62,6 +73,7 @@ describe('eligibleModules', () => { process.env.N8N_DISABLED_MODULES = 'insights'; expect(Container.get(ModuleRegistry).eligibleModules).toEqual([ 'policy-infrastructure', + 'inbound-auth-core', 'external-secrets', 'community-packages', 'data-table', @@ -92,7 +104,6 @@ describe('eligibleModules', () => { 'workflow-reviews', 'instance-ai', 'agents', - 'inbound-auth-core', ]); }); @@ -100,6 +111,7 @@ describe('eligibleModules', () => { process.env.N8N_ENABLED_MODULES = 'type-availability-policies'; expect(Container.get(ModuleRegistry).eligibleModules).toEqual([ 'policy-infrastructure', + 'inbound-auth-core', 'insights', 'external-secrets', 'community-packages', @@ -131,7 +143,6 @@ describe('eligibleModules', () => { 'workflow-reviews', 'instance-ai', 'agents', - 'inbound-auth-core', 'type-availability-policies', ]); }); diff --git a/packages/@n8n/backend-common/src/modules/module-registry.ts b/packages/@n8n/backend-common/src/modules/module-registry.ts index db0049adb57..cd7a690f2d2 100644 --- a/packages/@n8n/backend-common/src/modules/module-registry.ts +++ b/packages/@n8n/backend-common/src/modules/module-registry.ts @@ -46,6 +46,10 @@ export class ModuleRegistry { // policy-infrastructure leads: it registers the enforcement implementation // that every policy feature's checks are run by. 'policy-infrastructure', + // inbound-auth-core binds the inbound-auth contracts that later modules inject. Modules + // initialize in this order, and an unbound abstract constructor parameter resolves to + // `undefined` in the container, so it has to run before every consumer. + 'inbound-auth-core', 'insights', 'external-secrets', 'community-packages', @@ -79,7 +83,6 @@ export class ModuleRegistry { 'workflow-reviews', 'instance-ai', 'agents', - 'inbound-auth-core', ]; private readonly activeModules: string[] = []; diff --git a/packages/@n8n/inbound-auth/package.json b/packages/@n8n/inbound-auth/package.json index 5e643596dad..8ab7f589603 100644 --- a/packages/@n8n/inbound-auth/package.json +++ b/packages/@n8n/inbound-auth/package.json @@ -23,6 +23,7 @@ "dist/**/*" ], "dependencies": { + "@n8n/api-types": "workspace:*", "@n8n/permissions": "workspace:*", "n8n-workflow": "workspace:*" }, diff --git a/packages/@n8n/inbound-auth/src/__tests__/inbound.test.ts b/packages/@n8n/inbound-auth/src/__tests__/inbound.test.ts new file mode 100644 index 00000000000..ef3ae052021 --- /dev/null +++ b/packages/@n8n/inbound-auth/src/__tests__/inbound.test.ts @@ -0,0 +1,118 @@ +import type { ProtectedResource } from '../contracts'; +import { extractBearer, inboundFrom } from '../inbound'; +import type { Inbound, Verified } from '../pipeline'; + +const resourceUrl = 'https://n8n.example/mcp-server/http'; + +const resource: ProtectedResource = { + id: 'instance-mcp', + surface: 'instance-mcp', + scopes: [], + getResourceUrl: () => resourceUrl, + getAudiences: () => [resourceUrl, 'legacy-audience'], + authorize: async () => true, +}; + +const request: Inbound['request'] = { + method: 'POST', + url: '/mcp-server/http', + headers: {}, + ip: '203.0.113.7', +}; + +const inboundWith = (headers: Inbound['request']['headers']): Inbound => ({ + surface: 'instance-mcp', + resource: { url: resourceUrl, acceptedAudiences: [resourceUrl] }, + request: { ...request, headers }, + receivedAt: new Date('2026-09-30T10:00:00Z'), +}); + +describe('inboundFrom', () => { + it('takes surface and the resource ref from the resource, never from the caller', () => { + const inbound = inboundFrom(resource, request); + + expect(inbound.surface).toBe('instance-mcp'); + expect(inbound.resource).toEqual({ + url: resourceUrl, + acceptedAudiences: [resourceUrl, 'legacy-audience'], + }); + expect(inbound.request).toBe(request); + expect(inbound.receivedAt).toBeInstanceOf(Date); + }); + + it('leaves acceptedSourceIds unset when the resource does not narrow the surface', () => { + expect(inboundFrom(resource, request).acceptedSourceIds).toBeUndefined(); + }); + + it('takes acceptedSourceIds from the resource when it narrows the surface', () => { + const narrowed: ProtectedResource = { + ...resource, + surface: 'trigger', + getAcceptedSourceIds: () => ['source-a'], + }; + + const inbound = inboundFrom(narrowed, request); + + expect(inbound.surface).toBe('trigger'); + expect(inbound.acceptedSourceIds).toEqual(['source-a']); + }); +}); + +describe('extractBearer', () => { + it('returns Extracted with the bearer credential for a valid Authorization header', () => { + const inbound = inboundWith({ authorization: 'Bearer abc.def.ghi' }); + + const result = extractBearer(inbound); + + expect(result).toEqual({ + ok: true, + value: { ...inbound, credential: { kind: 'bearer', token: 'abc.def.ghi' } }, + }); + }); + + it('accepts the scheme case-insensitively, as RFC 9110 requires', () => { + const result = extractBearer(inboundWith({ authorization: 'bearer abc' })); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value.credential.token).toBe('abc'); + }); + + it('uses the first value when the header arrives as an array', () => { + const result = extractBearer(inboundWith({ authorization: ['Bearer first', 'Bearer second'] })); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value.credential.token).toBe('first'); + }); + + it('rejects with no-credential when there is no Authorization header', () => { + expect(extractBearer(inboundWith({}))).toEqual({ ok: false, reason: 'no-credential' }); + }); + + it.each([ + ['a non-bearer scheme', 'Basic dXNlcjpwYXNz'], + ['a bearer scheme without a token', 'Bearer'], + ['a bearer scheme with an empty token', 'Bearer '], + ])('rejects with malformed-credential for %s', (_label, authorization) => { + expect(extractBearer(inboundWith({ authorization }))).toMatchObject({ + ok: false, + reason: 'malformed-credential', + }); + }); +}); + +describe('Verified', () => { + it('has no field that can hold the token', () => { + type HoldsCredential = 'credential' extends keyof Verified ? true : false; + type HoldsToken = 'token' extends keyof Verified ? true : false; + type HoldsHeaders = 'headers' extends keyof Verified['request'] ? true : false; + + // Assigning `false` fails to compile if either key exists on the type. + const holdsCredential: HoldsCredential = false; + const holdsToken: HoldsToken = false; + const holdsHeaders: HoldsHeaders = false; + + expect(holdsCredential).toBe(false); + expect(holdsToken).toBe(false); + expect(holdsHeaders).toBe(false); + }); +}); diff --git a/packages/@n8n/inbound-auth/src/__tests__/trusted-source.test.ts b/packages/@n8n/inbound-auth/src/__tests__/trusted-source.test.ts new file mode 100644 index 00000000000..f06f6eab5d1 --- /dev/null +++ b/packages/@n8n/inbound-auth/src/__tests__/trusted-source.test.ts @@ -0,0 +1,232 @@ +import { + deriveCapabilities, + TrustedSourceMetadataSchema, + type Capability, + type TrustedSourceMetadata, +} from '../trusted-source'; +import { + migrateToLatest, + trustedSourceConfigSchemaFor, + type ManagedBy, + type TrustedSourceConfigLatest, +} from '../trusted-source-config'; + +const issuer = 'https://idp.example'; +const fetchedAt = '2026-09-30T10:00:00.000Z'; + +const endpoints = { + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, +}; +const fullMetadata = { issuer, jwks_uri: `${issuer}/keys`, ...endpoints }; + +const oidcDocument = { kind: 'openid-configuration', fetchedAt, document: fullMetadata }; +const oauth2Document = { kind: 'oauth2-authorization-server', fetchedAt, document: fullMetadata }; +const jwksDocument = { + kind: 'jwks', + fetchedAt, + url: `${issuer}/keys`, + keys: [{ kid: 'k1', kty: 'RSA', use: 'sig', alg: 'RS256', n: 'AQAB', e: 'AQAB' }], +}; + +describe('TrustedSourceMetadataSchema', () => { + it('accepts a v1 list with an OIDC document, an RFC 8414 document and a JWKS', () => { + const parsed = TrustedSourceMetadataSchema.parse({ + version: 1, + documents: [oidcDocument, oauth2Document, jwksDocument], + }); + + expect(parsed.documents.map((document) => document.kind)).toEqual([ + 'openid-configuration', + 'oauth2-authorization-server', + 'jwks', + ]); + }); + + it('keeps metadata and JWK fields it does not type, so a later reader finds them', () => { + const parsed = TrustedSourceMetadataSchema.parse({ + version: 1, + documents: [ + { + ...oidcDocument, + document: { ...fullMetadata, userinfo_endpoint: `${issuer}/userinfo` }, + }, + jwksDocument, + ], + }); + + expect(parsed.documents[0]).toMatchObject({ + document: { userinfo_endpoint: `${issuer}/userinfo` }, + }); + expect(parsed.documents[1]).toMatchObject({ keys: [{ n: 'AQAB', e: 'AQAB' }] }); + }); + + it('accepts an empty document list, the state before discovery has run', () => { + expect(TrustedSourceMetadataSchema.parse({ version: 1, documents: [] })).toEqual({ + version: 1, + documents: [], + }); + }); + + it.each([ + ['an unknown document kind', [{ ...oidcDocument, kind: 'saml' }]], + [ + 'a non-https jwks_uri in a metadata document', + [{ ...oidcDocument, document: { ...fullMetadata, jwks_uri: 'http://idp.example/keys' } }], + ], + ['a non-https JWKS url', [{ ...jwksDocument, url: 'http://idp.example/keys' }]], + ['a key without kty', [{ ...jwksDocument, keys: [{ kid: 'k1' }] }]], + ['two documents of the same kind', [oidcDocument, oidcDocument]], + ])('rejects %s', (_label, documents) => { + expect(TrustedSourceMetadataSchema.safeParse({ version: 1, documents }).success).toBe(false); + }); + + it('rejects an unknown version', () => { + expect(TrustedSourceMetadataSchema.safeParse({ version: 2, documents: [] }).success).toBe( + false, + ); + }); +}); + +describe('deriveCapabilities', () => { + const config = ( + authentication: Record, + managedBy: ManagedBy = 'admin', + ): TrustedSourceConfigLatest => + migrateToLatest( + trustedSourceConfigSchemaFor(managedBy).parse({ + version: 1, + authentication: { type: 'oauth2', ...authentication }, + surfaces: { 'public-api': {} }, + }), + ); + + type Fields = Partial>; + const metadata = (documents: { + oidc?: Fields; + oauth2?: Fields; + jwks?: boolean; + }): TrustedSourceMetadata => + TrustedSourceMetadataSchema.parse({ + version: 1, + documents: [ + ...(documents.oidc ? [{ ...oidcDocument, document: { issuer, ...documents.oidc } }] : []), + ...(documents.oauth2 + ? [{ ...oauth2Document, document: { issuer, ...documents.oauth2 } }] + : []), + ...(documents.jwks ? [jwksDocument] : []), + ], + }); + + const registeredClient = { kind: 'registered', clientId: 'n8n' }; + // Manual discovery with jwks-uri keys needs a jwksUri or metadataUrl to pass the config schema. + const manualAuthorizationEndpoint = { + mode: 'manual', + authorizationEndpoint: `${issuer}/authorize`, + metadataUrl: `${issuer}/.well-known/openid-configuration`, + }; + + const cases: Array< + [string, TrustedSourceConfigLatest, () => TrustedSourceMetadata | null, Capability[]] + > = [ + ['jwks-uri keys and no metadata grant nothing', config({}), () => null, []], + [ + 'a jwks_uri in the OIDC document grants verify-jwt', + config({}), + () => metadata({ oidc: { jwks_uri: `${issuer}/keys` } }), + ['verify-jwt'], + ], + [ + 'a jwks_uri in the RFC 8414 document grants verify-jwt', + config({}), + () => metadata({ oauth2: { jwks_uri: `${issuer}/keys` } }), + ['verify-jwt'], + ], + [ + 'a manual jwksUri grants verify-jwt without metadata', + config({ discovery: { mode: 'manual', jwksUri: `${issuer}/keys` } }), + () => null, + ['verify-jwt'], + ], + [ + 'local-keystore grants verify-jwt without metadata', + config({ keys: { kind: 'local-keystore' } }, 'system'), + () => null, + ['verify-jwt'], + ], + [ + 'discovered endpoints without a client grant no redirect-login', + config({}), + () => metadata({ oidc: endpoints }), + [], + ], + [ + 'discovered endpoints with a client grant redirect-login', + config({ client: registeredClient }), + () => metadata({ oidc: fullMetadata }), + ['verify-jwt', 'redirect-login'], + ], + [ + 'manual endpoints with a client grant redirect-login without metadata', + config({ + client: registeredClient, + discovery: { + mode: 'manual', + authorizationEndpoint: `${issuer}/authorize`, + tokenEndpoint: `${issuer}/token`, + jwksUri: `${issuer}/keys`, + }, + }), + () => null, + ['verify-jwt', 'redirect-login'], + ], + [ + 'only an authorization endpoint with a client grants no redirect-login', + config({ client: registeredClient }), + () => metadata({ oidc: { authorization_endpoint: `${issuer}/authorize` } }), + [], + ], + [ + 'a JWKS document without any jwks_uri grants nothing on its own', + config({}), + () => metadata({ jwks: true }), + [], + ], + // Fields merge: manual config, then the OIDC document, then the RFC 8414 document. + [ + 'a manual authorization endpoint completes discovered token and jwks endpoints', + config({ client: registeredClient, discovery: manualAuthorizationEndpoint }), + () => metadata({ oidc: { token_endpoint: `${issuer}/token`, jwks_uri: `${issuer}/keys` } }), + ['verify-jwt', 'redirect-login'], + ], + [ + 'a manual authorization endpoint with only a discovered token endpoint grants redirect-login alone', + config({ client: registeredClient, discovery: manualAuthorizationEndpoint }), + () => metadata({ oidc: { token_endpoint: `${issuer}/token` } }), + ['redirect-login'], + ], + [ + 'an authorization endpoint from OIDC and a token endpoint from RFC 8414 combine', + config({ client: registeredClient }), + () => + metadata({ + oidc: { authorization_endpoint: `${issuer}/authorize` }, + oauth2: { token_endpoint: `${issuer}/token` }, + }), + ['redirect-login'], + ], + [ + 'a manual jwksUri wins over a discovered one while the endpoints come from discovery', + config({ + client: registeredClient, + discovery: { mode: 'manual', jwksUri: `${issuer}/manual-keys` }, + }), + () => metadata({ oauth2: { ...endpoints, jwks_uri: 'https://elsewhere.example/keys' } }), + ['verify-jwt', 'redirect-login'], + ], + ]; + + it.each(cases)('%s', (_label, cfg, meta, expected) => { + expect([...deriveCapabilities(cfg, meta())].sort()).toEqual([...expected].sort()); + }); +}); diff --git a/packages/@n8n/inbound-auth/src/contracts.ts b/packages/@n8n/inbound-auth/src/contracts.ts new file mode 100644 index 00000000000..b4904bd86cc --- /dev/null +++ b/packages/@n8n/inbound-auth/src/contracts.ts @@ -0,0 +1,124 @@ +import type { ConsentUiHints } from '@n8n/api-types'; +import type { AuthPrincipal, SecurityContext } from '@n8n/permissions'; +import type { OAuthResourceGrant } from 'n8n-workflow'; + +import type { Extracted, Inbound, Result, Verified } from './pipeline'; +import type { TrustedSource } from './trusted-source'; +import type { SurfaceId } from './trusted-source-config'; + +/** What a protected resource reads from the user. The `User` entity satisfies it. */ +export type ResourceUser = AuthPrincipal & { id: string; createdAt: Date }; + +/** + * Descriptor for an OAuth 2.1 protected resource served by this instance. + * + * All resources share a single authorization server (one issuer, one set of + * `/authorize`/`/token`/`/register` endpoints, one signing key) but each + * resource has its own canonical RFC 8707 resource URL, accepted audiences, + * and advertised scopes. + */ +export interface ProtectedResource { + /** Stable identifier, e.g. `'instance-mcp'`. */ + id: string; + + /** The surface this resource is served on. Every resolver knows which one it serves. */ + readonly surface: SurfaceId; + + /** Human readable name, for consent screen */ + displayName?: string; + + /** Presentational hints for the consent screen; omit for the default generic MCP icon. */ + uiHints?: ConsentUiHints; + + /** + * Canonical RFC 8707 resource URL used as the JWT `aud` claim and advertised + * as the resource indicator (e.g. `https://instance.example/mcp-server/http`). + * Resolved lazily so the instance base URL is read per request. + */ + getResourceUrl(): string; + + /** + * Every resource URL this resource is served at, canonical URL + * (`getResourceUrl()`) first. Treated as `[getResourceUrl()]` when not implemented. + */ + getResourceUrls?(): string[]; + + /** + * All `aud` values accepted at this resource's gate. Must include the + * canonical resource URL; may include resource-specific legacy audiences. + */ + getAudiences(): string[]; + + /** Trusted sources this resource accepts, when it narrows the surface's set (a trigger's selection). */ + getAcceptedSourceIds?(): string[]; + + /** OAuth scopes advertised for this resource in discovery documents. */ + scopes: string[]; + + /** + * Scopes *this user* may actually grant, narrowing {@link scopes}. Omit when + * every authenticated user can grant everything the resource supports. + */ + getGrantableScopes?(user: ResourceUser): Promise; + + /** Tool names unlocked by each grantable scope, for display on the consent screen. */ + getScopeTools?(): Record | Promise>; + + /** + * Fallback audience for token requests that omit an RFC 8707 `resource` + * parameter (pre-8707 clients). At most one registered resource may be the default. + */ + isDefault?: boolean; + + /** + * Optional explicit allowlist of `redirect_uri` values accepted at `/authorize` for this + * resource. An empty array means "no additional restriction". + */ + getAllowedRedirectUris?(): Promise; + + /** First-party resources skip the third-party consent treatment. */ + isFirstParty?: boolean; + + /** Whether the resource is currently served. Treated as `true` when not implemented. */ + isAvailable?(): Promise; + + /** Whether `user` may access this resource at all. */ + authorize(user: ResourceUser): Promise; + + /** The grant sealed into a run, so the worker re-takes the same decision without the resource. */ + getGrant?(): OAuthResourceGrant; +} + +/** What a resource contributes to advertising: RFC 9728 metadata and the 401 challenge. */ +export type AdvertisedResource = Pick; + +/** The dispatcher. Owns what every method shares: driver lookup and surface acceptance. */ +export abstract class AuthenticationService { + abstract authenticate(extracted: Extracted): Promise>; + + abstract advertise( + resource: AdvertisedResource, + ): Promise<{ authorizationServers: string[]; challenges: string[] }>; +} + +export abstract class IdentityService { + abstract identify(verified: Verified): Promise>; +} + +/** Read-only: writes stay on the module's own store. Extend here when a second consumer needs one. */ +export abstract class TrustedSourceStore { + abstract getById(id: string): Promise; + + abstract getByIssuer(issuer: string): Promise; + + abstract listBySurface(surface: SurfaceId): Promise; +} + +export abstract class TrustedSourceGate { + /** The worker path: re-take the sealed grant and the binding status without a token. */ + abstract authorizeSealed(input: { + userId: string; + grant: OAuthResourceGrant; + binding?: { sourceId: string; subject: string }; + }): Promise; +} diff --git a/packages/@n8n/inbound-auth/src/inbound.ts b/packages/@n8n/inbound-auth/src/inbound.ts new file mode 100644 index 00000000000..196ef409b41 --- /dev/null +++ b/packages/@n8n/inbound-auth/src/inbound.ts @@ -0,0 +1,31 @@ +import type { ProtectedResource } from './contracts'; +import type { Extracted, Inbound, Result } from './pipeline'; + +/** Derives `Inbound` from the resource, so a surface integration cannot stamp it wrong. */ +export function inboundFrom(resource: ProtectedResource, request: Inbound['request']): Inbound { + const acceptedSourceIds = resource.getAcceptedSourceIds?.(); + return { + surface: resource.surface, + resource: { url: resource.getResourceUrl(), acceptedAudiences: resource.getAudiences() }, + ...(acceptedSourceIds !== undefined && { acceptedSourceIds }), + request, + receivedAt: new Date(), + }; +} + +// RFC 9110: the scheme is case-insensitive; the token is the rest, without surrounding blanks. +const BEARER = /^bearer\s+(\S+)\s*$/i; + +/** Extraction belongs to the surface; this is the shared parser for the `Authorization: Bearer` case. */ +export function extractBearer(inbound: Inbound): Result { + const entry = Object.entries(inbound.request.headers).find( + ([name]) => name.toLowerCase() === 'authorization', + ); + const header = Array.isArray(entry?.[1]) ? entry[1][0] : entry?.[1]; + if (header === undefined) return { ok: false, reason: 'no-credential' }; + + const token = BEARER.exec(header)?.[1]; + if (token === undefined) return { ok: false, reason: 'malformed-credential' }; + + return { ok: true, value: { ...inbound, credential: { kind: 'bearer', token } } }; +} diff --git a/packages/@n8n/inbound-auth/src/index.ts b/packages/@n8n/inbound-auth/src/index.ts index 072fc3d9cfc..907b992e1f3 100644 --- a/packages/@n8n/inbound-auth/src/index.ts +++ b/packages/@n8n/inbound-auth/src/index.ts @@ -1 +1,5 @@ +export * from './contracts'; +export * from './inbound'; +export type * from './pipeline'; +export * from './trusted-source'; export * from './trusted-source-config'; diff --git a/packages/@n8n/inbound-auth/src/pipeline.ts b/packages/@n8n/inbound-auth/src/pipeline.ts new file mode 100644 index 00000000000..47770136365 --- /dev/null +++ b/packages/@n8n/inbound-auth/src/pipeline.ts @@ -0,0 +1,57 @@ +import type { ProtectedResourceRef } from '@n8n/permissions'; + +import type { TrustedSource } from './trusted-source'; +import type { SurfaceId } from './trusted-source-config'; + +/** Stamped by the surface, never by the caller. */ +export type Inbound = { + surface: SurfaceId; + resource: ProtectedResourceRef; + /** A trigger's selected source narrows the set of sources accepted on the surface. */ + acceptedSourceIds?: string[]; + request: { + method: string; + url: string; + headers: Readonly>; + ip: string; + }; + receivedAt: Date; +}; + +/** A union: more kinds (API key, signed request) are added here, not on the pipeline. */ +export type Credential = { kind: 'bearer'; token: string }; + +export type Extracted = Inbound & { credential: Credential }; + +/** + * The token does not travel past authentication, so no later step can leak it. The request + * headers stop here as well, because the credential arrived in one of them. + */ +export type Verified = Omit & { + request: Omit; + credentialKind: Credential['kind']; + source: TrustedSource; + claims: Readonly>; + expiresAt: Date; +}; + +/** Closed: this is the vocabulary of the log line and of the 401 description. */ +export type RejectReason = + | 'no-credential' + | 'malformed-credential' + | 'unknown-issuer' + | 'source-not-accepted' + | 'source-unusable' + | 'signature-invalid' + | 'expired' + | 'audience-mismatch' + | 'not-an-access-token' + | 'unknown-subject' + | 'binding-inactive' + | 'link-refused' + | 'provision-refused' + | 'no-role'; + +export type Reject = { ok: false; reason: RejectReason; detail?: string }; +export type Ok = { ok: true; value: T }; +export type Result = Ok | Reject; diff --git a/packages/@n8n/inbound-auth/src/trusted-source.ts b/packages/@n8n/inbound-auth/src/trusted-source.ts new file mode 100644 index 00000000000..56e63bde4b9 --- /dev/null +++ b/packages/@n8n/inbound-auth/src/trusted-source.ts @@ -0,0 +1,127 @@ +import { z } from 'zod'; + +import type { + ManagedBy, + TrustedSourceAuthentication, + TrustedSourceConfigLatest, +} from './trusted-source-config'; + +export type TrustedSourceStatus = 'unchecked' | 'healthy' | 'error'; + +// 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' }); + +/** Only the fields the pipeline reads are typed; the rest passes through for later readers. */ +const JwkSchema = z + .object({ + kid: z.string().optional(), + kty: z.string().min(1), + use: z.string().optional(), + alg: z.string().optional(), + }) + .passthrough(); + +/** + * RFC 8414 fields. OpenID Connect Discovery documents carry the same core fields plus OIDC ones, + * so one schema reads both. Only what the pipeline reads is typed; the rest passes through. + */ +const AuthorizationServerMetadataSchema = z + .object({ + issuer: z.string().min(1), + jwks_uri: HttpsUrl.optional(), + authorization_endpoint: HttpsUrl.optional(), + token_endpoint: HttpsUrl.optional(), + }) + .passthrough(); +export type AuthorizationServerMetadata = z.infer; + +const fetchedAt = z.string().datetime(); + +/** + * One document per protocol artifact, discriminated on `kind`. OpenID Connect Discovery and + * RFC 8414 metadata are two documents at two URLs; an issuer may serve either or both. The JWKS + * is its own document because it comes from one URL with its own lifetime. A future SAML metadata + * document is a new member here, not a new column. + */ +export const DiscoveryDocumentSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('openid-configuration'), + fetchedAt, + document: AuthorizationServerMetadataSchema, + }), + z.object({ + kind: z.literal('oauth2-authorization-server'), + fetchedAt, + document: AuthorizationServerMetadataSchema, + }), + z.object({ kind: z.literal('jwks'), fetchedAt, url: HttpsUrl, keys: z.array(JwkSchema) }), +]); +export type DiscoveryDocument = z.infer; + +export const TrustedSourceMetadataSchema = z.object({ + version: z.literal(1), + // One document per kind: readers take the first match, so a duplicate could shadow it. + documents: z + .array(DiscoveryDocumentSchema) + .refine( + (documents) => new Set(documents.map((document) => document.kind)).size === documents.length, + { message: 'one document per kind' }, + ), +}); +export type TrustedSourceMetadata = z.infer; + +/** + * A trusted source as consumers see it: decrypted, validated and migrated to the latest config + * version. Dates are ISO strings so the shape survives a Redis round-trip unchanged. + */ +export type TrustedSource = { + id: string; + name: string; + type: TrustedSourceAuthentication['type']; + issuer: string; + managedBy: ManagedBy; + status: TrustedSourceStatus; + lastError: string | null; + lastCheckedAt: string | null; + createdAt: string; + updatedAt: string; + config: TrustedSourceConfigLatest; + /** `null` until discovery has run. */ + metadata: TrustedSourceMetadata | null; +}; + +export type Capability = 'verify-jwt' | 'redirect-login'; + +/** Pure: capabilities come from the config and the discovered metadata, never from the network. */ +export function deriveCapabilities( + config: TrustedSourceConfigLatest, + metadata: TrustedSourceMetadata | null, +): ReadonlySet { + const capabilities = new Set(); + const { authentication } = config; + if (authentication.type !== 'oauth2') return capabilities; + + const manual = authentication.discovery.mode === 'manual' ? authentication.discovery : undefined; + const metadataOf = (kind: 'openid-configuration' | 'oauth2-authorization-server') => + metadata?.documents.find( + (document): document is Extract => + document.kind === kind, + )?.document; + const oidc = metadataOf('openid-configuration'); + const oauth2 = metadataOf('oauth2-authorization-server'); + + // Field by field: manual config wins, then the OIDC document, then the RFC 8414 document. + const jwksUri = manual?.jwksUri ?? oidc?.jwks_uri ?? oauth2?.jwks_uri; + const authorizationEndpoint = + manual?.authorizationEndpoint ?? oidc?.authorization_endpoint ?? oauth2?.authorization_endpoint; + const tokenEndpoint = manual?.tokenEndpoint ?? oidc?.token_endpoint ?? oauth2?.token_endpoint; + + if (authentication.keys.kind === 'local-keystore' || jwksUri) capabilities.add('verify-jwt'); + if (authentication.client && authorizationEndpoint && tokenEndpoint) { + capabilities.add('redirect-login'); + } + return capabilities; +} diff --git a/packages/@n8n/inbound-auth/tsconfig.json b/packages/@n8n/inbound-auth/tsconfig.json index 33ccd974365..2b8e2f65cef 100644 --- a/packages/@n8n/inbound-auth/tsconfig.json +++ b/packages/@n8n/inbound-auth/tsconfig.json @@ -8,6 +8,7 @@ }, "include": ["src/**/*.ts"], "references": [ + { "path": "../api-types/tsconfig.build.json" }, { "path": "../permissions/tsconfig.build.json" }, { "path": "../../workflow/tsconfig.build.esm.json" } ] diff --git a/packages/cli/src/modules/inbound-auth-core/__tests__/inbound-auth-core.module.test.ts b/packages/cli/src/modules/inbound-auth-core/__tests__/inbound-auth-core.module.test.ts index 923a2982bef..6d58d3edcfa 100644 --- a/packages/cli/src/modules/inbound-auth-core/__tests__/inbound-auth-core.module.test.ts +++ b/packages/cli/src/modules/inbound-auth-core/__tests__/inbound-auth-core.module.test.ts @@ -1,10 +1,89 @@ import { ModuleMetadata } from '@n8n/decorators'; import { Container } from '@n8n/di'; +import { + AuthenticationService, + IdentityService, + migrateToLatest, + TrustedSourceConfigSchema, + TrustedSourceGate, + type Extracted, + type TrustedSource, + type Verified, +} from '@n8n/inbound-auth'; // Importing the module runs the @BackendModule decorator, registering its metadata. import { InboundAuthCoreModule } from '../inbound-auth-core.module'; describe('InboundAuthCoreModule', () => { + describe('init', () => { + const extracted = { + surface: 'instance-mcp', + resource: { url: 'https://n8n.example/mcp', acceptedAudiences: ['https://n8n.example/mcp'] }, + request: { method: 'POST', url: '/mcp', headers: {}, ip: '203.0.113.7' }, + receivedAt: new Date(), + credential: { kind: 'bearer', token: 'a.b.c' }, + } satisfies Extracted; + + beforeAll(async () => { + await new InboundAuthCoreModule().init(); + }); + + it('binds an AuthenticationService that fails closed until a driver is registered', async () => { + const result = await Container.get(AuthenticationService).authenticate(extracted); + + expect(result).toMatchObject({ ok: false, reason: 'source-unusable' }); + }); + + it('binds an IdentityService that fails closed until an implementation is registered', async () => { + const source: TrustedSource = { + id: 'source-1', + name: 'Example IdP', + type: 'oauth2', + issuer: 'https://idp.example', + managedBy: 'admin', + status: 'unchecked', + lastError: null, + lastCheckedAt: null, + createdAt: '2026-09-30T10:00:00.000Z', + updatedAt: '2026-09-30T10:00:00.000Z', + config: migrateToLatest( + TrustedSourceConfigSchema.parse({ + version: 1, + authentication: { type: 'oauth2' }, + surfaces: { 'instance-mcp': {} }, + }), + ), + metadata: null, + }; + const { + credential, + request: { headers: _headers, ...request }, + ...rest + } = extracted; + const verified = { + ...rest, + request, + credentialKind: credential.kind, + source, + claims: { sub: 'alice' }, + expiresAt: new Date(), + } satisfies Verified; + + const result = await Container.get(IdentityService).identify(verified); + + expect(result).toMatchObject({ ok: false, reason: 'source-unusable' }); + }); + + it('binds a TrustedSourceGate that denies until an implementation is registered', async () => { + const allowed = await Container.get(TrustedSourceGate).authorizeSealed({ + userId: 'user-1', + grant: { audiences: ['https://n8n.example/mcp'] }, + }); + + expect(allowed).toBe(false); + }); + }); + it('registers itself on main, webhook and worker instances without a license flag', () => { const entry = Container.get(ModuleMetadata).get('inbound-auth-core'); diff --git a/packages/cli/src/modules/inbound-auth-core/inbound-auth-core.module.ts b/packages/cli/src/modules/inbound-auth-core/inbound-auth-core.module.ts index af1eae4477e..058fb96cc92 100644 --- a/packages/cli/src/modules/inbound-auth-core/inbound-auth-core.module.ts +++ b/packages/cli/src/modules/inbound-auth-core/inbound-auth-core.module.ts @@ -1,12 +1,60 @@ import type { ModuleInterface } from '@n8n/decorators'; import { BackendModule } from '@n8n/decorators'; +import { Container } from '@n8n/di'; +import { + AuthenticationService, + IdentityService, + TrustedSourceGate, + type Extracted, + type Result, + type Verified, +} from '@n8n/inbound-auth'; +import type { SecurityContext } from '@n8n/permissions'; + +// Fail-closed defaults: a consumer that runs before a real implementation is registered gets a +// rejection, not a throw. The OAuth2 driver replaces the AuthenticationService binding. +class UnregisteredAuthenticationService extends AuthenticationService { + async authenticate(_extracted: Extracted): Promise> { + return { ok: false, reason: 'source-unusable' }; + } + + async advertise() { + return { authorizationServers: [], challenges: [] }; + } +} + +class UnregisteredIdentityService extends IdentityService { + async identify(_verified: Verified): Promise> { + return { ok: false, reason: 'source-unusable' }; + } +} + +class UnregisteredTrustedSourceGate extends TrustedSourceGate { + async authorizeSealed() { + return false; + } +} /** - * Owns the `trusted_source` and `trusted_source_identity` tables. Main, webhook and worker - * instances load it, because workers and webhook processes resolve the same entities as main. + * Owns the `trusted_source` and `trusted_source_identity` tables and binds the inbound-auth + * contracts. Main, webhook and worker instances load it, because workers and webhook processes + * resolve the same entities and contracts as main. */ @BackendModule({ name: 'inbound-auth-core', instanceTypes: ['main', 'webhook', 'worker'] }) export class InboundAuthCoreModule implements ModuleInterface { + async init() { + // A binding that already exists wins: the defaults only fill the gap. + if (!Container.has(AuthenticationService)) { + Container.set(AuthenticationService, new UnregisteredAuthenticationService()); + } + if (!Container.has(IdentityService)) { + Container.set(IdentityService, new UnregisteredIdentityService()); + } + if (!Container.has(TrustedSourceGate)) { + Container.set(TrustedSourceGate, new UnregisteredTrustedSourceGate()); + } + } + async entities() { const { TrustedSourceEntity } = await import('./database/entities/trusted-source.entity.js'); const { TrustedSourceIdentityEntity } = await import( diff --git a/packages/cli/src/modules/mcp/__tests__/mcp-protected-resource.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp-protected-resource.test.ts index 5beafb0b66f..e0dc32ab011 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp-protected-resource.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp-protected-resource.test.ts @@ -479,4 +479,8 @@ describe('McpProtectedResource', () => { }); }); }); + + it('is served on the instance-mcp surface', () => { + expect(resource.surface).toBe('instance-mcp'); + }); }); diff --git a/packages/cli/src/modules/mcp/mcp-protected-resource.ts b/packages/cli/src/modules/mcp/mcp-protected-resource.ts index ce409ea01dc..0491bb239c4 100644 --- a/packages/cli/src/modules/mcp/mcp-protected-resource.ts +++ b/packages/cli/src/modules/mcp/mcp-protected-resource.ts @@ -6,10 +6,9 @@ import { import { LicenseState, ModuleRegistry } from '@n8n/backend-common'; import { GlobalConfig } from '@n8n/config'; import { INSTANCE_MCP_RESOURCE_ID } from '@n8n/constants'; -import type { User } from '@n8n/db'; import { Container, Service } from '@n8n/di'; -import type { ProtectedResource } from '@/services/protected-resource.registry'; +import type { ProtectedResource, ResourceUser } from '@n8n/inbound-auth'; import { UrlService } from '@n8n/backend-services'; import { PostHogClient } from '@/posthog'; @@ -55,6 +54,8 @@ const LEGACY_MCP_AUDIENCE = 'mcp-server-api'; export class McpProtectedResource implements ProtectedResource { readonly id = INSTANCE_MCP_RESOURCE_ID; + readonly surface = 'instance-mcp' as const; + /** * Fallback audience for token requests without an RFC 8707 resource * indicator — the instance MCP server predates resource indicators, so @@ -169,7 +170,7 @@ export class McpProtectedResource implements ProtectedResource { * arm for the same reason, and one more: showing the scopes to the control * arm exposes the feature to the users the experiment keeps unaware of it. */ - async getGrantableScopes(user: User): Promise { + async getGrantableScopes(user: ResourceUser): Promise { const { CommunityPackagesConfig } = await import( '@/modules/community-packages/community-packages.config.js' ); @@ -189,7 +190,7 @@ export class McpProtectedResource implements ProtectedResource { ); } - private async arePreferencesEnabledFor(user: User): Promise { + private async arePreferencesEnabledFor(user: ResourceUser): Promise { try { return arePreferenceToolsEnabled(await this.postHogClient.getFeatureFlags(user)); } catch { @@ -202,7 +203,7 @@ export class McpProtectedResource implements ProtectedResource { return await this.mcpSettingsService.getEnabled(); } - async authorize(_user: User): Promise { + async authorize(_user: ResourceUser): Promise { // The instance MCP server has no per-user authorization rule: any // authenticated user may access it while the server is enabled, and all // users are denied when it is disabled. diff --git a/packages/cli/src/modules/mcp/mcp-tool-availability.ts b/packages/cli/src/modules/mcp/mcp-tool-availability.ts index dccec3d9f79..cfec10447e6 100644 --- a/packages/cli/src/modules/mcp/mcp-tool-availability.ts +++ b/packages/cli/src/modules/mcp/mcp-tool-availability.ts @@ -1,8 +1,7 @@ import { CONTEXT_PREFERENCES_ENABLED_VARIANT, CONTEXT_PREFERENCES_FLAG } from '@n8n/api-types'; import type { ModuleRegistry } from '@n8n/backend-common'; import type { GlobalConfig } from '@n8n/config'; -import type { User } from '@n8n/db'; -import { hasGlobalScope } from '@n8n/permissions'; +import { hasGlobalScope, type AuthPrincipal } from '@n8n/permissions'; import type { FeatureFlags } from 'n8n-workflow'; import type { CommunityPackagesConfig } from '@/modules/community-packages/community-packages.config'; @@ -60,7 +59,7 @@ export function isCommunityNodeInstallAvailable( >; }, mcpConfig: Pick, - user: User, + user: AuthPrincipal, ): boolean { return ( globalConfig.endpoints.mcpBuilderEnabled && diff --git a/packages/cli/src/modules/oauth-server/__tests__/oauth-consent.controller.api.test.ts b/packages/cli/src/modules/oauth-server/__tests__/oauth-consent.controller.api.test.ts index 01405e4970c..fafd8b4b241 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/oauth-consent.controller.api.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/oauth-consent.controller.api.test.ts @@ -102,6 +102,7 @@ describe('GET /rest/consent/details', () => { const resourceUrl = 'https://n8n.example.com/mcp/named-workflow'; Container.get(ProtectedResourceRegistry).register({ + surface: 'instance-mcp', id: 'test-named-resource', displayName: 'My Named Workflow', getResourceUrl: () => resourceUrl, diff --git a/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts b/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts index 06e0fb4713f..4b269bf204e 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts @@ -68,6 +68,7 @@ describe('OAuthServerService', () => { const resourceRegistry = new ProtectedResourceRegistry(mock()); resourceRegistry.register({ + surface: 'instance-mcp', id: 'instance-mcp', getResourceUrl: () => TEST_RESOURCE_URL, getAudiences: () => [TEST_RESOURCE_URL, 'mcp-server-api'], @@ -173,6 +174,7 @@ describe('OAuthServerService', () => { beforeAll(() => { const registry = new ProtectedResourceRegistry(mock()); registry.register({ + surface: 'trigger', id: 'form-abc', isFirstParty: true, displayName: 'My Form', @@ -185,6 +187,7 @@ describe('OAuthServerService', () => { // A chat trigger's resource: served under the generic webhook base URL rather // than a dedicated endpoint, so it covers the client-id guard's prefix check. registry.register({ + surface: 'trigger', id: 'chat-abc', isFirstParty: true, displayName: 'My Chat', @@ -196,6 +199,7 @@ describe('OAuthServerService', () => { }); // A resource that exists but is not first-party (mirror of an MCP resource). registry.register({ + surface: 'trigger', id: 'mcp-x', getResourceUrl: () => NON_FIRST_PARTY_URL, getAudiences: () => [NON_FIRST_PARTY_URL], @@ -307,6 +311,7 @@ describe('OAuthServerService', () => { resolveByUrl: async (url) => new URL(url).pathname.replace(/\/$/, '') === '/form/abc' ? { + surface: 'trigger', id: 'form-abc', isFirstParty: true, getResourceUrl: () => FIRST_PARTY_URL, @@ -1214,6 +1219,7 @@ describe('OAuthServerService', () => { const formResourceUrl = 'https://n8n.example.com/form/abc'; const registry = new ProtectedResourceRegistry(mock()); registry.register({ + surface: 'instance-mcp', id: 'instance-mcp', getResourceUrl: () => TEST_RESOURCE_URL, getAudiences: () => [TEST_RESOURCE_URL], @@ -1222,6 +1228,7 @@ describe('OAuthServerService', () => { authorize: async () => true, }); registry.register({ + surface: 'trigger', id: 'form-abc', getResourceUrl: () => formResourceUrl, getAudiences: () => [formResourceUrl], @@ -1349,6 +1356,7 @@ describe('OAuthServerService', () => { beforeEach(() => { const registry = new ProtectedResourceRegistry(mock()); registry.register({ + surface: 'instance-mcp', id: 'instance-mcp', getResourceUrl: () => TEST_RESOURCE_URL, getAudiences: () => [TEST_RESOURCE_URL], @@ -1357,6 +1365,7 @@ describe('OAuthServerService', () => { authorize: async () => true, }); registry.register({ + surface: 'instance-mcp', id: 'other-resource', getResourceUrl: () => otherResourceUrl, getAudiences: () => [otherResourceUrl], @@ -1889,6 +1898,7 @@ describe('OAuthServerService', () => { it('should accept any registered resource and reject unregistered ones', async () => { const multiRegistry = new ProtectedResourceRegistry(mock()); multiRegistry.register({ + surface: 'instance-mcp', id: 'instance-mcp', getResourceUrl: () => TEST_RESOURCE_URL, getAudiences: () => [TEST_RESOURCE_URL, 'mcp-server-api'], @@ -1898,6 +1908,7 @@ describe('OAuthServerService', () => { }); const secondResourceUrl = 'https://n8n.example.com/webhook/wf-1/mcp'; multiRegistry.register({ + surface: 'trigger', id: 'workflow-trigger', getResourceUrl: () => secondResourceUrl, getAudiences: () => [secondResourceUrl], diff --git a/packages/cli/src/modules/oauth-server/__tests__/oauth-token.service.test.ts b/packages/cli/src/modules/oauth-server/__tests__/oauth-token.service.test.ts index 9a3e9b84263..8f561b90aec 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/oauth-token.service.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/oauth-token.service.test.ts @@ -42,6 +42,7 @@ const OTHER_RESOURCE_URL = `${TEST_BASE_URL}/mcp/workflow-b`; const registry = new ProtectedResourceRegistry(mock()); registry.register({ + surface: 'instance-mcp', id: 'instance-mcp', getResourceUrl: () => TEST_RESOURCE_URL, getAudiences: () => [TEST_RESOURCE_URL, LEGACY_AUDIENCE], @@ -334,6 +335,7 @@ describe('OAuthTokenService', () => { beforeAll(() => { const boundRegistry = new ProtectedResourceRegistry(mock()); boundRegistry.register({ + surface: 'instance-mcp', id: 'instance-mcp', getResourceUrl: () => TEST_RESOURCE_URL, getAudiences: () => [TEST_RESOURCE_URL, LEGACY_AUDIENCE], @@ -342,6 +344,7 @@ describe('OAuthTokenService', () => { authorize: async () => true, }); boundRegistry.register({ + surface: 'instance-mcp', id: 'granted-resource', getResourceUrl: () => GRANTED_URL, getResourceUrls: () => [GRANTED_URL, GRANTED_ALIAS_URL], @@ -350,6 +353,7 @@ describe('OAuthTokenService', () => { authorize: async () => true, }); boundRegistry.register({ + surface: 'instance-mcp', id: 'another-resource', getResourceUrl: () => ANOTHER_URL, getAudiences: () => [ANOTHER_URL], @@ -761,6 +765,7 @@ describe('OAuthTokenService', () => { beforeAll(() => { const multiResourceRegistry = new ProtectedResourceRegistry(mock()); multiResourceRegistry.register({ + surface: 'instance-mcp', id: 'instance-mcp', getResourceUrl: () => RESOURCE_A_URL, getAudiences: () => [RESOURCE_A_URL, LEGACY_AUDIENCE], @@ -769,6 +774,7 @@ describe('OAuthTokenService', () => { isDefault: true, }); multiResourceRegistry.register({ + surface: 'trigger', id: 'workflow-trigger', getResourceUrl: () => RESOURCE_B_URL, getAudiences: () => [RESOURCE_B_URL], @@ -848,6 +854,7 @@ describe('OAuthTokenService', () => { beforeAll(() => { const scopedRegistry = new ProtectedResourceRegistry(mock()); scopedRegistry.register({ + surface: 'instance-mcp', id: 'instance-mcp', getResourceUrl: () => TEST_RESOURCE_URL, getAudiences: () => [TEST_RESOURCE_URL, LEGACY_AUDIENCE], diff --git a/packages/cli/src/modules/oauth-server/__tests__/oauth.controller.test.ts b/packages/cli/src/modules/oauth-server/__tests__/oauth.controller.test.ts index 5f5809bcde0..2ac06805ad1 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/oauth.controller.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/oauth.controller.test.ts @@ -42,6 +42,7 @@ const makeReq = (resourcePath: string | string[], originalUrl?: string): Request ({ params: { resourcePath }, originalUrl }) as unknown as Request; const resource = (scopes: string[], isAvailable?: () => Promise): ProtectedResource => ({ + surface: 'instance-mcp', id: 'instance-mcp', getResourceUrl: () => 'https://n8n.test/mcp-server/http', getAudiences: () => ['https://n8n.test/mcp-server/http'], diff --git a/packages/cli/src/modules/oauth-server/protected-resource-resolvers/chat-trigger-resource.base.ts b/packages/cli/src/modules/oauth-server/protected-resource-resolvers/chat-trigger-resource.base.ts index bf4f640435d..d6c1f747d1f 100644 --- a/packages/cli/src/modules/oauth-server/protected-resource-resolvers/chat-trigger-resource.base.ts +++ b/packages/cli/src/modules/oauth-server/protected-resource-resolvers/chat-trigger-resource.base.ts @@ -101,6 +101,7 @@ export abstract class ChatTriggerResourceResolverBase implements ProtectedResour // triggers, each its own resource. id: `workflow-chat:${workflowId}:${path}`, isFirstParty: true, + surface: 'trigger' as const, getResourceUrl: () => resourceUrl, getAudiences: () => audiences, getAllowedRedirectUris: async () => [resourceUrl], diff --git a/packages/cli/src/modules/oauth-server/protected-resource-resolvers/form-trigger-resource.resolver.ts b/packages/cli/src/modules/oauth-server/protected-resource-resolvers/form-trigger-resource.resolver.ts index dc682476ba7..5424059d266 100644 --- a/packages/cli/src/modules/oauth-server/protected-resource-resolvers/form-trigger-resource.resolver.ts +++ b/packages/cli/src/modules/oauth-server/protected-resource-resolvers/form-trigger-resource.resolver.ts @@ -83,6 +83,7 @@ export class FormTriggerResourceResolver implements ProtectedResourceResolver { return { id: 'workflow-form:' + workflow.id, isFirstParty: true, + surface: 'trigger' as const, getResourceUrl: () => resourceUrl, getAudiences: () => audiences, getAllowedRedirectUris: async () => [resourceUrl], diff --git a/packages/cli/src/modules/oauth-server/protected-resource-resolvers/form-trigger-test-resource.resolver.ts b/packages/cli/src/modules/oauth-server/protected-resource-resolvers/form-trigger-test-resource.resolver.ts index b74a2dc8e84..368815a0775 100644 --- a/packages/cli/src/modules/oauth-server/protected-resource-resolvers/form-trigger-test-resource.resolver.ts +++ b/packages/cli/src/modules/oauth-server/protected-resource-resolvers/form-trigger-test-resource.resolver.ts @@ -85,6 +85,7 @@ export class FormTriggerTestResourceResolver implements ProtectedResourceResolve return { id: 'workflow-form:' + workflowEntity.id, isFirstParty: true, + surface: 'trigger' as const, getResourceUrl: () => resourceUrl, getAudiences: () => audiences, getAllowedRedirectUris: async () => [resourceUrl], diff --git a/packages/cli/src/modules/oauth-server/protected-resource-resolvers/workflow-mcp-test-trigger-resource.resolver.ts b/packages/cli/src/modules/oauth-server/protected-resource-resolvers/workflow-mcp-test-trigger-resource.resolver.ts index 4250902e973..d5dce22c223 100644 --- a/packages/cli/src/modules/oauth-server/protected-resource-resolvers/workflow-mcp-test-trigger-resource.resolver.ts +++ b/packages/cli/src/modules/oauth-server/protected-resource-resolvers/workflow-mcp-test-trigger-resource.resolver.ts @@ -82,6 +82,7 @@ export class WorkflowMcpTestTriggerResourceResolver implements ProtectedResource const audiences = [resourceUrl]; return { id: 'workflow-mcp-test:' + workflowEntity.id, + surface: 'trigger' as const, getResourceUrl: () => resourceUrl, getAudiences: () => audiences, scopes: WORKFLOW_MCP_TRIGGER_SCOPES, diff --git a/packages/cli/src/modules/oauth-server/protected-resource-resolvers/workflow-mcp-trigger-resource.resolver.ts b/packages/cli/src/modules/oauth-server/protected-resource-resolvers/workflow-mcp-trigger-resource.resolver.ts index 06c654b163f..944e9485aef 100644 --- a/packages/cli/src/modules/oauth-server/protected-resource-resolvers/workflow-mcp-trigger-resource.resolver.ts +++ b/packages/cli/src/modules/oauth-server/protected-resource-resolvers/workflow-mcp-trigger-resource.resolver.ts @@ -102,6 +102,7 @@ export class WorkflowMcpTriggerResourceResolver implements ProtectedResourceReso const audiences = [resourceUrl]; return { id: 'workflow-mcp:' + workflow.id, + surface: 'trigger' as const, getResourceUrl: () => resourceUrl, getAudiences: () => audiences, scopes: WORKFLOW_MCP_TRIGGER_SCOPES, diff --git a/packages/cli/src/modules/oauth-server/protected-resource-resolvers/workflow-webhook-test-trigger-resource.resolver.ts b/packages/cli/src/modules/oauth-server/protected-resource-resolvers/workflow-webhook-test-trigger-resource.resolver.ts index cca6e1a601d..2aaa78676f7 100644 --- a/packages/cli/src/modules/oauth-server/protected-resource-resolvers/workflow-webhook-test-trigger-resource.resolver.ts +++ b/packages/cli/src/modules/oauth-server/protected-resource-resolvers/workflow-webhook-test-trigger-resource.resolver.ts @@ -146,6 +146,7 @@ export class WorkflowWebhookTestTriggerResourceResolver implements ProtectedReso const audiences = methods.map(urlFor); return { id: `workflow-webhook-test:${workflowEntity.id}:${resourcePath}`, + surface: 'trigger' as const, getResourceUrl: () => urlFor(requestedMethod), getAudiences: () => audiences, scopes: WEBHOOK_TRIGGER_SCOPES, diff --git a/packages/cli/src/modules/oauth-server/protected-resource-resolvers/workflow-webhook-trigger-resource.resolver.ts b/packages/cli/src/modules/oauth-server/protected-resource-resolvers/workflow-webhook-trigger-resource.resolver.ts index 43333ed41a2..311df222a6e 100644 --- a/packages/cli/src/modules/oauth-server/protected-resource-resolvers/workflow-webhook-trigger-resource.resolver.ts +++ b/packages/cli/src/modules/oauth-server/protected-resource-resolvers/workflow-webhook-trigger-resource.resolver.ts @@ -167,6 +167,7 @@ export class WorkflowWebhookTriggerResourceResolver implements ProtectedResource id: `workflow-webhook:${workflow.id}:${resourcePath}`, // Canonical URL = the method being resolved, so the metadata document served // at `?method=POST` advertises `?method=POST` back (RFC 9728 §3.1). + surface: 'trigger' as const, getResourceUrl: () => urlFor(requestedMethod), // A token minted for any of this trigger's methods is accepted at all of // them; cross-trigger replay stays impossible because the list is built diff --git a/packages/cli/src/modules/oauth-server/resource-gate.ts b/packages/cli/src/modules/oauth-server/resource-gate.ts index 8d5d5b4717b..bcde8238612 100644 --- a/packages/cli/src/modules/oauth-server/resource-gate.ts +++ b/packages/cli/src/modules/oauth-server/resource-gate.ts @@ -1,4 +1,4 @@ -import type { User } from '@n8n/db'; +import type { ResourceUser } from '@n8n/inbound-auth'; import type { OAuthResourceGrant } from 'n8n-workflow'; import type { ProtectedResource } from '@/services/protected-resource.registry'; @@ -12,7 +12,7 @@ import type { WorkflowFinderService } from '@/workflows/workflow-finder.service' export async function authorizeAgainstGrant( workflowFinderService: WorkflowFinderService, grant: OAuthResourceGrant, - user: User, + user: ResourceUser, ): Promise { if (!grant.executeAccessWorkflowId) return true; @@ -39,7 +39,7 @@ export function triggerResourceGate( ): Pick { return { getGrant: () => grant, - authorize: async (user: User) => + authorize: async (user: ResourceUser) => await authorizeAgainstGrant(workflowFinderService, grant, user), }; } diff --git a/packages/cli/src/services/__tests__/protected-resource.registry.test.ts b/packages/cli/src/services/__tests__/protected-resource.registry.test.ts index edd9ace8912..9e8f92c7b67 100644 --- a/packages/cli/src/services/__tests__/protected-resource.registry.test.ts +++ b/packages/cli/src/services/__tests__/protected-resource.registry.test.ts @@ -6,6 +6,7 @@ import { ProtectedResourceRegistry } from '../protected-resource.registry'; const resourceA: ProtectedResource = { id: 'instance-mcp', + surface: 'instance-mcp', getResourceUrl: () => 'https://n8n.example.com/mcp-server/http', getAudiences: () => ['https://n8n.example.com/mcp-server/http', 'mcp-server-api'], scopes: ['tool:listWorkflows', 'tool:getWorkflowDetails'], @@ -15,6 +16,7 @@ const resourceA: ProtectedResource = { const resourceB: ProtectedResource = { id: 'workflow-trigger', + surface: 'trigger', getResourceUrl: () => 'https://n8n.example.com/webhook/wf-1/mcp', getAudiences: () => ['https://n8n.example.com/webhook/wf-1/mcp'], authorize: async () => true, @@ -40,6 +42,7 @@ describe('ProtectedResourceRegistry', () => { it('should resolve a resource by any of its declared resource URLs', async () => { const multiUrlResource: ProtectedResource = { id: 'instance-mcp-multi', + surface: 'instance-mcp', getResourceUrl: () => 'https://n8n-mcp.example.com/mcp-server/http', getResourceUrls: () => [ 'https://n8n-mcp.example.com/mcp-server/http', diff --git a/packages/cli/src/services/protected-resource.registry.ts b/packages/cli/src/services/protected-resource.registry.ts index fc50e712b8b..2a1021ab149 100644 --- a/packages/cli/src/services/protected-resource.registry.ts +++ b/packages/cli/src/services/protected-resource.registry.ts @@ -1,118 +1,9 @@ -import type { ConsentUiHints } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; -import type { User } from '@n8n/db'; import { Service } from '@n8n/di'; import { ensureError } from '@n8n/utils/errors/ensure-error'; -import type { OAuthResourceGrant } from 'n8n-workflow'; +import type { ProtectedResource } from '@n8n/inbound-auth'; -/** - * Descriptor for an OAuth 2.1 protected resource served by this instance. - * - * All resources share a single authorization server (one issuer, one set of - * `/authorize`/`/token`/`/register` endpoints, one signing key) but each - * resource has its own canonical RFC 8707 resource URL, accepted audiences, - * and advertised scopes. - */ -export interface ProtectedResource { - /** Stable identifier, e.g. `'instance-mcp'`. */ - id: string; - - /** Human readable name, for consent screen */ - displayName?: string; - - /** Presentational hints for the consent screen; omit for the default generic MCP icon. */ - uiHints?: ConsentUiHints; - - /** - * Canonical RFC 8707 resource URL used as the JWT `aud` claim and advertised - * as the resource indicator (e.g. `https://instance.example/mcp-server/http`). - * Resolved lazily so the instance base URL is read per request, matching the - * previous `getCanonicalResourceUrl()` behaviour. - */ - getResourceUrl(): string; - - /** - * Every resource URL this resource is served at, canonical URL - * (`getResourceUrl()`) first. A configured MCP base URL becomes the - * canonical entry, and the instance-base-URL-derived resource follows so - * clients using the instance hostname keep working. Treated as - * `[getResourceUrl()]` when not implemented. - */ - getResourceUrls?(): string[]; - - /** - * All `aud` values accepted at this resource's gate. Must include the - * canonical resource URL; may include resource-specific legacy audiences. - */ - getAudiences(): string[]; - - /** OAuth scopes advertised for this resource in discovery documents. */ - scopes: string[]; - - /** - * Scopes *this user* may actually grant, narrowing {@link scopes}. Omit when - * every authenticated user can grant everything the resource supports. - * - * Kept separate from {@link scopes} because the two answer different - * questions: discovery metadata is unauthenticated and must describe what the - * resource supports, while the consent screen must only offer what the person - * in front of it could exercise. Offering a scope the caller's role cannot - * use produces a grant that silently does nothing. - */ - getGrantableScopes?(user: User): Promise; - - /** - * Tool names unlocked by each grantable scope, for display on the consent - * screen. Omit when the resource has no per-tool scope mapping. - */ - getScopeTools?(): Record | Promise>; - - /** - * Fallback audience for token requests that omit an RFC 8707 `resource` - * parameter (pre-8707 clients). At most one registered resource may be the - * default. - */ - isDefault?: boolean; - - /** - * Optional explicit allowlist of `redirect_uri` values accepted at - * `/authorize` for this resource. Returning an empty array means "no - * additional restriction" — the OAuth server still enforces the - * registered-URIs match per RFC 6749 §3.1.2.4. - */ - getAllowedRedirectUris?(): Promise; - - isFirstParty?: boolean; - - /** - * Whether the resource currently serves requests. An unavailable resource is - * hidden from RFC 9728 discovery, so clients see no authorization server to - * authenticate against. Treated as always available when not implemented. - * - * Discovery and the token/consent gates apply this automatically (the latter - * via {@link ProtectedResource.authorize}); answering 404 on the resource's - * own endpoint, rather than an authentication challenge, remains up to - * whoever serves it. - */ - isAvailable?(): Promise; - - /** - * Determine whether the given user is authorized to access this resource. - * Called during the consent flow to gate access to the resource. - * - * @param user The user to authorize - * @returns A promise that resolves to a boolean indicating whether the user is authorized - **/ - authorize(user: User): Promise; - - /** - * Serializable form of this resource's gate, sealed into the executions it grants - * access to — see {@link OAuthResourceGrant}. Implement it on any resource derived - * from something shorter-lived than an execution; omitting it makes those runs - * depend on the resource still resolving at every credential access. - */ - getGrant?(): OAuthResourceGrant; -} +export type { ProtectedResource }; /** * On-demand resolver for protected resources that aren't held in the registry's diff --git a/packages/cli/src/webhooks/__tests__/webhook-helpers.test.ts b/packages/cli/src/webhooks/__tests__/webhook-helpers.test.ts index 4750856d37f..26bb82fb25e 100644 --- a/packages/cli/src/webhooks/__tests__/webhook-helpers.test.ts +++ b/packages/cli/src/webhooks/__tests__/webhook-helpers.test.ts @@ -1790,6 +1790,7 @@ describe('executeWebhook establishTriggerIdentity', () => { const resourceWithoutGrant: ProtectedResource = { id: `workflow-webhook-test:${WORKFLOW_ID}:abc`, + surface: 'trigger', getResourceUrl: () => RESOURCE_URL, getAudiences: () => [RESOURCE_URL], scopes: [], diff --git a/packages/cli/src/workflows/workflow-finder.service.ts b/packages/cli/src/workflows/workflow-finder.service.ts index 152c4002fcc..ac96d0f8e5c 100644 --- a/packages/cli/src/workflows/workflow-finder.service.ts +++ b/packages/cli/src/workflows/workflow-finder.service.ts @@ -7,7 +7,7 @@ import { chunkIds, } from '@n8n/db'; import { Service } from '@n8n/di'; -import { hasGlobalScope, type Scope } from '@n8n/permissions'; +import { hasGlobalScope, type AuthPrincipal, type Scope } from '@n8n/permissions'; import type { EntityManager, FindOptionsWhere } from '@n8n/typeorm'; import { In, IsNull } from '@n8n/typeorm'; @@ -124,7 +124,12 @@ export class WorkflowFinderService { }; } - private async findAllWhere(user: User, scopes: Scope[], folderId?: string, projectId?: string) { + private async findAllWhere( + user: AuthPrincipal & Pick, + scopes: Scope[], + folderId?: string, + projectId?: string, + ) { let where: FindOptionsWhere = {}; if (folderId) { @@ -172,7 +177,7 @@ export class WorkflowFinderService { async findWorkflowIdsWithScopeForUser( workflowIds: string[], - user: User, + user: AuthPrincipal & Pick, scopes: Scope[], ): Promise> { if (workflowIds.length === 0) return new Set(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23657e22e66..f13054dd81a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3084,6 +3084,9 @@ importers: packages/@n8n/inbound-auth: dependencies: + '@n8n/api-types': + specifier: workspace:* + version: link:../api-types '@n8n/permissions': specifier: workspace:* version: link:../permissions