feat(core): Add inbound-auth contracts and fail-closed defaults (no-changelog) (#40004)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andreas Fitzek 2026-10-05 09:30:49 +00:00 committed by GitHub
parent 274639fb88
commit 32d7c8ebf4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 900 additions and 130 deletions

View File

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

View File

@ -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[] = [];

View File

@ -23,6 +23,7 @@
"dist/**/*"
],
"dependencies": {
"@n8n/api-types": "workspace:*",
"@n8n/permissions": "workspace:*",
"n8n-workflow": "workspace:*"
},

View File

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

View File

@ -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<string, unknown>,
managedBy: ManagedBy = 'admin',
): TrustedSourceConfigLatest =>
migrateToLatest(
trustedSourceConfigSchemaFor(managedBy).parse({
version: 1,
authentication: { type: 'oauth2', ...authentication },
surfaces: { 'public-api': {} },
}),
);
type Fields = Partial<Omit<typeof fullMetadata, 'issuer'>>;
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());
});
});

View File

@ -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<string[]>;
/** Tool names unlocked by each grantable scope, for display on the consent screen. */
getScopeTools?(): Record<string, string[]> | Promise<Record<string, string[]>>;
/**
* 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<string[]>;
/** 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<boolean>;
/** Whether `user` may access this resource at all. */
authorize(user: ResourceUser): Promise<boolean>;
/** 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<Inbound, 'surface' | 'resource' | 'acceptedSourceIds'>;
/** The dispatcher. Owns what every method shares: driver lookup and surface acceptance. */
export abstract class AuthenticationService {
abstract authenticate(extracted: Extracted): Promise<Result<Verified>>;
abstract advertise(
resource: AdvertisedResource,
): Promise<{ authorizationServers: string[]; challenges: string[] }>;
}
export abstract class IdentityService {
abstract identify(verified: Verified): Promise<Result<SecurityContext>>;
}
/** 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<TrustedSource | undefined>;
abstract getByIssuer(issuer: string): Promise<TrustedSource | undefined>;
abstract listBySurface(surface: SurfaceId): Promise<TrustedSource[]>;
}
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<boolean>;
}

View File

@ -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<Extracted> {
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 } } };
}

View File

@ -1 +1,5 @@
export * from './contracts';
export * from './inbound';
export type * from './pipeline';
export * from './trusted-source';
export * from './trusted-source-config';

View File

@ -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<Record<string, string | string[] | undefined>>;
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<Extracted, 'credential' | 'request'> & {
request: Omit<Inbound['request'], 'headers'>;
credentialKind: Credential['kind'];
source: TrustedSource;
claims: Readonly<Record<string, unknown>>;
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<T> = { ok: true; value: T };
export type Result<T> = Ok<T> | Reject;

View File

@ -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<typeof AuthorizationServerMetadataSchema>;
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<typeof DiscoveryDocumentSchema>;
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<typeof TrustedSourceMetadataSchema>;
/**
* 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<Capability> {
const capabilities = new Set<Capability>();
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<DiscoveryDocument, { kind: typeof kind }> =>
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;
}

View File

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

View File

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

View File

@ -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<Result<Verified>> {
return { ok: false, reason: 'source-unusable' };
}
async advertise() {
return { authorizationServers: [], challenges: [] };
}
}
class UnregisteredIdentityService extends IdentityService {
async identify(_verified: Verified): Promise<Result<SecurityContext>> {
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(

View File

@ -479,4 +479,8 @@ describe('McpProtectedResource', () => {
});
});
});
it('is served on the instance-mcp surface', () => {
expect(resource.surface).toBe('instance-mcp');
});
});

View File

@ -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<string[]> {
async getGrantableScopes(user: ResourceUser): Promise<string[]> {
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<boolean> {
private async arePreferencesEnabledFor(user: ResourceUser): Promise<boolean> {
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<boolean> {
async authorize(_user: ResourceUser): Promise<boolean> {
// 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.

View File

@ -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<McpConfig, 'communityNodeDiscoveryEnabled'>,
user: User,
user: AuthPrincipal,
): boolean {
return (
globalConfig.endpoints.mcpBuilderEnabled &&

View File

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

View File

@ -68,6 +68,7 @@ describe('OAuthServerService', () => {
const resourceRegistry = new ProtectedResourceRegistry(mock<Logger>());
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<Logger>());
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<Logger>());
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<Logger>());
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<Logger>());
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],

View File

@ -42,6 +42,7 @@ const OTHER_RESOURCE_URL = `${TEST_BASE_URL}/mcp/workflow-b`;
const registry = new ProtectedResourceRegistry(mock<Logger>());
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<Logger>());
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<Logger>());
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<Logger>());
scopedRegistry.register({
surface: 'instance-mcp',
id: 'instance-mcp',
getResourceUrl: () => TEST_RESOURCE_URL,
getAudiences: () => [TEST_RESOURCE_URL, LEGACY_AUDIENCE],

View File

@ -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<boolean>): ProtectedResource => ({
surface: 'instance-mcp',
id: 'instance-mcp',
getResourceUrl: () => 'https://n8n.test/mcp-server/http',
getAudiences: () => ['https://n8n.test/mcp-server/http'],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<boolean> {
if (!grant.executeAccessWorkflowId) return true;
@ -39,7 +39,7 @@ export function triggerResourceGate(
): Pick<ProtectedResource, 'getGrant' | 'authorize'> {
return {
getGrant: () => grant,
authorize: async (user: User) =>
authorize: async (user: ResourceUser) =>
await authorizeAgainstGrant(workflowFinderService, grant, user),
};
}

View File

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

View File

@ -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<string[]>;
/**
* 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<string, string[]> | Promise<Record<string, string[]>>;
/**
* 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<string[]>;
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<boolean>;
/**
* 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<boolean>;
/**
* 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

View File

@ -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: [],

View File

@ -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<User, 'id'>,
scopes: Scope[],
folderId?: string,
projectId?: string,
) {
let where: FindOptionsWhere<SharedWorkflow> = {};
if (folderId) {
@ -172,7 +177,7 @@ export class WorkflowFinderService {
async findWorkflowIdsWithScopeForUser(
workflowIds: string[],
user: User,
user: AuthPrincipal & Pick<User, 'id'>,
scopes: Scope[],
): Promise<Set<string>> {
if (workflowIds.length === 0) return new Set();

View File

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