mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-12 02:19:43 +00:00
chore(core): Adds LocalAuthorizationServer implementation for token authentication (no-changelog) (#40702)
This commit is contained in:
+183
@@ -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' });
|
||||
});
|
||||
});
|
||||
+74
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user