feat(core): Accept OAuth2 bearers on the public API through the trusted-source pipeline (no-changelog) (#41127)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andreas Fitzek 2026-10-14 15:34:39 +00:00 committed by GitHub
parent b270d48686
commit f5924004ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1100 additions and 13 deletions

View File

@ -19,6 +19,7 @@ import type {
} from 'n8n-workflow';
import { z } from 'zod';
import type { OperationContext } from '../services/transaction';
import type { CredentialUsageScope, CredentialsEntity } from './credentials-entity';
import type { ExecutionDataStorageLocation } from './execution-entity';
import type { Folder } from './folder';
@ -476,6 +477,8 @@ export type AuthenticatedRequest<
'push-ref': string;
};
tokenGrant?: TokenGrant;
/** Set by the authentication edge; controllers thread it into services as `ctx`. */
ctx?: OperationContext;
};
export function isAuthenticatedRequest(req: express.Request): req is AuthenticatedRequest {

View File

@ -41,7 +41,7 @@ const verified: Verified = {
};
const context: SecurityContext = {
subject: { id: 'user-1', type: 'human' } as Principal,
subject: { id: 'user-1', type: 'human', email: 'alice@example.com' } as Principal,
authMethod: 'oauth-access-token',
subjectClaim: { sourceId: SOURCE_ID, issuer: 'https://idp.example', subject: 'alice' },
};
@ -58,7 +58,7 @@ beforeEach(() => {
});
describe('InboundAuthPipeline', () => {
it('returns the identity result when both steps pass, without logging', async () => {
it('returns the identity result when both steps pass, without a warning', async () => {
authentication.authenticate.mockResolvedValue({ ok: true, value: verified });
identity.identify.mockResolvedValue({ ok: true, value: context });
@ -70,6 +70,34 @@ describe('InboundAuthPipeline', () => {
expect(logger.warn).not.toHaveBeenCalled();
});
describe('accept log line', () => {
beforeEach(() => {
authentication.authenticate.mockResolvedValue({ ok: true, value: verified });
identity.identify.mockResolvedValue({ ok: true, value: context });
});
it('logs one info line with surface, resource, source, auth method and user id', async () => {
await pipeline.run(extracted);
expect(logger.info).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith(expect.any(String), {
surface: 'instance-mcp',
resource: RESOURCE,
sourceId: SOURCE_ID,
authMethod: 'oauth-access-token',
userId: 'user-1',
});
});
it('never writes the bearer token, the external subject or the email', async () => {
await pipeline.run(extracted);
const written = JSON.stringify(logger.info.mock.calls);
expect(written).not.toContain(TOKEN);
expect(written).not.toContain('alice');
});
});
it('returns the authentication reject and skips the identity step', async () => {
const reject: Reject = { ok: false, reason: 'signature-invalid', detail: 'bad sig' };
authentication.authenticate.mockResolvedValue(reject);
@ -146,5 +174,13 @@ describe('InboundAuthPipeline', () => {
expect(JSON.stringify(logger.warn.mock.calls)).not.toContain(TOKEN);
},
);
it.each(cases)('writes no accept line on an $step reject', async ({ arrange, reject }) => {
arrange(reject);
await pipeline.run(extracted);
expect(logger.info).not.toHaveBeenCalled();
});
});
});

View File

@ -12,11 +12,16 @@ import { InstanceSettings } from 'n8n-core';
import { OperationalError } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
import { AuthStrategyRegistry } from '@/services/auth-strategy.registry';
import { ProtectedResourceRegistry } from '@/services/protected-resource.registry';
// Importing the module runs the @BackendModule decorator, registering its metadata.
import { InboundAuthPipeline } from '../authentication-pipeline';
import { OAuth2AuthenticationService } from '../authentication.service';
import { TrustedSourceIdentityService } from '../identity/trusted-source-identity.service';
import { InboundAuthCoreModule } from '../inbound-auth-core.module';
import { PublicApiOAuthStrategy } from '../public-api-oauth.strategy';
import { PublicApiProtectedResource } from '../public-api-protected-resource';
import { TrustedSourceDiscoveryTask } from '../trusted-source-discovery.task';
import { TrustedSourceDbGate } from '../trusted-source.gate';
import { TrustedSourceDbStore } from '../trusted-source.store';
@ -35,6 +40,10 @@ describe('InboundAuthCoreModule', () => {
const dbStore = mock<TrustedSourceDbStore>();
const dbGate = mock<TrustedSourceDbGate>();
const identityService = mock<TrustedSourceIdentityService>();
const strategyRegistry = mock<AuthStrategyRegistry>();
const resourceRegistry = mock<ProtectedResourceRegistry>();
const publicApiResource = mock<PublicApiProtectedResource>();
const publicApiStrategy = mock<PublicApiOAuthStrategy>();
beforeAll(async () => {
// The shell may carry the flag; the first init must not be the one that imports the controller.
@ -43,6 +52,10 @@ describe('InboundAuthCoreModule', () => {
Container.set(TrustedSourceDbStore, dbStore);
Container.set(TrustedSourceDbGate, dbGate);
Container.set(TrustedSourceIdentityService, identityService);
Container.set(AuthStrategyRegistry, strategyRegistry);
Container.set(ProtectedResourceRegistry, resourceRegistry);
Container.set(PublicApiProtectedResource, publicApiResource);
Container.set(PublicApiOAuthStrategy, publicApiStrategy);
await new InboundAuthCoreModule().init();
});
@ -96,6 +109,38 @@ describe('InboundAuthCoreModule', () => {
it('binds the AuthenticationPipeline contract to the inbound-auth pipeline', () => {
expect(Container.get(AuthenticationPipeline)).toBeInstanceOf(InboundAuthPipeline);
});
describe('public API surface', () => {
afterEach(() => {
Container.set(InstanceSettings, mock<InstanceSettings>({ instanceType: 'main' }));
});
it('registers the public API resource and its OAuth2 strategy once on a main instance', async () => {
resourceRegistry.register.mockClear();
strategyRegistry.register.mockClear();
await new InboundAuthCoreModule().init();
expect(resourceRegistry.register).toHaveBeenCalledTimes(1);
expect(resourceRegistry.register).toHaveBeenCalledWith(publicApiResource);
expect(strategyRegistry.register).toHaveBeenCalledTimes(1);
expect(strategyRegistry.register).toHaveBeenCalledWith(publicApiStrategy);
});
it.each(['webhook', 'worker'] as const)(
'registers neither on a %s instance, which serves no public API',
async (instanceType) => {
Container.set(InstanceSettings, mock<InstanceSettings>({ instanceType }));
resourceRegistry.register.mockClear();
strategyRegistry.register.mockClear();
await new InboundAuthCoreModule().init();
expect(resourceRegistry.register).not.toHaveBeenCalled();
expect(strategyRegistry.register).not.toHaveBeenCalled();
},
);
});
});
it('registers itself on main, webhook and worker instances without a license flag', () => {

View File

@ -0,0 +1,261 @@
import { JwtService } from '@n8n/backend-services';
import type { AuthenticatedRequest, User, UserRepository } from '@n8n/db';
import type { AuthenticationPipeline, AuthenticationService } from '@n8n/inbound-auth';
import { getApiKeyScopesForRole, type Scope, type SecurityContext } from '@n8n/permissions';
import type { Response } from 'express';
import { sign } from 'jsonwebtoken';
import type { InstanceSettings } from 'n8n-core';
import { mock } from 'vitest-mock-extended';
import { PublicApiOAuthStrategy } from '../public-api-oauth.strategy';
import type { PublicApiProtectedResource } from '../public-api-protected-resource';
const RESOURCE_URL = 'https://n8n.test/api/v1';
const CHALLENGE =
'Bearer resource_metadata="https://n8n.test/.well-known/oauth-protected-resource/api/v1"';
const jwtService = new JwtService(
mock<InstanceSettings>({ encryptionKey: 'test-key' }),
mock(),
mock(),
);
const resource = mock<PublicApiProtectedResource>({ surface: 'public-api' });
const pipeline = mock<AuthenticationPipeline>();
const authenticationService = mock<AuthenticationService>();
const userRepository = mock<UserRepository>();
let strategy: PublicApiOAuthStrategy;
/** Only `iss` matters here: the pipeline, not the strategy, verifies the signature. */
const tokenFrom = (iss: string) => sign({ iss, sub: 'alice' }, 'unverified');
const makeReq = (headers: Record<string, string>) => {
const res = mock<Response>();
const req = mock<AuthenticatedRequest>({
headers: headers as AuthenticatedRequest['headers'],
method: 'GET',
originalUrl: '/api/v1/workflows',
ip: '203.0.113.7',
res,
});
return { req, res };
};
const makeUser = (id: string, scopeSlugs: string[]): User =>
({
...mock<User>(),
id,
role: {
...mock<User['role']>(),
slug: 'global:member',
scopes: scopeSlugs.map((slug) => ({ ...mock(), slug: slug as Scope })),
},
}) as User;
beforeEach(() => {
vi.clearAllMocks();
resource.isAvailable.mockResolvedValue(true);
resource.getResourceUrl.mockReturnValue(RESOURCE_URL);
resource.getAudiences.mockReturnValue([RESOURCE_URL]);
authenticationService.advertise.mockResolvedValue({
authorizationServers: ['https://idp.example'],
challenges: [CHALLENGE],
});
strategy = new PublicApiOAuthStrategy(
resource,
pipeline,
authenticationService,
jwtService,
userRepository,
);
});
describe('PublicApiOAuthStrategy', () => {
describe('buildTokenGrant', () => {
it('abstains: the pipeline needs the request, not a bare token', async () => {
expect(await strategy.buildTokenGrant()).toBeNull();
});
});
describe('authenticate', () => {
it.each([
['no Authorization header', {}],
['a non-bearer Authorization header', { authorization: 'Basic dXNlcjpwYXNz' }],
['a bearer that is not a JWT', { authorization: 'Bearer not-a-jwt' }],
])('abstains with %s', async (_name, headers) => {
const { req } = makeReq(headers);
expect(await strategy.authenticate(req)).toBeNull();
expect(pipeline.run).not.toHaveBeenCalled();
});
it.each(['n8n', 'n8n-token-exchange', 'urn:example:idp', 'ftp://idp.example', ''])(
'abstains for issuer %j, which is not an http(s) URL',
async (iss) => {
const { req } = makeReq({ authorization: `Bearer ${tokenFrom(iss)}` });
expect(await strategy.authenticate(req)).toBeNull();
expect(pipeline.run).not.toHaveBeenCalled();
},
);
it('abstains while the public API is unavailable', async () => {
resource.isAvailable.mockResolvedValue(false);
const { req } = makeReq({ authorization: `Bearer ${tokenFrom('https://idp.example')}` });
expect(await strategy.authenticate(req)).toBeNull();
expect(pipeline.run).not.toHaveBeenCalled();
});
it.each(['https://idp.example', 'http://localhost:5678'])(
'runs the pipeline for issuer %s on the public-api surface with the resource and the request',
async (iss) => {
const token = tokenFrom(iss);
pipeline.run.mockResolvedValue({ ok: false, reason: 'unknown-issuer' });
const { req } = makeReq({ authorization: `Bearer ${token}` });
await strategy.authenticate(req);
expect(pipeline.run).toHaveBeenCalledTimes(1);
expect(pipeline.run).toHaveBeenCalledWith(
expect.objectContaining({
surface: 'public-api',
resource: { url: RESOURCE_URL, acceptedAudiences: [RESOURCE_URL] },
credential: { kind: 'bearer', token },
request: expect.objectContaining({
method: 'GET',
url: '/api/v1/workflows',
ip: '203.0.113.7',
}),
}),
);
},
);
describe('on a pipeline reject', () => {
it('fails fast with the advertised challenge, error=invalid_token and the reason', async () => {
pipeline.run.mockResolvedValue({
ok: false,
reason: 'source-not-accepted',
detail: 'internal detail',
});
const { req, res } = makeReq({
authorization: `Bearer ${tokenFrom('https://idp.example')}`,
});
expect(await strategy.authenticate(req)).toBe(false);
expect(authenticationService.advertise).toHaveBeenCalledWith(
expect.objectContaining({
surface: 'public-api',
resource: { url: RESOURCE_URL, acceptedAudiences: [RESOURCE_URL] },
}),
);
expect(res.setHeader).toHaveBeenCalledWith(
'WWW-Authenticate',
`${CHALLENGE}, error="invalid_token", error_description="source-not-accepted"`,
);
});
it('never puts the reject detail into the response header', async () => {
pipeline.run.mockResolvedValue({
ok: false,
reason: 'source-unusable',
detail: 'source status is error',
});
const { req, res } = makeReq({
authorization: `Bearer ${tokenFrom('https://idp.example')}`,
});
await strategy.authenticate(req);
expect(JSON.stringify(res.setHeader.mock.calls)).not.toContain('source status is error');
});
it('falls back to a bare Bearer challenge when no source is advertised', async () => {
authenticationService.advertise.mockResolvedValue({
authorizationServers: [],
challenges: [],
});
pipeline.run.mockResolvedValue({ ok: false, reason: 'unknown-issuer' });
const { req, res } = makeReq({
authorization: `Bearer ${tokenFrom('https://idp.example')}`,
});
expect(await strategy.authenticate(req)).toBe(false);
expect(res.setHeader).toHaveBeenCalledWith(
'WWW-Authenticate',
'Bearer, error="invalid_token", error_description="unknown-issuer"',
);
});
});
describe('on success', () => {
const contextWith = (tokenScopes?: string[]): SecurityContext => ({
subject: { id: 'user-1', type: 'human', role: mock() },
authMethod: 'oauth-access-token',
subjectClaim: { sourceId: 'source-1', issuer: 'https://idp.example', subject: 'alice' },
...(tokenScopes && { tokenScopes }),
});
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']);
pipeline.run.mockResolvedValue({ ok: true, value: context });
userRepository.findByIdWithRole.mockResolvedValue(user);
const { req, res } = makeReq({
authorization: `Bearer ${tokenFrom('https://idp.example')}`,
});
expect(await strategy.authenticate(req)).toBe(true);
expect(userRepository.findByIdWithRole).toHaveBeenCalledWith('user-1');
expect(req.user).toBe(user);
expect(req.tokenGrant).toEqual({
scopes: ['workflow:list', 'workflow:create'],
apiKeyScopes: ['workflow:list'],
subject: user,
});
expect(req.ctx).toEqual({ security: context });
expect(res.setHeader).not.toHaveBeenCalled();
});
// Only API key scopes narrow the grant; an IdP's own scopes are ignored.
it.each<{ name: string; tokenScopes?: string[]; expected: (role: string[]) => string[] }>([
{
name: 'an API key scope',
tokenScopes: ['workflow:list'],
expected: () => ['workflow:list'],
},
{
name: 'an API key scope next to IdP scopes',
tokenScopes: ['workflow:list', 'openid'],
expected: () => ['workflow:list'],
},
{ name: 'only IdP scopes', tokenScopes: ['openid', 'profile'], expected: (role) => role },
{ name: 'no scopes', tokenScopes: [], expected: (role) => role },
{ name: 'no scope claim', expected: (role) => role },
{ name: 'an API key scope the role lacks', tokenScopes: ['user:list'], expected: () => [] },
])('grants $name as the role allows', async ({ tokenScopes, expected }) => {
const user = makeUser('user-1', ['workflow:list', 'workflow:create']);
pipeline.run.mockResolvedValue({ ok: true, value: contextWith(tokenScopes) });
userRepository.findByIdWithRole.mockResolvedValue(user);
const { req } = makeReq({ authorization: `Bearer ${tokenFrom('https://idp.example')}` });
await strategy.authenticate(req);
expect(req.tokenGrant?.apiKeyScopes).toEqual(expected(getApiKeyScopesForRole(user)));
});
it('fails fast when the subject no longer exists', async () => {
pipeline.run.mockResolvedValue({ ok: true, value: contextWith() });
userRepository.findByIdWithRole.mockResolvedValue(null);
const { req } = makeReq({
authorization: `Bearer ${tokenFrom('https://idp.example')}`,
});
expect(await strategy.authenticate(req)).toBe(false);
});
});
});
});

View File

@ -0,0 +1,95 @@
import type { LicenseState } from '@n8n/backend-common';
import type { UrlService } from '@n8n/backend-services';
import type { GlobalConfig } from '@n8n/config';
import type { ProtectedResource, ResourceUser } from '@n8n/inbound-auth';
import { ALL_API_KEY_SCOPES, getApiKeyScopesForRole, type Scope } from '@n8n/permissions';
import { mock } from 'vitest-mock-extended';
import { PublicApiProtectedResource } from '../public-api-protected-resource';
const urlService = mock<UrlService>();
const licenseState = mock<LicenseState>();
const globalConfig = { publicApi: { disabled: false, path: 'api' } } as unknown as GlobalConfig;
let resource: PublicApiProtectedResource;
const principal = (roleSlug: string, scopeSlugs: string[]): ResourceUser => ({
...mock<ResourceUser>(),
role: {
...mock<ResourceUser['role']>(),
slug: roleSlug,
scopes: scopeSlugs.map((slug) => ({ ...mock(), slug: slug as Scope })),
},
});
beforeEach(() => {
vi.clearAllMocks();
globalConfig.publicApi.disabled = false;
globalConfig.publicApi.path = 'api';
urlService.getInstanceBaseUrl.mockReturnValue('https://n8n.test/');
licenseState.isAPIDisabled.mockReturnValue(false);
resource = new PublicApiProtectedResource(urlService, globalConfig, licenseState);
});
describe('PublicApiProtectedResource', () => {
it('is served on the public-api surface, advertises the API key scopes, and is neither default nor first-party', () => {
const contract: ProtectedResource = resource;
expect(contract.surface).toBe('public-api');
expect(contract.scopes).toEqual([...ALL_API_KEY_SCOPES]);
expect(contract.isDefault).toBeFalsy();
expect(contract.isFirstParty).toBeFalsy();
});
it('names itself on the consent screen', () => {
expect(resource.displayName).toBe('n8n public API');
expect(resource.uiHints).toEqual({ icon: 'key-round', consentType: 'api' });
});
it('lets a member grant the API key scopes of their role', async () => {
const member = principal('global:member', [
'workflow:list',
'workflow:create',
'workflow:read',
]);
await expect(resource.getGrantableScopes(member)).resolves.toEqual(
getApiKeyScopesForRole(member),
);
});
it('lets a chat user grant nothing', async () => {
await expect(resource.getGrantableScopes(principal('global:chatUser', []))).resolves.toEqual(
[],
);
});
it('derives the resource URL from the instance base URL and the public API path', () => {
expect(resource.getResourceUrl()).toBe('https://n8n.test/api/v1');
});
it('follows a custom public API path segment', () => {
globalConfig.publicApi.path = 'my-api';
expect(resource.getResourceUrl()).toBe('https://n8n.test/my-api/v1');
});
it('accepts only its own resource URL as audience', () => {
expect(resource.getAudiences()).toEqual(['https://n8n.test/api/v1']);
});
it('is available and authorizes any user while the public API is enabled', async () => {
await expect(resource.isAvailable()).resolves.toBe(true);
await expect(resource.authorize()).resolves.toBe(true);
});
it.each([
['the public API is disabled by config', () => (globalConfig.publicApi.disabled = true)],
['the license disables the API', () => licenseState.isAPIDisabled.mockReturnValue(true)],
])('is unavailable and authorizes nobody when %s', async (_name, arrange) => {
arrange();
await expect(resource.isAvailable()).resolves.toBe(false);
await expect(resource.authorize()).resolves.toBe(false);
});
});

View File

@ -30,6 +30,13 @@ export class InboundAuthPipeline extends AuthenticationPipeline {
if (!context.ok) {
return this.reject(extracted, context, verified.value.source.id);
}
this.logger.info('Inbound authentication accepted', {
surface: extracted.surface,
resource: extracted.resource.url,
sourceId: verified.value.source.id,
authMethod: context.value.authMethod,
userId: context.value.subject.id,
});
return context;
}

View File

@ -15,6 +15,9 @@ import {
import { InstanceSettings } from 'n8n-core';
import { OperationalError } from 'n8n-workflow';
import { AuthStrategyRegistry } from '@/services/auth-strategy.registry';
import { ProtectedResourceRegistry } from '@/services/protected-resource.registry';
// Discovery of the local source must fail, not succeed with made-up documents.
class UnregisteredLocalAuthorizationServer extends LocalAuthorizationServer {
async getMetadata(): Promise<AuthorizationServerMetadata> {
@ -73,6 +76,14 @@ export class InboundAuthCoreModule implements ModuleInterface {
const { InboundAuthPipeline } = await import('./authentication-pipeline.js');
Container.set(AuthenticationPipeline, Container.get(InboundAuthPipeline));
// 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');
const { PublicApiOAuthStrategy } = await import('./public-api-oauth.strategy.js');
Container.get(ProtectedResourceRegistry).register(Container.get(PublicApiProtectedResource));
Container.get(AuthStrategyRegistry).register(Container.get(PublicApiOAuthStrategy));
}
}
// Ungated: the runner skips a cluster-scoped task on webhook and worker instances itself.

View File

@ -0,0 +1,98 @@
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 {
getApiKeyScopesForRole,
isApiKeyScope,
type ApiKeyScope,
type Scope,
} from '@n8n/permissions';
import type { AuthStrategy } from '@/services/auth-strategy.types';
import { JwtService } from '@n8n/backend-services';
import { PublicApiProtectedResource } from './public-api-protected-resource';
// Token scopes arrive as plain strings; `isApiKeyScope` only takes known scope names.
const isApiKeyScopeName = (scope: string): scope is ApiKeyScope =>
isApiKeyScope(scope as Scope | ApiKeyScope);
/**
* Sends `Authorization: Bearer` JWTs from an OAuth2 issuer through the inbound-auth pipeline.
* API keys and token-exchange JWTs stay with their own strategies.
*/
@Service()
export class PublicApiOAuthStrategy implements AuthStrategy {
constructor(
private readonly resource: PublicApiProtectedResource,
private readonly pipeline: AuthenticationPipeline,
private readonly authenticationService: AuthenticationService,
private readonly jwtService: JwtService,
private readonly userRepository: UserRepository,
) {}
async buildTokenGrant(): Promise<TokenGrant | false | null> {
return null;
}
private isOAuthIssuer(token: string): boolean {
const iss: unknown = this.jwtService.decodeUnverified<{ iss?: unknown } | null>(token)?.iss;
return typeof iss === 'string' && URL.canParse(iss) && /^https?:$/.test(new URL(iss).protocol);
}
async authenticate(req: AuthenticatedRequest): Promise<boolean | null> {
const isAvailable = await this.resource.isAvailable();
if (!isAvailable) {
return null;
}
const inbound = inboundFrom(this.resource, {
method: req.method,
url: req.originalUrl,
headers: req.headers,
ip: req.ip ?? '',
});
const extracted = extractBearer(inbound);
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}"`,
);
return false;
}
const user = await this.userRepository.findByIdWithRole(result.value.subject.id);
if (!user) {
return false;
}
// 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);
req.user = user;
req.tokenGrant = {
scopes: user.role.scopes.map((scope) => scope.slug),
apiKeyScopes:
requested.length > 0 ? roleScopes.filter((s) => requested.includes(s)) : roleScopes,
subject: user,
};
req.ctx = {
security: result.value,
};
return true;
}
}

View File

@ -0,0 +1,48 @@
import type { ConsentUiHints } from '@n8n/api-types';
import { LicenseState } from '@n8n/backend-common';
import { UrlService } from '@n8n/backend-services';
import { GlobalConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import type { ProtectedResource, ResourceUser } from '@n8n/inbound-auth';
import { ALL_API_KEY_SCOPES, getApiKeyScopesForRole } from '@n8n/permissions';
/** The public API at `/api/v1` as an OAuth 2.1 protected resource on the `public-api` surface. */
@Service()
export class PublicApiProtectedResource implements ProtectedResource {
readonly id = 'public-api';
readonly surface = 'public-api' as const;
readonly displayName = 'n8n public API';
readonly uiHints: ConsentUiHints = { icon: 'key-round', consentType: 'api' };
readonly scopes: string[] = [...ALL_API_KEY_SCOPES];
constructor(
private readonly urlService: UrlService,
private readonly globalConfig: GlobalConfig,
private readonly licenseState: LicenseState,
) {}
getResourceUrl(): string {
const baseUrl = this.urlService.getInstanceBaseUrl().replace(/\/$/, '');
return `${baseUrl}/${this.globalConfig.publicApi.path}/v1`;
}
getAudiences(): string[] {
return [this.getResourceUrl()];
}
async isAvailable(): Promise<boolean> {
return !this.globalConfig.publicApi.disabled && !this.licenseState.isAPIDisabled();
}
async authorize(): Promise<boolean> {
return await this.isAvailable();
}
async getGrantableScopes(user: ResourceUser): Promise<string[]> {
return getApiKeyScopesForRole(user);
}
}

View File

@ -312,6 +312,16 @@ describe('POST /rest/consent/approve', () => {
);
});
test('rejects a scope list with duplicates', async () => {
const response = await testServer
.authAgentFor(owner)
.post('/consent/approve')
.set('Cookie', `n8n-oauth-session=${sessionToken}`)
.send({ approved: true, scopes: ['workflow:read', 'workflow:read'] });
expect(response.statusCode).toBe(400);
});
test('should handle consent denial and return error redirect URL', async () => {
const response = await testServer
.authAgentFor(owner)

View File

@ -8,6 +8,7 @@ import type {
ProtectedResourceRegistry,
} from '@/services/protected-resource.registry';
import type { UrlService } from '@n8n/backend-services';
import type { AuthenticationService } from '@n8n/inbound-auth';
import type { OAuthServerLocalAuthorizationServer } from '../oauth-local-authorization-server';
import { OAuthServerService } from '../oauth-server.service';
@ -29,6 +30,7 @@ beforeAll(async () => {
const urlService = mock<UrlService>();
const registry = mock<ProtectedResourceRegistry>();
const localServer = mock<OAuthServerLocalAuthorizationServer>();
const authenticationService = mock<AuthenticationService>();
let controller: OAuthControllerClass;
const makeRes = () => {
@ -53,10 +55,19 @@ const resource = (scopes: string[], isAvailable?: () => Promise<boolean>): Prote
scopes,
});
const publicApiResource: ProtectedResource = {
surface: 'public-api',
id: 'public-api',
getResourceUrl: () => 'https://n8n.test/api/v1',
getAudiences: () => ['https://n8n.test/api/v1'],
authorize: async () => true,
scopes: [],
};
beforeEach(() => {
vi.resetAllMocks();
urlService.getInstanceBaseUrl.mockReturnValue('https://n8n.test');
controller = new OAuthController(urlService, registry, localServer);
controller = new OAuthController(urlService, registry, localServer, authenticationService);
});
describe('OAuthController', () => {
@ -128,6 +139,33 @@ describe('OAuthController', () => {
authorization_servers: ['https://n8n.test'],
scopes_supported: scopes,
});
expect(authenticationService.advertise).not.toHaveBeenCalled();
});
test('lists the issuers of the trusted sources accepted on the public-api surface', async () => {
registry.getByResourcePath.mockResolvedValue(publicApiResource);
authenticationService.advertise.mockResolvedValue({
authorizationServers: ['https://n8n.test', 'https://idp.example'],
challenges: [],
});
const res = makeRes();
await controller.protectedResourceMetadata(makeReq(['api', 'v1']), res);
expect(authenticationService.advertise).toHaveBeenCalledWith(
expect.objectContaining({
surface: 'public-api',
resource: {
url: 'https://n8n.test/api/v1',
acceptedAudiences: ['https://n8n.test/api/v1'],
},
}),
);
expect(res.json).toHaveBeenCalledWith({
resource: 'https://n8n.test/api/v1',
bearer_methods_supported: ['header'],
authorization_servers: ['https://n8n.test', 'https://idp.example'],
});
});
test('omits scopes_supported when the resource advertises no scopes', async () => {

View File

@ -3,5 +3,12 @@ import { z } from 'zod';
export class ApproveConsentRequestDto extends Z.class({
approved: z.boolean(),
scopes: z.array(z.string().max(64)).max(32).optional(),
// Granted scopes go into the token verbatim, so a repeated scope only inflates the bearer.
scopes: z
.array(z.string().max(64))
.max(256)
.refine((scopes) => new Set(scopes).size === scopes.length, {
message: 'scopes must not contain duplicates',
})
.optional(),
}) {}

View File

@ -13,6 +13,7 @@ import {
StaticRouterMetadata,
} from '@n8n/decorators';
import { Container } from '@n8n/di';
import { AuthenticationService } from '@n8n/inbound-auth';
import type { Response, Request, RequestHandler, Router } from 'express';
import type { ProtectedResource } from '@/services/protected-resource.registry';
@ -149,6 +150,7 @@ export class OAuthController {
private readonly urlService: UrlService,
private readonly resourceRegistry: ProtectedResourceRegistry,
private readonly localServer: OAuthServerLocalAuthorizationServer,
private readonly authenticationService: AuthenticationService,
) {}
// Add CORS headers for OAuth discovery endpoints
@ -252,7 +254,7 @@ export class OAuthController {
return;
}
res.json(this.buildProtectedResourceMetadata(resource));
res.json(await this.buildProtectedResourceMetadata(resource));
}
@Options('/.well-known/oauth-protected-resource', {
@ -297,15 +299,29 @@ export class OAuthController {
return;
}
res.json(this.buildProtectedResourceMetadata(resource));
res.json(await this.buildProtectedResourceMetadata(resource));
}
private buildProtectedResourceMetadata(resource: ProtectedResource): Record<string, unknown> {
const baseUrl = this.urlService.getInstanceBaseUrl();
private async buildProtectedResourceMetadata(
resource: ProtectedResource,
): Promise<Record<string, unknown>> {
const authorizationServers =
resource.surface === 'public-api'
? (
await this.authenticationService.advertise({
surface: resource.surface,
resource: {
url: resource.getResourceUrl(),
acceptedAudiences: resource.getAudiences(),
},
})
).authorizationServers
: [this.urlService.getInstanceBaseUrl()];
const metadata: Record<string, unknown> = {
resource: resource.getResourceUrl(),
bearer_methods_supported: ['header'],
authorization_servers: [baseUrl],
authorization_servers: authorizationServers,
};
if (resource.scopes.length > 0) {

View File

@ -0,0 +1,176 @@
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 { sign } from 'jsonwebtoken';
import { mock } from 'vitest-mock-extended';
import { createMember, createOwnerWithApiKey } from '@test-integration/db/users';
import { setupTestServer } from '@test-integration/utils';
const CHALLENGE =
'Bearer resource_metadata="https://n8n.test/.well-known/oauth-protected-resource/api/v1"';
// The pipeline is the unit under test elsewhere; here it is a boundary. Only `iss` is read before it runs.
const tokenFrom = (iss: string) => sign({ iss, sub: 'alice' }, 'unverified-here');
const pipeline = mock<AuthenticationPipeline>();
const authenticationService = mock<AuthenticationService>();
describe('Public API with OAuth2 bearer tokens', () => {
const testServer = setupTestServer({ endpointGroups: ['publicApi'] });
beforeAll(async () => {
await testDb.init();
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.
const { PublicApiOAuthStrategy } = await import(
'@/modules/inbound-auth-core/public-api-oauth.strategy.js'
);
const { AuthStrategyRegistry } = await import('@/services/auth-strategy.registry.js');
Container.get(AuthStrategyRegistry).register(Container.get(PublicApiOAuthStrategy));
});
beforeEach(async () => {
await testDb.truncate(['User', 'TagEntity']);
vi.clearAllMocks();
authenticationService.advertise.mockResolvedValue({
authorizationServers: ['https://idp.example'],
challenges: [CHALLENGE],
});
});
afterAll(async () => {
await testDb.terminate();
});
test('authenticates a token the pipeline accepts as the bound user, with API key scopes from the role', async () => {
const member = await createMember();
pipeline.run.mockResolvedValue({
ok: true,
value: { subject: principalFromUser(member), authMethod: 'oauth-access-token' },
});
const token = tokenFrom('https://idp.example');
const response = await testServer
.publicApiAgentWithoutApiKey()
.set('Authorization', `Bearer ${token}`)
.get('/workflows');
expect(response.statusCode).toBe(200);
expect(pipeline.run).toHaveBeenCalledWith(
expect.objectContaining({
surface: 'public-api',
resource: expect.objectContaining({ url: expect.stringMatching(/\/api\/v1$/) }),
credential: { kind: 'bearer', token },
}),
);
});
test('answers 401 with a bearer challenge for a token the pipeline rejects', async () => {
pipeline.run.mockResolvedValue({ ok: false, reason: 'source-not-accepted' });
const response = await testServer
.publicApiAgentWithoutApiKey()
.set('Authorization', `Bearer ${tokenFrom('https://idp.example')}`)
.get('/workflows');
expect(response.statusCode).toBe(401);
expect(response.headers['www-authenticate']).toBe(
`${CHALLENGE}, error="invalid_token", error_description="source-not-accepted"`,
);
});
test('leaves API keys to the API key strategy', async () => {
const owner = await createOwnerWithApiKey();
const response = await testServer.publicApiAgentFor(owner).get('/workflows');
expect(response.statusCode).toBe(200);
expect(pipeline.run).not.toHaveBeenCalled();
});
test('leaves a bearer whose issuer is not a URL to the other strategies', async () => {
const response = await testServer
.publicApiAgentWithoutApiKey()
.set('Authorization', `Bearer ${tokenFrom('n8n-token-exchange')}`)
.get('/workflows');
expect(response.statusCode).toBe(401);
expect(pipeline.run).not.toHaveBeenCalled();
expect(response.headers['www-authenticate']).toBeUndefined();
});
describe('scope enforcement', () => {
let member: User;
const bearer = () => `Bearer ${tokenFrom('https://idp.example')}`;
const acceptWith = (tokenScopes?: string[]) =>
pipeline.run.mockResolvedValue({
ok: true,
value: {
subject: principalFromUser(member),
authMethod: 'oauth-access-token',
...(tokenScopes && { tokenScopes }),
},
});
beforeEach(async () => {
member = await createMember();
});
test.each<{ name: string; tokenScopes?: string[]; list: number; create: number }>([
{ name: 'a read scope only', tokenScopes: ['tag:list'], list: 200, create: 403 },
{
name: 'read and write scopes',
tokenScopes: ['tag:list', 'tag:create'],
list: 200,
create: 201,
},
{ name: 'no scope claim (full role)', list: 200, create: 201 },
{
name: 'only IdP scopes (full role)',
tokenScopes: ['openid', 'profile'],
list: 200,
create: 201,
},
{ name: 'a scope outside the token', tokenScopes: ['workflow:list'], list: 403, create: 403 },
{ name: 'a scope the role lacks', tokenScopes: ['user:create'], list: 403, create: 403 },
])('with $name: list tags $list, create tag $create', async ({ tokenScopes, list, create }) => {
acceptWith(tokenScopes);
const agent = testServer.publicApiAgentWithoutApiKey();
const listed = await agent.get('/tags').set('Authorization', bearer());
const created = await agent
.post('/tags')
.set('Authorization', bearer())
.send({ name: 'oauth' });
expect(listed.statusCode).toBe(list);
expect(created.statusCode).toBe(create);
// A refused write must leave no trace.
expect(await Container.get(TagRepository).count()).toBe(create === 201 ? 1 : 0);
});
test('never grants a scope the role lacks, even when the token carries it', async () => {
acceptWith(['user:create', 'tag:list']);
const created = await testServer
.publicApiAgentWithoutApiKey()
.post('/users')
.set('Authorization', bearer())
.send([{ email: 'new@example.com', role: 'global:member' }]);
const tags = await testServer
.publicApiAgentWithoutApiKey()
.get('/tags')
.set('Authorization', bearer());
expect(created.statusCode).toBe(403);
expect(tags.statusCode).toBe(200);
});
});
});

View File

@ -0,0 +1,208 @@
import { Logger } from '@n8n/backend-common';
import {
CacheService,
JwtService,
type OAuthDiscoveryClient,
UrlService,
} from '@n8n/backend-services';
import { testDb } from '@n8n/backend-test-utils';
import { TagRepository, type User } from '@n8n/db';
import { Container } from '@n8n/di';
import { LocalAuthorizationServer } from '@n8n/inbound-auth';
import { createHash, randomBytes } from 'node:crypto';
import { mock } from 'vitest-mock-extended';
import { PublicApiProtectedResource } from '@/modules/inbound-auth-core/public-api-protected-resource';
import { TrustedSourceDiscoveryService } from '@/modules/inbound-auth-core/trusted-source-discovery.service';
import { TrustedSourceDbStore } from '@/modules/inbound-auth-core/trusted-source.store';
import { OAuthClientRepository } from '@/modules/oauth-server/database/repositories/oauth-client.repository';
import type { OAuthSessionPayload } from '@/modules/oauth-server/oauth-session.service';
import { createMember, createOwner } from '@test-integration/db/users';
import { setupTestServer } from '@test-integration/utils';
/**
* The whole chain, nothing mocked but the network: the user picks scopes on the consent screen,
* the instance mints a token for `/api/v1`, the trusted-source pipeline verifies it against the
* instance's own keys, and the public API allows only what was picked.
*/
const testServer = setupTestServer({
modules: ['oauth-jwe', 'oauth-server', 'mcp', 'inbound-auth-core'],
endpointGroups: ['mcp', 'jwks', 'publicApi'],
});
const REDIRECT_URI = 'https://example.com/callback';
const b64url = (buf: Buffer) => buf.toString('base64url');
let owner: User;
let member: User;
let resource: string;
let jwtService: JwtService;
beforeAll(async () => {
await Container.get(CacheService).init();
owner = await createOwner();
member = await createMember();
jwtService = Container.get(JwtService);
resource = Container.get(PublicApiProtectedResource).getResourceUrl();
// The module seeded `n8n-internal` at init; discovery normally runs as a system task. Run it
// once here, reading the local authorization server in process, never over HTTP.
const offline = mock<OAuthDiscoveryClient>();
const refused = new Error('discovery must not go over HTTP');
offline.fetchOpenIdConfiguration.mockRejectedValue(refused);
offline.fetchOAuth2ServerMetadata.mockRejectedValue(refused);
offline.fetchJwks.mockRejectedValue(refused);
const discovery = new TrustedSourceDiscoveryService(
Container.get(Logger),
Container.get(TrustedSourceDbStore),
offline,
Container.get(LocalAuthorizationServer),
);
await discovery.refresh('n8n-internal');
await Container.get(CacheService).reset();
});
afterEach(async () => {
await testDb.truncate([
'OAuthClient',
'AuthorizationCode',
'AccessToken',
'RefreshToken',
'UserConsent',
'TagEntity',
]);
});
async function registerClient(): Promise<string> {
const id = `client-${randomBytes(6).toString('hex')}`;
await Container.get(OAuthClientRepository).save({
id,
name: 'scope test client',
redirectUris: [REDIRECT_URI],
grantTypes: ['authorization_code', 'refresh_token'],
tokenEndpointAuthMethod: 'none',
clientSecret: null,
clientSecretExpiresAt: null,
isFirstParty: false,
});
return id;
}
/** What the consent screen posts after the user picked `scopes`, then the code-for-token exchange. */
async function consentAndExchange(user: User, scopes: string[], requestedScopes?: string[]) {
const clientId = await registerClient();
const verifier = b64url(randomBytes(32));
const payload: OAuthSessionPayload = {
clientId,
redirectUri: REDIRECT_URI,
codeChallenge: b64url(createHash('sha256').update(verifier).digest()),
state: null,
resource,
...(requestedScopes && { requestedScopes }),
};
const session = jwtService.sign('oauthSession', payload, { expiresIn: '10m' });
const approval = await testServer
.authAgentFor(user)
.post('/consent/approve')
.set('Cookie', `n8n-oauth-session=${session}`)
.send({ approved: true, scopes });
if (approval.statusCode !== 200) return { approval, token: undefined };
const code = new URL(approval.body.data.redirectUrl as string).searchParams.get('code');
const token = await testServer.restlessAgent.post('/oauth/token').type('form').send({
grant_type: 'authorization_code',
code,
code_verifier: verifier,
client_id: clientId,
redirect_uri: REDIRECT_URI,
resource,
});
return { approval, token };
}
const api = () => testServer.publicApiAgentWithoutApiKey();
describe('Public API access from a consent-scoped token', () => {
test('advertises the API key scopes and the instance as authorization server for /api/v1', async () => {
const metadata = await testServer.restlessAgent
.get('/.well-known/oauth-protected-resource/api/v1')
.expect(200);
expect(metadata.body).toMatchObject({
resource,
authorization_servers: [Container.get(UrlService).getInstanceBaseUrl()],
scopes_supported: expect.arrayContaining(['tag:list', 'tag:create']),
});
});
test('a token granted tag:list can list tags but cannot create one', async () => {
const { token } = await consentAndExchange(owner, ['tag:list']);
expect(token?.statusCode).toBe(200);
const accessToken = token!.body.access_token as string;
expect(jwtService.decodeUnverified(accessToken)).toMatchObject({
aud: resource,
sub: owner.id,
scope: 'tag:list',
});
const listed = await api().get('/tags').set('Authorization', `Bearer ${accessToken}`);
const created = await api()
.post('/tags')
.set('Authorization', `Bearer ${accessToken}`)
.send({ name: 'oauth' });
expect(listed.statusCode).toBe(200);
expect(created.statusCode).toBe(403);
expect(await Container.get(TagRepository).count()).toBe(0);
});
test('a token granted tag:list and tag:create can do both', async () => {
const { token } = await consentAndExchange(owner, ['tag:list', 'tag:create']);
const accessToken = token!.body.access_token as string;
const created = await api()
.post('/tags')
.set('Authorization', `Bearer ${accessToken}`)
.send({ name: 'oauth' });
const listed = await api().get('/tags').set('Authorization', `Bearer ${accessToken}`);
expect(created.statusCode).toBe(201);
expect(listed.statusCode).toBe(200);
expect(listed.body.data).toHaveLength(1);
});
test('the consent screen refuses a scope the client did not request', async () => {
const { approval } = await consentAndExchange(owner, ['tag:create'], ['tag:list']);
expect(approval.statusCode).toBe(400);
});
test('the consent screen refuses a scope the user cannot grant', async () => {
const { approval } = await consentAndExchange(member, ['tag:list', 'user:create']);
expect(approval.statusCode).toBe(400);
});
test('a refreshed token keeps the granted scopes', async () => {
const { token } = await consentAndExchange(owner, ['tag:list']);
const clientId = jwtService.decodeUnverified(token!.body.access_token as string).client_id;
const refreshed = await testServer.restlessAgent.post('/oauth/token').type('form').send({
grant_type: 'refresh_token',
refresh_token: token!.body.refresh_token,
client_id: clientId,
scope: 'tag:list tag:create',
});
expect(refreshed.statusCode).toBe(200);
const accessToken = refreshed.body.access_token as string;
expect(jwtService.decodeUnverified(accessToken).scope).toBe('tag:list');
const created = await api()
.post('/tags')
.set('Authorization', `Bearer ${accessToken}`)
.send({ name: 'oauth' });
expect(created.statusCode).toBe(403);
});
});

View File

@ -2741,6 +2741,7 @@
"oauth.consentView.title": "OAuth access consent",
"oauth.consentView.heading": "{clientName} wants access to your n8n instance",
"oauth.consentView.headingWithWorkflow": "{clientName} requests access to workflow {resourceName}",
"oauth.consentView.headingWithResource": "{clientName} requests access to {resourceName}",
"oauth.consentView.description": "This will allow {clientName} to perform the following actions:",
"oauth.consentView.descriptionWithWorkflow": "This will allow {clientName} to access this workflow on your behalf.",
"oauth.consentView.action.listWorkflows": "Get a list of your workflows",
@ -2780,6 +2781,7 @@
"oauth.consentView.redirectWarning.confirm": "I recognize and trust this URL",
"oauth.consentView.firstParty.heading": "\"{resourceName}\" wants to run using your n8n login",
"oauth.consentView.firstParty.description": "Running this {resourceType} executes its workflow using your account and any connected credentials. Only continue if you trust the creator of this {resourceType}.",
"oauth.consentView.firstParty.type.api": "API client",
"oauth.consentView.firstParty.type.form": "form",
"oauth.consentView.firstParty.type.chat": "chatbot",
"oauth.consentView.firstParty.type.default": "resource",

View File

@ -65,6 +65,24 @@ describe('OAuthConsentView', () => {
expect(queryByText('Get a list of your workflows')).toBeNull();
});
it('should show a neutral heading and the scope picker when a named resource advertises scopes', async () => {
consentStore.consentDetails = {
clientName: 'Test MCP Client',
clientId: 'test-client-id',
resourceName: 'n8n public API',
scopes: ['workflow:list', 'workflow:create'],
};
consentStore.fetchConsentDetails.mockResolvedValue(consentStore.consentDetails);
const { getByText, getByTestId, queryByText } = renderComponent();
await waitAllPromises();
expect(getByText('Test MCP Client requests access to n8n public API')).toBeVisible();
expect(getByText(/Choose what Test MCP Client is allowed to do/)).toBeVisible();
expect(getByTestId('consent-scopes')).toBeVisible();
expect(queryByText(/access this workflow on your behalf/)).toBeNull();
});
it('should show the generic heading and permission list when no resource is named', async () => {
consentStore.fetchConsentDetails.mockResolvedValue(consentStore.consentDetails!);

View File

@ -254,6 +254,14 @@ onMounted(async () => {
})
}}
</N8nHeading>
<!-- A resource that advertises scopes is not a workflow; the scope list says what it grants. -->
<N8nHeading v-else-if="resourceName && hasScopes" tag="h2" size="large" :bold="true">
{{
i18n.baseText('oauth.consentView.headingWithResource', {
interpolate: { clientName: clientDetails?.clientName ?? '', resourceName },
})
}}
</N8nHeading>
<N8nHeading v-else-if="resourceName" tag="h2" size="large" :bold="true">
{{
i18n.baseText('oauth.consentView.headingWithWorkflow', {
@ -276,16 +284,16 @@ onMounted(async () => {
})
}}
</N8nText>
<N8nText v-else-if="resourceName" color="text-base" size="medium">
<N8nText v-else-if="hasScopes" color="text-base" size="medium">
{{
i18n.baseText('oauth.consentView.descriptionWithWorkflow', {
i18n.baseText('oauth.consentView.scopes.description', {
interpolate: { clientName: clientDetails?.clientName ?? '' },
})
}}
</N8nText>
<N8nText v-else-if="hasScopes" color="text-base" size="medium">
<N8nText v-else-if="resourceName" color="text-base" size="medium">
{{
i18n.baseText('oauth.consentView.scopes.description', {
i18n.baseText('oauth.consentView.descriptionWithWorkflow', {
interpolate: { clientName: clientDetails?.clientName ?? '' },
})
}}