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:
Andreas Fitzek 2026-10-07 10:30:24 +00:00 committed by GitHub
parent 0885af531a
commit 4e802ff11d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1241 additions and 73 deletions

View File

@ -58,6 +58,7 @@ export const LOG_SCOPES = [
'policy',
'activity-log',
'instance-reporting',
'inbound-auth',
] as const;
export type LogScope = (typeof LOG_SCOPES)[number];

View File

@ -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,
},

View File

@ -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,
},

View File

@ -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>>;
}

View File

@ -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)

View File

@ -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'],

View File

@ -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'],

View File

@ -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"',
],
});
});
});
});

View File

@ -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 () => {

View File

@ -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"',
);
});
});
});

View File

@ -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 };
}
}

View File

@ -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.

View 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',
});
};

View File

@ -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 {};
}
}
}

View File

@ -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' },

View File

@ -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;

View File

@ -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,

View File

@ -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 ?? '')) {

View File

@ -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' });
});
});
});

View File

@ -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 })]),