mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
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:
parent
b270d48686
commit
f5924004ca
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
176
packages/cli/test/integration/public-api/oauth-bearer.test.ts
Normal file
176
packages/cli/test/integration/public-api/oauth-bearer.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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!);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ?? '' },
|
||||
})
|
||||
}}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user