chore(core): Adds LocalAuthorizationServer implementation for token authentication (no-changelog) (#40702)

This commit is contained in:
James Martin
2026-10-09 07:56:57 +00:00
committed by GitHub
parent 690fbb525a
commit 6f5f79321b
7 changed files with 354 additions and 36 deletions
@@ -0,0 +1,183 @@
import { Logger } from '@n8n/backend-common';
import type { OAuthDiscoveryClient } from '@n8n/backend-services';
import { CacheService, UrlService } from '@n8n/backend-services';
import { testDb } from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import { Container } from '@n8n/di';
import {
AuthenticationService,
LocalAuthorizationServer,
trustedSourceConfigSchemaFor,
TrustedSourceMetadataSchema,
type Extracted,
} from '@n8n/inbound-auth';
import { DataSource, type Repository } from '@n8n/typeorm';
import type { JSONWebKeySet } from 'jose';
import { decodeProtectedHeader } from 'jose';
import { Cipher } from 'n8n-core';
import { mock, type MockProxy } from 'vitest-mock-extended';
import { TrustedSourceEntity } from '@/modules/inbound-auth-core/database/entities/trusted-source.entity';
import { TrustedSourceDiscoveryService } from '@/modules/inbound-auth-core/trusted-source-discovery.service';
import { TrustedSourceDbStore } from '@/modules/inbound-auth-core/trusted-source.store';
import { createOwner } from '@test-integration/db/users';
import { setupTestServer } from '@test-integration/utils';
import { OAuthServerLocalAuthorizationServer } from '../oauth-local-authorization-server';
import { OAuthTokenService } from '../oauth-token.service';
const testServer = setupTestServer({
modules: ['oauth-jwe', 'oauth-server', 'mcp', 'inbound-auth-core'],
endpointGroups: ['mcp', 'jwks'],
});
let owner: User;
let issuer: string;
let resource: string;
let rows: Repository<TrustedSourceEntity>;
let client: MockProxy<OAuthDiscoveryClient>;
let discovery: TrustedSourceDiscoveryService;
const extracted = (token: string): Extracted => ({
surface: 'instance-mcp',
resource: { url: resource, acceptedAudiences: [resource] },
request: {
method: 'POST',
url: '/mcp-server/http',
headers: { authorization: 'Bearer x' },
ip: '203.0.113.7',
},
receivedAt: new Date(),
credential: { kind: 'bearer', token },
});
/** The system source as IAM-1441 seeds it: not discovered yet. */
async function seedSystemSource(): Promise<string> {
const config = trustedSourceConfigSchemaFor('system').parse({
version: 1,
authentication: { type: 'oauth2', keys: { kind: 'local-keystore' } },
surfaces: { 'instance-mcp': {} },
});
const row = await rows.save(
rows.create({
name: 'n8n-internal',
type: 'oauth2',
issuer,
managedBy: 'system',
status: 'unchecked',
lastError: null,
lastCheckedAt: null,
configVersion: 1,
config: await Container.get(Cipher).encryptV2(config),
metadata: null,
discoveryClaimToken: null,
discoveryClaimedAt: null,
}),
);
return row.id;
}
beforeAll(async () => {
owner = await createOwner();
await Container.get(CacheService).init();
issuer = Container.get(UrlService).getInstanceBaseUrl();
resource = `${issuer}/mcp-server/http`;
rows = Container.get(DataSource).getRepository(TrustedSourceEntity);
client = mock<OAuthDiscoveryClient>();
const offline = new Error('discovery must not go over HTTP');
client.fetchOpenIdConfiguration.mockRejectedValue(offline);
client.fetchOAuth2ServerMetadata.mockRejectedValue(offline);
client.fetchJwks.mockRejectedValue(offline);
discovery = new TrustedSourceDiscoveryService(
Container.get(Logger),
Container.get(TrustedSourceDbStore),
client,
Container.get(LocalAuthorizationServer),
);
});
beforeEach(async () => {
await testDb.truncate(['TrustedSourceEntity']);
await Container.get(CacheService).reset();
vi.clearAllMocks();
});
describe('OAuth server as a trusted source', () => {
it('binds the local authorization server contract', () => {
expect(Container.get(LocalAuthorizationServer)).toBeInstanceOf(
OAuthServerLocalAuthorizationServer,
);
});
it('discovers the system source in process, from the documents the instance publishes', async () => {
const id = await seedSystemSource();
await discovery.refresh(id);
const row = await rows.findOneByOrFail({ id });
expect(row).toMatchObject({ status: 'healthy', lastError: null });
const { documents } = TrustedSourceMetadataSchema.parse(JSON.parse(row.metadata!));
const served = await testServer.restlessAgent
.get('/.well-known/oauth-authorization-server')
.expect(200);
const metadata = documents.find((doc) => doc.kind === 'oauth2-authorization-server');
expect(metadata?.document).toEqual(served.body);
expect(metadata?.document).toMatchObject({
issuer,
jwks_uri: Container.get(UrlService).getInstanceJwksUri(),
});
const published = await testServer.restlessAgent
.get(new URL(served.body.jwks_uri as string).pathname)
.expect(200);
const activeKid = (published.body as JSONWebKeySet).keys.find((key) => key.use === 'sig')?.kid;
const jwks = documents.find((doc) => doc.kind === 'jwks');
expect(activeKid).toBeDefined();
expect(jwks?.keys.map((key) => key.kid)).toContain(activeKid);
expect(client.fetchOpenIdConfiguration).not.toHaveBeenCalled();
expect(client.fetchOAuth2ServerMetadata).not.toHaveBeenCalled();
expect(client.fetchJwks).not.toHaveBeenCalled();
});
it('authenticates an access token the instance minted against the discovered source', async () => {
const id = await seedSystemSource();
await discovery.refresh(id);
const { accessToken } = Container.get(OAuthTokenService).generateTokenPair(
owner.id,
'client-1',
resource,
['tool:read'],
);
expect(decodeProtectedHeader(accessToken)).toMatchObject({
kid: expect.any(String),
typ: 'at+jwt',
});
const result = await Container.get(AuthenticationService).authenticate(extracted(accessToken));
expect(result).toMatchObject({
ok: true,
value: { source: { id, issuer }, claims: { sub: owner.id } },
});
});
it('rejects an access token the instance minted for another audience', async () => {
const id = await seedSystemSource();
await discovery.refresh(id);
const { accessToken } = Container.get(OAuthTokenService).generateTokenPair(
owner.id,
'client-1',
`${issuer}/other-resource`,
['tool:read'],
);
const result = await Container.get(AuthenticationService).authenticate(extracted(accessToken));
expect(result).toMatchObject({ ok: false, reason: 'audience-mismatch' });
});
});
@@ -0,0 +1,74 @@
import type { UrlService } from '@n8n/backend-services';
import { AuthorizationServerMetadataSchema } from '@n8n/inbound-auth';
import type { JWK } from 'jose';
import { mock } from 'vitest-mock-extended';
import type { ProtectedResourceRegistry } from '@/services/protected-resource.registry';
import { OAuthServerLocalAuthorizationServer } from '../oauth-local-authorization-server';
import type { OAuthSigningJwksProvider } from '../oauth-signing-jwks.provider';
const BASE_URL = 'https://n8n.test';
const JWKS_URI = `${BASE_URL}/rest/.well-known/jwks.json`;
const signingJwk: JWK = {
kty: 'EC',
kid: 'row-sig',
use: 'sig',
alg: 'ES256',
crv: 'P-256',
x: 'x-coordinate',
y: 'y-coordinate',
};
describe('OAuthServerLocalAuthorizationServer', () => {
const urlService = mock<UrlService>();
const registry = mock<ProtectedResourceRegistry>();
const jwksProvider = mock<OAuthSigningJwksProvider>();
const server = new OAuthServerLocalAuthorizationServer(urlService, registry, jwksProvider);
beforeEach(() => {
vi.resetAllMocks();
urlService.getInstanceBaseUrl.mockReturnValue(BASE_URL);
urlService.getInstanceJwksUri.mockReturnValue(JWKS_URI);
registry.getAllScopes.mockReturnValue([]);
});
describe('getMetadata', () => {
test('names the instance as issuer and advertises its JWKS and endpoints', async () => {
const metadata = await server.getMetadata();
expect(metadata).toMatchObject({
issuer: BASE_URL,
jwks_uri: JWKS_URI,
authorization_endpoint: `${BASE_URL}/mcp-oauth/authorize`,
token_endpoint: `${BASE_URL}/mcp-oauth/token`,
registration_endpoint: `${BASE_URL}/mcp-oauth/register`,
revocation_endpoint: `${BASE_URL}/mcp-oauth/revoke`,
});
expect(AuthorizationServerMetadataSchema.parse(metadata)).toEqual(metadata);
});
test('omits scopes_supported when no resource advertises scopes', async () => {
const metadata = await server.getMetadata();
expect(metadata).not.toHaveProperty('scopes_supported');
});
test('advertises the scopes of every registered resource', async () => {
registry.getAllScopes.mockReturnValue(['tool:read', 'tool:write']);
const metadata = await server.getMetadata();
expect(metadata.scopes_supported).toEqual(['tool:read', 'tool:write']);
});
});
describe('getJwks', () => {
test('returns the published signing keys', async () => {
jwksProvider.getPublicJwks.mockResolvedValue([signingJwk]);
await expect(server.getJwks()).resolves.toEqual({ keys: [signingJwk] });
});
});
});
@@ -9,6 +9,7 @@ import type {
} from '@/services/protected-resource.registry';
import type { UrlService } from '@n8n/backend-services';
import type { OAuthServerLocalAuthorizationServer } from '../oauth-local-authorization-server';
import { OAuthServerService } from '../oauth-server.service';
import type { OAuthController as OAuthControllerClass } from '../oauth.controller';
@@ -27,6 +28,7 @@ beforeAll(async () => {
const urlService = mock<UrlService>();
const registry = mock<ProtectedResourceRegistry>();
const localServer = mock<OAuthServerLocalAuthorizationServer>();
let controller: OAuthControllerClass;
const makeRes = () => {
@@ -54,10 +56,23 @@ const resource = (scopes: string[], isAvailable?: () => Promise<boolean>): Prote
beforeEach(() => {
vi.resetAllMocks();
urlService.getInstanceBaseUrl.mockReturnValue('https://n8n.test');
controller = new OAuthController(urlService, registry);
controller = new OAuthController(urlService, registry, localServer);
});
describe('OAuthController', () => {
describe('metadata', () => {
test("returns the local authorization server's document with CORS headers", async () => {
const document = { issuer: 'https://n8n.test', jwks_uri: 'https://n8n.test/jwks' };
localServer.getMetadata.mockResolvedValue(document);
const res = makeRes();
await controller.metadata(makeReq([]), res);
expect(res.header).toHaveBeenCalledWith('Access-Control-Allow-Origin', '*');
expect(res.json).toHaveBeenCalledWith(document);
});
});
describe('protectedResourceMetadata', () => {
test('resolves the registry with the reconstructed leading-slash path', async () => {
registry.getByResourcePath.mockResolvedValue(resource(['tool:listWorkflows']));
@@ -0,0 +1,67 @@
import { UrlService } from '@n8n/backend-services';
import { Service } from '@n8n/di';
import {
JwkSchema,
LocalAuthorizationServer,
type AuthorizationServerMetadata,
type Jwk,
} from '@n8n/inbound-auth';
import { ProtectedResourceRegistry } from '@/services/protected-resource.registry';
import { OAuthSigningJwksProvider } from './oauth-signing-jwks.provider';
/**
* The controller serves this document over HTTP. Discovery reads the same document in process,
* so the issuer is the same in both places.
*/
@Service()
export class OAuthServerLocalAuthorizationServer extends LocalAuthorizationServer {
constructor(
private readonly urlService: UrlService,
private readonly resourceRegistry: ProtectedResourceRegistry,
private readonly jwksProvider: OAuthSigningJwksProvider,
) {
super();
}
/**
* Single RFC 8414 authorization-server metadata document, shared by all
* protected resources: one issuer (the instance origin), one set of
* endpoints, one signing key.
*
* Keeps advertising the legacy `/mcp-oauth/*` endpoint paths — clients that
* registered via DCR persist these URLs, so changing them would strand
* every already-connected client.
*/
async getMetadata(): Promise<AuthorizationServerMetadata> {
const baseUrl = this.urlService.getInstanceBaseUrl();
const allScopes = this.resourceRegistry.getAllScopes();
const metadata: AuthorizationServerMetadata = {
issuer: baseUrl,
authorization_endpoint: `${baseUrl}/mcp-oauth/authorize`,
token_endpoint: `${baseUrl}/mcp-oauth/token`,
registration_endpoint: `${baseUrl}/mcp-oauth/register`,
revocation_endpoint: `${baseUrl}/mcp-oauth/revoke`,
// RFC 8414 §2: public keys that verify the access tokens this server signs.
jwks_uri: this.urlService.getInstanceJwksUri(),
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
token_endpoint_auth_methods_supported: ['none', 'client_secret_post', 'client_secret_basic'],
code_challenge_methods_supported: ['S256'],
// RFC 9207: we include the `iss` parameter on authorization responses
authorization_response_iss_parameter_supported: true,
};
if (allScopes.length > 0) {
metadata.scopes_supported = allScopes;
}
return metadata;
}
/** The `sig` keys of the published JWKS: the active key and the retired keys still in grace. */
async getJwks(): Promise<{ keys: Jwk[] }> {
return { keys: JwkSchema.array().parse(await this.jwksProvider.getPublicJwks()) };
}
}
@@ -42,6 +42,13 @@ export class OAuthServerModule implements ModuleInterface {
Container.get(JwksRegistry).register(Container.get(OAuthSigningJwksProvider));
}
// Discovery reads this server's metadata and keys in process, like any other trusted source.
const { LocalAuthorizationServer } = await import('@n8n/inbound-auth');
const { OAuthServerLocalAuthorizationServer } = await import(
'./oauth-local-authorization-server.js'
);
Container.set(LocalAuthorizationServer, Container.get(OAuthServerLocalAuthorizationServer));
// Register the token service as the OAuth token verifier provider, so
// protected-resource modules verify bearer tokens through the core
// `OAuthTokenVerifierProxy` instead of importing this module.
@@ -19,6 +19,7 @@ import type { ProtectedResource } from '@/services/protected-resource.registry';
import { ProtectedResourceRegistry } from '@/services/protected-resource.registry';
import { UrlService } from '@n8n/backend-services';
import { OAuthServerLocalAuthorizationServer } from './oauth-local-authorization-server';
import { OAuthServerConfig } from './oauth-server.config';
import { OAuthServerService } from './oauth-server.service';
import { buildOAuthClientLimitReachedMessage } from './oauth.errors';
@@ -147,6 +148,7 @@ export class OAuthController {
constructor(
private readonly urlService: UrlService,
private readonly resourceRegistry: ProtectedResourceRegistry,
private readonly localServer: OAuthServerLocalAuthorizationServer,
) {}
// Add CORS headers for OAuth discovery endpoints
@@ -172,46 +174,15 @@ export class OAuthController {
res.status(204).end();
}
/**
* Single RFC 8414 authorization-server metadata document, shared by all
* protected resources: one issuer (the instance origin), one set of
* endpoints, one signing key.
*
* Keeps advertising the legacy `/mcp-oauth/*` endpoint paths — clients that
* registered via DCR persist these URLs, so changing them would strand
* every already-connected client.
*/
/** RFC 8414 authorization-server metadata, built by the local authorization server. */
@Get('/.well-known/oauth-authorization-server', {
skipAuth: true,
usesTemplates: true,
ipRateLimit: wellKnownIpRateLimit,
})
metadata(_req: Request, res: Response) {
async metadata(_req: Request, res: Response) {
this.setCorsHeaders(res);
const baseUrl = this.urlService.getInstanceBaseUrl();
const allScopes = this.resourceRegistry.getAllScopes();
const metadata: Record<string, unknown> = {
issuer: baseUrl,
authorization_endpoint: `${baseUrl}/mcp-oauth/authorize`,
token_endpoint: `${baseUrl}/mcp-oauth/token`,
registration_endpoint: `${baseUrl}/mcp-oauth/register`,
revocation_endpoint: `${baseUrl}/mcp-oauth/revoke`,
// RFC 8414 §2: public keys that verify the access tokens this server signs.
jwks_uri: this.urlService.getInstanceJwksUri(),
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
token_endpoint_auth_methods_supported: ['none', 'client_secret_post', 'client_secret_basic'],
code_challenge_methods_supported: ['S256'],
// RFC 9207: we include the `iss` parameter on authorization responses
authorization_response_iss_parameter_supported: true,
};
if (allScopes.length > 0) {
metadata.scopes_supported = allScopes;
}
res.json(metadata);
res.json(await this.localServer.getMetadata());
}
@Options('/.well-known/oauth-authorization-server/*issuerPath', {
@@ -84,7 +84,8 @@ type ModuleName =
| 'token-exchange'
| 'policy-infrastructure'
| 'workflow-reviews'
| 'type-availability-policies';
| 'type-availability-policies'
| 'inbound-auth-core';
export interface SetupProps {
endpointGroups?: EndpointGroup[];