From f5924004ca3a98249dbcd0bf5cfc7b939248f9c6 Mon Sep 17 00:00:00 2001 From: Andreas Fitzek Date: Wed, 14 Oct 2026 15:34:39 +0000 Subject: [PATCH] feat(core): Accept OAuth2 bearers on the public API through the trusted-source pipeline (no-changelog) (#41127) Co-authored-by: Claude Fable 5.1 --- packages/@n8n/db/src/entities/types-db.ts | 3 + .../__tests__/authentication-pipeline.test.ts | 40 ++- .../inbound-auth-core.module.test.ts | 45 +++ .../public-api-oauth.strategy.test.ts | 261 ++++++++++++++++++ .../public-api-protected-resource.test.ts | 95 +++++++ .../authentication-pipeline.ts | 7 + .../inbound-auth-core.module.ts | 11 + .../public-api-oauth.strategy.ts | 98 +++++++ .../public-api-protected-resource.ts | 48 ++++ .../oauth-consent.controller.api.test.ts | 10 + .../__tests__/oauth.controller.test.ts | 40 ++- .../dto/approve-consent-request.dto.ts | 9 +- .../modules/oauth-server/oauth.controller.ts | 26 +- .../public-api/oauth-bearer.test.ts | 176 ++++++++++++ .../public-api/oauth-consent-scopes.test.ts | 208 ++++++++++++++ .../frontend/@n8n/i18n/src/locales/en.json | 2 + .../src/app/views/OAuthConsentView.test.ts | 18 ++ .../src/app/views/OAuthConsentView.vue | 16 +- 18 files changed, 1100 insertions(+), 13 deletions(-) create mode 100644 packages/cli/src/modules/inbound-auth-core/__tests__/public-api-oauth.strategy.test.ts create mode 100644 packages/cli/src/modules/inbound-auth-core/__tests__/public-api-protected-resource.test.ts create mode 100644 packages/cli/src/modules/inbound-auth-core/public-api-oauth.strategy.ts create mode 100644 packages/cli/src/modules/inbound-auth-core/public-api-protected-resource.ts create mode 100644 packages/cli/test/integration/public-api/oauth-bearer.test.ts create mode 100644 packages/cli/test/integration/public-api/oauth-consent-scopes.test.ts diff --git a/packages/@n8n/db/src/entities/types-db.ts b/packages/@n8n/db/src/entities/types-db.ts index 89dd2939db4..19dc00e59ef 100644 --- a/packages/@n8n/db/src/entities/types-db.ts +++ b/packages/@n8n/db/src/entities/types-db.ts @@ -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 { diff --git a/packages/cli/src/modules/inbound-auth-core/__tests__/authentication-pipeline.test.ts b/packages/cli/src/modules/inbound-auth-core/__tests__/authentication-pipeline.test.ts index 145b5e238b6..d87a05f9427 100644 --- a/packages/cli/src/modules/inbound-auth-core/__tests__/authentication-pipeline.test.ts +++ b/packages/cli/src/modules/inbound-auth-core/__tests__/authentication-pipeline.test.ts @@ -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(); + }); }); }); diff --git a/packages/cli/src/modules/inbound-auth-core/__tests__/inbound-auth-core.module.test.ts b/packages/cli/src/modules/inbound-auth-core/__tests__/inbound-auth-core.module.test.ts index 9dccf2ecc93..d6382aada12 100644 --- a/packages/cli/src/modules/inbound-auth-core/__tests__/inbound-auth-core.module.test.ts +++ b/packages/cli/src/modules/inbound-auth-core/__tests__/inbound-auth-core.module.test.ts @@ -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(); const dbGate = mock(); const identityService = mock(); + const strategyRegistry = mock(); + const resourceRegistry = mock(); + const publicApiResource = mock(); + const publicApiStrategy = mock(); 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({ 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({ 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', () => { diff --git a/packages/cli/src/modules/inbound-auth-core/__tests__/public-api-oauth.strategy.test.ts b/packages/cli/src/modules/inbound-auth-core/__tests__/public-api-oauth.strategy.test.ts new file mode 100644 index 00000000000..894b5d1ba26 --- /dev/null +++ b/packages/cli/src/modules/inbound-auth-core/__tests__/public-api-oauth.strategy.test.ts @@ -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({ encryptionKey: 'test-key' }), + mock(), + mock(), +); +const resource = mock({ surface: 'public-api' }); +const pipeline = mock(); +const authenticationService = mock(); +const userRepository = mock(); + +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) => { + const res = mock(); + const req = mock({ + 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(), + id, + role: { + ...mock(), + 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); + }); + }); + }); +}); diff --git a/packages/cli/src/modules/inbound-auth-core/__tests__/public-api-protected-resource.test.ts b/packages/cli/src/modules/inbound-auth-core/__tests__/public-api-protected-resource.test.ts new file mode 100644 index 00000000000..58ad1b3e7e1 --- /dev/null +++ b/packages/cli/src/modules/inbound-auth-core/__tests__/public-api-protected-resource.test.ts @@ -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(); +const licenseState = mock(); +const globalConfig = { publicApi: { disabled: false, path: 'api' } } as unknown as GlobalConfig; + +let resource: PublicApiProtectedResource; + +const principal = (roleSlug: string, scopeSlugs: string[]): ResourceUser => ({ + ...mock(), + role: { + ...mock(), + 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); + }); +}); diff --git a/packages/cli/src/modules/inbound-auth-core/authentication-pipeline.ts b/packages/cli/src/modules/inbound-auth-core/authentication-pipeline.ts index 241b8100989..64e1dca3c48 100644 --- a/packages/cli/src/modules/inbound-auth-core/authentication-pipeline.ts +++ b/packages/cli/src/modules/inbound-auth-core/authentication-pipeline.ts @@ -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; } diff --git a/packages/cli/src/modules/inbound-auth-core/inbound-auth-core.module.ts b/packages/cli/src/modules/inbound-auth-core/inbound-auth-core.module.ts index 22c02022804..b6ec193a7b5 100644 --- a/packages/cli/src/modules/inbound-auth-core/inbound-auth-core.module.ts +++ b/packages/cli/src/modules/inbound-auth-core/inbound-auth-core.module.ts @@ -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 { @@ -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. diff --git a/packages/cli/src/modules/inbound-auth-core/public-api-oauth.strategy.ts b/packages/cli/src/modules/inbound-auth-core/public-api-oauth.strategy.ts new file mode 100644 index 00000000000..1192c820fe1 --- /dev/null +++ b/packages/cli/src/modules/inbound-auth-core/public-api-oauth.strategy.ts @@ -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 { + 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 { + 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; + } +} diff --git a/packages/cli/src/modules/inbound-auth-core/public-api-protected-resource.ts b/packages/cli/src/modules/inbound-auth-core/public-api-protected-resource.ts new file mode 100644 index 00000000000..5bd7b05ab5c --- /dev/null +++ b/packages/cli/src/modules/inbound-auth-core/public-api-protected-resource.ts @@ -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 { + return !this.globalConfig.publicApi.disabled && !this.licenseState.isAPIDisabled(); + } + + async authorize(): Promise { + return await this.isAvailable(); + } + + async getGrantableScopes(user: ResourceUser): Promise { + return getApiKeyScopesForRole(user); + } +} diff --git a/packages/cli/src/modules/oauth-server/__tests__/oauth-consent.controller.api.test.ts b/packages/cli/src/modules/oauth-server/__tests__/oauth-consent.controller.api.test.ts index c28cd59218a..1eefced3671 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/oauth-consent.controller.api.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/oauth-consent.controller.api.test.ts @@ -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) diff --git a/packages/cli/src/modules/oauth-server/__tests__/oauth.controller.test.ts b/packages/cli/src/modules/oauth-server/__tests__/oauth.controller.test.ts index dd1fa2912a4..a0eb9f0d0c5 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/oauth.controller.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/oauth.controller.test.ts @@ -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(); const registry = mock(); const localServer = mock(); +const authenticationService = mock(); let controller: OAuthControllerClass; const makeRes = () => { @@ -53,10 +55,19 @@ const resource = (scopes: string[], isAvailable?: () => Promise): 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 () => { diff --git a/packages/cli/src/modules/oauth-server/dto/approve-consent-request.dto.ts b/packages/cli/src/modules/oauth-server/dto/approve-consent-request.dto.ts index b0232a40087..66f1adc815a 100644 --- a/packages/cli/src/modules/oauth-server/dto/approve-consent-request.dto.ts +++ b/packages/cli/src/modules/oauth-server/dto/approve-consent-request.dto.ts @@ -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(), }) {} diff --git a/packages/cli/src/modules/oauth-server/oauth.controller.ts b/packages/cli/src/modules/oauth-server/oauth.controller.ts index a5dc2562afb..3291bca4dc8 100644 --- a/packages/cli/src/modules/oauth-server/oauth.controller.ts +++ b/packages/cli/src/modules/oauth-server/oauth.controller.ts @@ -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 { - const baseUrl = this.urlService.getInstanceBaseUrl(); + private async buildProtectedResourceMetadata( + resource: ProtectedResource, + ): Promise> { + 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 = { resource: resource.getResourceUrl(), bearer_methods_supported: ['header'], - authorization_servers: [baseUrl], + authorization_servers: authorizationServers, }; if (resource.scopes.length > 0) { diff --git a/packages/cli/test/integration/public-api/oauth-bearer.test.ts b/packages/cli/test/integration/public-api/oauth-bearer.test.ts new file mode 100644 index 00000000000..f4531b8afcb --- /dev/null +++ b/packages/cli/test/integration/public-api/oauth-bearer.test.ts @@ -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(); +const authenticationService = mock(); + +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); + }); + }); +}); diff --git a/packages/cli/test/integration/public-api/oauth-consent-scopes.test.ts b/packages/cli/test/integration/public-api/oauth-consent-scopes.test.ts new file mode 100644 index 00000000000..c84e035cf01 --- /dev/null +++ b/packages/cli/test/integration/public-api/oauth-consent-scopes.test.ts @@ -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(); + 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 { + 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); + }); +}); diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 09b8e62269e..7822f6d1ddb 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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", diff --git a/packages/frontend/editor-ui/src/app/views/OAuthConsentView.test.ts b/packages/frontend/editor-ui/src/app/views/OAuthConsentView.test.ts index 3b5c9ae9034..a7a66ff1e2d 100644 --- a/packages/frontend/editor-ui/src/app/views/OAuthConsentView.test.ts +++ b/packages/frontend/editor-ui/src/app/views/OAuthConsentView.test.ts @@ -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!); diff --git a/packages/frontend/editor-ui/src/app/views/OAuthConsentView.vue b/packages/frontend/editor-ui/src/app/views/OAuthConsentView.vue index 5d7910ed8dc..9932d952dcd 100644 --- a/packages/frontend/editor-ui/src/app/views/OAuthConsentView.vue +++ b/packages/frontend/editor-ui/src/app/views/OAuthConsentView.vue @@ -254,6 +254,14 @@ onMounted(async () => { }) }} + + + {{ + i18n.baseText('oauth.consentView.headingWithResource', { + interpolate: { clientName: clientDetails?.clientName ?? '', resourceName }, + }) + }} + {{ i18n.baseText('oauth.consentView.headingWithWorkflow', { @@ -276,16 +284,16 @@ onMounted(async () => { }) }} - + {{ - i18n.baseText('oauth.consentView.descriptionWithWorkflow', { + i18n.baseText('oauth.consentView.scopes.description', { interpolate: { clientName: clientDetails?.clientName ?? '' }, }) }} - + {{ - i18n.baseText('oauth.consentView.scopes.description', { + i18n.baseText('oauth.consentView.descriptionWithWorkflow', { interpolate: { clientName: clientDetails?.clientName ?? '' }, }) }}