From 4e802ff11debbf4fb2be78ebadbf1822aa7cda13 Mon Sep 17 00:00:00 2001 From: Andreas Fitzek Date: Wed, 7 Oct 2026 10:30:24 +0000 Subject: [PATCH] feat(core): Verify bearer tokens against trusted sources (no-changelog) (#40279) Co-authored-by: Claude Fable 5.1 --- .../@n8n/config/src/configs/logging.config.ts | 1 + .../fixtures/trusted-source-config.v1.ts | 1 + .../__tests__/trusted-source-config.test.ts | 15 +- packages/@n8n/inbound-auth/src/contracts.ts | 21 +- .../inbound-auth/src/trusted-source-config.ts | 2 +- packages/cli/eslint.config.mjs | 30 +- packages/cli/oxlint.config.mts | 30 +- .../__tests__/authentication.service.test.ts | 269 +++++++++++++ .../inbound-auth-core.module.test.ts | 7 +- .../__tests__/oauth2-bearer.driver.test.ts | 359 ++++++++++++++++++ .../authentication.service.ts | 103 +++++ .../inbound-auth-core.module.ts | 19 +- .../modules/inbound-auth-core/key.utils.ts | 17 + .../inbound-auth-core/oauth2-bearer.driver.ts | 200 ++++++++++ .../services/__tests__/jwks-resolver.test.ts | 31 +- .../token-exchange/services/jwks-resolver.ts | 17 +- .../services/token-exchange.service.ts | 3 +- .../services/trusted-key.service.ts | 4 +- ...authentication.service.integration.test.ts | 175 +++++++++ .../trusted-key.service.integration.test.ts | 10 - 20 files changed, 1241 insertions(+), 73 deletions(-) create mode 100644 packages/cli/src/modules/inbound-auth-core/__tests__/authentication.service.test.ts create mode 100644 packages/cli/src/modules/inbound-auth-core/__tests__/oauth2-bearer.driver.test.ts create mode 100644 packages/cli/src/modules/inbound-auth-core/authentication.service.ts create mode 100644 packages/cli/src/modules/inbound-auth-core/key.utils.ts create mode 100644 packages/cli/src/modules/inbound-auth-core/oauth2-bearer.driver.ts create mode 100644 packages/cli/test/integration/inbound-auth-core/authentication.service.integration.test.ts diff --git a/packages/@n8n/config/src/configs/logging.config.ts b/packages/@n8n/config/src/configs/logging.config.ts index d55b83bffcf..65af63ca912 100644 --- a/packages/@n8n/config/src/configs/logging.config.ts +++ b/packages/@n8n/config/src/configs/logging.config.ts @@ -58,6 +58,7 @@ export const LOG_SCOPES = [ 'policy', 'activity-log', 'instance-reporting', + 'inbound-auth', ] as const; export type LogScope = (typeof LOG_SCOPES)[number]; diff --git a/packages/@n8n/inbound-auth/src/__tests__/fixtures/trusted-source-config.v1.ts b/packages/@n8n/inbound-auth/src/__tests__/fixtures/trusted-source-config.v1.ts index 803a271eea8..ff6dad2c184 100644 --- a/packages/@n8n/inbound-auth/src/__tests__/fixtures/trusted-source-config.v1.ts +++ b/packages/@n8n/inbound-auth/src/__tests__/fixtures/trusted-source-config.v1.ts @@ -9,6 +9,7 @@ export const trustedSourceConfigV1Fixture = { keys: { kind: 'jwks-uri' }, verification: { mode: 'jwt' }, algorithms: ['RS256', 'ES256'], + allowedTokenTypes: ['at+jwt', 'application/at+jwt'], maxTokenLifetimeSeconds: 3600, clockSkewSeconds: 30, }, diff --git a/packages/@n8n/inbound-auth/src/__tests__/trusted-source-config.test.ts b/packages/@n8n/inbound-auth/src/__tests__/trusted-source-config.test.ts index 5f95c9fea0c..ef84eef69e3 100644 --- a/packages/@n8n/inbound-auth/src/__tests__/trusted-source-config.test.ts +++ b/packages/@n8n/inbound-auth/src/__tests__/trusted-source-config.test.ts @@ -2,6 +2,7 @@ import { UnexpectedError } from 'n8n-workflow'; import { configMigrations, + JwtAlgorithmSchema, migrateToLatest, TrustedSourceConfigSchema, trustedSourceConfigSchemaFor, @@ -22,18 +23,8 @@ const fullyDefaultedDocument = { discovery: { mode: 'auto' }, keys: { kind: 'jwks-uri' }, verification: { mode: 'jwt' }, - algorithms: [ - 'RS256', - 'RS384', - 'RS512', - 'ES256', - 'ES384', - 'ES512', - 'PS256', - 'PS384', - 'PS512', - 'EdDSA', - ], + algorithms: [...JwtAlgorithmSchema.options], + allowedTokenTypes: ['at+jwt', 'application/at+jwt'], maxTokenLifetimeSeconds: 86400, clockSkewSeconds: 60, }, diff --git a/packages/@n8n/inbound-auth/src/contracts.ts b/packages/@n8n/inbound-auth/src/contracts.ts index ea95b864dc7..c2d80c6daec 100644 --- a/packages/@n8n/inbound-auth/src/contracts.ts +++ b/packages/@n8n/inbound-auth/src/contracts.ts @@ -2,7 +2,7 @@ import type { ConsentUiHints } from '@n8n/api-types'; import type { AuthPrincipal, SecurityContext } from '@n8n/permissions'; import type { OAuthResourceGrant } from 'n8n-workflow'; -import type { Extracted, Inbound, Result, Verified } from './pipeline'; +import type { Credential, Extracted, Inbound, Result, Verified } from './pipeline'; import type { AuthorizationServerMetadata, Jwk, TrustedSource } from './trusted-source'; import type { SurfaceId } from './trusted-source-config'; @@ -101,6 +101,25 @@ export abstract class AuthenticationService { ): Promise<{ authorizationServers: string[]; challenges: string[] }>; } +/** + * One driver per credential kind and protocol family (`bearer` x `oauth2`). How the driver + * verifies (signed JWT, introspection) is the driver's business, not a boundary. + */ +export abstract class AuthenticationDriver { + abstract readonly credentialKind: Credential['kind']; + + abstract readonly sourceTypes: Array; + + abstract selectSource(extracted: Extracted): Promise; + + abstract verify(extracted: Extracted, source: TrustedSource): Promise>; + + abstract advertise( + resource: AdvertisedResource, + sources: TrustedSource[], + ): { authorizationServers?: string[]; challenge?: string }; +} + export abstract class IdentityService { abstract identify(verified: Verified): Promise>; } diff --git a/packages/@n8n/inbound-auth/src/trusted-source-config.ts b/packages/@n8n/inbound-auth/src/trusted-source-config.ts index 9741e33bf6d..e7223179f21 100644 --- a/packages/@n8n/inbound-auth/src/trusted-source-config.ts +++ b/packages/@n8n/inbound-auth/src/trusted-source-config.ts @@ -16,7 +16,6 @@ export const JwtAlgorithmSchema = z.enum([ 'PS256', 'PS384', 'PS512', - 'EdDSA', ]); export type JwtAlgorithm = z.infer; @@ -68,6 +67,7 @@ export const Oauth2AuthenticationSchema = z.object({ verification: z .discriminatedUnion('mode', [z.object({ mode: z.literal('jwt') })]) .default({ mode: 'jwt' }), + allowedTokenTypes: z.array(z.string().min(1)).min(1).default(['at+jwt', 'application/at+jwt']), algorithms: z .array(JwtAlgorithmSchema) .min(1) diff --git a/packages/cli/eslint.config.mjs b/packages/cli/eslint.config.mjs index ea28c1905a0..2c5c4d0e6a1 100644 --- a/packages/cli/eslint.config.mjs +++ b/packages/cli/eslint.config.mjs @@ -55,6 +55,14 @@ const jsonwebtokenSubpathRestriction = { 'Sign and verify through JwtService, so the token is bound to a purpose in token-purposes.ts.', }; +// Verifying a token signed by a foreign key is the one thing JwtService cannot do. +// This widens the allowlist to `decode` and `verify` only; `sign` stays restricted. +const jsonwebtokenVerifyOnlyRestriction = { + ...jsonwebtokenSigningRestriction, + allowImportNames: [...jsonwebtokenSigningRestriction.allowImportNames, 'decode', 'verify'], + message: 'Sign through JwtService, so the token is bound to a purpose in token-purposes.ts.', +}; + const engineV2ModuleOnlyImport = { name: '@n8n/engine', allowTypeImports: true, @@ -217,7 +225,9 @@ export default defineConfig( }, }, { - // The two places that hold the raw signing API. NEVER add to this list. + // The places that hold the raw signing API. The one admitted reason to be here besides + // JwtService itself: verifying tokens signed by a foreign key, which JwtService cannot + // verify. Do NOT add a file for any other reason. files: [ // Owns the signing key and derives every audience from a purpose. './src/services/jwt.service.ts', @@ -232,6 +242,24 @@ export default defineConfig( ], }, }, + { + // Verifies bearer tokens with the JWKS discovered for a trusted source. Only + // `decode` and `verify` are admitted; signing stays with JwtService. + files: ['./src/modules/inbound-auth-core/oauth2-bearer.driver.ts'], + rules: { + '@typescript-eslint/no-restricted-imports': [ + 'error', + { + paths: [ + POLICY_INTERNAL_RESTRICTION, + engineV2ModuleOnlyImport, + jsonwebtokenVerifyOnlyRestriction, + ], + patterns: [jsonwebtokenSubpathRestriction], + }, + ], + }, + }, { // Tests mint tokens as fixtures, including malformed ones a purpose cannot express. files: ['./src/**/__tests__/**/*.ts'], diff --git a/packages/cli/oxlint.config.mts b/packages/cli/oxlint.config.mts index 107597dedfb..45bad86a8f6 100644 --- a/packages/cli/oxlint.config.mts +++ b/packages/cli/oxlint.config.mts @@ -55,6 +55,14 @@ const jsonwebtokenSubpathRestriction = { 'Sign and verify through JwtService, so the token is bound to a purpose in token-purposes.ts.', }; +// Verifying a token signed by a foreign key is the one thing JwtService cannot do. +// This widens the allowlist to `decode` and `verify` only; `sign` stays restricted. +const jsonwebtokenVerifyOnlyRestriction = { + ...jsonwebtokenSigningRestriction, + allowImportNames: [...jsonwebtokenSigningRestriction.allowImportNames, 'decode', 'verify'], + message: 'Sign through JwtService, so the token is bound to a purpose in token-purposes.ts.', +}; + const engineV2ModuleOnlyImport = { name: '@n8n/engine', allowTypeImports: true, @@ -220,7 +228,9 @@ export default defineConfig({ }, }, { - // The two places that hold the raw signing API. NEVER add to this list. + // The places that hold the raw signing API. The one admitted reason to be here besides + // JwtService itself: verifying tokens signed by a foreign key, which JwtService cannot + // verify. Do NOT add a file for any other reason. files: [ // Owns the signing key and derives every audience from a purpose. './src/services/jwt.service.ts', @@ -235,6 +245,24 @@ export default defineConfig({ ], }, }, + { + // Verifies bearer tokens with the JWKS discovered for a trusted source. Only + // `decode` and `verify` are admitted; signing stays with JwtService. + files: ['./src/modules/inbound-auth-core/oauth2-bearer.driver.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + POLICY_INTERNAL_RESTRICTION, + engineV2ModuleOnlyImport, + jsonwebtokenVerifyOnlyRestriction, + ], + patterns: [jsonwebtokenSubpathRestriction], + }, + ], + }, + }, { // Tests mint tokens as fixtures, including malformed ones a purpose cannot express. files: ['./src/**/__tests__/**/*.ts'], diff --git a/packages/cli/src/modules/inbound-auth-core/__tests__/authentication.service.test.ts b/packages/cli/src/modules/inbound-auth-core/__tests__/authentication.service.test.ts new file mode 100644 index 00000000000..bf2e015d3ff --- /dev/null +++ b/packages/cli/src/modules/inbound-auth-core/__tests__/authentication.service.test.ts @@ -0,0 +1,269 @@ +import { + migrateToLatest, + trustedSourceConfigSchemaFor, + type AuthenticationDriver, + type Credential, + type Extracted, + type Result, + type SurfaceId, + type TrustedSource, + type TrustedSourceStore, + type Verified, +} from '@n8n/inbound-auth'; +import { mock } from 'vitest-mock-extended'; + +import { OAuth2AuthenticationService } from '../authentication.service'; +import type { Oauth2BearerDriver } from '../oauth2-bearer.driver'; + +const ISSUER = 'https://idp.example'; +const RESOURCE = 'https://n8n.example/mcp'; +const SURFACE: SurfaceId = 'instance-mcp'; + +/** A driver as a plain object: the dispatcher only reads the contract. */ +function fakeDriver(credentialKind: Credential['kind'], sourceTypes: Array) { + return { + credentialKind, + sourceTypes, + selectSource: vi.fn(), + verify: vi.fn(), + advertise: vi.fn(), + }; +} + +type SourceOverrides = Partial> & { + surfaces?: Partial>; +}; + +let counter = 0; + +function source(overrides: SourceOverrides = {}): TrustedSource { + const { surfaces = { [SURFACE]: {} }, ...rest } = overrides; + counter += 1; + return { + id: `source-${counter}`, + name: `Source ${counter}`, + type: 'oauth2', + issuer: ISSUER, + managedBy: 'admin', + status: 'healthy', + lastError: null, + lastCheckedAt: '2026-10-01T12:00:00.000Z', + createdAt: '2026-09-30T10:00:00.000Z', + updatedAt: '2026-10-01T12:00:00.000Z', + config: migrateToLatest( + trustedSourceConfigSchemaFor('admin').parse({ + version: 1, + authentication: { type: 'oauth2' }, + surfaces, + }), + ), + metadata: { + version: 1, + documents: [ + { + kind: 'jwks', + fetchedAt: '2026-10-01T12:00:00.000Z', + url: `${ISSUER}/keys`, + keys: [{ kid: 'k1', kty: 'EC', alg: 'ES256', use: 'sig' }], + }, + ], + }, + ...rest, + }; +} + +const extracted = (overrides: Partial = {}): Extracted => ({ + surface: SURFACE, + resource: { url: RESOURCE, acceptedAudiences: [RESOURCE] }, + request: { + method: 'POST', + url: '/mcp', + headers: { authorization: 'Bearer x' }, + ip: '203.0.113.7', + }, + receivedAt: new Date(), + credential: { kind: 'bearer', token: 'a.b.c' }, + ...overrides, +}); + +function verified(input: Extracted, src: TrustedSource): Result { + const { + credential, + request: { headers: _headers, ...request }, + ...rest + } = input; + return { + ok: true, + value: { + ...rest, + request, + credentialKind: credential.kind, + source: src, + claims: { sub: 'alice' }, + expiresAt: new Date(Date.now() + 3600_000), + }, + }; +} + +const store = mock(); +let bearerFake: ReturnType; +let otherFake: ReturnType; +let service: OAuth2AuthenticationService; + +const serviceWith = (drivers: AuthenticationDriver[]) => + new OAuth2AuthenticationService(store, bearerFake as unknown as Oauth2BearerDriver, drivers); + +beforeEach(() => { + vi.clearAllMocks(); + bearerFake = fakeDriver('bearer', ['oauth2']); + otherFake = fakeDriver('fake' as Credential['kind'], ['oauth2']); + service = serviceWith([bearerFake, otherFake]); +}); + +describe('OAuth2AuthenticationService', () => { + describe('authenticate', () => { + it('rejects a credential kind no driver handles as unusable without calling a driver', async () => { + const input = extracted(); + + const result = await serviceWith([otherFake]).authenticate(input); + + expect(result).toMatchObject({ ok: false, reason: 'source-unusable' }); + expect(otherFake.selectSource).not.toHaveBeenCalled(); + expect(otherFake.verify).not.toHaveBeenCalled(); + }); + + it('rejects as unknown-issuer when no driver of the kind selects a source', async () => { + bearerFake.selectSource.mockResolvedValue(undefined); + const input = extracted(); + + const result = await service.authenticate(input); + + expect(result).toMatchObject({ ok: false, reason: 'unknown-issuer' }); + expect(bearerFake.selectSource).toHaveBeenCalledWith(input); + expect(bearerFake.verify).not.toHaveBeenCalled(); + expect(otherFake.selectSource).not.toHaveBeenCalled(); + }); + + it('tries every driver of the kind and verifies with the one that selects a source', async () => { + const secondBearer = fakeDriver('bearer', ['oauth2']); + const src = source(); + const input = extracted(); + const expected = verified(input, src); + bearerFake.selectSource.mockResolvedValue(undefined); + secondBearer.selectSource.mockResolvedValue(src); + secondBearer.verify.mockResolvedValue(expected); + + const result = await serviceWith([bearerFake, secondBearer]).authenticate(input); + + expect(result).toBe(expected); + expect(bearerFake.selectSource).toHaveBeenCalledWith(input); + expect(secondBearer.verify).toHaveBeenCalledWith(input, src); + expect(bearerFake.verify).not.toHaveBeenCalled(); + }); + + it.each<{ name: string; src: () => TrustedSource; input: () => Extracted }>([ + { + name: 'the source is not accepted on the surface', + src: () => source({ surfaces: { 'public-api': {} } }), + input: () => extracted(), + }, + { + name: 'the resource narrows the accepted sources to others', + src: () => source(), + input: () => extracted({ acceptedSourceIds: ['other'] }), + }, + ])('rejects as source-not-accepted when $name', async ({ src, input }) => { + const selected = src(); + bearerFake.selectSource.mockResolvedValue(selected); + + const result = await service.authenticate(input()); + + expect(result).toMatchObject({ ok: false, reason: 'source-not-accepted' }); + expect(bearerFake.verify).not.toHaveBeenCalled(); + }); + + it.each<{ name: string; src: () => TrustedSource; sourceTypes?: Array }>( + [ + { name: 'the source is in error', src: () => source({ status: 'error', lastError: 'x' }) }, + { name: 'the source has no JWKS document', src: () => source({ metadata: null }) }, + { + name: 'the source type is outside the driver types', + src: () => source(), + sourceTypes: [], + }, + ], + )('rejects as source-unusable when $name', async ({ src, sourceTypes }) => { + const selected = src(); + const driver = fakeDriver('bearer', sourceTypes ?? ['oauth2']); + driver.selectSource.mockResolvedValue(selected); + + const result = await serviceWith([driver]).authenticate(extracted()); + + expect(result).toMatchObject({ ok: false, reason: 'source-unusable' }); + expect(driver.verify).not.toHaveBeenCalled(); + }); + + it('returns what the driver verified', async () => { + const src = source(); + const input = extracted(); + const expected = verified(input, src); + bearerFake.selectSource.mockResolvedValue(src); + bearerFake.verify.mockResolvedValue(expected); + + const result = await service.authenticate(input); + + expect(result).toBe(expected); + expect(bearerFake.verify).toHaveBeenCalledWith(input, src); + }); + + it('dispatches by credential kind', async () => { + const src = source(); + const input = extracted({ + credential: { kind: 'fake', token: 'x' } as unknown as Credential, + }); + const expected = verified(input, src); + otherFake.selectSource.mockResolvedValue(src); + otherFake.verify.mockResolvedValue(expected); + + const result = await service.authenticate(input); + + expect(result).toBe(expected); + expect(bearerFake.selectSource).not.toHaveBeenCalled(); + expect(bearerFake.verify).not.toHaveBeenCalled(); + }); + }); + + describe('advertise', () => { + it('collects deduplicated issuers and every challenge over the accepted surface sources', async () => { + const [first, second, third] = [source(), source(), source()]; + store.listBySurface.mockResolvedValue([first, second, third]); + bearerFake.advertise.mockReturnValue({ + authorizationServers: ['https://a.example', 'https://b.example'], + challenge: + 'Bearer resource_metadata="https://n8n.example/.well-known/oauth-protected-resource/mcp"', + }); + otherFake.advertise.mockReturnValue({ + authorizationServers: ['https://b.example', 'https://c.example'], + challenge: 'Fake realm="n8n"', + }); + const resource = { + surface: SURFACE, + resource: { url: RESOURCE, acceptedAudiences: [RESOURCE] }, + acceptedSourceIds: [first.id, second.id], + }; + + const advertised = await service.advertise(resource); + + expect(store.listBySurface).toHaveBeenCalledWith(SURFACE); + expect(bearerFake.advertise).toHaveBeenCalledWith(resource, [first, second]); + expect(otherFake.advertise).toHaveBeenCalledWith(resource, [first, second]); + expect(advertised).toEqual({ + authorizationServers: ['https://a.example', 'https://b.example', 'https://c.example'], + challenges: [ + 'Bearer resource_metadata="https://n8n.example/.well-known/oauth-protected-resource/mcp"', + 'Fake realm="n8n"', + ], + }); + }); + }); +}); 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 feb58d0638a..72e99f35d64 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 @@ -16,6 +16,7 @@ import { OperationalError } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; // Importing the module runs the @BackendModule decorator, registering its metadata. +import { OAuth2AuthenticationService } from '../authentication.service'; import { InboundAuthCoreModule } from '../inbound-auth-core.module'; import { TrustedSourceDiscoveryTask } from '../trusted-source-discovery.task'; import { TrustedSourceDbStore } from '../trusted-source.store'; @@ -48,10 +49,8 @@ describe('InboundAuthCoreModule', () => { await expect(server.getJwks()).rejects.toThrow(OperationalError); }); - it('binds an AuthenticationService that fails closed until a driver is registered', async () => { - const result = await Container.get(AuthenticationService).authenticate(extracted); - - expect(result).toMatchObject({ ok: false, reason: 'source-unusable' }); + it('binds the OAuth2 AuthenticationService', () => { + expect(Container.get(AuthenticationService)).toBeInstanceOf(OAuth2AuthenticationService); }); it('binds an IdentityService that fails closed until an implementation is registered', async () => { diff --git a/packages/cli/src/modules/inbound-auth-core/__tests__/oauth2-bearer.driver.test.ts b/packages/cli/src/modules/inbound-auth-core/__tests__/oauth2-bearer.driver.test.ts new file mode 100644 index 00000000000..723d3fc48d1 --- /dev/null +++ b/packages/cli/src/modules/inbound-auth-core/__tests__/oauth2-bearer.driver.test.ts @@ -0,0 +1,359 @@ +import type { Logger } from '@n8n/backend-common'; +import { + JwkSchema, + migrateToLatest, + trustedSourceConfigSchemaFor, + type Extracted, + type Jwk, + type Result, + type SurfaceId, + type TrustedSource, + type TrustedSourceStore, +} from '@n8n/inbound-auth'; +import { exportJWK, generateKeyPair, SignJWT } from 'jose'; +import type { CryptoKey, JWTHeaderParameters, JWTPayload } from 'jose'; +import { mock, type MockProxy } from 'vitest-mock-extended'; + +import { Oauth2BearerDriver } from '../oauth2-bearer.driver'; + +const ISSUER = 'https://idp.example'; +const RESOURCE = 'https://n8n.example/mcp'; +const SURFACE: SurfaceId = 'instance-mcp'; +const HEADER: JWTHeaderParameters = { alg: 'ES256', kid: 'k1', typ: 'at+jwt' }; + +let privateKey: CryptoKey; +let otherPrivateKey: CryptoKey; +let jwk: Jwk; + +const now = () => Math.floor(Date.now() / 1000); + +/** Signs `iss`, `sub`, `aud`, `iat` and `exp` defaults merged with `claims`; `undefined` removes a claim. */ +async function sign(claims: JWTPayload = {}, header = HEADER, key = privateKey) { + const iat = now(); + const merged: JWTPayload = { + iss: ISSUER, + sub: 'alice', + aud: RESOURCE, + iat, + exp: iat + 3600, + ...claims, + }; + const payload = Object.fromEntries( + Object.entries(merged).filter(([, value]) => value !== undefined), + ); + return await new SignJWT(payload).setProtectedHeader(header).sign(key); +} + +type SourceOverrides = Partial> & { + authentication?: Record; + surfaces?: Partial>; +}; + +let counter = 0; + +function source(overrides: SourceOverrides = {}): TrustedSource { + const { authentication = {}, surfaces = { [SURFACE]: {} }, ...rest } = overrides; + counter += 1; + return { + id: `source-${counter}`, + name: `Source ${counter}`, + type: 'oauth2', + issuer: ISSUER, + managedBy: 'admin', + status: 'healthy', + lastError: null, + lastCheckedAt: '2026-10-01T12:00:00.000Z', + createdAt: '2026-09-30T10:00:00.000Z', + updatedAt: '2026-10-01T12:00:00.000Z', + config: migrateToLatest( + trustedSourceConfigSchemaFor('admin').parse({ + version: 1, + authentication: { type: 'oauth2', ...authentication }, + surfaces, + }), + ), + metadata: { + version: 1, + documents: [ + { + kind: 'jwks', + fetchedAt: '2026-10-01T12:00:00.000Z', + url: `${ISSUER}/keys`, + keys: [jwk], + }, + ], + }, + ...rest, + }; +} + +const extracted = (token: string): Extracted => ({ + surface: SURFACE, + resource: { url: RESOURCE, acceptedAudiences: [RESOURCE] }, + request: { + method: 'POST', + url: '/mcp', + headers: { authorization: 'Bearer x' }, + ip: '203.0.113.7', + }, + receivedAt: new Date(), + credential: { kind: 'bearer', token }, +}); + +function ok(result: Result): T { + if (!result.ok) throw new Error(`expected ok, got ${result.reason}: ${result.detail ?? ''}`); + return result.value; +} + +let store: MockProxy; +let driver: Oauth2BearerDriver; + +beforeAll(async () => { + const pair = await generateKeyPair('ES256'); + privateKey = pair.privateKey; + jwk = JwkSchema.parse({ + ...(await exportJWK(pair.publicKey)), + kid: 'k1', + alg: 'ES256', + use: 'sig', + }); + otherPrivateKey = (await generateKeyPair('ES256')).privateKey; +}); + +beforeEach(() => { + store = mock(); + const logger = mock(); + logger.scoped.mockReturnValue(logger); + driver = new Oauth2BearerDriver(store, logger); +}); + +describe('Oauth2BearerDriver', () => { + it('declares the bearer kind and the oauth2 source type', () => { + expect(driver.credentialKind).toBe('bearer'); + expect(driver.sourceTypes).toEqual(['oauth2']); + }); + + describe('selectSource', () => { + it('looks the source up by the token issuer', async () => { + const src = source(); + store.getByIssuer.mockResolvedValue(src); + + const selected = await driver.selectSource(extracted(await sign())); + + expect(selected).toBe(src); + expect(store.getByIssuer).toHaveBeenCalledWith(ISSUER); + }); + + it('returns undefined for a bearer that is not a JWT without reading the store', async () => { + expect(await driver.selectSource(extracted('not-a-jwt'))).toBeUndefined(); + expect(store.getByIssuer).not.toHaveBeenCalled(); + }); + + it('returns undefined for a JWT without iss without reading the store', async () => { + const token = await sign({ iss: undefined }); + + expect(await driver.selectSource(extracted(token))).toBeUndefined(); + expect(store.getByIssuer).not.toHaveBeenCalled(); + }); + + it.each([ + ['a number', 42], + ['an object', { url: ISSUER }], + ])('returns undefined for an iss that is %s without reading the store', async (_name, iss) => { + const token = await sign({ iss: iss as never }); + + expect(await driver.selectSource(extracted(token))).toBeUndefined(); + expect(store.getByIssuer).not.toHaveBeenCalled(); + }); + }); + + describe('verify', () => { + const verify = async (token: string, src = source()) => + await driver.verify(extracted(token), src); + + it('accepts a valid access token and strips the credential and the headers', async () => { + const src = source(); + const iat = now(); + const exp = iat + 3600; + + const value = ok(await verify(await sign({ iat, exp }), src)); + + expect(value.source).toBe(src); + expect(value.credentialKind).toBe('bearer'); + expect(value.claims.sub).toBe('alice'); + expect(value.expiresAt).toEqual(new Date(exp * 1000)); + expect(value).not.toHaveProperty('credential'); + expect(value.request).not.toHaveProperty('headers'); + expect(value.request).toMatchObject({ method: 'POST', url: '/mcp', ip: '203.0.113.7' }); + }); + + describe('typ header', () => { + it('accepts the application/ prefixed access token type', async () => { + const token = await sign({}, { ...HEADER, typ: 'application/at+jwt' }); + + expect(await verify(token)).toMatchObject({ ok: true }); + }); + + it.each([ + ['typ JWT', { ...HEADER, typ: 'JWT' }], + ['no typ', { alg: 'ES256', kid: 'k1' }], + ])('rejects a token with %s as not an access token', async (_name, header) => { + const token = await sign({}, header); + + expect(await verify(token)).toMatchObject({ ok: false, reason: 'not-an-access-token' }); + }); + }); + + describe('signature and key selection', () => { + it('rejects a token signed with another key', async () => { + const token = await sign({}, HEADER, otherPrivateKey); + + expect(await verify(token)).toMatchObject({ ok: false, reason: 'signature-invalid' }); + }); + + it('rejects an unknown kid', async () => { + const token = await sign({}, { ...HEADER, kid: 'k2' }); + + expect(await verify(token)).toMatchObject({ ok: false, reason: 'signature-invalid' }); + }); + + it('rejects an unsigned token (alg none)', async () => { + const encode = (part: unknown) => Buffer.from(JSON.stringify(part)).toString('base64url'); + const iat = now(); + const token = `${encode({ alg: 'none', typ: 'at+jwt' })}.${encode({ + iss: ISSUER, + sub: 'alice', + aud: RESOURCE, + iat, + exp: iat + 3600, + })}.`; + + expect(await verify(token)).toMatchObject({ ok: false, reason: 'signature-invalid' }); + }); + + it('rejects a token with a symmetric algorithm', async () => { + const iat = now(); + const token = await new SignJWT({ + iss: ISSUER, + sub: 'alice', + aud: RESOURCE, + iat, + exp: iat + 3600, + }) + .setProtectedHeader({ alg: 'HS256', kid: 'k1', typ: 'at+jwt' }) + .sign(new Uint8Array(32)); + + expect(await verify(token)).toMatchObject({ ok: false, reason: 'signature-invalid' }); + }); + + it('rejects an algorithm the source does not allow', async () => { + const src = source({ authentication: { algorithms: ['RS256'] } }); + + expect(await verify(await sign(), src)).toMatchObject({ + ok: false, + reason: 'signature-invalid', + }); + }); + }); + + describe('time claims', () => { + it.each([ + ['exp in the past', { iat: now() - 7200, exp: now() - 3600 }], + ['missing exp', { exp: undefined }], + ['missing iat', { iat: undefined }], + ['nbf 10 minutes ahead', { nbf: now() + 600 }], + ['iat 10 minutes ahead', { iat: now() + 600, exp: now() + 4200 }], + ])('rejects a token with %s as expired', async (_name, claims) => { + expect(await verify(await sign(claims))).toMatchObject({ ok: false, reason: 'expired' }); + }); + + it('tolerates an iat within the default clock skew', async () => { + const iat = now() + 30; + + expect(await verify(await sign({ iat, exp: iat + 3600 }))).toMatchObject({ ok: true }); + }); + + it('rejects a lifetime above the source maximum with a detail', async () => { + const src = source({ authentication: { maxTokenLifetimeSeconds: 3600 } }); + const iat = now(); + + expect(await verify(await sign({ iat, exp: iat + 7200 }), src)).toMatchObject({ + ok: false, + reason: 'expired', + detail: expect.any(String), + }); + }); + }); + + describe('audience', () => { + it.each<{ + name: string; + aud?: unknown; + surfaceAudiences?: string[]; + expected: 'ok' | 'audience-mismatch'; + }>([ + { name: 'a string equal to the resource URL', aud: RESOURCE, expected: 'ok' }, + { name: 'an array containing the resource URL', aud: ['other', RESOURCE], expected: 'ok' }, + { + name: 'the surface audience of the source', + aud: 'api://n8n', + surfaceAudiences: ['api://n8n'], + expected: 'ok', + }, + { + name: 'the resource URL when the source lists other audiences', + aud: RESOURCE, + surfaceAudiences: ['api://n8n'], + expected: 'audience-mismatch', + }, + { name: 'an unrelated audience', aud: 'other', expected: 'audience-mismatch' }, + { name: 'no aud', aud: undefined, expected: 'audience-mismatch' }, + { name: 'a number', aud: 42, expected: 'audience-mismatch' }, + { name: 'an object', aud: { url: RESOURCE }, expected: 'audience-mismatch' }, + { + name: 'an array with a non-string element and the resource URL', + aud: [42, RESOURCE], + expected: 'ok', + }, + ])('$name -> $expected', async ({ aud, surfaceAudiences, expected }) => { + const src = source({ surfaces: { [SURFACE]: { audiences: surfaceAudiences } } }); + + const result = await verify(await sign({ aud: aud as never }), src); + + if (expected === 'ok') expect(result).toMatchObject({ ok: true }); + else expect(result).toMatchObject({ ok: false, reason: expected }); + }); + }); + + it('rejects a source without a JWKS document as unusable', async () => { + const src = source({ metadata: null }); + + expect(await verify(await sign(), src)).toMatchObject({ + ok: false, + reason: 'source-unusable', + }); + }); + }); + + describe('advertise', () => { + it('lists only oauth2 issuers and challenges with the protected resource metadata URL', () => { + const oauth2Source = source(); + const samlSource: TrustedSource = { + ...oauth2Source, + id: 's2', + type: 'saml' as never, + issuer: 'https://saml.example', + }; + + const advertised = driver.advertise( + { surface: SURFACE, resource: { url: RESOURCE, acceptedAudiences: [RESOURCE] } }, + [oauth2Source, samlSource], + ); + + expect(advertised.authorizationServers).toEqual([ISSUER]); + expect(advertised.challenge).toBe( + 'Bearer resource_metadata="https://n8n.example/.well-known/oauth-protected-resource/mcp"', + ); + }); + }); +}); diff --git a/packages/cli/src/modules/inbound-auth-core/authentication.service.ts b/packages/cli/src/modules/inbound-auth-core/authentication.service.ts new file mode 100644 index 00000000000..c54709d1e73 --- /dev/null +++ b/packages/cli/src/modules/inbound-auth-core/authentication.service.ts @@ -0,0 +1,103 @@ +import { Service } from '@n8n/di'; +import { + AuthenticationDriver, + AuthenticationService, + TrustedSource, + TrustedSourceStore, + type AdvertisedResource, + type Extracted, + type Result, + type Verified, +} from '@n8n/inbound-auth'; + +import { Oauth2BearerDriver } from './oauth2-bearer.driver'; + +/** Dispatches by credential kind to the driver that recognises the token's source. */ +@Service() +export class OAuth2AuthenticationService extends AuthenticationService { + // ponytail: no driver registry. DI passes `undefined` for an `Array` paramtype, so the default + // applies in production; tests pass the array. + constructor( + private readonly store: TrustedSourceStore, + bearer: Oauth2BearerDriver, + private readonly drivers: AuthenticationDriver[] = [bearer], + ) { + super(); + } + + async authenticate(extracted: Extracted): Promise> { + const { kind } = extracted.credential; + + const candidates = this.drivers.filter((driver) => driver.credentialKind === kind); + + if (candidates.length === 0) { + return { + ok: false, + reason: 'source-unusable', + detail: `No suitable authentication driver found for the credential kind "${kind}"`, + }; + } + + let selected: + | { + driver: AuthenticationDriver; + source: TrustedSource; + } + | undefined; + + for (const driver of candidates) { + const source = await driver.selectSource(extracted); + if (source) { + selected = { driver, source }; + break; + } + } + + if (!selected) { + return { + ok: false, + reason: 'unknown-issuer', + }; + } + + const { driver, source } = selected; + + if (source.config.surfaces[extracted.surface] === undefined) + return { ok: false, reason: 'source-not-accepted' }; + + if (extracted.acceptedSourceIds && !extracted.acceptedSourceIds.includes(source.id)) + return { ok: false, reason: 'source-not-accepted' }; + + if (source.status !== 'healthy') + return { ok: false, reason: 'source-unusable', detail: `source status is ${source.status}` }; + + if (!driver.sourceTypes.includes(source.type)) + return { + ok: false, + reason: 'source-unusable', + detail: `source type ${source.type} not handled`, + }; + if (source.metadata === null) + return { ok: false, reason: 'source-unusable', detail: 'source not discovered yet' }; + + return await driver.verify(extracted, source); + } + + async advertise( + resource: AdvertisedResource, + ): Promise<{ authorizationServers: string[]; challenges: string[] }> { + const onSurface = await this.store.listBySurface(resource.surface); + const { acceptedSourceIds } = resource; + const accepted = acceptedSourceIds + ? onSurface.filter((s) => acceptedSourceIds.includes(s.id)) + : onSurface; + const authorizationServers = new Set(); + const challenges: string[] = []; + for (const driver of this.drivers) { + const advertised = driver.advertise(resource, accepted); + for (const issuer of advertised.authorizationServers ?? []) authorizationServers.add(issuer); + if (advertised.challenge) challenges.push(advertised.challenge); + } + return { authorizationServers: [...authorizationServers], challenges }; + } +} 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 079dbf3f8c8..01169adbb20 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 @@ -8,7 +8,6 @@ import { TrustedSourceGate, TrustedSourceStore, type AuthorizationServerMetadata, - type Extracted, type Jwk, type Result, type Verified, @@ -16,18 +15,6 @@ import { import type { SecurityContext } from '@n8n/permissions'; import { OperationalError } from 'n8n-workflow'; -// Fail-closed defaults: a consumer that runs before a real implementation is registered gets a -// rejection, not a throw. The OAuth2 driver replaces the AuthenticationService binding. -class UnregisteredAuthenticationService extends AuthenticationService { - async authenticate(_extracted: Extracted): Promise> { - return { ok: false, reason: 'source-unusable' }; - } - - async advertise() { - return { authorizationServers: [], challenges: [] }; - } -} - class UnregisteredIdentityService extends IdentityService { async identify(_verified: Verified): Promise> { return { ok: false, reason: 'source-unusable' }; @@ -60,9 +47,6 @@ class UnregisteredLocalAuthorizationServer extends LocalAuthorizationServer { export class InboundAuthCoreModule implements ModuleInterface { async init() { // A binding that already exists wins: the defaults only fill the gap. - if (!Container.has(AuthenticationService)) { - Container.set(AuthenticationService, new UnregisteredAuthenticationService()); - } if (!Container.has(IdentityService)) { Container.set(IdentityService, new UnregisteredIdentityService()); } @@ -77,6 +61,9 @@ export class InboundAuthCoreModule implements ModuleInterface { if (!Container.has(TrustedSourceStore)) { Container.set(TrustedSourceStore, Container.get(TrustedSourceDbStore)); } + + const { OAuth2AuthenticationService } = await import('./authentication.service.js'); + Container.set(AuthenticationService, Container.get(OAuth2AuthenticationService)); } // Ungated: the runner skips a cluster-scoped task on webhook and worker instances itself. diff --git a/packages/cli/src/modules/inbound-auth-core/key.utils.ts b/packages/cli/src/modules/inbound-auth-core/key.utils.ts new file mode 100644 index 00000000000..0f417996c74 --- /dev/null +++ b/packages/cli/src/modules/inbound-auth-core/key.utils.ts @@ -0,0 +1,17 @@ +import { type Jwk } from '@n8n/inbound-auth'; + +import { createPublicKey } from 'node:crypto'; + +export const getPublicKeyFromJwk = (jwks: Array, kid: string, usage: string = 'sig') => { + const foundKey = jwks + .filter((key) => key.use === usage || !key.use) + .find((key) => key.kid === kid); + + if (!foundKey) { + return undefined; + } + return createPublicKey({ + key: foundKey, + format: 'jwk', + }); +}; diff --git a/packages/cli/src/modules/inbound-auth-core/oauth2-bearer.driver.ts b/packages/cli/src/modules/inbound-auth-core/oauth2-bearer.driver.ts new file mode 100644 index 00000000000..10a69f04118 --- /dev/null +++ b/packages/cli/src/modules/inbound-auth-core/oauth2-bearer.driver.ts @@ -0,0 +1,200 @@ +import { Service } from '@n8n/di'; +import { + AuthenticationDriver, + Reject, + TrustedSourceStore, + type AdvertisedResource, + type Extracted, + type Result, + type TrustedSource, + type Verified, +} from '@n8n/inbound-auth'; +import { decode, verify as verifyJwt, type Jwt } from 'jsonwebtoken'; + +import { Logger } from '@n8n/backend-common'; +import { getPublicKeyFromJwk } from './key.utils'; + +type JwtFailure = { name: string; message: string }; +const isJwtFailure = (error: unknown): error is JwtFailure => + typeof error === 'object' && error !== null && 'name' in error && typeof error.name === 'string'; + +/** `aud` is attacker-controlled: keep a string or the string elements of an array, drop any other shape. */ +const normalizeAudiences = (aud: unknown): string[] => { + if (typeof aud === 'string') return [aud]; + if (Array.isArray(aud)) return aud.filter((entry): entry is string => typeof entry === 'string'); + return []; +}; + +/** Verifies `Authorization: Bearer` JWTs against the JWKS discovered for an oauth2 source. */ +@Service() +export class Oauth2BearerDriver extends AuthenticationDriver { + readonly credentialKind = 'bearer' as const; + + readonly sourceTypes: Array = ['oauth2']; + + private readonly logger: Logger; + + constructor( + private readonly store: TrustedSourceStore, + logger: Logger, + ) { + super(); + this.logger = logger.scoped('inbound-auth'); + } + + async selectSource(extracted: Extracted): Promise { + let issuer: string | undefined; + try { + const decoded = decode(extracted.credential.token); + // The payload is attacker-controlled, so `iss` can be any JSON value at runtime. + issuer = + typeof decoded === 'object' && decoded !== null && typeof decoded.iss === 'string' + ? decoded.iss + : undefined; + } catch (error) { + this.logger.warn('Failed to decode JWT', { error }); + } + return issuer ? await this.store.getByIssuer(issuer) : undefined; + } + + private reject(error: unknown): Reject { + if (!isJwtFailure(error)) return { ok: false, reason: 'signature-invalid' }; + switch (error.name) { + case 'TokenExpiredError': + return { ok: false, reason: 'expired', detail: error.message }; + case 'JsonWebTokenError': + if (error.message === 'jwt must be provided') + return { ok: false, reason: 'not-an-access-token' }; + if (error.message.startsWith('jwt audience invalid')) + return { ok: false, reason: 'audience-mismatch', detail: error.message }; + return { ok: false, reason: 'signature-invalid', detail: error.message }; + case 'NotBeforeError': + return { ok: false, reason: 'expired', detail: error.message }; + default: + return { ok: false, reason: 'signature-invalid', detail: error.name }; + } + } + + async verify(extracted: Extracted, source: TrustedSource): Promise> { + const { authentication } = source.config; + + const jwks = source.metadata?.documents.find((doc) => doc.kind === 'jwks'); + + if (!jwks) { + return { + ok: false, + reason: 'source-unusable', + detail: 'JWKS document not found', + }; + } + + const allowedAudiences = + source.config.surfaces[extracted.surface]?.audiences ?? extracted.resource.acceptedAudiences; + + let token: Jwt; + try { + token = await new Promise((resolve, reject) => { + verifyJwt( + extracted.credential.token, + (header, keyCallback) => { + if (!header.kid) { + return keyCallback(new Error('Missing kid in JWT header')); + } + const key = getPublicKeyFromJwk(jwks.keys, header.kid); + if (!key) { + return keyCallback(new Error('Public key not found for the given kid')); + } + keyCallback(null, key); + }, + { + issuer: source.issuer, + algorithms: authentication.algorithms, + clockTolerance: authentication.clockSkewSeconds, + complete: true, + }, + (jwtError, decoded) => { + if (jwtError) { + reject(jwtError); + } else { + if (!decoded) { + reject(new Error('Decoded JWT is undefined')); + return; + } + resolve(decoded); + } + }, + ); + }); + } catch (error) { + return this.reject(error); + } + + const { typ } = token.header; + const allowedTokenTypes = authentication.allowedTokenTypes; + if (!typ || !allowedTokenTypes.includes(typ)) { + return { ok: false, reason: 'not-an-access-token' }; + } + + const payload = + typeof token.payload === 'object' && token.payload !== null ? token.payload : {}; + + const { exp, iat } = payload; + if (typeof exp !== 'number' || typeof iat !== 'number') return { ok: false, reason: 'expired' }; + const max = authentication.maxTokenLifetimeSeconds; + if (exp - iat > max) + return { + ok: false, + reason: 'expired', + detail: `lifetime ${exp - iat}s exceeds the source maximum of ${max}s`, + }; + + if (iat > Math.floor(Date.now() / 1000) + authentication.clockSkewSeconds) { + return { ok: false, reason: 'expired', detail: 'Token is not yet valid' }; + } + + const tokenAudiences = normalizeAudiences(payload.aud); + + if (!tokenAudiences.some((aud) => allowedAudiences.includes(aud))) { + return { + ok: false, + reason: 'audience-mismatch', + detail: 'JWT audience is not allowed', + }; + } + + const { + credential, + request: { headers: _headers, ...request }, + ...rest + } = extracted; + return { + ok: true, + value: { + ...rest, + request, + credentialKind: credential.kind, + source, + claims: payload, + expiresAt: new Date(exp * 1000), + }, + }; + } + + advertise( + resource: AdvertisedResource, + sources: TrustedSource[], + ): { authorizationServers?: string[]; challenge?: string } { + try { + const url = new URL(resource.resource.url); + + const path = url.pathname === '/' ? '' : url.pathname; + return { + authorizationServers: sources.filter((s) => s.type === 'oauth2').map((s) => s.issuer), + challenge: `Bearer resource_metadata="${url.origin}/.well-known/oauth-protected-resource${path}"`, + }; + } catch (error) { + this.logger.warn('Failed to advertise resource', { error }); + return {}; + } + } +} diff --git a/packages/cli/src/modules/token-exchange/services/__tests__/jwks-resolver.test.ts b/packages/cli/src/modules/token-exchange/services/__tests__/jwks-resolver.test.ts index 973b0e2074c..2be4d9073b5 100644 --- a/packages/cli/src/modules/token-exchange/services/__tests__/jwks-resolver.test.ts +++ b/packages/cli/src/modules/token-exchange/services/__tests__/jwks-resolver.test.ts @@ -117,15 +117,6 @@ describe('JwksResolverService', () => { expect(result.keys.map((k) => k.kid)).toEqual(['rsa-1', 'ec-1']); }); - it('should resolve Ed25519 OKP JWK to EdDSA', async () => { - const fetcher = mockFetchResponse([{ ...ed25519Jwk, kid: 'ed-1' }]); - - const result = await service.resolveKeys(DEFAULT_SOURCE, { fetcher }); - - expect(result.keys).toHaveLength(1); - expect(result.keys[0].algorithms).toEqual(['EdDSA']); - }); - it('should infer RS256 from RSA JWK without explicit alg', async () => { const fetcher = mockFetchResponse([{ ...rsaJwk, kid: 'rsa-no-alg' }]); @@ -200,6 +191,28 @@ describe('JwksResolverService', () => { expect(result.keys[0].kid).toBe('rsa-1'); }); + // jsonwebtoken has no EdDSA support, so the resolver must not store a key it can never verify. + it('should skip Ed25519 keys (kty: OKP)', async () => { + const fetcher = mockFetchResponse([ + { ...ed25519Jwk, kid: 'ed-key', alg: 'EdDSA' }, + { ...rsaJwk, kid: 'rsa-1', alg: 'RS256' }, + ]); + + const result = await service.resolveKeys(DEFAULT_SOURCE, { fetcher }); + + expect(result.keys).toHaveLength(1); + expect(result.keys[0].kid).toBe('rsa-1'); + expect(result.skipped).toEqual([{ kid: 'ed-key', reason: 'failed schema validation' }]); + }); + + it('should throw OperationalError when an Ed25519 key is the only key', async () => { + const fetcher = mockFetchResponse([{ ...ed25519Jwk, kid: 'ed-key', alg: 'EdDSA' }]); + + const error = await service.resolveKeys(DEFAULT_SOURCE, { fetcher }).catch((e) => e); + expect(error).toBeInstanceOf(OperationalError); + expect(error.message).toMatch(/no usable signing keys.*ed-key/); + }); + it('should skip keys with unsupported explicit alg', async () => { const fetcher = mockFetchResponse([ { ...rsaJwk, kid: 'hmac-key', alg: 'HS256' }, diff --git a/packages/cli/src/modules/token-exchange/services/jwks-resolver.ts b/packages/cli/src/modules/token-exchange/services/jwks-resolver.ts index 632e4bbc0cc..50101ba0950 100644 --- a/packages/cli/src/modules/token-exchange/services/jwks-resolver.ts +++ b/packages/cli/src/modules/token-exchange/services/jwks-resolver.ts @@ -9,13 +9,6 @@ import { z } from 'zod'; import type { JwksKeySource } from '../token-exchange.schemas'; import { JwtAlgorithmSchema } from '../token-exchange.schemas'; -/** - * Superset of jsonwebtoken's Algorithm that includes EdDSA. - * The `@types/jsonwebtoken` definition omits EdDSA, but jsonwebtoken - * handles it at runtime and the JwtAlgorithmSchema accepts it. - */ -type SupportedAlgorithm = Algorithm | 'EdDSA'; - const FETCH_TIMEOUT_MS = 10_000; const DEFAULT_TTL_SECONDS = 3600; const MIN_TTL_SECONDS = 60; @@ -52,7 +45,7 @@ const supportedAlgorithmSet = new Set(JwtAlgorithmSchema.options); const SigningJwkSchema = z .object({ kid: z.string().min(1), - kty: z.enum(['RSA', 'EC', 'OKP']), + kty: z.enum(['RSA', 'EC']), alg: z.string().optional(), use: z .string() @@ -75,23 +68,21 @@ const JwkSetSchema = z.object({ // Algorithm inference // ────────────────────────────────────────────────────────────────────── -const CURVE_TO_ALGORITHM: Record = { +const CURVE_TO_ALGORITHM: Record = { 'P-256': 'ES256', 'P-384': 'ES384', 'P-521': 'ES512', - Ed25519: 'EdDSA', }; -function inferAlgorithm(jwk: SigningJwk): SupportedAlgorithm | undefined { +function inferAlgorithm(jwk: SigningJwk): Algorithm | undefined { if (jwk.alg) { - return supportedAlgorithmSet.has(jwk.alg) ? (jwk.alg as SupportedAlgorithm) : undefined; + return supportedAlgorithmSet.has(jwk.alg) ? (jwk.alg as Algorithm) : undefined; } switch (jwk.kty) { case 'RSA': return 'RS256'; case 'EC': - case 'OKP': return jwk.crv ? CURVE_TO_ALGORITHM[jwk.crv] : undefined; default: return undefined; diff --git a/packages/cli/src/modules/token-exchange/services/token-exchange.service.ts b/packages/cli/src/modules/token-exchange/services/token-exchange.service.ts index ac2e84815bc..3489e7187ae 100644 --- a/packages/cli/src/modules/token-exchange/services/token-exchange.service.ts +++ b/packages/cli/src/modules/token-exchange/services/token-exchange.service.ts @@ -93,8 +93,7 @@ export class TokenExchangeService { let payload: jwt.JwtPayload; try { const result = jwt.verify(subjectToken, resolvedKey.key, { - // EdDSA is valid at runtime but missing from @types/jsonwebtoken - algorithms: resolvedKey.algorithms as jwt.Algorithm[], + algorithms: resolvedKey.algorithms, issuer: resolvedKey.issuer, audience: resolvedKey.expectedAudience, ignoreExpiration: false, diff --git a/packages/cli/src/modules/token-exchange/services/trusted-key.service.ts b/packages/cli/src/modules/token-exchange/services/trusted-key.service.ts index 1b5dafe1a41..3c56b62e173 100644 --- a/packages/cli/src/modules/token-exchange/services/trusted-key.service.ts +++ b/packages/cli/src/modules/token-exchange/services/trusted-key.service.ts @@ -27,7 +27,7 @@ import type { import { TrustedKeyDataSchema, TrustedKeySourceSchema } from '../token-exchange.schemas'; import { JwksResolverService } from './jwks-resolver'; -type AlgorithmFamily = 'RSA' | 'EC' | 'EdDSA'; +type AlgorithmFamily = 'RSA' | 'EC'; const ALGORITHM_FAMILY: Record = { RS256: 'RSA', @@ -39,7 +39,6 @@ const ALGORITHM_FAMILY: Record = { ES256: 'EC', ES384: 'EC', ES512: 'EC', - EdDSA: 'EdDSA', }; const STATIC_SOURCE_ID = 'static'; @@ -540,7 +539,6 @@ export class TrustedKeyService { const expectedTypes: Record = { RSA: ['rsa'], EC: ['ec'], - EdDSA: ['ed25519', 'ed448'], }; if (!expectedTypes[family].includes(keyType ?? '')) { diff --git a/packages/cli/test/integration/inbound-auth-core/authentication.service.integration.test.ts b/packages/cli/test/integration/inbound-auth-core/authentication.service.integration.test.ts new file mode 100644 index 00000000000..26b7cc1bcb8 --- /dev/null +++ b/packages/cli/test/integration/inbound-auth-core/authentication.service.integration.test.ts @@ -0,0 +1,175 @@ +import { CacheService, UrlService } from '@n8n/backend-services'; +import { testDb, testModules } from '@n8n/backend-test-utils'; +import { Container } from '@n8n/di'; +import { + AuthenticationService, + JwkSchema, + trustedSourceConfigSchemaFor, + type Extracted, + type ManagedBy, + type TrustedSourceMetadata, +} from '@n8n/inbound-auth'; +import { DataSource, type Repository } from '@n8n/typeorm'; +import { exportJWK, generateKeyPair, SignJWT } from 'jose'; +import type { CryptoKey, JWTPayload } from 'jose'; +import { Cipher } from 'n8n-core'; +import { mock } from 'vitest-mock-extended'; + +import { TrustedSourceEntity } from '@/modules/inbound-auth-core/database/entities/trusted-source.entity'; +import { InboundAuthCoreModule } from '@/modules/inbound-auth-core/inbound-auth-core.module'; +import { TrustedSourceDiscoveryService } from '@/modules/inbound-auth-core/trusted-source-discovery.service'; + +const RESOURCE = 'https://n8n.example/mcp'; +const ADMIN_ISSUER = 'https://idp.example'; + +let cipher: Cipher; +let cacheService: CacheService; +let rows: Repository; +let service: AuthenticationService; +let privateKey: CryptoKey; +let jwksDocument: TrustedSourceMetadata['documents'][number]; + +type Row = { id: string; issuer: string }; +let seeded: Record; + +const now = () => Math.floor(Date.now() / 1000); + +async function sign(issuer: string, claims: JWTPayload = {}, kid = 'k1') { + const iat = now(); + return await new SignJWT({ + iss: issuer, + sub: 'alice', + aud: RESOURCE, + iat, + exp: iat + 3600, + ...claims, + }) + .setProtectedHeader({ alg: 'ES256', kid, typ: 'at+jwt' }) + .sign(privateKey); +} + +const extracted = (token: string): Extracted => ({ + surface: 'instance-mcp', + resource: { url: RESOURCE, acceptedAudiences: [RESOURCE] }, + request: { + method: 'POST', + url: '/mcp', + headers: { authorization: 'Bearer x' }, + ip: '203.0.113.7', + }, + receivedAt: new Date(), + credential: { kind: 'bearer', token }, +}); + +async function seedRow(managedBy: ManagedBy, issuer: string): Promise { + const config = trustedSourceConfigSchemaFor(managedBy).parse({ + version: 1, + authentication: { + type: 'oauth2', + ...(managedBy === 'system' && { keys: { kind: 'local-keystore' } }), + }, + surfaces: { 'instance-mcp': {} }, + }); + const row = await rows.save( + rows.create({ + name: `${managedBy}-source`, + type: 'oauth2', + issuer, + managedBy, + status: 'healthy', + lastError: null, + lastCheckedAt: new Date(), + configVersion: 1, + config: await cipher.encryptV2(config), + metadata: JSON.stringify({ version: 1, documents: [jwksDocument] }), + discoveryClaimToken: null, + discoveryClaimedAt: null, + }), + ); + return { id: row.id, issuer }; +} + +beforeAll(async () => { + await testModules.loadModules(['inbound-auth-core']); + await testDb.init(); + + cipher = Container.get(Cipher); + cacheService = Container.get(CacheService); + rows = Container.get(DataSource).getRepository(TrustedSourceEntity); + await cacheService.init(); + + // `loadModules` imports the module; the bindings come from `init`. + Container.set(TrustedSourceDiscoveryService, mock()); + await new InboundAuthCoreModule().init(); + service = Container.get(AuthenticationService); + + const pair = await generateKeyPair('ES256'); + privateKey = pair.privateKey; + const jwk = JwkSchema.parse({ + ...(await exportJWK(pair.publicKey)), + kid: 'k1', + alg: 'ES256', + use: 'sig', + }); + jwksDocument = { + kind: 'jwks', + fetchedAt: new Date().toISOString(), + url: 'https://idp.example/keys', + keys: [jwk], + }; +}); + +beforeEach(async () => { + await testDb.truncate(['TrustedSourceIdentityEntity', 'TrustedSourceEntity']); + await cacheService.reset(); + seeded = { + admin: await seedRow('admin', ADMIN_ISSUER), + system: await seedRow('system', Container.get(UrlService).getInstanceBaseUrl()), + }; +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +describe('AuthenticationService (integration)', () => { + describe.each(['admin', 'system'])('%s-managed source', (managedBy) => { + it('accepts a token signed for the resource and returns the stored source', async () => { + const row = seeded[managedBy]; + + const result = await service.authenticate(extracted(await sign(row.issuer))); + + expect(result).toMatchObject({ + ok: true, + value: { source: { id: row.id, issuer: row.issuer }, claims: { sub: 'alice' } }, + }); + }); + + it('rejects a token for another audience', async () => { + const row = seeded[managedBy]; + + const result = await service.authenticate( + extracted(await sign(row.issuer, { aud: 'other' })), + ); + + expect(result).toMatchObject({ ok: false, reason: 'audience-mismatch' }); + }); + + it('rejects an expired token', async () => { + const row = seeded[managedBy]; + const token = await sign(row.issuer, { iat: now() - 7200, exp: now() - 3600 }); + + const result = await service.authenticate(extracted(token)); + + expect(result).toMatchObject({ ok: false, reason: 'expired' }); + }); + + it('rejects a token signed with a key the source does not publish', async () => { + const row = seeded[managedBy]; + + const result = await service.authenticate(extracted(await sign(row.issuer, {}, 'k2'))); + + expect(result).toMatchObject({ ok: false, reason: 'signature-invalid' }); + }); + }); +}); diff --git a/packages/cli/test/integration/token-exchange/trusted-key.service.integration.test.ts b/packages/cli/test/integration/token-exchange/trusted-key.service.integration.test.ts index d0adc3aa238..b3949b6c87d 100644 --- a/packages/cli/test/integration/token-exchange/trusted-key.service.integration.test.ts +++ b/packages/cli/test/integration/token-exchange/trusted-key.service.integration.test.ts @@ -30,10 +30,6 @@ MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEpCuPN2BHQ7G0A2qD2Bd27bwwUB9M Npzv5WS/ygt55l8y2X+Vfm5TQFRMNkqEx+/GXaPIU/hDmtnBdCxAUIRM9g== -----END PUBLIC KEY-----`; -const ED25519_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- -MCowBQYDK2VwAyEAPBUxurC3wGyi/yXTTjNwTzgHjSioAIa4Qx6nyOqof0U= ------END PUBLIC KEY-----`; - // ────────────────────────────────────────────────────────────────────── // Helpers // ────────────────────────────────────────────────────────────────────── @@ -429,12 +425,6 @@ describe('TrustedKeyService (integration)', () => { it.each([ { name: 'RSA key with RS256', kid: 'rsa-key', algorithms: ['RS256'], key: RSA_PUBLIC_KEY }, { name: 'EC key with ES256', kid: 'ec-key', algorithms: ['ES256'], key: EC_PUBLIC_KEY }, - { - name: 'Ed25519 key with EdDSA', - kid: 'ed-key', - algorithms: ['EdDSA'], - key: ED25519_PUBLIC_KEY, - }, ])('should accept $name', async ({ kid, algorithms, key }) => { await insertSource({ config: JSON.stringify([staticKeyEntry({ kid, algorithms, key })]),