mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(core): Verify bearer tokens against trusted sources (no-changelog) (#40279)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
0885af531a
commit
4e802ff11d
|
|
@ -58,6 +58,7 @@ export const LOG_SCOPES = [
|
|||
'policy',
|
||||
'activity-log',
|
||||
'instance-reporting',
|
||||
'inbound-auth',
|
||||
] as const;
|
||||
|
||||
export type LogScope = (typeof LOG_SCOPES)[number];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<TrustedSource['type']>;
|
||||
|
||||
abstract selectSource(extracted: Extracted): Promise<TrustedSource | undefined>;
|
||||
|
||||
abstract verify(extracted: Extracted, source: TrustedSource): Promise<Result<Verified>>;
|
||||
|
||||
abstract advertise(
|
||||
resource: AdvertisedResource,
|
||||
sources: TrustedSource[],
|
||||
): { authorizationServers?: string[]; challenge?: string };
|
||||
}
|
||||
|
||||
export abstract class IdentityService {
|
||||
abstract identify(verified: Verified): Promise<Result<SecurityContext>>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ export const JwtAlgorithmSchema = z.enum([
|
|||
'PS256',
|
||||
'PS384',
|
||||
'PS512',
|
||||
'EdDSA',
|
||||
]);
|
||||
export type JwtAlgorithm = z.infer<typeof JwtAlgorithmSchema>;
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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<TrustedSource['type']>) {
|
||||
return {
|
||||
credentialKind,
|
||||
sourceTypes,
|
||||
selectSource: vi.fn<AuthenticationDriver['selectSource']>(),
|
||||
verify: vi.fn<AuthenticationDriver['verify']>(),
|
||||
advertise: vi.fn<AuthenticationDriver['advertise']>(),
|
||||
};
|
||||
}
|
||||
|
||||
type SourceOverrides = Partial<Omit<TrustedSource, 'config'>> & {
|
||||
surfaces?: Partial<Record<SurfaceId, { audiences?: string[] }>>;
|
||||
};
|
||||
|
||||
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> = {}): 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<Verified> {
|
||||
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<TrustedSourceStore>();
|
||||
let bearerFake: ReturnType<typeof fakeDriver>;
|
||||
let otherFake: ReturnType<typeof fakeDriver>;
|
||||
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<TrustedSource['type']> }>(
|
||||
[
|
||||
{ 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"',
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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<Omit<TrustedSource, 'config'>> & {
|
||||
authentication?: Record<string, unknown>;
|
||||
surfaces?: Partial<Record<SurfaceId, { audiences?: string[] }>>;
|
||||
};
|
||||
|
||||
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<T>(result: Result<T>): T {
|
||||
if (!result.ok) throw new Error(`expected ok, got ${result.reason}: ${result.detail ?? ''}`);
|
||||
return result.value;
|
||||
}
|
||||
|
||||
let store: MockProxy<TrustedSourceStore>;
|
||||
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<TrustedSourceStore>();
|
||||
const logger = mock<Logger>();
|
||||
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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<Result<Verified>> {
|
||||
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<string>();
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Result<Verified>> {
|
||||
return { ok: false, reason: 'source-unusable' };
|
||||
}
|
||||
|
||||
async advertise() {
|
||||
return { authorizationServers: [], challenges: [] };
|
||||
}
|
||||
}
|
||||
|
||||
class UnregisteredIdentityService extends IdentityService {
|
||||
async identify(_verified: Verified): Promise<Result<SecurityContext>> {
|
||||
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.
|
||||
|
|
|
|||
17
packages/cli/src/modules/inbound-auth-core/key.utils.ts
Normal file
17
packages/cli/src/modules/inbound-auth-core/key.utils.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { type Jwk } from '@n8n/inbound-auth';
|
||||
|
||||
import { createPublicKey } from 'node:crypto';
|
||||
|
||||
export const getPublicKeyFromJwk = (jwks: Array<Jwk>, 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',
|
||||
});
|
||||
};
|
||||
|
|
@ -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<TrustedSource['type']> = ['oauth2'];
|
||||
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(
|
||||
private readonly store: TrustedSourceStore,
|
||||
logger: Logger,
|
||||
) {
|
||||
super();
|
||||
this.logger = logger.scoped('inbound-auth');
|
||||
}
|
||||
|
||||
async selectSource(extracted: Extracted): Promise<TrustedSource | undefined> {
|
||||
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<Result<Verified>> {
|
||||
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 {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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<string>(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<string, SupportedAlgorithm> = {
|
||||
const CURVE_TO_ALGORITHM: Record<string, Algorithm> = {
|
||||
'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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string, AlgorithmFamily> = {
|
||||
RS256: 'RSA',
|
||||
|
|
@ -39,7 +39,6 @@ const ALGORITHM_FAMILY: Record<string, AlgorithmFamily> = {
|
|||
ES256: 'EC',
|
||||
ES384: 'EC',
|
||||
ES512: 'EC',
|
||||
EdDSA: 'EdDSA',
|
||||
};
|
||||
|
||||
const STATIC_SOURCE_ID = 'static';
|
||||
|
|
@ -540,7 +539,6 @@ export class TrustedKeyService {
|
|||
const expectedTypes: Record<AlgorithmFamily, string[]> = {
|
||||
RSA: ['rsa'],
|
||||
EC: ['ec'],
|
||||
EdDSA: ['ed25519', 'ed448'],
|
||||
};
|
||||
|
||||
if (!expectedTypes[family].includes(keyType ?? '')) {
|
||||
|
|
|
|||
|
|
@ -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<TrustedSourceEntity>;
|
||||
let service: AuthenticationService;
|
||||
let privateKey: CryptoKey;
|
||||
let jwksDocument: TrustedSourceMetadata['documents'][number];
|
||||
|
||||
type Row = { id: string; issuer: string };
|
||||
let seeded: Record<ManagedBy, Row>;
|
||||
|
||||
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<Row> {
|
||||
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<TrustedSourceDiscoveryService>());
|
||||
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<ManagedBy>(['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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 })]),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user