mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
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:
parent
274639fb88
commit
32d7c8ebf4
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
"dist/**/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@n8n/api-types": "workspace:*",
|
||||
"@n8n/permissions": "workspace:*",
|
||||
"n8n-workflow": "workspace:*"
|
||||
},
|
||||
|
|
|
|||
118
packages/@n8n/inbound-auth/src/__tests__/inbound.test.ts
Normal file
118
packages/@n8n/inbound-auth/src/__tests__/inbound.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
232
packages/@n8n/inbound-auth/src/__tests__/trusted-source.test.ts
Normal file
232
packages/@n8n/inbound-auth/src/__tests__/trusted-source.test.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
124
packages/@n8n/inbound-auth/src/contracts.ts
Normal file
124
packages/@n8n/inbound-auth/src/contracts.ts
Normal 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>;
|
||||
}
|
||||
31
packages/@n8n/inbound-auth/src/inbound.ts
Normal file
31
packages/@n8n/inbound-auth/src/inbound.ts
Normal 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 } } };
|
||||
}
|
||||
|
|
@ -1 +1,5 @@
|
|||
export * from './contracts';
|
||||
export * from './inbound';
|
||||
export type * from './pipeline';
|
||||
export * from './trusted-source';
|
||||
export * from './trusted-source-config';
|
||||
|
|
|
|||
57
packages/@n8n/inbound-auth/src/pipeline.ts
Normal file
57
packages/@n8n/inbound-auth/src/pipeline.ts
Normal 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;
|
||||
127
packages/@n8n/inbound-auth/src/trusted-source.ts
Normal file
127
packages/@n8n/inbound-auth/src/trusted-source.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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" }
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -479,4 +479,8 @@ describe('McpProtectedResource', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('is served on the instance-mcp surface', () => {
|
||||
expect(resource.surface).toBe('instance-mcp');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user