mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-22 04:21:32 +02:00
feat(core): Accept OAuth2 bearers on the instance MCP server through the trusted-source pipeline (no-changelog) (#41438)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9f074c20c9
commit
af65c1dffa
|
|
@ -174,7 +174,7 @@ describe('AuthStrategyRegistry', () => {
|
|||
expect(strategy3.buildTokenGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null when all strategies abstain or when a strategy returns false (fail-fast)', async () => {
|
||||
it('returns null when all strategies abstain', async () => {
|
||||
const emptyRegistry = new AuthStrategyRegistry();
|
||||
expect(await emptyRegistry.buildContextFromToken('token')).toBeNull();
|
||||
|
||||
|
|
@ -182,14 +182,16 @@ describe('AuthStrategyRegistry', () => {
|
|||
abstain.buildTokenGrant.mockResolvedValue(null);
|
||||
registry.register(abstain);
|
||||
expect(await registry.buildContextFromToken('token')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns false and stops when a strategy refuses the token (fail-fast)', async () => {
|
||||
const unreached = mock<AuthStrategy>();
|
||||
const failing = mock<AuthStrategy>();
|
||||
failing.buildTokenGrant.mockResolvedValue(false);
|
||||
const failRegistry = new AuthStrategyRegistry();
|
||||
failRegistry.register(failing);
|
||||
failRegistry.register(unreached);
|
||||
expect(await failRegistry.buildContextFromToken('token')).toBeNull();
|
||||
expect(await failRegistry.buildContextFromToken('token')).toBe(false);
|
||||
expect(unreached.buildTokenGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,22 +28,19 @@ export class AuthStrategyRegistry {
|
|||
this.strategies.push(strategy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks each strategy in order. `false`: a strategy recognised the token and refused it.
|
||||
* `null`: every strategy abstained, so no strategy here owns the token.
|
||||
*/
|
||||
async buildContextFromToken(
|
||||
token: string,
|
||||
options?: AuthStrategyOptions,
|
||||
): Promise<TokenGrant | null> {
|
||||
): Promise<TokenGrant | false | null> {
|
||||
for (const strategy of this.strategies) {
|
||||
const result = await strategy.buildTokenGrant(token, options);
|
||||
if (result === false) {
|
||||
// this strategy was responsible, but the token validation failed
|
||||
return null;
|
||||
}
|
||||
if (result !== null) {
|
||||
return result; // true = success, false = fail fast
|
||||
}
|
||||
// null = this strategy abstained, continue to next
|
||||
if (result !== null) return result;
|
||||
}
|
||||
return null; // all strategies abstained = unauthenticated
|
||||
return null;
|
||||
}
|
||||
|
||||
async authenticate(req: AuthenticatedRequest): Promise<boolean> {
|
||||
|
|
|
|||
|
|
@ -38,9 +38,10 @@ export type TokenPurpose = keyof typeof TOKEN_PURPOSES;
|
|||
* Known overlap: `mcp-server-api` is also the MCP protected resource's
|
||||
* pre-RFC-8707 audience (`LEGACY_MCP_AUDIENCE` in `mcp-protected-resource.ts`),
|
||||
* so the audience alone does not tell an MCP API key from an OAuth access token
|
||||
* minted for that resource. What separates them is the signed `meta.isOAuth`
|
||||
* claim the MCP middleware routes on, and the row each kind must have — in
|
||||
* `api_key` for one, `oauth_access_token` for the other.
|
||||
* minted for that resource. What separates them is the `iss` claim the MCP
|
||||
* middleware routes on — `n8n` for an API key, an http(s) URL for an access
|
||||
* token — and the row each kind must have — in `api_key` for one,
|
||||
* `oauth_access_token` for the other.
|
||||
*
|
||||
* Giving API keys an audience of their own would invalidate every key in the
|
||||
* field: they are persisted, handed to clients, and minted without an expiry,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { ProtectedResource } from '../contracts';
|
||||
import { extractBearer, inboundFrom, inboundRequestOf } from '../inbound';
|
||||
import { extractBearer, inboundFrom, inboundRequestOf, isUrlIssuer } from '../inbound';
|
||||
import type { Inbound, Verified } from '../pipeline';
|
||||
|
||||
const resourceUrl = 'https://n8n.example/mcp-server/http';
|
||||
|
|
@ -135,6 +135,22 @@ describe('extractBearer', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('isUrlIssuer', () => {
|
||||
it.each(['https://idp.example', 'http://localhost:5678'])('routes %s as a URL issuer', (iss) => {
|
||||
expect(isUrlIssuer(iss)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['the API-key issuer', 'n8n'],
|
||||
['the token-exchange issuer', 'n8n-token-exchange'],
|
||||
['a non-http URL', 'ftp://idp.example'],
|
||||
['a number', 42],
|
||||
['no issuer', undefined],
|
||||
])('does not route %s as a URL issuer', (_label, iss) => {
|
||||
expect(isUrlIssuer(iss)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Verified', () => {
|
||||
it('has no field that can hold the token', () => {
|
||||
type HoldsCredential = 'credential' extends keyof Verified ? true : false;
|
||||
|
|
|
|||
|
|
@ -128,6 +128,9 @@ export type Refused = {
|
|||
|
||||
export type AdmissionResult = Admitted | Refused;
|
||||
|
||||
/** What a challenge needs. A `Refused` fits. Without a reason, the challenge has no error. */
|
||||
export type Refusal = Pick<Refused, 'advertised'> & { reason?: RefusalReason };
|
||||
|
||||
/**
|
||||
* What every surface does between taking a credential off the wire and acting as the caller:
|
||||
* stamp the inbound with the resource, run the pipeline, ask the resource to authorize the
|
||||
|
|
@ -150,8 +153,17 @@ export abstract class ResourceAdmission {
|
|||
token: string,
|
||||
): Promise<AdmissionResult>;
|
||||
|
||||
/** The `WWW-Authenticate` value for a refusal: the advertised challenge, then the RFC 6750 error. */
|
||||
abstract challenge(refused: Refused): Promise<string>;
|
||||
/**
|
||||
* The `WWW-Authenticate` value for a refusal: the advertised challenge, then the RFC 6750
|
||||
* error. A refusal without a reason, or for a missing credential, gets no error (RFC 6750 §3.1).
|
||||
*/
|
||||
abstract challenge(refusal: Refusal): Promise<string>;
|
||||
|
||||
/**
|
||||
* Refuses for a reason the surface decides itself, before or after the pipeline, and writes
|
||||
* the one denial line. The line has no token, no external subject and no email.
|
||||
*/
|
||||
abstract refuse(inbound: Inbound, reason: RefusalReason, context?: SecurityContext): Refused;
|
||||
}
|
||||
|
||||
/** The dispatcher. Owns what every method shares: driver lookup and surface acceptance. */
|
||||
|
|
|
|||
|
|
@ -41,3 +41,11 @@ export function extractBearer(inbound: Inbound): Result<Extracted> {
|
|||
|
||||
return { ok: true, value: { ...inbound, credential: { kind: 'bearer', token } } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Routing, not verification: a token from an OAuth2 trusted source names its issuer with an
|
||||
* http(s) URL. The instance's own issuer is its base URL, which is http on a local instance.
|
||||
*/
|
||||
export function isUrlIssuer(iss: unknown): iss is string {
|
||||
return typeof iss === 'string' && URL.canParse(iss) && /^https?:$/.test(new URL(iss).protocol);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -200,12 +200,61 @@ describe('InboundResourceAdmission', () => {
|
|||
authorizationServers: [],
|
||||
challenges: [],
|
||||
});
|
||||
const refused = await admission.admitBearer(resource, REQUEST, '');
|
||||
pipeline.run.mockResolvedValue({ ok: false, reason: 'source-not-accepted' });
|
||||
const refused = await admission.admitBearer(resource, REQUEST, 'the-token');
|
||||
if (refused.ok) throw new Error('expected a refusal');
|
||||
|
||||
expect(await admission.challenge(refused)).toBe(
|
||||
'Bearer, error="invalid_token", error_description="no-credential"',
|
||||
'Bearer, error="invalid_token", error_description="source-not-accepted"',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves out the error when the request had no credential (RFC 6750 §3.1)', async () => {
|
||||
const refused = await admission.admitBearer(resource, REQUEST, '');
|
||||
if (refused.ok) throw new Error('expected a refusal');
|
||||
|
||||
expect(await admission.challenge(refused)).toBe(CHALLENGE);
|
||||
});
|
||||
|
||||
it('leaves out the error for a refusal without a reason', async () => {
|
||||
const challenge = await admission.challenge({
|
||||
advertised: {
|
||||
surface: 'trigger',
|
||||
resource: { url: RESOURCE_URL, acceptedAudiences: [RESOURCE_URL] },
|
||||
},
|
||||
});
|
||||
|
||||
expect(challenge).toBe(CHALLENGE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refuse', () => {
|
||||
it('writes one denial line with the context and returns the refusal', () => {
|
||||
const inbound = {
|
||||
surface: 'trigger' as const,
|
||||
resource: { url: RESOURCE_URL, acceptedAudiences: [RESOURCE_URL] },
|
||||
request: { ...REQUEST, headers: { authorization: 'Bearer the-token' } },
|
||||
receivedAt: new Date(),
|
||||
};
|
||||
|
||||
const refused = admission.refuse(inbound, 'unknown-subject', context);
|
||||
|
||||
expect(refused).toEqual({
|
||||
ok: false,
|
||||
reason: 'unknown-subject',
|
||||
advertised: { surface: 'trigger', resource: inbound.resource },
|
||||
context,
|
||||
});
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith('Inbound authentication denied', {
|
||||
surface: 'trigger',
|
||||
resource: RESOURCE_URL,
|
||||
sourceId: 'idp-1',
|
||||
authMethod: 'oauth-access-token',
|
||||
userId: 'user-1',
|
||||
reason: 'unknown-subject',
|
||||
});
|
||||
expect(JSON.stringify(refused)).not.toContain('the-token');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import type { AuthenticatedRequest, TokenGrant } from '@n8n/db';
|
||||
import { UserRepository } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { extractBearer, inboundFrom, inboundRequestOf, ResourceAdmission } from '@n8n/inbound-auth';
|
||||
import {
|
||||
extractBearer,
|
||||
inboundFrom,
|
||||
inboundRequestOf,
|
||||
isUrlIssuer,
|
||||
ResourceAdmission,
|
||||
} from '@n8n/inbound-auth';
|
||||
import {
|
||||
getApiKeyScopesForRole,
|
||||
isApiKeyScope,
|
||||
|
|
@ -36,8 +42,7 @@ export class PublicApiOAuthStrategy implements AuthStrategy {
|
|||
}
|
||||
|
||||
private isOAuthIssuer(token: string): boolean {
|
||||
const iss: unknown = this.jwtService.decodeUnverified<{ iss?: unknown } | null>(token)?.iss;
|
||||
return typeof iss === 'string' && URL.canParse(iss) && /^https?:$/.test(new URL(iss).protocol);
|
||||
return isUrlIssuer(this.jwtService.decodeUnverified<{ iss?: unknown } | null>(token)?.iss);
|
||||
}
|
||||
|
||||
async authenticate(req: AuthenticatedRequest): Promise<boolean | null> {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
type Extracted,
|
||||
type Inbound,
|
||||
type ProtectedResource,
|
||||
type Refusal,
|
||||
type RefusalReason,
|
||||
type Refused,
|
||||
} from '@n8n/inbound-auth';
|
||||
|
|
@ -51,17 +52,21 @@ export class InboundResourceAdmission extends ResourceAdmission {
|
|||
return { ok: true, context };
|
||||
}
|
||||
|
||||
async challenge({ advertised, reason }: Refused): Promise<string> {
|
||||
async challenge({ advertised, reason }: Refusal): Promise<string> {
|
||||
const { challenges } = await this.authenticationService.advertise(advertised);
|
||||
const challenge = challenges[0] ?? 'Bearer';
|
||||
// RFC 6750 §3.1: a request that lacks any authentication information
|
||||
// SHOULD NOT get an error code.
|
||||
if (reason === undefined || reason === 'no-credential') return challenge;
|
||||
const error = reason === 'insufficient_scope' ? 'insufficient_scope' : 'invalid_token';
|
||||
return `${challenges[0] ?? 'Bearer'}, error="${error}", error_description="${reason}"`;
|
||||
return `${challenge}, error="${error}", error_description="${reason}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one log line for a refusal the surface decides itself, before or after the pipeline.
|
||||
* Carries no token, no external subject and no email.
|
||||
*/
|
||||
private refuse(inbound: Inbound, reason: RefusalReason, context?: SecurityContext): Refused {
|
||||
refuse(inbound: Inbound, reason: RefusalReason, context?: SecurityContext): Refused {
|
||||
this.logger.warn('Inbound authentication denied', {
|
||||
surface: inbound.surface,
|
||||
resource: inbound.resource.url,
|
||||
|
|
|
|||
|
|
@ -66,9 +66,10 @@ describe('McpServerApiKeyService.verifyApiKey (integration)', () => {
|
|||
|
||||
const result = await service.verifyApiKey(apiKey);
|
||||
|
||||
expect(result.user?.id).toBe(owner.id);
|
||||
expect(result.actor).toBeUndefined();
|
||||
expect(result.context).toBeUndefined();
|
||||
expect(result?.user?.id).toBe(owner.id);
|
||||
expect(result?.actor).toBeUndefined();
|
||||
expect(result?.context).toBeUndefined();
|
||||
expect(result?.authMethod).toBe('api-key');
|
||||
});
|
||||
|
||||
it('accepts a scoped JWT and returns the subject when no actor is present', async () => {
|
||||
|
|
@ -76,9 +77,10 @@ describe('McpServerApiKeyService.verifyApiKey (integration)', () => {
|
|||
|
||||
const result = await service.verifyApiKey(makeScopedJwt(jwtService, subject.id));
|
||||
|
||||
expect(result.user?.id).toBe(subject.id);
|
||||
expect(result.actor).toBeUndefined();
|
||||
expect(result.context).toBeUndefined();
|
||||
expect(result?.user?.id).toBe(subject.id);
|
||||
expect(result?.actor).toBeUndefined();
|
||||
expect(result?.context).toBeUndefined();
|
||||
expect(result?.authMethod).toBe('scoped-jwt');
|
||||
});
|
||||
|
||||
it('accepts a scoped JWT with delegation and returns the actor as user', async () => {
|
||||
|
|
@ -89,8 +91,18 @@ describe('McpServerApiKeyService.verifyApiKey (integration)', () => {
|
|||
makeScopedJwt(jwtService, subject.id, { actSub: actor.id }),
|
||||
);
|
||||
|
||||
expect(result.user?.id).toBe(actor.id);
|
||||
expect(result.actor?.id).toBe(actor.id);
|
||||
expect(result?.user?.id).toBe(actor.id);
|
||||
expect(result?.actor?.id).toBe(actor.id);
|
||||
});
|
||||
|
||||
it('abstains on a valid browser session JWT, so the caller can send it down another path', async () => {
|
||||
// SessionCookieAuthStrategy is registered into the same registry above, so this proves
|
||||
// its buildTokenGrant genuinely abstains: the mcp-server-api audience is never satisfied.
|
||||
const owner = await createOwner();
|
||||
|
||||
const result = await service.verifyApiKey(Container.get(AuthService).issueJWT(owner, false));
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
describe('rejects tokens that fail validation', () => {
|
||||
|
|
@ -143,21 +155,11 @@ describe('McpServerApiKeyService.verifyApiKey (integration)', () => {
|
|||
name: 'scoped JWT whose subject is not in the database',
|
||||
token: () => makeScopedJwt(jwtService, '00000000-0000-0000-0000-000000000000'),
|
||||
},
|
||||
{
|
||||
// SessionCookieAuthStrategy is registered into the same registry above,
|
||||
// so this proves its buildTokenGrant genuinely abstains rather than the
|
||||
// audience check merely never encountering the strategy.
|
||||
name: 'a valid browser session cookie (must not satisfy the mcp-server-api audience)',
|
||||
token: async () => {
|
||||
const owner = await createOwner();
|
||||
return Container.get(AuthService).issueJWT(owner, false);
|
||||
},
|
||||
},
|
||||
])('rejects $name', async ({ token }) => {
|
||||
const result = await service.verifyApiKey(await token());
|
||||
|
||||
expect(result.user).toBeNull();
|
||||
expect(result.context?.reason).toBe('invalid_token');
|
||||
expect(result?.user).toBeNull();
|
||||
expect(result?.context?.reason).toBe('invalid_token');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -183,7 +185,7 @@ describe('McpServerApiKeyService.verifyApiKey (integration)', () => {
|
|||
|
||||
const result = await service.verifyApiKey(jwt);
|
||||
|
||||
expect(result.user).toBeNull();
|
||||
expect(result.context?.reason).toBe('invalid_token');
|
||||
expect(result?.user).toBeNull();
|
||||
expect(result?.context?.reason).toBe('invalid_token');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ describe('McpServerApiKeyService.verifyApiKey with public API disabled', () => {
|
|||
|
||||
const result = await service.verifyApiKey(apiKey);
|
||||
|
||||
expect(result.user?.id).toBe(owner.id);
|
||||
expect(result.context).toBeUndefined();
|
||||
expect(result?.user?.id).toBe(owner.id);
|
||||
expect(result?.context).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -35,8 +35,8 @@ describe('McpServerApiKeyService', () => {
|
|||
expect(authStrategyRegistry.buildContextFromToken).toHaveBeenCalledWith('any-token', {
|
||||
audience: 'mcp-server-api',
|
||||
});
|
||||
expect(result.user).toBe(subject);
|
||||
expect(result.actor).toBeUndefined();
|
||||
expect(result?.user).toBe(subject);
|
||||
expect(result?.actor).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the actor as user when delegation is present', async () => {
|
||||
|
|
@ -50,8 +50,8 @@ describe('McpServerApiKeyService', () => {
|
|||
|
||||
const result = await service.verifyApiKey('token');
|
||||
|
||||
expect(result.user).toBe(actor);
|
||||
expect(result.actor).toBe(actor);
|
||||
expect(result?.user).toBe(actor);
|
||||
expect(result?.actor).toBe(actor);
|
||||
});
|
||||
|
||||
it('reports `api_key` for a delegated token-exchange grant, not a distinct type', async () => {
|
||||
|
|
@ -68,16 +68,38 @@ describe('McpServerApiKeyService', () => {
|
|||
|
||||
const result = await service.verifyApiKey('scoped-jwt');
|
||||
|
||||
expect(result.caller).toEqual({ authType: 'api_key' });
|
||||
expect(result?.caller).toEqual({ authType: 'api_key' });
|
||||
});
|
||||
|
||||
it('returns a rejection context when the registry returns null', async () => {
|
||||
it('reports which strategy admitted the caller, for the log line', async () => {
|
||||
const subject = makeUser('subject-1');
|
||||
authStrategyRegistry.buildContextFromToken.mockResolvedValueOnce({
|
||||
subject,
|
||||
scopes: [],
|
||||
apiKeyId: 'key-1',
|
||||
} satisfies TokenGrant);
|
||||
authStrategyRegistry.buildContextFromToken.mockResolvedValueOnce({
|
||||
subject,
|
||||
scopes: [],
|
||||
} satisfies TokenGrant);
|
||||
|
||||
expect((await service.verifyApiKey('api-key'))?.authMethod).toBe('api-key');
|
||||
expect((await service.verifyApiKey('scoped-jwt'))?.authMethod).toBe('scoped-jwt');
|
||||
});
|
||||
|
||||
it('abstains when no strategy knows the token', async () => {
|
||||
authStrategyRegistry.buildContextFromToken.mockResolvedValue(null);
|
||||
|
||||
expect(await service.verifyApiKey('unknown-token')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns a rejection context when a strategy refuses the token', async () => {
|
||||
authStrategyRegistry.buildContextFromToken.mockResolvedValue(false);
|
||||
|
||||
const result = await service.verifyApiKey('bad-token');
|
||||
|
||||
expect(result.user).toBeNull();
|
||||
expect(result.context).toEqual({ reason: 'invalid_token', auth_type: 'api_key' });
|
||||
expect(result?.user).toBeNull();
|
||||
expect(result?.context).toEqual({ reason: 'invalid_token', auth_type: 'api_key' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
|
@ -89,8 +111,8 @@ describe('McpServerApiKeyService', () => {
|
|||
|
||||
const result = await service.verifyApiKey('token');
|
||||
|
||||
expect(result.user).toBeNull();
|
||||
expect(result.context).toEqual({
|
||||
expect(result?.user).toBeNull();
|
||||
expect(result?.context).toEqual({
|
||||
reason,
|
||||
auth_type: 'api_key',
|
||||
error_details: message,
|
||||
|
|
|
|||
|
|
@ -1,157 +1,108 @@
|
|||
import type { Mocked } from 'vitest';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import { mockInstance } from '@n8n/backend-test-utils';
|
||||
import type { User } from '@n8n/db';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import type { Transaction, User, UserRepository } from '@n8n/db';
|
||||
import type { Inbound, RefusalReason, Refused, ResourceAdmission } from '@n8n/inbound-auth';
|
||||
import type { Principal, SecurityContext } from '@n8n/permissions';
|
||||
import type { Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
import { mock, mockDeep } from 'vitest-mock-extended';
|
||||
import type { InstanceSettings } from 'n8n-core';
|
||||
|
||||
import { JwtService } from '@n8n/backend-services';
|
||||
import { type McpCallerAuth, OAuthTokenVerifierProxy } from '@n8n/backend-services/auth';
|
||||
import { Telemetry } from '@/telemetry';
|
||||
|
||||
import { McpServerApiKeyService } from '../mcp-api-key.service';
|
||||
import { MCP_CLIENT_INFO_META_KEY, MCP_PROTOCOL_VERSION_META_KEY } from '../mcp.constants';
|
||||
import { McpProtectedResource } from '../mcp-protected-resource';
|
||||
import { McpServerMiddlewareService } from '../mcp-server-middleware.service';
|
||||
import type { McpAuthenticatedRequest } from '../mcp.types';
|
||||
|
||||
const mockReqWith = (authHeader: string | undefined, body?: any) => {
|
||||
const req = mockDeep<Request>();
|
||||
const RESOURCE_URL = 'https://n8n.example.com/mcp-server/http';
|
||||
const CHALLENGE =
|
||||
'Bearer resource_metadata="https://n8n.example.com/.well-known/oauth-protected-resource/mcp-server/http"';
|
||||
|
||||
const mockReqWith = (authHeader: string | undefined, body?: unknown) => {
|
||||
const req = mockDeep<McpAuthenticatedRequest>({
|
||||
// `extractBearer` reads the headers object, not `req.header()`.
|
||||
headers: authHeader === undefined ? {} : { authorization: authHeader },
|
||||
method: 'POST',
|
||||
originalUrl: '/mcp-server/http',
|
||||
ip: '203.0.113.7',
|
||||
});
|
||||
req.header.mockImplementation((name: string) => {
|
||||
if (name === 'authorization') return authHeader;
|
||||
return undefined;
|
||||
});
|
||||
req.body = body || {};
|
||||
req.body = body ?? {};
|
||||
return req;
|
||||
};
|
||||
|
||||
const mockRes = () => {
|
||||
const res = mockDeep<Response>();
|
||||
res.status.mockReturnThis();
|
||||
res.send.mockReturnThis();
|
||||
res.header.mockReturnThis();
|
||||
return res;
|
||||
};
|
||||
|
||||
const instanceSettings = mock<InstanceSettings>({ encryptionKey: 'test-key' });
|
||||
const jwtService = new JwtService(instanceSettings, mock(), mock());
|
||||
const signingKey = generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey;
|
||||
|
||||
/** An access token in the shape n8n mints. The verifier is mocked, so only its claims matter. */
|
||||
const signOAuthToken = () =>
|
||||
jwt.sign({ sub: 'user-123', meta: { isOAuth: true } }, signingKey, {
|
||||
algorithm: 'ES256',
|
||||
audience: 'https://n8n.example.com/mcp-server/http',
|
||||
header: { alg: 'ES256', typ: 'at+jwt', kid: 'signing-kid' },
|
||||
});
|
||||
/** A token from an OAuth2 trusted source. The admission is mocked, so only `iss` matters. */
|
||||
const signIssuerToken = (iss: string) => jwt.sign({ iss, sub: 'idp-alice' }, 'any-secret');
|
||||
|
||||
const context: SecurityContext = {
|
||||
subject: { id: 'user-123', type: 'human', email: 'alice@example.com' } as Principal,
|
||||
authMethod: 'oauth-access-token',
|
||||
subjectClaim: { sourceId: 'idp-1', issuer: 'https://idp.example', subject: 'idp-alice' },
|
||||
clientId: 'client-abc',
|
||||
tokenScopes: ['workflow:read'],
|
||||
};
|
||||
|
||||
let mcpServerApiKeyService: Mocked<McpServerApiKeyService>;
|
||||
let oauthTokenVerifier: Mocked<OAuthTokenVerifierProxy>;
|
||||
let mcpProtectedResource: Mocked<McpProtectedResource>;
|
||||
let admission: Mocked<ResourceAdmission>;
|
||||
let userRepository: Mocked<UserRepository>;
|
||||
let telemetry: Mocked<Telemetry>;
|
||||
let logger: Mocked<Logger>;
|
||||
let service: McpServerMiddlewareService;
|
||||
|
||||
describe('McpServerMiddlewareService', () => {
|
||||
beforeAll(() => {
|
||||
mcpServerApiKeyService = mockInstance(McpServerApiKeyService) as Mocked<McpServerApiKeyService>;
|
||||
oauthTokenVerifier = mockInstance(OAuthTokenVerifierProxy) as Mocked<OAuthTokenVerifierProxy>;
|
||||
mcpProtectedResource = mockInstance(McpProtectedResource) as Mocked<McpProtectedResource>;
|
||||
mcpProtectedResource = mockInstance(McpProtectedResource, {
|
||||
surface: 'instance-mcp',
|
||||
}) as Mocked<McpProtectedResource>;
|
||||
admission = mock<ResourceAdmission>();
|
||||
userRepository = mock<UserRepository>();
|
||||
telemetry = mockInstance(Telemetry);
|
||||
logger = mock<Logger>();
|
||||
|
||||
service = new McpServerMiddlewareService(
|
||||
mcpServerApiKeyService,
|
||||
oauthTokenVerifier,
|
||||
mcpProtectedResource,
|
||||
admission,
|
||||
userRepository,
|
||||
jwtService,
|
||||
telemetry,
|
||||
logger,
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mcpProtectedResource.getResourceUrl.mockReturnValue('https://n8n.example.com/mcp-server/http');
|
||||
mcpProtectedResource.getProtectedResourceMetadataUrl.mockReturnValue(
|
||||
'https://n8n.example.com/.well-known/oauth-protected-resource/mcp-server/http',
|
||||
mcpProtectedResource.getResourceUrl.mockReturnValue(RESOURCE_URL);
|
||||
mcpProtectedResource.getAudiences.mockReturnValue([RESOURCE_URL]);
|
||||
admission.refuse.mockImplementation(
|
||||
(inbound: Inbound, reason: RefusalReason, ctx?: SecurityContext): Refused => ({
|
||||
ok: false,
|
||||
reason,
|
||||
advertised: { surface: inbound.surface, resource: inbound.resource },
|
||||
...(ctx && { context: ctx }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('getUserForToken', () => {
|
||||
it('should return user for valid OAuth token (meta.isOAuth = true)', async () => {
|
||||
const user = mock<User>({ id: 'user-123' });
|
||||
const oauthToken = signOAuthToken();
|
||||
|
||||
oauthTokenVerifier.verifyOAuthAccessToken.mockResolvedValue({
|
||||
user,
|
||||
caller: { authType: 'oauth', clientId: 'client-abc' },
|
||||
});
|
||||
|
||||
const result = await service.getUserForToken(oauthToken);
|
||||
|
||||
expect(result).toEqual({ user, caller: { authType: 'oauth', clientId: 'client-abc' } });
|
||||
expect(oauthTokenVerifier.verifyOAuthAccessToken).toHaveBeenCalledWith(
|
||||
oauthToken,
|
||||
'https://n8n.example.com/mcp-server/http',
|
||||
);
|
||||
expect(mcpServerApiKeyService.verifyApiKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return user for valid API key (no meta.isOAuth)', async () => {
|
||||
const user = mock<User>({ id: 'user-123' });
|
||||
const apiKeyToken = jwtService.sign('mcpApiKey', { sub: 'user-123' });
|
||||
|
||||
mcpServerApiKeyService.verifyApiKey.mockResolvedValue({
|
||||
user,
|
||||
caller: { authType: 'api_key' },
|
||||
});
|
||||
|
||||
const result = await service.getUserForToken(apiKeyToken);
|
||||
|
||||
expect(result).toEqual({ user, caller: { authType: 'api_key' } });
|
||||
expect(mcpServerApiKeyService.verifyApiKey).toHaveBeenCalledWith(apiKeyToken);
|
||||
expect(oauthTokenVerifier.verifyOAuthAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return user for valid API key (meta.isOAuth = false)', async () => {
|
||||
const user = mock<User>({ id: 'user-123' });
|
||||
const apiKeyToken = jwtService.sign('mcpApiKey', {
|
||||
sub: 'user-123',
|
||||
meta: { isOAuth: false },
|
||||
});
|
||||
|
||||
mcpServerApiKeyService.verifyApiKey.mockResolvedValue({
|
||||
user,
|
||||
caller: { authType: 'api_key' },
|
||||
});
|
||||
|
||||
const result = await service.getUserForToken(apiKeyToken);
|
||||
|
||||
expect(result).toEqual({ user, caller: { authType: 'api_key' } });
|
||||
expect(mcpServerApiKeyService.verifyApiKey).toHaveBeenCalledWith(apiKeyToken);
|
||||
expect(oauthTokenVerifier.verifyOAuthAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return null for invalid JWT format', async () => {
|
||||
const invalidToken = 'not-a-jwt-token';
|
||||
|
||||
mcpServerApiKeyService.verifyApiKey.mockResolvedValue({ user: null });
|
||||
const result = await service.getUserForToken(invalidToken);
|
||||
|
||||
expect(result).toMatchObject({ user: null });
|
||||
expect(oauthTokenVerifier.verifyOAuthAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return null when OAuth token verification fails', async () => {
|
||||
const oauthToken = signOAuthToken();
|
||||
|
||||
oauthTokenVerifier.verifyOAuthAccessToken.mockResolvedValue({ user: null });
|
||||
|
||||
const result = await service.getUserForToken(oauthToken);
|
||||
|
||||
expect(result).toMatchObject({ user: null });
|
||||
});
|
||||
|
||||
it('should return null when API key verification fails', async () => {
|
||||
const apiKeyToken = jwtService.sign('mcpApiKey', { sub: 'user-123' });
|
||||
|
||||
mcpServerApiKeyService.verifyApiKey.mockResolvedValue({ user: null });
|
||||
|
||||
const result = await service.getUserForToken(apiKeyToken);
|
||||
|
||||
expect(result).toMatchObject({ user: null });
|
||||
});
|
||||
admission.challenge.mockResolvedValue(CHALLENGE);
|
||||
});
|
||||
|
||||
describe('getEnabledMiddleware', () => {
|
||||
|
|
@ -208,279 +159,352 @@ describe('McpServerMiddlewareService', () => {
|
|||
});
|
||||
|
||||
describe('getAuthMiddleware', () => {
|
||||
it('should return 401 with WWW-Authenticate header when authorization header is missing', async () => {
|
||||
const req = mockReqWith(undefined);
|
||||
const res = mockDeep<Response>();
|
||||
res.status.mockReturnThis();
|
||||
res.send.mockReturnThis();
|
||||
res.header.mockReturnThis();
|
||||
const run = async (authHeader: string | undefined, body?: unknown) => {
|
||||
const req = mockReqWith(authHeader, body);
|
||||
const res = mockRes();
|
||||
const next = vi.fn() as NextFunction;
|
||||
|
||||
const middleware = service.getAuthMiddleware();
|
||||
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(res.header).toHaveBeenCalledWith(
|
||||
'WWW-Authenticate',
|
||||
'Bearer realm="n8n MCP Server", resource_metadata="https://n8n.example.com/.well-known/oauth-protected-resource/mcp-server/http"',
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.send).toHaveBeenCalledWith({
|
||||
message: 'Unauthorized: Authorization header not sent',
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(telemetry.track).toHaveBeenCalledWith('User connected to MCP server', {
|
||||
mcp_connection_status: 'error',
|
||||
http_status: 401,
|
||||
error: 'Unauthorized',
|
||||
client_name: undefined,
|
||||
client_version: undefined,
|
||||
auth_type: 'unknown',
|
||||
error_details: 'Authorization header not sent',
|
||||
reason: 'missing_authorization_header',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 401 with WWW-Authenticate header when authorization header does not start with Bearer', async () => {
|
||||
const req = mockReqWith('Basic sometoken');
|
||||
const res = mockDeep<Response>();
|
||||
res.status.mockReturnThis();
|
||||
const next = vi.fn() as NextFunction;
|
||||
|
||||
const middleware = service.getAuthMiddleware();
|
||||
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(res.header).toHaveBeenCalledWith(
|
||||
'WWW-Authenticate',
|
||||
'Bearer realm="n8n MCP Server", resource_metadata="https://n8n.example.com/.well-known/oauth-protected-resource/mcp-server/http"',
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.send).toHaveBeenCalledWith({
|
||||
message: 'Unauthorized: Invalid authorization header format - Missing Bearer prefix',
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(telemetry.track).toHaveBeenCalledWith('User connected to MCP server', {
|
||||
mcp_connection_status: 'error',
|
||||
http_status: 401,
|
||||
error: 'Unauthorized',
|
||||
client_name: undefined,
|
||||
client_version: undefined,
|
||||
auth_type: 'unknown',
|
||||
error_details: 'Invalid authorization header format - Missing Bearer prefix',
|
||||
reason: 'invalid_bearer_format',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 401 with WWW-Authenticate header when Bearer token is malformed', async () => {
|
||||
const req = mockReqWith('Bearer');
|
||||
const res = mockDeep<Response>();
|
||||
res.status.mockReturnThis();
|
||||
const next = vi.fn() as NextFunction;
|
||||
|
||||
const middleware = service.getAuthMiddleware();
|
||||
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(res.header).toHaveBeenCalledWith(
|
||||
'WWW-Authenticate',
|
||||
'Bearer realm="n8n MCP Server", resource_metadata="https://n8n.example.com/.well-known/oauth-protected-resource/mcp-server/http"',
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.send).toHaveBeenCalledWith({
|
||||
message: 'Unauthorized: Invalid authorization header format - Malformed Bearer token',
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(telemetry.track).toHaveBeenCalledWith('User connected to MCP server', {
|
||||
mcp_connection_status: 'error',
|
||||
http_status: 401,
|
||||
error: 'Unauthorized',
|
||||
client_name: undefined,
|
||||
client_version: undefined,
|
||||
auth_type: 'unknown',
|
||||
error_details: 'Invalid authorization header format - Malformed Bearer token',
|
||||
reason: 'invalid_bearer_format',
|
||||
});
|
||||
});
|
||||
|
||||
it('should authenticate with valid OAuth token and call next', async () => {
|
||||
const user = mock<User>({ id: 'user-123' });
|
||||
const oauthToken = signOAuthToken();
|
||||
|
||||
const req = mockReqWith(`Bearer ${oauthToken}`);
|
||||
const res = mockDeep<Response>();
|
||||
const next = vi.fn() as NextFunction;
|
||||
|
||||
oauthTokenVerifier.verifyOAuthAccessToken.mockResolvedValue({
|
||||
user,
|
||||
caller: { authType: 'oauth', clientId: 'client-abc' },
|
||||
});
|
||||
|
||||
const middleware = service.getAuthMiddleware();
|
||||
|
||||
await middleware(req, res, next);
|
||||
const authenticatedReq = req as Request & { user?: User; mcpCaller?: McpCallerAuth };
|
||||
|
||||
expect(authenticatedReq.user).toEqual(user);
|
||||
expect(authenticatedReq.mcpCaller?.authType).toBe('oauth');
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should attach the OAuth token scopes to the request', async () => {
|
||||
const user = mock<User>({ id: 'user-123' });
|
||||
const oauthToken = signOAuthToken();
|
||||
|
||||
const req = mockReqWith(`Bearer ${oauthToken}`);
|
||||
const res = mockDeep<Response>();
|
||||
const next = vi.fn() as NextFunction;
|
||||
|
||||
oauthTokenVerifier.verifyOAuthAccessToken.mockResolvedValue({
|
||||
user,
|
||||
caller: { authType: 'oauth', clientId: 'client-abc' },
|
||||
scopes: ['workflow:read'],
|
||||
});
|
||||
|
||||
await service.getAuthMiddleware()(req, res, next);
|
||||
const authenticatedReq = req as Request & { mcpScopes?: string[] };
|
||||
return { req, res, next };
|
||||
};
|
||||
|
||||
expect(authenticatedReq.mcpScopes).toEqual(['workflow:read']);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should attach the OAuth client the token was issued to', async () => {
|
||||
const user = mock<User>({ id: 'user-123' });
|
||||
const oauthToken = signOAuthToken();
|
||||
|
||||
const req = mockReqWith(`Bearer ${oauthToken}`);
|
||||
const res = mockDeep<Response>();
|
||||
const next = vi.fn() as NextFunction;
|
||||
|
||||
oauthTokenVerifier.verifyOAuthAccessToken.mockResolvedValue({
|
||||
user,
|
||||
caller: { authType: 'oauth', clientId: 'client-abc' },
|
||||
});
|
||||
|
||||
await service.getAuthMiddleware()(req, res, next);
|
||||
|
||||
expect((req as Request & { mcpCaller?: McpCallerAuth }).mcpCaller).toEqual({
|
||||
authType: 'oauth',
|
||||
clientId: 'client-abc',
|
||||
});
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should authenticate with valid API key and call next', async () => {
|
||||
const user = mock<User>({ id: 'user-123' });
|
||||
const apiKeyToken = jwtService.sign('mcpApiKey', { sub: 'user-123' });
|
||||
|
||||
const req = mockReqWith(`Bearer ${apiKeyToken}`);
|
||||
const res = mockDeep<Response>();
|
||||
const next = vi.fn() as NextFunction;
|
||||
|
||||
mcpServerApiKeyService.verifyApiKey.mockResolvedValue({
|
||||
user,
|
||||
caller: { authType: 'api_key' },
|
||||
});
|
||||
|
||||
const middleware = service.getAuthMiddleware();
|
||||
|
||||
await middleware(req, res, next);
|
||||
const authenticatedReq = req as Request & {
|
||||
user?: User;
|
||||
mcpCaller?: McpCallerAuth;
|
||||
mcpScopes?: string[];
|
||||
};
|
||||
|
||||
expect(authenticatedReq.user).toEqual(user);
|
||||
// An API key is not issued to an OAuth client, so there is none to report
|
||||
expect(authenticatedReq.mcpCaller).toEqual({ authType: 'api_key' });
|
||||
// API keys are not scope-bearing: undefined means full tool access
|
||||
expect(authenticatedReq.mcpScopes).toBeUndefined();
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should authenticate with a delegated scoped JWT and set req.user to the actor', async () => {
|
||||
const actor = mock<User>({ id: 'actor-1' });
|
||||
const scopedJwt = jwtService.sign('tokenExchange', {
|
||||
iss: 'n8n-token-exchange',
|
||||
sub: 'subject-1',
|
||||
act: { sub: 'actor-1' },
|
||||
jti: 'test-jti',
|
||||
});
|
||||
|
||||
const req = mockReqWith(`Bearer ${scopedJwt}`);
|
||||
const res = mockDeep<Response>();
|
||||
const next = vi.fn() as NextFunction;
|
||||
|
||||
// verifyApiKey returns the acting principal as `user` and keeps the actor field populated.
|
||||
mcpServerApiKeyService.verifyApiKey.mockResolvedValue({ user: actor, actor });
|
||||
|
||||
const middleware = service.getAuthMiddleware();
|
||||
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect((req as any).user).toEqual(actor);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
// Scoped JWTs flow through verifyApiKey (no meta.isOAuth), not the OAuth path.
|
||||
expect(mcpServerApiKeyService.verifyApiKey).toHaveBeenCalledWith(scopedJwt);
|
||||
expect(oauthTokenVerifier.verifyOAuthAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 401 with WWW-Authenticate header when token validation fails', async () => {
|
||||
const invalidToken = signOAuthToken();
|
||||
|
||||
const req = mockReqWith(`Bearer ${invalidToken}`);
|
||||
const res = mockDeep<Response>();
|
||||
res.status.mockReturnThis();
|
||||
res.send.mockReturnThis();
|
||||
res.header.mockReturnThis();
|
||||
const next = vi.fn() as NextFunction;
|
||||
|
||||
oauthTokenVerifier.verifyOAuthAccessToken.mockResolvedValue({ user: null });
|
||||
|
||||
const middleware = service.getAuthMiddleware();
|
||||
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(res.header).toHaveBeenCalledWith(
|
||||
'WWW-Authenticate',
|
||||
'Bearer realm="n8n MCP Server", resource_metadata="https://n8n.example.com/.well-known/oauth-protected-resource/mcp-server/http"',
|
||||
);
|
||||
const expectUnauthorized = (res: Mocked<Response>, next: NextFunction, message: string) => {
|
||||
expect(res.header).toHaveBeenCalledWith('WWW-Authenticate', CHALLENGE);
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.send).toHaveBeenCalledWith({ message: 'Unauthorized' });
|
||||
expect(res.send).toHaveBeenCalledWith({ message });
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
describe('a header without a bearer', () => {
|
||||
it('refuses a missing header with no-credential and keeps the telemetry', async () => {
|
||||
const { res, next } = await run(undefined);
|
||||
|
||||
expect(admission.refuse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ surface: 'instance-mcp' }),
|
||||
'no-credential',
|
||||
);
|
||||
expect(admission.challenge).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ reason: 'no-credential' }),
|
||||
);
|
||||
expectUnauthorized(res, next, 'Unauthorized: Authorization header not sent');
|
||||
expect(telemetry.track).toHaveBeenCalledWith('User connected to MCP server', {
|
||||
mcp_connection_status: 'error',
|
||||
http_status: 401,
|
||||
error: 'Unauthorized',
|
||||
client_name: undefined,
|
||||
client_version: undefined,
|
||||
auth_type: 'unknown',
|
||||
error_details: 'Authorization header not sent',
|
||||
reason: 'missing_authorization_header',
|
||||
});
|
||||
expect(admission.admit).not.toHaveBeenCalled();
|
||||
// The shared admission writes the denial line.
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Basic sometoken', 'Missing Bearer prefix'],
|
||||
['Bearer', 'Malformed Bearer token'],
|
||||
['Bearer two parts', 'Malformed Bearer token'],
|
||||
])('refuses %j with malformed-credential and today’s message', async (header, problem) => {
|
||||
const { res, next } = await run(header);
|
||||
|
||||
expect(admission.refuse).toHaveBeenCalledWith(expect.anything(), 'malformed-credential');
|
||||
expectUnauthorized(
|
||||
res,
|
||||
next,
|
||||
`Unauthorized: Invalid authorization header format - ${problem}`,
|
||||
);
|
||||
expect(telemetry.track).toHaveBeenCalledWith(
|
||||
'User connected to MCP server',
|
||||
expect.objectContaining({
|
||||
reason: 'invalid_bearer_format',
|
||||
auth_type: 'unknown',
|
||||
error_details: `Invalid authorization header format - ${problem}`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a bearer from a URL issuer', () => {
|
||||
beforeEach(() => {
|
||||
admission.admit.mockResolvedValue({ ok: true, context });
|
||||
userRepository.findByIdWithRole.mockResolvedValue(mock<User>({ id: 'user-123' }));
|
||||
});
|
||||
|
||||
it.each(['https://idp.example', 'http://localhost:5678'])(
|
||||
'sends a token from %s to the pipeline, not to the API-key path',
|
||||
async (iss) => {
|
||||
const token = signIssuerToken(iss);
|
||||
|
||||
await run(`Bearer ${token}`);
|
||||
|
||||
expect(admission.admit).toHaveBeenCalledWith(
|
||||
mcpProtectedResource,
|
||||
expect.objectContaining({
|
||||
surface: 'instance-mcp',
|
||||
credential: { kind: 'bearer', token },
|
||||
}),
|
||||
);
|
||||
expect(mcpServerApiKeyService.verifyApiKey).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('sets the user, the OAuth caller, the token scopes and the security context', async () => {
|
||||
const user = mock<User>({ id: 'user-123' });
|
||||
userRepository.findByIdWithRole.mockResolvedValue(user);
|
||||
|
||||
const { req, res, next } = await run(`Bearer ${signIssuerToken('https://idp.example')}`);
|
||||
|
||||
expect(userRepository.findByIdWithRole).toHaveBeenCalledWith('user-123');
|
||||
expect(req.user).toBe(user);
|
||||
expect(req.mcpCaller).toEqual({ authType: 'oauth', clientId: 'client-abc' });
|
||||
expect(req.mcpScopes).toEqual(['workflow:read']);
|
||||
expect(req.ctx?.security).toBe(context);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
// The pipeline writes the accept line.
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps what earlier middleware put on the operation context', async () => {
|
||||
const req = mockReqWith(`Bearer ${signIssuerToken('https://idp.example')}`);
|
||||
const earlier = { trx: mock<Transaction>() };
|
||||
req.ctx = earlier;
|
||||
|
||||
await service.getAuthMiddleware()(req, mockRes(), vi.fn());
|
||||
|
||||
expect(req.ctx).toEqual({ ...earlier, security: context });
|
||||
});
|
||||
|
||||
it('treats a token without scopes as scope-bearing with no scopes, not full access', async () => {
|
||||
admission.admit.mockResolvedValue({
|
||||
ok: true,
|
||||
context: { ...context, tokenScopes: undefined },
|
||||
});
|
||||
|
||||
const { req } = await run(`Bearer ${signIssuerToken('https://idp.example')}`);
|
||||
|
||||
expect(req.mcpScopes).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses an admitted token without a client id', async () => {
|
||||
const withoutClient = { ...context, clientId: undefined };
|
||||
admission.admit.mockResolvedValue({ ok: true, context: withoutClient });
|
||||
|
||||
const { res, next } = await run(`Bearer ${signIssuerToken('https://idp.example')}`);
|
||||
|
||||
expect(admission.refuse).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'not-an-access-token',
|
||||
withoutClient,
|
||||
);
|
||||
expectUnauthorized(res, next, 'Unauthorized: not-an-access-token');
|
||||
expect(telemetry.track).toHaveBeenCalledWith(
|
||||
'User connected to MCP server',
|
||||
expect.objectContaining({
|
||||
reason: 'invalid_token',
|
||||
auth_type: 'oauth',
|
||||
error_details: 'not-an-access-token',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses an admitted token whose user row is gone', async () => {
|
||||
userRepository.findByIdWithRole.mockResolvedValue(null);
|
||||
|
||||
const { res, next } = await run(`Bearer ${signIssuerToken('https://idp.example')}`);
|
||||
|
||||
expect(admission.refuse).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'unknown-subject',
|
||||
context,
|
||||
);
|
||||
expectUnauthorized(res, next, 'Unauthorized: unknown-subject');
|
||||
expect(telemetry.track).toHaveBeenCalledWith(
|
||||
'User connected to MCP server',
|
||||
expect.objectContaining({ reason: 'user_not_found', auth_type: 'oauth' }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['source-not-accepted', 'invalid_token'],
|
||||
['signature-invalid', 'invalid_token'],
|
||||
['revoked', 'token_not_found_in_db'],
|
||||
] as const)(
|
||||
'answers a %s refusal with the admission challenge and the reason',
|
||||
async (reason, telemetryReason) => {
|
||||
const refused: Refused = {
|
||||
ok: false,
|
||||
reason,
|
||||
advertised: {
|
||||
surface: 'instance-mcp',
|
||||
resource: { url: RESOURCE_URL, acceptedAudiences: [RESOURCE_URL] },
|
||||
},
|
||||
};
|
||||
admission.admit.mockResolvedValue(refused);
|
||||
|
||||
const { res, next } = await run(`Bearer ${signIssuerToken('https://idp.example')}`);
|
||||
|
||||
expect(admission.challenge).toHaveBeenCalledWith(refused);
|
||||
expectUnauthorized(res, next, `Unauthorized: ${reason}`);
|
||||
expect(telemetry.track).toHaveBeenCalledWith(
|
||||
'User connected to MCP server',
|
||||
expect.objectContaining({
|
||||
reason: telemetryReason,
|
||||
auth_type: 'oauth',
|
||||
error_details: reason,
|
||||
}),
|
||||
);
|
||||
// The pipeline writes the reject line.
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('a bearer for the API-key path', () => {
|
||||
it('authenticates an MCP API key without the pipeline and logs the accept', async () => {
|
||||
const user = mock<User>({ id: 'user-123' });
|
||||
const apiKey = jwtService.sign('mcpApiKey', { sub: 'user-123', iss: 'n8n' });
|
||||
mcpServerApiKeyService.verifyApiKey.mockResolvedValue({
|
||||
user,
|
||||
caller: { authType: 'api_key' },
|
||||
authMethod: 'api-key',
|
||||
});
|
||||
|
||||
const { req, res, next } = await run(`Bearer ${apiKey}`);
|
||||
|
||||
expect(mcpServerApiKeyService.verifyApiKey).toHaveBeenCalledWith(apiKey);
|
||||
expect(admission.admit).not.toHaveBeenCalled();
|
||||
expect(req.user).toBe(user);
|
||||
// An API key is not issued to an OAuth client, so there is none to report
|
||||
expect(req.mcpCaller).toEqual({ authType: 'api_key' });
|
||||
// API keys are not scope-bearing: undefined means full tool access
|
||||
expect(req.mcpScopes).toBeUndefined();
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledTimes(1);
|
||||
expect(logger.info).toHaveBeenCalledWith('Inbound authentication accepted', {
|
||||
surface: 'instance-mcp',
|
||||
resource: RESOURCE_URL,
|
||||
authMethod: 'api-key',
|
||||
userId: 'user-123',
|
||||
});
|
||||
});
|
||||
|
||||
it('authenticates a delegated token-exchange JWT and sets req.user to the actor', async () => {
|
||||
const actor = mock<User>({ id: 'actor-1' });
|
||||
const scopedJwt = jwtService.sign('tokenExchange', {
|
||||
iss: 'n8n-token-exchange',
|
||||
sub: 'subject-1',
|
||||
act: { sub: 'actor-1' },
|
||||
jti: 'test-jti',
|
||||
});
|
||||
mcpServerApiKeyService.verifyApiKey.mockResolvedValue({
|
||||
user: actor,
|
||||
actor,
|
||||
caller: { authType: 'api_key' },
|
||||
authMethod: 'scoped-jwt',
|
||||
});
|
||||
|
||||
const { req, next } = await run(`Bearer ${scopedJwt}`);
|
||||
|
||||
expect(req.user).toBe(actor);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(admission.admit).not.toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Inbound authentication accepted',
|
||||
expect.objectContaining({ authMethod: 'scoped-jwt', userId: 'actor-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('answers a rejected API key with the challenge without an error, as before', async () => {
|
||||
mcpServerApiKeyService.verifyApiKey.mockResolvedValue({
|
||||
user: null,
|
||||
context: { reason: 'invalid_token', auth_type: 'api_key' },
|
||||
});
|
||||
|
||||
const { res, next } = await run('Bearer invalid-token-format');
|
||||
|
||||
expect(admission.challenge).toHaveBeenCalledWith({
|
||||
advertised: {
|
||||
surface: 'instance-mcp',
|
||||
resource: { url: RESOURCE_URL, acceptedAudiences: [RESOURCE_URL] },
|
||||
},
|
||||
});
|
||||
expectUnauthorized(res, next, 'Unauthorized');
|
||||
expect(telemetry.track).toHaveBeenCalledWith(
|
||||
'User connected to MCP server',
|
||||
expect.objectContaining({ reason: 'invalid_token', auth_type: 'api_key' }),
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith('Inbound authentication denied', {
|
||||
surface: 'instance-mcp',
|
||||
resource: RESOURCE_URL,
|
||||
reason: 'invalid_token',
|
||||
});
|
||||
expect(admission.admit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends a token that no API-key strategy knows to the pipeline', async () => {
|
||||
mcpServerApiKeyService.verifyApiKey.mockResolvedValue(null);
|
||||
admission.admit.mockResolvedValue({
|
||||
ok: false,
|
||||
reason: 'unknown-issuer',
|
||||
advertised: {
|
||||
surface: 'instance-mcp',
|
||||
resource: { url: RESOURCE_URL, acceptedAudiences: [RESOURCE_URL] },
|
||||
},
|
||||
});
|
||||
|
||||
const { res, next } = await run('Bearer some-session-jwt');
|
||||
|
||||
expect(admission.admit).toHaveBeenCalled();
|
||||
expectUnauthorized(res, next, 'Unauthorized: unknown-issuer');
|
||||
expect(telemetry.track).toHaveBeenCalledWith(
|
||||
'User connected to MCP server',
|
||||
expect.objectContaining({
|
||||
reason: 'invalid_token',
|
||||
auth_type: 'unknown',
|
||||
error_details: 'unknown-issuer',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('routes a JWT whose payload is not JSON to the API-key path instead of throwing', async () => {
|
||||
// `jwt.decode` throws for a token typed `JWT` whose payload does not parse.
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString(
|
||||
'base64url',
|
||||
);
|
||||
const token = `${header}.${Buffer.from('not-json').toString('base64url')}.sig`;
|
||||
mcpServerApiKeyService.verifyApiKey.mockResolvedValue({ user: null });
|
||||
|
||||
const { res } = await run(`Bearer ${token}`);
|
||||
|
||||
expect(mcpServerApiKeyService.verifyApiKey).toHaveBeenCalledWith(token);
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
});
|
||||
|
||||
it('keeps the token and the email out of the log lines', async () => {
|
||||
const apiKey = jwtService.sign('mcpApiKey', { sub: 'user-123', iss: 'n8n' });
|
||||
mcpServerApiKeyService.verifyApiKey.mockResolvedValueOnce({
|
||||
user: mock<User>({ id: 'user-123', email: 'alice@example.com' }),
|
||||
caller: { authType: 'api_key' },
|
||||
authMethod: 'api-key',
|
||||
});
|
||||
mcpServerApiKeyService.verifyApiKey.mockResolvedValueOnce({ user: null });
|
||||
|
||||
await run(`Bearer ${apiKey}`);
|
||||
await run(`Bearer ${apiKey}`);
|
||||
|
||||
const logged = JSON.stringify([logger.info.mock.calls, logger.warn.mock.calls]);
|
||||
expect(logged).not.toContain(apiKey);
|
||||
expect(logged).not.toContain('alice@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
it('should track telemetry with client info from request body', async () => {
|
||||
const req = mockReqWith(undefined, {
|
||||
params: {
|
||||
clientInfo: {
|
||||
name: 'test-client',
|
||||
version: '1.0.0',
|
||||
},
|
||||
},
|
||||
});
|
||||
const res = mockDeep<Response>();
|
||||
res.status.mockReturnThis();
|
||||
res.send.mockReturnThis();
|
||||
res.header.mockReturnThis();
|
||||
const next = vi.fn() as NextFunction;
|
||||
await run(undefined, { params: { clientInfo: { name: 'test-client', version: '1.0.0' } } });
|
||||
|
||||
const middleware = service.getAuthMiddleware();
|
||||
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(res.header).toHaveBeenCalledWith(
|
||||
'WWW-Authenticate',
|
||||
'Bearer realm="n8n MCP Server", resource_metadata="https://n8n.example.com/.well-known/oauth-protected-resource/mcp-server/http"',
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.send).toHaveBeenCalledWith({
|
||||
message: 'Unauthorized: Authorization header not sent',
|
||||
});
|
||||
expect(telemetry.track).toHaveBeenCalledWith('User connected to MCP server', {
|
||||
mcp_connection_status: 'error',
|
||||
http_status: 401,
|
||||
|
|
@ -498,7 +522,7 @@ describe('McpServerMiddlewareService', () => {
|
|||
// points the client at the OAuth protected-resource metadata. The client
|
||||
// identity and protocol version it declared in `_meta` are still captured.
|
||||
it('should reject an unauthenticated server/discover probe with 401 and capture _meta', async () => {
|
||||
const req = mockReqWith(undefined, {
|
||||
const { res, next } = await run(undefined, {
|
||||
jsonrpc: '2.0',
|
||||
method: 'server/discover',
|
||||
params: {
|
||||
|
|
@ -508,20 +532,8 @@ describe('McpServerMiddlewareService', () => {
|
|||
},
|
||||
},
|
||||
});
|
||||
const res = mockDeep<Response>();
|
||||
res.status.mockReturnThis();
|
||||
res.send.mockReturnThis();
|
||||
res.header.mockReturnThis();
|
||||
const next = vi.fn() as NextFunction;
|
||||
|
||||
await service.getAuthMiddleware()(req, res, next);
|
||||
|
||||
expect(res.header).toHaveBeenCalledWith(
|
||||
'WWW-Authenticate',
|
||||
'Bearer realm="n8n MCP Server", resource_metadata="https://n8n.example.com/.well-known/oauth-protected-resource/mcp-server/http"',
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expectUnauthorized(res, next, 'Unauthorized: Authorization header not sent');
|
||||
expect(telemetry.track).toHaveBeenCalledWith('User connected to MCP server', {
|
||||
mcp_connection_status: 'error',
|
||||
http_status: 401,
|
||||
|
|
@ -534,28 +546,5 @@ describe('McpServerMiddlewareService', () => {
|
|||
reason: 'missing_authorization_header',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle invalid token format gracefully with WWW-Authenticate header', async () => {
|
||||
const req = mockReqWith('Bearer invalid-token-format');
|
||||
const res = mockDeep<Response>();
|
||||
res.status.mockReturnThis();
|
||||
res.send.mockReturnThis();
|
||||
res.header.mockReturnThis();
|
||||
const next = vi.fn() as NextFunction;
|
||||
|
||||
mcpServerApiKeyService.verifyApiKey.mockResolvedValue({ user: null });
|
||||
|
||||
const middleware = service.getAuthMiddleware();
|
||||
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(res.header).toHaveBeenCalledWith(
|
||||
'WWW-Authenticate',
|
||||
'Bearer realm="n8n MCP Server", resource_metadata="https://n8n.example.com/.well-known/oauth-protected-resource/mcp-server/http"',
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.send).toHaveBeenCalledWith({ message: 'Unauthorized' });
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ import { setupTestServer } from '@test-integration/utils';
|
|||
|
||||
import { McpSettingsService } from '../mcp.settings.service';
|
||||
|
||||
const testServer = setupTestServer({ modules: ['oauth-server', 'mcp'], endpointGroups: ['mcp'] });
|
||||
const testServer = setupTestServer({
|
||||
// Production order: the MCP middleware and metadata need the inbound-auth contracts.
|
||||
modules: ['inbound-auth-core', 'oauth-server', 'mcp'],
|
||||
endpointGroups: ['mcp'],
|
||||
});
|
||||
|
||||
let mcpSettingsService: McpSettingsService;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { ApiKey, ApiKeyRepository, User } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import type { AuthMethod } from '@n8n/permissions';
|
||||
import { EntityManager } from '@n8n/typeorm';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { ensureError } from '@n8n/utils/errors/ensure-error';
|
||||
|
|
@ -15,6 +16,11 @@ const REDACT_API_KEY_MAX_LENGTH = 10;
|
|||
const API_KEY_LABEL = 'MCP Server API Key';
|
||||
const REDACT_API_KEY_MIN_HIDDEN_CHARS = 6;
|
||||
|
||||
export type McpApiKeyResult = UserWithContext & {
|
||||
/** Set on success, for the authentication log line. */
|
||||
authMethod?: Extract<AuthMethod, 'api-key' | 'scoped-jwt'>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Service for managing MCP server API keys, including creation, retrieval, deletion, and authentication middleware.
|
||||
*/
|
||||
|
|
@ -63,29 +69,34 @@ export class McpServerApiKeyService {
|
|||
return apiKey;
|
||||
}
|
||||
|
||||
async verifyApiKey(apiKey: string): Promise<UserWithContext> {
|
||||
/** `null`: no strategy knows the token, so the caller must send it down another path. */
|
||||
async verifyApiKey(apiKey: string): Promise<McpApiKeyResult | null> {
|
||||
try {
|
||||
const tokenGrant = await this.authStrategyRegistry.buildContextFromToken(apiKey, {
|
||||
audience: API_KEY_AUDIENCE,
|
||||
});
|
||||
|
||||
if (tokenGrant) {
|
||||
if (tokenGrant === null) return null;
|
||||
|
||||
if (tokenGrant === false) {
|
||||
return {
|
||||
user: tokenGrant.actor ?? tokenGrant.subject,
|
||||
actor: tokenGrant.actor,
|
||||
// Every non-OAuth bearer token the strategy chain admits is reported
|
||||
// as `api_key`, token-exchange scoped JWTs included. The distinction
|
||||
// between them is not surfaced to telemetry or log streaming.
|
||||
caller: { authType: 'api_key' },
|
||||
user: null,
|
||||
context: {
|
||||
reason: 'invalid_token',
|
||||
auth_type: 'api_key',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
user: null,
|
||||
context: {
|
||||
reason: 'invalid_token',
|
||||
auth_type: 'api_key',
|
||||
},
|
||||
user: tokenGrant.actor ?? tokenGrant.subject,
|
||||
actor: tokenGrant.actor,
|
||||
// Every non-OAuth bearer token the strategy chain admits is reported
|
||||
// as `api_key`, token-exchange scoped JWTs included. The distinction
|
||||
// between them is not surfaced to telemetry or log streaming.
|
||||
caller: { authType: 'api_key' },
|
||||
// Only the API-key strategy sets `apiKeyId`; a token-exchange grant has none.
|
||||
authMethod: tokenGrant.apiKeyId === undefined ? 'scoped-jwt' : 'api-key',
|
||||
};
|
||||
} catch (error) {
|
||||
const errorForSure = ensureError(error);
|
||||
|
|
|
|||
|
|
@ -1,18 +1,24 @@
|
|||
import { AuthenticatedRequest } from '@n8n/db';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { UserRepository } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { NextFunction, Response, Request } from 'express';
|
||||
import { ensureError } from '@n8n/utils/errors/ensure-error';
|
||||
|
||||
import { AuthError } from '@n8n/errors';
|
||||
import { JwtService } from '@n8n/backend-services';
|
||||
import {
|
||||
OAuthTokenVerifierProxy,
|
||||
type TelemetryAuthContext,
|
||||
type UserWithContext,
|
||||
} from '@n8n/backend-services/auth';
|
||||
extractBearer,
|
||||
inboundFrom,
|
||||
inboundRequestOf,
|
||||
isUrlIssuer,
|
||||
ResourceAdmission,
|
||||
type Inbound,
|
||||
type Refusal,
|
||||
type RefusalReason,
|
||||
} from '@n8n/inbound-auth';
|
||||
import type { SecurityContext } from '@n8n/permissions';
|
||||
import { NextFunction, Response, Request } from 'express';
|
||||
|
||||
import { JwtService } from '@n8n/backend-services';
|
||||
import type { AuthFailureReason, TelemetryAuthContext } from '@n8n/backend-services/auth';
|
||||
import { Telemetry } from '@/telemetry';
|
||||
|
||||
import { McpServerApiKeyService } from './mcp-api-key.service';
|
||||
import { McpServerApiKeyService, type McpApiKeyResult } from './mcp-api-key.service';
|
||||
import { McpProtectedResource } from './mcp-protected-resource';
|
||||
import {
|
||||
USER_CONNECTED_TO_MCP_EVENT,
|
||||
|
|
@ -22,48 +28,62 @@ import {
|
|||
import type { McpAuthenticatedRequest } from './mcp.types';
|
||||
import { getClientInfo, getProtocolVersion, isConnectionHandshake } from './mcp.utils';
|
||||
|
||||
/** Pipeline reasons that have their own value in the MCP connection telemetry. */
|
||||
const TELEMETRY_REASONS: Partial<Record<RefusalReason, AuthFailureReason>> = {
|
||||
'no-credential': 'missing_authorization_header',
|
||||
'malformed-credential': 'invalid_bearer_format',
|
||||
'unknown-subject': 'user_not_found',
|
||||
insufficient_scope: 'insufficient_scope',
|
||||
revoked: 'token_not_found_in_db',
|
||||
};
|
||||
|
||||
/** The pipeline reason goes into `error_details`, so dashboards keep the detail. */
|
||||
const telemetryOf = (reason: RefusalReason, urlIssuer: boolean): TelemetryAuthContext => ({
|
||||
reason: TELEMETRY_REASONS[reason] ?? 'invalid_token',
|
||||
auth_type: urlIssuer ? 'oauth' : 'unknown',
|
||||
error_details: reason,
|
||||
});
|
||||
|
||||
/**
|
||||
* The telemetry for a request whose header carries no bearer, as it was before the pipeline.
|
||||
* `extractBearer` reports a missing header as `no-credential` and any other header as malformed.
|
||||
*/
|
||||
function headerDenial(header: string | undefined): TelemetryAuthContext {
|
||||
if (header === undefined) {
|
||||
return {
|
||||
reason: 'missing_authorization_header',
|
||||
auth_type: 'unknown',
|
||||
error_details: 'Authorization header not sent',
|
||||
};
|
||||
}
|
||||
const problem = /^bearer(\s|$)/i.test(header)
|
||||
? 'Malformed Bearer token'
|
||||
: 'Missing Bearer prefix';
|
||||
return {
|
||||
reason: 'invalid_bearer_format',
|
||||
auth_type: 'unknown',
|
||||
error_details: `Invalid authorization header format - ${problem}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP Server Middleware Service
|
||||
* Centralizes authentication for MCP server endpoints
|
||||
* Supports both API key and OAuth token validation
|
||||
* Centralizes authentication for MCP server endpoints. An MCP API key or a token-exchange JWT
|
||||
* goes through `McpServerApiKeyService`. An OAuth2 access token from any trusted source that
|
||||
* accepts the `instance-mcp` surface goes through the inbound-auth pipeline.
|
||||
*/
|
||||
@Service()
|
||||
export class McpServerMiddlewareService {
|
||||
constructor(
|
||||
private readonly mcpServerApiKeyService: McpServerApiKeyService,
|
||||
private readonly oauthTokenVerifier: OAuthTokenVerifierProxy,
|
||||
private readonly mcpProtectedResource: McpProtectedResource,
|
||||
private readonly admission: ResourceAdmission,
|
||||
private readonly userRepository: UserRepository,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly telemetry: Telemetry,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get user for a given token (API key or OAuth access token)
|
||||
* Uses JWT metadata to determine token type and route to correct validation
|
||||
*/
|
||||
async getUserForToken(token: string): Promise<UserWithContext> {
|
||||
let decoded: { meta?: { isOAuth?: boolean } };
|
||||
try {
|
||||
decoded = this.jwtService.decodeUnverified<{ meta?: { isOAuth?: boolean } }>(token);
|
||||
} catch (error) {
|
||||
return {
|
||||
user: null,
|
||||
context: {
|
||||
reason: 'jwt_decode_failed',
|
||||
auth_type: 'unknown',
|
||||
error_details: ensureError(error).message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (decoded?.meta?.isOAuth === true) {
|
||||
const expectedAudience = this.mcpProtectedResource.getResourceUrl();
|
||||
return await this.oauthTokenVerifier.verifyOAuthAccessToken(token, expectedAudience);
|
||||
}
|
||||
|
||||
return await this.mcpServerApiKeyService.verifyApiKey(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Express middleware hiding the MCP server while instance MCP access is off.
|
||||
* Answers 404 instead of an authentication challenge, so OAuth-aware clients
|
||||
|
|
@ -87,75 +107,135 @@ export class McpServerMiddlewareService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Express middleware for MCP server authentication
|
||||
* Validates Bearer token (OAuth or API key) and attaches user to request
|
||||
* Express middleware for MCP server authentication. Routes the bearer on its `iss`: an
|
||||
* http(s) issuer goes to the pipeline, anything else to the API-key path first.
|
||||
*/
|
||||
getAuthMiddleware() {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
const authorizationHeader = req.header('authorization');
|
||||
|
||||
if (!authorizationHeader) {
|
||||
this.responseWithUnauthorized(res, req, {
|
||||
reason: 'missing_authorization_header',
|
||||
auth_type: 'unknown',
|
||||
error_details: 'Authorization header not sent',
|
||||
});
|
||||
const inbound = inboundFrom(this.mcpProtectedResource, inboundRequestOf(req));
|
||||
const extracted = extractBearer(inbound);
|
||||
if (!extracted.ok) {
|
||||
const refused = this.admission.refuse(inbound, extracted.reason);
|
||||
await this.deny(req, res, refused, headerDenial(req.header('authorization')));
|
||||
return;
|
||||
}
|
||||
|
||||
let token: string;
|
||||
try {
|
||||
token = this.extractBearerToken(authorizationHeader);
|
||||
} catch (er) {
|
||||
const error = ensureError(er);
|
||||
this.responseWithUnauthorized(res, req, {
|
||||
reason: 'invalid_bearer_format',
|
||||
auth_type: 'unknown',
|
||||
error_details: error.message,
|
||||
});
|
||||
return;
|
||||
const { token } = extracted.value.credential;
|
||||
const urlIssuer = isUrlIssuer(this.issuerOf(token));
|
||||
if (!urlIssuer) {
|
||||
const result = await this.mcpServerApiKeyService.verifyApiKey(token);
|
||||
if (result !== null) {
|
||||
await this.settleApiKey(req, res, next, inbound, result);
|
||||
return;
|
||||
}
|
||||
// No API-key strategy knows the token. The pipeline refuses it with its own reason.
|
||||
}
|
||||
|
||||
const result = await this.getUserForToken(token);
|
||||
const user = result.user;
|
||||
|
||||
if (!user) {
|
||||
this.responseWithUnauthorized(res, req, result.context);
|
||||
const admitted = await this.admission.admit(this.mcpProtectedResource, extracted.value);
|
||||
if (!admitted.ok) {
|
||||
await this.deny(req, res, admitted, telemetryOf(admitted.reason, urlIssuer));
|
||||
return;
|
||||
}
|
||||
|
||||
(req as AuthenticatedRequest).user = user;
|
||||
const mcpReq = req as McpAuthenticatedRequest;
|
||||
mcpReq.mcpCaller = result.caller;
|
||||
// undefined for API keys = not scope-bearing → full tool access
|
||||
mcpReq.mcpScopes = result.scopes;
|
||||
|
||||
next();
|
||||
await this.settleSecurityContext(req, res, next, inbound, admitted.context);
|
||||
};
|
||||
}
|
||||
|
||||
private extractBearerToken(headerValue: string): string {
|
||||
if (!headerValue.startsWith('Bearer')) {
|
||||
throw new AuthError('Invalid authorization header format - Missing Bearer prefix');
|
||||
/** Routing only, never a decision: the token is not verified yet. */
|
||||
private issuerOf(token: string): unknown {
|
||||
try {
|
||||
return this.jwtService.decodeUnverified<{ iss?: unknown } | null>(token)?.iss;
|
||||
} catch {
|
||||
// `jwt.decode` throws on a JWT-typed token whose payload is not JSON.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const tokenMatch = headerValue.match(/^Bearer\s+(.+)$/i);
|
||||
if (tokenMatch) {
|
||||
return tokenMatch[1];
|
||||
}
|
||||
|
||||
throw new AuthError('Invalid authorization header format - Malformed Bearer token');
|
||||
}
|
||||
|
||||
private responseWithUnauthorized(res: Response, req: Request, context?: TelemetryAuthContext) {
|
||||
this.trackFailedConnection(req, UNAUTHORIZED_ERROR_MESSAGE, 401, context);
|
||||
// RFC 6750 Section 3 / RFC 9728 Section 5.1: include the WWW-Authenticate
|
||||
// header on 401s, advertising the protected-resource metadata URL so
|
||||
// clients discover it directly instead of guessing the well-known path.
|
||||
const prmUrl = this.mcpProtectedResource.getProtectedResourceMetadataUrl();
|
||||
res.header('WWW-Authenticate', `Bearer realm="n8n MCP Server", resource_metadata="${prmUrl}"`);
|
||||
private async settleSecurityContext(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
inbound: Inbound,
|
||||
context: SecurityContext,
|
||||
) {
|
||||
// RFC 9068 §2.2 requires `client_id`, and MCP attributes every OAuth call to a client.
|
||||
if (context.clientId === undefined) {
|
||||
await this.refuseAdmitted(req, res, inbound, context, 'not-an-access-token');
|
||||
return;
|
||||
}
|
||||
|
||||
// The context names a Principal. The tools need the User entity with its role.
|
||||
const user = await this.userRepository.findByIdWithRole(context.subject.id);
|
||||
if (!user) {
|
||||
await this.refuseAdmitted(req, res, inbound, context, 'unknown-subject');
|
||||
return;
|
||||
}
|
||||
|
||||
const mcpReq = req as McpAuthenticatedRequest;
|
||||
mcpReq.user = user;
|
||||
mcpReq.mcpCaller = { authType: 'oauth', clientId: context.clientId };
|
||||
// An OAuth caller is always scope-bearing: `undefined` would mean full tool access.
|
||||
mcpReq.mcpScopes = context.tokenScopes ?? [];
|
||||
// Keep what earlier middleware put on the operation context.
|
||||
mcpReq.ctx = { ...mcpReq.ctx, security: context };
|
||||
next();
|
||||
}
|
||||
|
||||
private async refuseAdmitted(
|
||||
req: Request,
|
||||
res: Response,
|
||||
inbound: Inbound,
|
||||
context: SecurityContext,
|
||||
reason: RefusalReason,
|
||||
) {
|
||||
const refused = this.admission.refuse(inbound, reason, context);
|
||||
await this.deny(req, res, refused, telemetryOf(reason, true));
|
||||
}
|
||||
|
||||
/** The API-key path is outside the pipeline's vocabulary, so it writes its own line. */
|
||||
private async settleApiKey(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
inbound: Inbound,
|
||||
result: McpApiKeyResult,
|
||||
) {
|
||||
const where = { surface: inbound.surface, resource: inbound.resource.url };
|
||||
if (!result.user) {
|
||||
const telemetry: TelemetryAuthContext = result.context ?? {
|
||||
reason: 'invalid_token',
|
||||
auth_type: 'api_key',
|
||||
};
|
||||
this.logger.warn('Inbound authentication denied', { ...where, reason: telemetry.reason });
|
||||
// No reason: the challenge carries no error, as before.
|
||||
const advertised = { surface: inbound.surface, resource: inbound.resource };
|
||||
await this.deny(req, res, { advertised }, telemetry);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.info('Inbound authentication accepted', {
|
||||
...where,
|
||||
authMethod: result.authMethod,
|
||||
userId: result.user.id,
|
||||
});
|
||||
const mcpReq = req as McpAuthenticatedRequest;
|
||||
mcpReq.user = result.user;
|
||||
mcpReq.mcpCaller = result.caller;
|
||||
// undefined for API keys = not scope-bearing → full tool access
|
||||
mcpReq.mcpScopes = result.scopes;
|
||||
next();
|
||||
}
|
||||
|
||||
private async deny(
|
||||
req: Request,
|
||||
res: Response,
|
||||
refusal: Refusal,
|
||||
telemetry: TelemetryAuthContext,
|
||||
) {
|
||||
this.trackFailedConnection(req, UNAUTHORIZED_ERROR_MESSAGE, 401, telemetry);
|
||||
// RFC 6750 §3 / RFC 9728 §5.1: the challenge names the protected-resource metadata.
|
||||
res.header('WWW-Authenticate', await this.admission.challenge(refusal));
|
||||
res.status(401).send({
|
||||
message: `${UNAUTHORIZED_ERROR_MESSAGE}${context?.error_details ? ': ' + context.error_details : ''}`,
|
||||
message: `${UNAUTHORIZED_ERROR_MESSAGE}${telemetry.error_details ? ': ' + telemetry.error_details : ''}`,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ import { Container } from '@n8n/di';
|
|||
/**
|
||||
* Handles instance-level MCP access.
|
||||
* Runs MCP server and exposes endpoints for MCP clients to connect to.
|
||||
* Requires MCP access to be enabled in settings and a valid API key or an
|
||||
* OAuth token issued by the shared `oauth-server` module.
|
||||
* Requires MCP access to be enabled in settings and a valid API key, a
|
||||
* token-exchange JWT, or an OAuth2 access token from any trusted source that
|
||||
* accepts the `instance-mcp` surface.
|
||||
*/
|
||||
@BackendModule({ name: 'mcp', instanceTypes: ['main'] })
|
||||
export class McpModule implements ModuleInterface {
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ describe('OAuth access-token signing', () => {
|
|||
iat: now,
|
||||
exp: now + 60,
|
||||
scope: '',
|
||||
meta: { isOAuth: true },
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -80,7 +79,6 @@ describe('OAuth access-token signing', () => {
|
|||
iat: expect.any(Number),
|
||||
exp: payload.iat! + OAUTH_ACCESS_TOKEN_TTL_SECONDS,
|
||||
scope: 'tool:read',
|
||||
meta: { isOAuth: true },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ describe('OAuthTokenService', () => {
|
|||
expect(decoded.sub).toBe(userId);
|
||||
expect(decoded.aud).toBe(TEST_RESOURCE_URL);
|
||||
expect(decoded.client_id).toBe(clientId);
|
||||
expect(decoded.meta.isOAuth).toBe(true);
|
||||
expect(decoded.meta).toBeUndefined();
|
||||
expect(decoded.jti).toBeDefined();
|
||||
expect(decoded.iat).toBeDefined();
|
||||
expect(decoded.exp).toBeDefined();
|
||||
|
|
@ -1024,7 +1024,6 @@ describe('OAuthTokenService', () => {
|
|||
iat: now,
|
||||
exp: now + OAUTH_ACCESS_TOKEN_TTL_SECONDS,
|
||||
scope: '',
|
||||
meta: { isOAuth: true },
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import request from 'supertest';
|
|||
|
||||
import { AuthService } from '@/auth/auth.service';
|
||||
import { AUTH_COOKIE_NAME } from '@/constants';
|
||||
import { McpProtectedResource } from '@/modules/mcp/mcp-protected-resource';
|
||||
import { McpSettingsService } from '@/modules/mcp/mcp.settings.service';
|
||||
import { UrlService } from '@n8n/backend-services';
|
||||
import { ProtectedResourceRegistry } from '@n8n/backend-services/auth';
|
||||
|
|
@ -19,16 +20,24 @@ import { OAuthServerConfig } from '../oauth-server.config';
|
|||
import type { OAuthController as OAuthControllerClass } from '../oauth.controller';
|
||||
import { pendingFlowFrom } from './oauth-flow-test-utils';
|
||||
|
||||
const testServer = setupTestServer({ modules: ['oauth-server', 'mcp'], endpointGroups: ['mcp'] });
|
||||
const testServer = setupTestServer({
|
||||
// Production order: the MCP middleware and metadata need the inbound-auth contracts.
|
||||
modules: ['inbound-auth-core', 'oauth-server', 'mcp'],
|
||||
endpointGroups: ['mcp'],
|
||||
});
|
||||
|
||||
let owner: User;
|
||||
let mcpSettingsService: McpSettingsService;
|
||||
/** Every resource's scopes: the authorization server advertises the union. */
|
||||
let supportedScopes: string[];
|
||||
/** The MCP resource's own scopes, which its protected-resource metadata advertises. */
|
||||
let mcpScopes: string[];
|
||||
|
||||
beforeAll(async () => {
|
||||
owner = await createOwner();
|
||||
mcpSettingsService = Container.get(McpSettingsService);
|
||||
supportedScopes = Container.get(ProtectedResourceRegistry).getAllScopes();
|
||||
mcpScopes = Container.get(McpProtectedResource).scopes;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -148,7 +157,7 @@ describe('GET /.well-known/oauth-protected-resource/mcp-server/http', () => {
|
|||
resource: expect.stringContaining('/mcp-server/http'),
|
||||
bearer_methods_supported: ['header'],
|
||||
authorization_servers: [expect.any(String)],
|
||||
...(supportedScopes.length > 0 && { scopes_supported: supportedScopes }),
|
||||
...(mcpScopes.length > 0 && { scopes_supported: mcpScopes }),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -182,7 +191,7 @@ describe('GET /.well-known/oauth-protected-resource/mcp-server/http', () => {
|
|||
);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body.scopes_supported).toEqual(supportedScopes);
|
||||
expect(response.body.scopes_supported).toEqual(mcpScopes);
|
||||
});
|
||||
|
||||
test('should be accessible without authentication', async () => {
|
||||
|
|
@ -220,7 +229,7 @@ describe('GET /.well-known/oauth-protected-resource (bare path)', () => {
|
|||
resource: expect.stringContaining('/mcp-server/http'),
|
||||
bearer_methods_supported: ['header'],
|
||||
authorization_servers: [expect.any(String)],
|
||||
...(supportedScopes.length > 0 && { scopes_supported: supportedScopes }),
|
||||
...(mcpScopes.length > 0 && { scopes_supported: mcpScopes }),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -669,7 +678,7 @@ describe('OAuth Discovery - Cross-validation', () => {
|
|||
expect(protectedResource.authorization_servers).toContain(authServer.issuer);
|
||||
});
|
||||
|
||||
test('should have consistent scopes between authorization server and protected resource', async () => {
|
||||
test('should advertise every MCP scope on the authorization server', async () => {
|
||||
const authServerResponse = await testServer.restlessAgent.get(
|
||||
'/.well-known/oauth-authorization-server',
|
||||
);
|
||||
|
|
@ -683,7 +692,8 @@ describe('OAuth Discovery - Cross-validation', () => {
|
|||
const authServerScopes = authServerResponse.body.scopes_supported;
|
||||
const protectedResourceScopes = protectedResourceResponse.body.scopes_supported;
|
||||
|
||||
expect(authServerScopes).toEqual(protectedResourceScopes);
|
||||
// The public API's resource is registered too, so the server lists more than MCP's scopes.
|
||||
expect(authServerScopes).toEqual(expect.arrayContaining(protectedResourceScopes));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -736,7 +746,7 @@ describe('Full authorization-code flow (PKCE)', () => {
|
|||
// is withheld at consent here because the community-packages module is
|
||||
// inactive in the test instance. `aiPreference:*` is withheld too: the test
|
||||
// instance has no PostHog, so no user is in the preferences experiment arm.
|
||||
const grantedScopes = supportedScopes.filter(
|
||||
const grantedScopes = mcpScopes.filter(
|
||||
(scope) => scope !== 'communityPackage:install' && !scope.startsWith('aiPreference:'),
|
||||
);
|
||||
const authAgent = testServer.authAgentFor(owner);
|
||||
|
|
|
|||
|
|
@ -64,6 +64,10 @@ const publicApiResource: ProtectedResource = {
|
|||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
urlService.getInstanceBaseUrl.mockReturnValue('https://n8n.test');
|
||||
authenticationService.advertise.mockResolvedValue({
|
||||
authorizationServers: ['https://n8n.test'],
|
||||
challenges: [],
|
||||
});
|
||||
controller = new OAuthController(urlService, registry, localServer, authenticationService);
|
||||
});
|
||||
|
||||
|
|
@ -121,9 +125,9 @@ describe('OAuthController', () => {
|
|||
expect(registry.getByResourcePath).toHaveBeenCalledWith('/webhook/orders?method=GET');
|
||||
});
|
||||
|
||||
test('returns the RFC 9728 metadata document for a resolved resource', async () => {
|
||||
test('returns the RFC 9728 metadata document for a surface outside the pipeline', async () => {
|
||||
const scopes = ['tool:listWorkflows', 'tool:getWorkflowDetails'];
|
||||
registry.getByResourcePath.mockResolvedValue(resource(scopes));
|
||||
registry.getByResourcePath.mockResolvedValue({ ...resource(scopes), surface: 'trigger' });
|
||||
const res = makeRes();
|
||||
|
||||
await controller.protectedResourceMetadata(makeReq(['mcp-server', 'http']), res);
|
||||
|
|
@ -139,6 +143,30 @@ describe('OAuthController', () => {
|
|||
expect(authenticationService.advertise).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('lists the issuers of the trusted sources accepted on the instance-mcp surface', async () => {
|
||||
registry.getByResourcePath.mockResolvedValue(resource(['tool:listWorkflows']));
|
||||
authenticationService.advertise.mockResolvedValue({
|
||||
authorizationServers: ['https://n8n.test', 'https://idp.example'],
|
||||
challenges: [],
|
||||
});
|
||||
const res = makeRes();
|
||||
|
||||
await controller.protectedResourceMetadata(makeReq(['mcp-server', 'http']), res);
|
||||
|
||||
expect(authenticationService.advertise).toHaveBeenCalledWith({
|
||||
surface: 'instance-mcp',
|
||||
resource: {
|
||||
url: 'https://n8n.test/mcp-server/http',
|
||||
acceptedAudiences: ['https://n8n.test/mcp-server/http'],
|
||||
},
|
||||
});
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authorization_servers: ['https://n8n.test', 'https://idp.example'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('lists the issuers of the trusted sources accepted on the public-api surface', async () => {
|
||||
registry.getByResourcePath.mockResolvedValue(publicApiResource);
|
||||
authenticationService.advertise.mockResolvedValue({
|
||||
|
|
|
|||
|
|
@ -93,9 +93,6 @@ export class OAuthTokenService implements OAuthTokenVerifier {
|
|||
// (empty string for scope-less grants), so an absent claim
|
||||
// unambiguously identifies a token minted before scoping shipped.
|
||||
scope: scopes.join(' '),
|
||||
meta: {
|
||||
isOAuth: true,
|
||||
},
|
||||
},
|
||||
audience,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
StaticRouterMetadata,
|
||||
} from '@n8n/decorators';
|
||||
import { Container } from '@n8n/di';
|
||||
import { AuthenticationService } from '@n8n/inbound-auth';
|
||||
import { AuthenticationService, type SurfaceId } from '@n8n/inbound-auth';
|
||||
import { isRecord } from '@n8n/utils/is-record';
|
||||
import type { Response, Request, RequestHandler, Router } from 'express';
|
||||
|
||||
|
|
@ -32,6 +32,15 @@ const oauthServerConfig = Container.get(OAuthServerConfig);
|
|||
const logger = Container.get(Logger);
|
||||
const urlService = Container.get(UrlService);
|
||||
|
||||
/**
|
||||
* Surfaces that authenticate through the inbound-auth pipeline advertise the trusted sources
|
||||
* accepted there. The trigger surface joins with its own ticket.
|
||||
*/
|
||||
const PIPELINE_SURFACES: ReadonlySet<SurfaceId> = new Set<SurfaceId>([
|
||||
'public-api',
|
||||
'instance-mcp',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Pre-check guard for the unauthenticated DCR endpoint. Short-circuits with
|
||||
* a structured `server_error` response when the instance is at the
|
||||
|
|
@ -340,18 +349,17 @@ export class OAuthController {
|
|||
private async buildProtectedResourceMetadata(
|
||||
resource: ProtectedResource,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const authorizationServers =
|
||||
resource.surface === 'public-api'
|
||||
? (
|
||||
await this.authenticationService.advertise({
|
||||
surface: resource.surface,
|
||||
resource: {
|
||||
url: resource.getResourceUrl(),
|
||||
acceptedAudiences: resource.getAudiences(),
|
||||
},
|
||||
})
|
||||
).authorizationServers
|
||||
: [this.urlService.getInstanceBaseUrl()];
|
||||
const authorizationServers = PIPELINE_SURFACES.has(resource.surface)
|
||||
? (
|
||||
await this.authenticationService.advertise({
|
||||
surface: resource.surface,
|
||||
resource: {
|
||||
url: resource.getResourceUrl(),
|
||||
acceptedAudiences: resource.getAudiences(),
|
||||
},
|
||||
})
|
||||
).authorizationServers
|
||||
: [this.urlService.getInstanceBaseUrl()];
|
||||
|
||||
const metadata: Record<string, unknown> = {
|
||||
resource: resource.getResourceUrl(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,330 @@
|
|||
import { Logger } from '@n8n/backend-common';
|
||||
import { CacheService, type OAuthDiscoveryClient, UrlService } from '@n8n/backend-services';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { User } from '@n8n/db';
|
||||
import {
|
||||
AuthenticationPipeline,
|
||||
JwkSchema,
|
||||
LocalAuthorizationServer,
|
||||
trustedSourceConfigSchemaFor,
|
||||
type SurfaceId,
|
||||
} from '@n8n/inbound-auth';
|
||||
import { DataSource } from '@n8n/typeorm';
|
||||
import { exportJWK, generateKeyPair, SignJWT } from 'jose';
|
||||
import type { CryptoKey, JWTPayload } from 'jose';
|
||||
import { Cipher } from 'n8n-core';
|
||||
import { randomString } from 'n8n-workflow';
|
||||
import { mock, mockDeep } 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 { McpServerApiKeyService } from '@/modules/mcp/mcp-api-key.service';
|
||||
import { McpProtectedResource } from '@/modules/mcp/mcp-protected-resource';
|
||||
import { McpService } from '@/modules/mcp/mcp.service';
|
||||
import { McpSettingsService } from '@/modules/mcp/mcp.settings.service';
|
||||
import { OAuthClientRepository } from '@/modules/oauth-server/database/repositories/oauth-client.repository';
|
||||
import { OAuthTokenService } from '@/modules/oauth-server/oauth-token.service';
|
||||
import { ScopedJwtStrategy } from '@/modules/token-exchange/services/scoped-jwt.strategy';
|
||||
import { TOKEN_EXCHANGE_ISSUER } from '@/modules/token-exchange/token-exchange.types';
|
||||
import { AuthStrategyRegistry } from '@n8n/backend-services/auth';
|
||||
import { JwtService } from '@n8n/backend-services';
|
||||
import { createMember } from '@test-integration/db/users';
|
||||
import { setupTestServer } from '@test-integration/utils';
|
||||
|
||||
/**
|
||||
* The instance MCP server on the trusted-source pipeline, nothing mocked but the network: the
|
||||
* pipeline, the admission and the identity service are the real ones.
|
||||
*/
|
||||
const testServer = setupTestServer({
|
||||
// Production order: the MCP middleware resolves the inbound-auth contracts when it is built.
|
||||
modules: ['inbound-auth-core', 'oauth-jwe', 'oauth-server', 'mcp'],
|
||||
endpointGroups: ['mcp', 'jwks'],
|
||||
});
|
||||
|
||||
const MCP_ISSUER = 'https://idp-a.example';
|
||||
const PUBLIC_API_ISSUER = 'https://idp-b.example';
|
||||
const CLIENT_ID = 'external-client';
|
||||
|
||||
let member: User;
|
||||
let mcpResource: string;
|
||||
let metadataUrl: string;
|
||||
let privateKey: CryptoKey;
|
||||
let apiKey: string;
|
||||
|
||||
const now = () => Math.floor(Date.now() / 1000);
|
||||
|
||||
/** A token from an external IdP for `member`, linked by its verified email on first use. */
|
||||
async function externalToken(issuer: string, claims: JWTPayload = {}) {
|
||||
const iat = now();
|
||||
return await new SignJWT({
|
||||
iss: issuer,
|
||||
sub: 'idp-alice',
|
||||
aud: mcpResource,
|
||||
email: member.email,
|
||||
email_verified: true,
|
||||
client_id: CLIENT_ID,
|
||||
scope: 'workflow:read',
|
||||
iat,
|
||||
exp: iat + 3600,
|
||||
...claims,
|
||||
})
|
||||
.setProtectedHeader({ alg: 'ES256', kid: 'k1', typ: 'at+jwt' })
|
||||
.sign(privateKey);
|
||||
}
|
||||
|
||||
async function seedSource(issuer: string, surface: SurfaceId, keys: unknown[]) {
|
||||
const config = trustedSourceConfigSchemaFor('admin').parse({
|
||||
version: 1,
|
||||
authentication: { type: 'oauth2' },
|
||||
surfaces: { [surface]: {} },
|
||||
});
|
||||
const rows = Container.get(DataSource).getRepository(TrustedSourceEntity);
|
||||
await rows.save(
|
||||
rows.create({
|
||||
name: `${surface} source`,
|
||||
type: 'oauth2',
|
||||
issuer,
|
||||
managedBy: 'admin',
|
||||
status: 'healthy',
|
||||
lastError: null,
|
||||
lastCheckedAt: new Date(),
|
||||
configVersion: 1,
|
||||
config: await Container.get(Cipher).encryptV2(config),
|
||||
metadata: JSON.stringify({
|
||||
version: 1,
|
||||
documents: [
|
||||
{ kind: 'jwks', fetchedAt: new Date().toISOString(), url: `${issuer}/keys`, keys },
|
||||
],
|
||||
}),
|
||||
discoveryClaimToken: null,
|
||||
discoveryClaimedAt: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** An access token as the instance issues it: signed and recorded, so it stays active until revoked. */
|
||||
async function internalToken() {
|
||||
const tokens = Container.get(OAuthTokenService);
|
||||
const scopes = ['workflow:read'];
|
||||
const pair = tokens.generateTokenPair(member.id, CLIENT_ID, mcpResource, scopes);
|
||||
await tokens.saveTokenPair(
|
||||
pair.accessToken,
|
||||
pair.refreshToken,
|
||||
CLIENT_ID,
|
||||
member.id,
|
||||
scopes,
|
||||
pair.audience,
|
||||
);
|
||||
return pair.accessToken;
|
||||
}
|
||||
|
||||
const probe = async (token?: string) => {
|
||||
const request = testServer.restlessAgent.get('/mcp-server/http');
|
||||
return await (token === undefined ? request : request.set('Authorization', `Bearer ${token}`));
|
||||
};
|
||||
|
||||
/** Every line the authentication edge wrote, from the pipeline, the admission or the middleware. */
|
||||
const authLines = (logger: Logger) =>
|
||||
[...vi.mocked(logger.info).mock.calls, ...vi.mocked(logger.warn).mock.calls].filter(([message]) =>
|
||||
message.startsWith('Inbound authentication'),
|
||||
);
|
||||
|
||||
const spyOnLogger = () => {
|
||||
const logger = Container.get(Logger);
|
||||
vi.spyOn(logger, 'info');
|
||||
vi.spyOn(logger, 'warn');
|
||||
return logger;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
await Container.get(CacheService).init();
|
||||
member = await createMember();
|
||||
const resource = Container.get(McpProtectedResource);
|
||||
mcpResource = resource.getResourceUrl();
|
||||
metadataUrl = resource.getProtectedResourceMetadataUrl();
|
||||
Container.get(AuthStrategyRegistry).register(Container.get(ScopedJwtStrategy));
|
||||
apiKey = (await Container.get(McpServerApiKeyService).createMcpServerApiKey(member)).apiKey;
|
||||
await Container.get(McpSettingsService).setEnabled(true);
|
||||
// The token rows of the local server need a registered client.
|
||||
await Container.get(OAuthClientRepository).save({
|
||||
id: CLIENT_ID,
|
||||
name: 'MCP pipeline tests',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
grantTypes: ['authorization_code'],
|
||||
tokenEndpointAuthMethod: 'none',
|
||||
});
|
||||
|
||||
// The module seeded `n8n-internal` at init; discovery normally runs as a system task. Run it
|
||||
// once here, reading the local authorization server in process, never over HTTP.
|
||||
const offline = mock<OAuthDiscoveryClient>();
|
||||
const refused = new Error('discovery must not go over HTTP');
|
||||
offline.fetchOpenIdConfiguration.mockRejectedValue(refused);
|
||||
offline.fetchOAuth2ServerMetadata.mockRejectedValue(refused);
|
||||
offline.fetchJwks.mockRejectedValue(refused);
|
||||
await new TrustedSourceDiscoveryService(
|
||||
Container.get(Logger),
|
||||
Container.get(TrustedSourceDbStore),
|
||||
offline,
|
||||
Container.get(LocalAuthorizationServer),
|
||||
).refresh('n8n-internal');
|
||||
|
||||
const pair = await generateKeyPair('ES256');
|
||||
privateKey = pair.privateKey;
|
||||
const jwk = JwkSchema.parse({
|
||||
...(await exportJWK(pair.publicKey)),
|
||||
kid: 'k1',
|
||||
alg: 'ES256',
|
||||
use: 'sig',
|
||||
});
|
||||
await seedSource(MCP_ISSUER, 'instance-mcp', [jwk]);
|
||||
await seedSource(PUBLIC_API_ISSUER, 'public-api', [jwk]);
|
||||
await Container.get(CacheService).reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Instance MCP authentication through the trusted-source pipeline', () => {
|
||||
test('admits an access token from the instance itself', async () => {
|
||||
const response = await probe(await internalToken());
|
||||
|
||||
// GET answers 405 once the caller is authenticated.
|
||||
expect(response.statusCode).toBe(405);
|
||||
});
|
||||
|
||||
test('refuses an access token from the instance after it is revoked', async () => {
|
||||
const token = await internalToken();
|
||||
await Container.get(OAuthTokenService).revokeAllTokensForGrant(CLIENT_ID, member.id);
|
||||
|
||||
const response = await probe(token);
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
expect(response.headers['www-authenticate']).toMatch(/error_description="revoked"$/);
|
||||
});
|
||||
|
||||
test('admits a token from a trusted source that accepts instance-mcp, as an OAuth caller', async () => {
|
||||
const token = await externalToken(MCP_ISSUER);
|
||||
expect((await probe(token)).statusCode).toBe(405);
|
||||
// Only what the middleware hands to the server matters here, so stop there.
|
||||
const mcpService = Container.get(McpService);
|
||||
vi.spyOn(mcpService, 'resolveFeatureFlags').mockResolvedValue(
|
||||
mockDeep<Awaited<ReturnType<McpService['resolveFeatureFlags']>>>(),
|
||||
);
|
||||
const getServer = vi
|
||||
.spyOn(mcpService, 'getServer')
|
||||
.mockRejectedValue(new Error('stop after authentication'));
|
||||
|
||||
await testServer.restlessAgent
|
||||
.post('/mcp-server/http')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('Accept', 'application/json, text/event-stream')
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2025-06-18',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'test-client', version: '1.0.0' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(getServer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: member.id }),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
{ caller: { authType: 'oauth', clientId: CLIENT_ID }, grantedScopes: ['workflow:read'] },
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses a source that does not accept instance-mcp, with a reason that differs from a bad token', async () => {
|
||||
const notAccepted = await probe(await externalToken(PUBLIC_API_ISSUER));
|
||||
const tampered = await probe(`${(await internalToken()).slice(0, -2)}xx`);
|
||||
|
||||
expect(notAccepted.statusCode).toBe(401);
|
||||
expect(notAccepted.headers['www-authenticate']).toMatch(
|
||||
/error="invalid_token", error_description="source-not-accepted"$/,
|
||||
);
|
||||
expect(tampered.statusCode).toBe(401);
|
||||
expect(tampered.headers['www-authenticate']).toMatch(
|
||||
/error="invalid_token", error_description="signature-invalid"$/,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps an MCP API key and a token-exchange JWT off the pipeline', async () => {
|
||||
const run = vi.spyOn(Container.get(AuthenticationPipeline), 'run');
|
||||
const scopedJwt = Container.get(JwtService).sign('tokenExchange', {
|
||||
iss: TOKEN_EXCHANGE_ISSUER,
|
||||
sub: member.id,
|
||||
iat: now(),
|
||||
exp: now() + 3600,
|
||||
jti: `jti-${randomString(8)}`,
|
||||
});
|
||||
|
||||
expect((await probe(apiKey)).statusCode).toBe(405);
|
||||
expect((await probe(scopedJwt)).statusCode).toBe(405);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('names the protected-resource metadata in every challenge', async () => {
|
||||
const responses = [
|
||||
await probe(),
|
||||
await probe('not-a-token'),
|
||||
await probe(await externalToken(PUBLIC_API_ISSUER)),
|
||||
];
|
||||
|
||||
for (const response of responses) {
|
||||
expect(response.statusCode).toBe(401);
|
||||
expect(response.headers['www-authenticate']).toContain(`resource_metadata="${metadataUrl}"`);
|
||||
}
|
||||
// A request without a credential gets no error code (RFC 6750 §3.1).
|
||||
expect(responses[0].headers['www-authenticate']).not.toContain('error=');
|
||||
});
|
||||
|
||||
test('lists the issuers of the sources that accept instance-mcp in the metadata', async () => {
|
||||
const metadata = await testServer.restlessAgent
|
||||
.get('/.well-known/oauth-protected-resource/mcp-server/http')
|
||||
.expect(200);
|
||||
|
||||
expect(metadata.body.authorization_servers).toEqual(
|
||||
expect.arrayContaining([Container.get(UrlService).getInstanceBaseUrl(), MCP_ISSUER]),
|
||||
);
|
||||
expect(metadata.body.authorization_servers).not.toContain(PUBLIC_API_ISSUER);
|
||||
});
|
||||
|
||||
test('writes one authentication line per outcome, with no token, external subject or email', async () => {
|
||||
const logger = spyOnLogger();
|
||||
const tokens = [
|
||||
undefined,
|
||||
await internalToken(),
|
||||
await externalToken(MCP_ISSUER),
|
||||
apiKey,
|
||||
await externalToken(PUBLIC_API_ISSUER),
|
||||
];
|
||||
|
||||
for (const token of tokens) {
|
||||
vi.mocked(logger.info).mockClear();
|
||||
vi.mocked(logger.warn).mockClear();
|
||||
await probe(token);
|
||||
expect(authLines(logger)).toHaveLength(1);
|
||||
|
||||
const logged = JSON.stringify(authLines(logger));
|
||||
if (token !== undefined) expect(logged).not.toContain(token);
|
||||
expect(logged).not.toContain('idp-alice');
|
||||
expect(logged).not.toContain(member.email);
|
||||
}
|
||||
});
|
||||
|
||||
test('refuses a JWT without an issuer with unknown-issuer, in one line', async () => {
|
||||
const logger = spyOnLogger();
|
||||
const token = Container.get(JwtService).sign('mcpApiKey', { sub: member.id });
|
||||
|
||||
const response = await probe(token);
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
expect(response.headers['www-authenticate']).toMatch(/error_description="unknown-issuer"$/);
|
||||
expect(authLines(logger)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user