refactor(core): Share the inbound admission steps between protected-resource surfaces (#41162)

This commit is contained in:
MHMD 2026-10-15 13:32:58 +00:00 committed by GitHub
parent 9e3b52cbbb
commit 378847ae1e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 431 additions and 42 deletions

View File

@ -1,5 +1,5 @@
import type { ProtectedResource } from '../contracts';
import { extractBearer, inboundFrom } from '../inbound';
import { extractBearer, inboundFrom, inboundRequestOf } from '../inbound';
import type { Inbound, Verified } from '../pipeline';
const resourceUrl = 'https://n8n.example/mcp-server/http';
@ -74,6 +74,25 @@ describe('inboundFrom', () => {
});
});
describe('inboundRequestOf', () => {
it('takes method, original URL, headers and ip from an Express-shaped request', () => {
const headers = { authorization: 'Bearer t', 'x-forwarded-for': '198.51.100.1' };
expect(
inboundRequestOf({
method: 'GET',
originalUrl: '/api/v1/workflows?limit=1',
headers,
ip: '203.0.113.7',
}),
).toEqual({ method: 'GET', url: '/api/v1/workflows?limit=1', headers, ip: '203.0.113.7' });
});
it('stamps an empty ip when the request has none', () => {
expect(inboundRequestOf({ method: 'GET', originalUrl: '/', headers: {} }).ip).toBe('');
});
});
describe('extractBearer', () => {
it('returns Extracted with the bearer credential for a valid Authorization header', () => {
const inbound = inboundWith({ authorization: 'Bearer abc.def.ghi' });

View File

@ -1,12 +1,15 @@
import type { AuthPrincipal, SecurityContext } from '@n8n/permissions';
import type { OAuthResourceGrant } from 'n8n-workflow';
import type { Credential, Extracted, Inbound, Result, Verified } from './pipeline';
import type { Credential, Extracted, Inbound, RejectReason, Result, Verified } from './pipeline';
import type { AuthorizationServerMetadata, Jwk, 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 };
/** What a resource's gate reads from the caller. A `Principal` and the `User` entity both satisfy it. */
export type ResourcePrincipal = AuthPrincipal & { id: string };
/** What the consent screen reads from the user. The `User` entity satisfies it. */
export type ResourceUser = ResourcePrincipal & { createdAt: Date };
export interface ConsentUiHints {
/** design-system icon name (updatedIconSet) for the consent logo tile; omit to fall back to the generic mcp icon */
@ -89,7 +92,7 @@ export interface ProtectedResource {
isAvailable?(): Promise<boolean>;
/** Whether `user` may access this resource at all. */
authorize(user: ResourceUser): Promise<boolean>;
authorize(user: ResourcePrincipal): Promise<boolean>;
/** The grant sealed into a run, so the worker re-takes the same decision without the resource. */
getGrant?(): OAuthResourceGrant;
@ -106,6 +109,51 @@ export abstract class AuthenticationPipeline {
abstract run(extracted: Extracted): Promise<Result<SecurityContext>>;
}
/** Why the caller was turned away: the pipeline's vocabulary, plus the resource's own refusal. */
export type RefusalReason = RejectReason | 'insufficient_scope';
export type Admitted = {
ok: true;
context: SecurityContext;
};
export type Refused = {
ok: false;
reason: RefusalReason;
/** The resource as the pipeline saw it. Enough to build the challenge, without the request. */
advertised: AdvertisedResource;
/** Present when the pipeline accepted the token and the resource then refused the subject. */
context?: SecurityContext;
};
export type AdmissionResult = Admitted | Refused;
/**
* What every surface does between taking a credential off the wire and acting as the caller:
* stamp the inbound with the resource, run the pipeline, ask the resource to authorize the
* subject, and build the 401 challenge when any of that fails.
*
* A surface keeps what is its own: where the credential travels (`Authorization`, a cookie,
* a custom header, a token it just exchanged) and how it answers a refusal.
*/
export abstract class ResourceAdmission {
/** Admits an extracted credential, for a surface that inspects it before the pipeline runs. */
abstract admit(resource: ProtectedResource, extracted: Extracted): Promise<AdmissionResult>;
/**
* Admits a bearer the surface already took out of its own carrier. An empty token is
* refused with `no-credential`, so the surface can still answer with the challenge.
*/
abstract admitBearer(
resource: ProtectedResource,
request: Omit<Inbound['request'], 'headers'>,
token: string,
): Promise<AdmissionResult>;
/** The `WWW-Authenticate` value for a refusal: the advertised challenge, then the RFC 6750 error. */
abstract challenge(refused: Refused): Promise<string>;
}
/** The dispatcher. Owns what every method shares: driver lookup and surface acceptance. */
export abstract class AuthenticationService {
abstract authenticate(extracted: Extracted): Promise<Result<Verified>>;

View File

@ -15,6 +15,16 @@ export function inboundFrom(resource: ProtectedResource, request: Inbound['reque
};
}
/** The request as the pipeline wants it, from an Express-shaped request. */
export function inboundRequestOf(req: {
method: string;
originalUrl: string;
headers: Inbound['request']['headers'];
ip?: string;
}): Inbound['request'] {
return { method: req.method, url: req.originalUrl, headers: req.headers, ip: req.ip ?? '' };
}
// RFC 9110: the scheme is case-insensitive; the token is the rest, without surrounding blanks.
const BEARER = /^bearer\s+(\S+)\s*$/i;

View File

@ -2,6 +2,7 @@ import { ModuleMetadata, type ModuleInterface } from '@n8n/decorators';
import { Container } from '@n8n/di';
import {
AuthenticationPipeline,
ResourceAdmission,
AuthenticationService,
IdentityService,
LocalAuthorizationServer,
@ -21,6 +22,7 @@ import { TrustedSourceIdentityService } from '../identity/trusted-source-identit
import { InboundAuthCoreModule } from '../inbound-auth-core.module';
import { PublicApiOAuthStrategy } from '../public-api-oauth.strategy';
import { PublicApiProtectedResource } from '../public-api-protected-resource';
import { InboundResourceAdmission } from '../resource-admission';
import { TrustedSourceDiscoveryTask } from '../trusted-source-discovery.task';
import { TrustedSourceDbGate } from '../trusted-source.gate';
import { TrustedSourceDbStore } from '../trusted-source.store';
@ -109,6 +111,10 @@ describe('InboundAuthCoreModule', () => {
expect(Container.get(AuthenticationPipeline)).toBeInstanceOf(InboundAuthPipeline);
});
it('binds the ResourceAdmission contract to the pipeline-backed admission', () => {
expect(Container.get(ResourceAdmission)).toBeInstanceOf(InboundResourceAdmission);
});
describe('public API surface', () => {
afterEach(() => {
Container.set(InstanceSettings, mock<InstanceSettings>({ instanceType: 'main' }));

View File

@ -1,3 +1,4 @@
import type { Logger } from '@n8n/backend-common';
import { JwtService } from '@n8n/backend-services';
import type { AuthenticatedRequest, User, UserRepository } from '@n8n/db';
import type { AuthenticationPipeline, AuthenticationService } from '@n8n/inbound-auth';
@ -9,6 +10,7 @@ import { mock } from 'vitest-mock-extended';
import { PublicApiOAuthStrategy } from '../public-api-oauth.strategy';
import type { PublicApiProtectedResource } from '../public-api-protected-resource';
import { InboundResourceAdmission } from '../resource-admission';
const RESOURCE_URL = 'https://n8n.test/api/v1';
const CHALLENGE =
@ -55,6 +57,7 @@ const makeUser = (id: string, scopeSlugs: string[]): User =>
beforeEach(() => {
vi.clearAllMocks();
resource.isAvailable.mockResolvedValue(true);
resource.authorize.mockResolvedValue(true);
resource.getResourceUrl.mockReturnValue(RESOURCE_URL);
resource.getAudiences.mockReturnValue([RESOURCE_URL]);
authenticationService.advertise.mockResolvedValue({
@ -63,8 +66,7 @@ beforeEach(() => {
});
strategy = new PublicApiOAuthStrategy(
resource,
pipeline,
authenticationService,
new InboundResourceAdmission(pipeline, authenticationService, mock<Logger>()),
jwtService,
userRepository,
);
@ -198,6 +200,22 @@ describe('PublicApiOAuthStrategy', () => {
...(tokenScopes && { tokenScopes }),
});
it('fails fast with error=insufficient_scope when the resource refuses the subject', async () => {
pipeline.run.mockResolvedValue({ ok: true, value: contextWith() });
resource.authorize.mockResolvedValue(false);
const { req, res } = makeReq({
authorization: `Bearer ${tokenFrom('https://idp.example')}`,
});
expect(await strategy.authenticate(req)).toBe(false);
expect(res.setHeader).toHaveBeenCalledWith(
'WWW-Authenticate',
`${CHALLENGE}, error="insufficient_scope", error_description="insufficient_scope"`,
);
expect(userRepository.findByIdWithRole).not.toHaveBeenCalled();
});
it('sets the reloaded user, a role-derived grant and the security context on the request', async () => {
const user = makeUser('user-1', ['workflow:list', 'workflow:create']);
const context = contextWith(['workflow:list']);

View File

@ -0,0 +1,211 @@
import type { Logger } from '@n8n/backend-common';
import type {
AuthenticationPipeline,
AuthenticationService,
Extracted,
ProtectedResource,
} from '@n8n/inbound-auth';
import type { Principal, SecurityContext } from '@n8n/permissions';
import { mock } from 'vitest-mock-extended';
import { InboundResourceAdmission } from '../resource-admission';
const RESOURCE_URL = 'https://n8n.test/form/abc';
const GRANT = { audiences: [RESOURCE_URL], executeAccessWorkflowId: 'wf-1' };
const CHALLENGE =
'Bearer resource_metadata="https://n8n.test/.well-known/oauth-protected-resource/form/abc"';
const REQUEST = { method: 'POST', url: '/form/abc', ip: '203.0.113.7' };
const authorize = vi.fn<ProtectedResource['authorize']>();
const resource: ProtectedResource = {
id: 'workflow-form:wf-1',
surface: 'trigger',
isFirstParty: true,
getResourceUrl: () => RESOURCE_URL,
getAudiences: () => [RESOURCE_URL],
getAcceptedSourceIds: () => ['idp-1'],
scopes: [],
authorize,
getGrant: () => GRANT,
};
const context: SecurityContext = {
subject: { id: 'user-1', type: 'human', email: 'alice@example.com' } as Principal,
authMethod: 'oauth-access-token',
subjectClaim: { sourceId: 'idp-1', issuer: 'https://idp.example', subject: 'idp-alice' },
tokenScopes: [],
};
const pipeline = mock<AuthenticationPipeline>();
const authenticationService = mock<AuthenticationService>();
const logger = mock<Logger>();
const admission = new InboundResourceAdmission(pipeline, authenticationService, logger);
/** What the pipeline advertises for this resource, as every challenge must name it. */
const advertised = expect.objectContaining({
surface: 'trigger',
resource: { url: RESOURCE_URL, acceptedAudiences: [RESOURCE_URL] },
acceptedSourceIds: ['idp-1'],
});
beforeEach(() => {
vi.clearAllMocks();
authorize.mockResolvedValue(true);
pipeline.run.mockResolvedValue({ ok: true, value: context });
authenticationService.advertise.mockResolvedValue({
authorizationServers: ['https://idp.example'],
challenges: [CHALLENGE],
});
});
describe('InboundResourceAdmission', () => {
describe('admitBearer', () => {
it('stamps the inbound with the resource and sends the token through the pipeline', async () => {
const result = await admission.admitBearer(resource, REQUEST, 'the-token');
expect(result).toEqual({ ok: true, context });
expect(pipeline.run).toHaveBeenCalledWith({
surface: 'trigger',
resource: { url: RESOURCE_URL, acceptedAudiences: [RESOURCE_URL] },
acceptedSourceIds: ['idp-1'],
grant: GRANT,
request: { ...REQUEST, headers: {} },
receivedAt: expect.any(Date),
credential: { kind: 'bearer', token: 'the-token' },
});
expect(authorize).toHaveBeenCalledWith(context.subject);
});
it('writes no line of its own when the caller is admitted: the pipeline logs its accept', async () => {
await admission.admitBearer(resource, REQUEST, 'the-token');
expect(logger.info).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
});
it('refuses an empty token with no-credential without running the pipeline', async () => {
const result = await admission.admitBearer(resource, REQUEST, '');
expect(result).toEqual({ ok: false, reason: 'no-credential', advertised });
expect(pipeline.run).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith('Inbound authentication denied', {
surface: 'trigger',
resource: RESOURCE_URL,
sourceId: undefined,
authMethod: undefined,
userId: undefined,
reason: 'no-credential',
});
});
it('passes a pipeline rejection through without a line of its own', async () => {
pipeline.run.mockResolvedValue({ ok: false, reason: 'expired', detail: 'exp 1700000000' });
const result = await admission.admitBearer(resource, REQUEST, 'the-token');
expect(result).toEqual({ ok: false, reason: 'expired', advertised });
expect(authorize).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
});
it('refuses a subject the resource does not authorize, with the context the pipeline produced', async () => {
authorize.mockResolvedValue(false);
const result = await admission.admitBearer(resource, REQUEST, 'the-token');
expect(result).toEqual({ ok: false, reason: 'insufficient_scope', advertised, context });
expect(logger.warn).toHaveBeenCalledWith('Inbound authentication denied', {
surface: 'trigger',
resource: RESOURCE_URL,
sourceId: 'idp-1',
authMethod: 'oauth-access-token',
userId: 'user-1',
reason: 'insufficient_scope',
});
});
it('keeps the token out of the log and the result, and the external subject and the email out of the log', async () => {
authorize.mockResolvedValue(false);
const result = await admission.admitBearer(resource, REQUEST, 'the-token');
const logged = JSON.stringify(logger.warn.mock.calls);
expect(logged).not.toContain('the-token');
expect(logged).not.toContain('idp-alice');
expect(logged).not.toContain('alice@example.com');
expect(JSON.stringify(result)).not.toContain('the-token');
});
});
describe('admit', () => {
const extracted: Extracted = {
surface: 'public-api',
resource: { url: 'https://n8n.test/api/v1', acceptedAudiences: ['https://n8n.test/api/v1'] },
request: {
method: 'GET',
url: '/api/v1/workflows',
headers: { authorization: 'Bearer the-token' },
ip: '203.0.113.7',
},
receivedAt: new Date(),
credential: { kind: 'bearer', token: 'the-token' },
};
it('runs the pipeline on what the surface extracted, then the resource authorization', async () => {
const result = await admission.admit(resource, extracted);
expect(result).toEqual({ ok: true, context });
expect(pipeline.run).toHaveBeenCalledWith(extracted);
expect(authorize).toHaveBeenCalledWith(context.subject);
});
it('drops the request, and with it the header that carried the token, from a refusal', async () => {
pipeline.run.mockResolvedValue({ ok: false, reason: 'unknown-issuer' });
const result = await admission.admit(resource, extracted);
expect(result).toEqual({
ok: false,
reason: 'unknown-issuer',
advertised: { surface: 'public-api', resource: extracted.resource },
});
expect(JSON.stringify(result)).not.toContain('the-token');
});
});
describe('challenge', () => {
it('names the advertised metadata, error=invalid_token and the reason for a pipeline reject', async () => {
pipeline.run.mockResolvedValue({ ok: false, reason: 'source-not-accepted' });
const refused = await admission.admitBearer(resource, REQUEST, 'the-token');
if (refused.ok) throw new Error('expected a refusal');
expect(await admission.challenge(refused)).toBe(
`${CHALLENGE}, error="invalid_token", error_description="source-not-accepted"`,
);
expect(authenticationService.advertise).toHaveBeenCalledWith(advertised);
});
it('answers error=insufficient_scope when the resource refused the subject', async () => {
authorize.mockResolvedValue(false);
const refused = await admission.admitBearer(resource, REQUEST, 'the-token');
if (refused.ok) throw new Error('expected a refusal');
expect(await admission.challenge(refused)).toBe(
`${CHALLENGE}, error="insufficient_scope", error_description="insufficient_scope"`,
);
});
it('falls back to a bare Bearer challenge when no source is advertised', async () => {
authenticationService.advertise.mockResolvedValue({
authorizationServers: [],
challenges: [],
});
const refused = await admission.admitBearer(resource, REQUEST, '');
if (refused.ok) throw new Error('expected a refusal');
expect(await admission.challenge(refused)).toBe(
'Bearer, error="invalid_token", error_description="no-credential"',
);
});
});
});

View File

@ -1,4 +1,4 @@
import type { ResourceUser } from '@n8n/inbound-auth';
import type { ResourcePrincipal } from '@n8n/inbound-auth';
import type { OAuthResourceGrant } from 'n8n-workflow';
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
@ -11,7 +11,7 @@ import type { WorkflowFinderService } from '@/workflows/workflow-finder.service'
export async function authorizeAgainstGrant(
workflowFinderService: WorkflowFinderService,
grant: OAuthResourceGrant,
user: ResourceUser,
user: ResourcePrincipal,
): Promise<boolean> {
if (!grant.executeAccessWorkflowId) return true;

View File

@ -7,6 +7,7 @@ import {
AuthenticationService,
IdentityService,
LocalAuthorizationServer,
ResourceAdmission,
TrustedSourceGate,
TrustedSourceStore,
type AuthorizationServerMetadata,
@ -76,6 +77,9 @@ export class InboundAuthCoreModule implements ModuleInterface {
const { InboundAuthPipeline } = await import('./authentication-pipeline.js');
Container.set(AuthenticationPipeline, Container.get(InboundAuthPipeline));
const { InboundResourceAdmission } = await import('./resource-admission.js');
Container.set(ResourceAdmission, Container.get(InboundResourceAdmission));
// Only main serves the public API. The strategy resolves the pipeline, so it registers last.
if (Container.get(InstanceSettings).instanceType === 'main') {
const { PublicApiProtectedResource } = await import('./public-api-protected-resource.js');

View File

@ -1,12 +1,7 @@
import type { AuthenticatedRequest, TokenGrant } from '@n8n/db';
import { UserRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import {
AuthenticationPipeline,
AuthenticationService,
extractBearer,
inboundFrom,
} from '@n8n/inbound-auth';
import { extractBearer, inboundFrom, inboundRequestOf, ResourceAdmission } from '@n8n/inbound-auth';
import {
getApiKeyScopesForRole,
isApiKeyScope,
@ -31,8 +26,7 @@ const isApiKeyScopeName = (scope: string): scope is ApiKeyScope =>
export class PublicApiOAuthStrategy implements AuthStrategy {
constructor(
private readonly resource: PublicApiProtectedResource,
private readonly pipeline: AuthenticationPipeline,
private readonly authenticationService: AuthenticationService,
private readonly admission: ResourceAdmission,
private readonly jwtService: JwtService,
private readonly userRepository: UserRepository,
) {}
@ -52,29 +46,19 @@ export class PublicApiOAuthStrategy implements AuthStrategy {
return null;
}
const inbound = inboundFrom(this.resource, {
method: req.method,
url: req.originalUrl,
headers: req.headers,
ip: req.ip ?? '',
});
const extracted = extractBearer(inbound);
// Routing, not verification: a bearer another strategy owns is left to it.
const extracted = extractBearer(inboundFrom(this.resource, inboundRequestOf(req)));
if (!extracted.ok || !this.isOAuthIssuer(extracted.value.credential.token)) {
return null;
}
const result = await this.pipeline.run(extracted.value);
if (!result.ok) {
const { challenges } = await this.authenticationService.advertise(inbound);
req.res?.setHeader(
'WWW-Authenticate',
`${challenges[0] ?? 'Bearer'}, error="invalid_token", error_description="${result.reason}"`,
);
const admitted = await this.admission.admit(this.resource, extracted.value);
if (!admitted.ok) {
req.res?.setHeader('WWW-Authenticate', await this.admission.challenge(admitted));
return false;
}
const user = await this.userRepository.findByIdWithRole(result.value.subject.id);
const user = await this.userRepository.findByIdWithRole(admitted.context.subject.id);
if (!user) {
return false;
}
@ -82,7 +66,7 @@ export class PublicApiOAuthStrategy implements AuthStrategy {
// Only API key scopes narrow the grant. An external IdP's own scopes (`openid`, ...) are
// ignored, so its users keep the role set until a source can map scopes itself.
const roleScopes = getApiKeyScopesForRole(user);
const requested = (result.value.tokenScopes ?? []).filter(isApiKeyScopeName);
const requested = (admitted.context.tokenScopes ?? []).filter(isApiKeyScopeName);
req.user = user;
req.tokenGrant = {
scopes: user.role.scopes.map((scope) => scope.slug),
@ -91,7 +75,7 @@ export class PublicApiOAuthStrategy implements AuthStrategy {
subject: user,
};
req.ctx = {
security: result.value,
security: admitted.context,
};
return true;
}

View File

@ -0,0 +1,80 @@
import { Logger } from '@n8n/backend-common';
import { Service } from '@n8n/di';
import {
AuthenticationPipeline,
AuthenticationService,
inboundFrom,
ResourceAdmission,
type AdmissionResult,
type AdvertisedResource,
type Extracted,
type Inbound,
type ProtectedResource,
type RefusalReason,
type Refused,
} from '@n8n/inbound-auth';
import type { SecurityContext } from '@n8n/permissions';
/** Admission through the inbound-auth pipeline, with the surface's own refusals logged here. */
@Service()
export class InboundResourceAdmission extends ResourceAdmission {
constructor(
private readonly pipeline: AuthenticationPipeline,
private readonly authenticationService: AuthenticationService,
private readonly logger: Logger,
) {
super();
}
async admitBearer(
resource: ProtectedResource,
request: Omit<Inbound['request'], 'headers'>,
token: string,
): Promise<AdmissionResult> {
// The credential travels in `credential`; no header carried it.
const inbound = inboundFrom(resource, { ...request, headers: {} });
if (!token) return this.refuse(inbound, 'no-credential');
return await this.admit(resource, { ...inbound, credential: { kind: 'bearer', token } });
}
async admit(resource: ProtectedResource, extracted: Extracted): Promise<AdmissionResult> {
// The pipeline logs its own decisions.
const authenticated = await this.pipeline.run(extracted);
if (!authenticated.ok) {
return { ok: false, reason: authenticated.reason, advertised: advertisedOf(extracted) };
}
const context = authenticated.value;
if (!(await resource.authorize(context.subject))) {
return this.refuse(extracted, 'insufficient_scope', context);
}
return { ok: true, context };
}
async challenge({ advertised, reason }: Refused): Promise<string> {
const { challenges } = await this.authenticationService.advertise(advertised);
const error = reason === 'insufficient_scope' ? 'insufficient_scope' : 'invalid_token';
return `${challenges[0] ?? 'Bearer'}, error="${error}", error_description="${reason}"`;
}
/**
* The one log line for a refusal the surface decides itself, before or after the pipeline.
* Carries no token, no external subject and no email.
*/
private refuse(inbound: Inbound, reason: RefusalReason, context?: SecurityContext): Refused {
this.logger.warn('Inbound authentication denied', {
surface: inbound.surface,
resource: inbound.resource.url,
sourceId: context?.subjectClaim?.sourceId,
authMethod: context?.authMethod,
userId: context?.subject.id,
reason,
});
return { ok: false, reason, advertised: advertisedOf(inbound), ...(context && { context }) };
}
}
/** Keeps the request, and with it the credential, out of the result. */
function advertisedOf({ surface, resource, acceptedSourceIds }: Inbound): AdvertisedResource {
return { surface, resource, ...(acceptedSourceIds !== undefined && { acceptedSourceIds }) };
}

View File

@ -8,7 +8,7 @@ import { GlobalConfig } from '@n8n/config';
import { INSTANCE_MCP_RESOURCE_ID } from '@n8n/constants';
import { Container, Service } from '@n8n/di';
import type { ProtectedResource, ResourceUser } from '@n8n/inbound-auth';
import type { ProtectedResource, ResourcePrincipal, ResourceUser } from '@n8n/inbound-auth';
import { UrlService } from '@n8n/backend-services';
import { PostHogClient } from '@/posthog';
@ -203,7 +203,7 @@ export class McpProtectedResource implements ProtectedResource {
return await this.mcpSettingsService.getEnabled();
}
async authorize(_user: ResourceUser): Promise<boolean> {
async authorize(_user: ResourcePrincipal): 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,4 +1,4 @@
import type { ResourceUser } from '@n8n/inbound-auth';
import type { ResourcePrincipal } from '@n8n/inbound-auth';
import type { OAuthResourceGrant } from 'n8n-workflow';
import { authorizeAgainstGrant } from '@/modules/inbound-auth-core/grant-authorization';
@ -19,7 +19,7 @@ export function triggerResourceGate(
): Pick<ProtectedResource, 'getGrant' | 'authorize'> {
return {
getGrant: () => grant,
authorize: async (user: ResourceUser) =>
authorize: async (user: ResourcePrincipal) =>
await authorizeAgainstGrant(workflowFinderService, grant, user),
};
}

View File

@ -1,7 +1,11 @@
import { testDb } from '@n8n/backend-test-utils';
import { principalFromUser, TagRepository, type User } from '@n8n/db';
import { Container } from '@n8n/di';
import { AuthenticationPipeline, AuthenticationService } from '@n8n/inbound-auth';
import {
AuthenticationPipeline,
AuthenticationService,
ResourceAdmission,
} from '@n8n/inbound-auth';
import { sign } from 'jsonwebtoken';
import { mock } from 'vitest-mock-extended';
@ -26,8 +30,13 @@ describe('Public API with OAuth2 bearer tokens', () => {
Container.set(AuthenticationPipeline, pipeline);
Container.set(AuthenticationService, authenticationService);
// `InboundAuthCoreModule.init()` registers the strategy on main; the test server loads no
// modules, so register it by hand after the API key and session-cookie strategies.
// `InboundAuthCoreModule.init()` binds the admission and registers the strategy on main; the
// test server loads no modules, so do both by hand, the strategy after the API key and
// session-cookie strategies.
const { InboundResourceAdmission } = await import(
'@/modules/inbound-auth-core/resource-admission.js'
);
Container.set(ResourceAdmission, Container.get(InboundResourceAdmission));
const { PublicApiOAuthStrategy } = await import(
'@/modules/inbound-auth-core/public-api-oauth.strategy.js'
);