mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
refactor(core): Share the inbound admission steps between protected-resource surfaces (#41162)
This commit is contained in:
parent
9e3b52cbbb
commit
378847ae1e
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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>>;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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' }));
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }) };
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user