diff --git a/packages/@n8n/inbound-auth/src/__tests__/inbound.test.ts b/packages/@n8n/inbound-auth/src/__tests__/inbound.test.ts index 20e2e9d75c5..ade1e919574 100644 --- a/packages/@n8n/inbound-auth/src/__tests__/inbound.test.ts +++ b/packages/@n8n/inbound-auth/src/__tests__/inbound.test.ts @@ -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' }); diff --git a/packages/@n8n/inbound-auth/src/contracts.ts b/packages/@n8n/inbound-auth/src/contracts.ts index b6029f3e9df..7fe4a273754 100644 --- a/packages/@n8n/inbound-auth/src/contracts.ts +++ b/packages/@n8n/inbound-auth/src/contracts.ts @@ -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; /** Whether `user` may access this resource at all. */ - authorize(user: ResourceUser): Promise; + authorize(user: ResourcePrincipal): Promise; /** 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>; } +/** 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; + + /** + * 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, + token: string, + ): Promise; + + /** The `WWW-Authenticate` value for a refusal: the advertised challenge, then the RFC 6750 error. */ + abstract challenge(refused: Refused): Promise; +} + /** The dispatcher. Owns what every method shares: driver lookup and surface acceptance. */ export abstract class AuthenticationService { abstract authenticate(extracted: Extracted): Promise>; diff --git a/packages/@n8n/inbound-auth/src/inbound.ts b/packages/@n8n/inbound-auth/src/inbound.ts index 26622e61e3a..81240047762 100644 --- a/packages/@n8n/inbound-auth/src/inbound.ts +++ b/packages/@n8n/inbound-auth/src/inbound.ts @@ -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; diff --git a/packages/cli/src/modules/inbound-auth-core/__tests__/inbound-auth-core.module.test.ts b/packages/cli/src/modules/inbound-auth-core/__tests__/inbound-auth-core.module.test.ts index 4844cf568e9..6f27d61c89f 100644 --- a/packages/cli/src/modules/inbound-auth-core/__tests__/inbound-auth-core.module.test.ts +++ b/packages/cli/src/modules/inbound-auth-core/__tests__/inbound-auth-core.module.test.ts @@ -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({ instanceType: 'main' })); diff --git a/packages/cli/src/modules/inbound-auth-core/__tests__/public-api-oauth.strategy.test.ts b/packages/cli/src/modules/inbound-auth-core/__tests__/public-api-oauth.strategy.test.ts index 894b5d1ba26..818249a34ce 100644 --- a/packages/cli/src/modules/inbound-auth-core/__tests__/public-api-oauth.strategy.test.ts +++ b/packages/cli/src/modules/inbound-auth-core/__tests__/public-api-oauth.strategy.test.ts @@ -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()), 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']); diff --git a/packages/cli/src/modules/inbound-auth-core/__tests__/resource-admission.test.ts b/packages/cli/src/modules/inbound-auth-core/__tests__/resource-admission.test.ts new file mode 100644 index 00000000000..0437cd10fce --- /dev/null +++ b/packages/cli/src/modules/inbound-auth-core/__tests__/resource-admission.test.ts @@ -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(); +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(); +const authenticationService = mock(); +const logger = mock(); +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"', + ); + }); + }); +}); diff --git a/packages/cli/src/modules/inbound-auth-core/grant-authorization.ts b/packages/cli/src/modules/inbound-auth-core/grant-authorization.ts index 7d8173503df..6869cdb18f8 100644 --- a/packages/cli/src/modules/inbound-auth-core/grant-authorization.ts +++ b/packages/cli/src/modules/inbound-auth-core/grant-authorization.ts @@ -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 { if (!grant.executeAccessWorkflowId) return true; diff --git a/packages/cli/src/modules/inbound-auth-core/inbound-auth-core.module.ts b/packages/cli/src/modules/inbound-auth-core/inbound-auth-core.module.ts index 88f10a1e91d..5563f3c7ec7 100644 --- a/packages/cli/src/modules/inbound-auth-core/inbound-auth-core.module.ts +++ b/packages/cli/src/modules/inbound-auth-core/inbound-auth-core.module.ts @@ -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'); diff --git a/packages/cli/src/modules/inbound-auth-core/public-api-oauth.strategy.ts b/packages/cli/src/modules/inbound-auth-core/public-api-oauth.strategy.ts index c7cfb89b29d..9bfe0cfb01e 100644 --- a/packages/cli/src/modules/inbound-auth-core/public-api-oauth.strategy.ts +++ b/packages/cli/src/modules/inbound-auth-core/public-api-oauth.strategy.ts @@ -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; } diff --git a/packages/cli/src/modules/inbound-auth-core/resource-admission.ts b/packages/cli/src/modules/inbound-auth-core/resource-admission.ts new file mode 100644 index 00000000000..4a2dea461b6 --- /dev/null +++ b/packages/cli/src/modules/inbound-auth-core/resource-admission.ts @@ -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, + token: string, + ): Promise { + // 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 { + // 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 { + 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 }) }; +} diff --git a/packages/cli/src/modules/mcp/mcp-protected-resource.ts b/packages/cli/src/modules/mcp/mcp-protected-resource.ts index 0491bb239c4..7835e45066c 100644 --- a/packages/cli/src/modules/mcp/mcp-protected-resource.ts +++ b/packages/cli/src/modules/mcp/mcp-protected-resource.ts @@ -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 { + async authorize(_user: ResourcePrincipal): Promise { // The instance MCP server has no per-user authorization rule: any // authenticated user may access it while the server is enabled, and all // users are denied when it is disabled. diff --git a/packages/cli/src/modules/oauth-server/resource-gate.ts b/packages/cli/src/modules/oauth-server/resource-gate.ts index c98de2462d2..a49bea7a47b 100644 --- a/packages/cli/src/modules/oauth-server/resource-gate.ts +++ b/packages/cli/src/modules/oauth-server/resource-gate.ts @@ -1,4 +1,4 @@ -import type { 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 { return { getGrant: () => grant, - authorize: async (user: ResourceUser) => + authorize: async (user: ResourcePrincipal) => await authorizeAgainstGrant(workflowFinderService, grant, user), }; } diff --git a/packages/cli/test/integration/public-api/oauth-bearer.test.ts b/packages/cli/test/integration/public-api/oauth-bearer.test.ts index e1756922c58..d0ea3706f22 100644 --- a/packages/cli/test/integration/public-api/oauth-bearer.test.ts +++ b/packages/cli/test/integration/public-api/oauth-bearer.test.ts @@ -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' );