refactor(core): Migrate MCP OAuth server to the v2 SDK auth toolkit

Wave 4 of the v2 SDK migration (ADO-5681). Moves the oauth-server module
off @modelcontextprotocol/sdk 1.x and onto
@modelcontextprotocol/server-legacy/auth 2.0.0 for the authorization-server
toolkit (handlers, provider, store, errors), sourcing the shared OAuth data
types from @modelcontextprotocol/server. cli no longer depends on the 1.x
package.

With the toolkit on v2, authorizationHandler now accepts { issuerUrl }, so the
handler itself stamps the RFC 9207 iss parameter onto its request-validation
error redirects. This replaces the rfc9207IssuerParam middleware that patched
res.location, which is deleted along with the now-unused setIssuerParam helper.

The SDK derives iss from issuerUrl.href, which canonicalizes an origin-only
base URL with a trailing slash. RFC 9207 (mandatory for clients under SEP-2468)
requires iss to match the advertised issuer exactly, so the authorization-server
metadata issuer, the protected-resource authorization_servers, and the
consent-flow iss now use the same href form via OAuthHelpers.canonicalIssuer.
This commit is contained in:
Ricardo Espinoza 2026-08-03 19:08:24 -04:00
parent b4cc709db8
commit 7a40b4eafe
No known key found for this signature in database
18 changed files with 110 additions and 106 deletions

View File

@ -140,8 +140,8 @@
"@joplin/turndown-plugin-gfm": "catalog:",
"@langchain/core": "catalog:",
"@modelcontextprotocol/node": "catalog:",
"@modelcontextprotocol/sdk": "catalog:",
"@modelcontextprotocol/server": "catalog:",
"@modelcontextprotocol/server-legacy": "catalog:",
"@mozilla/readability": "catalog:",
"@n8n/agents": "workspace:*",
"@n8n/ai-node-sdk": "workspace:*",

View File

@ -1,5 +1,5 @@
import type { Mocked } from 'vitest';
import { InvalidGrantError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
import { InvalidGrantError } from '@modelcontextprotocol/server-legacy/auth';
import { mockInstance } from '@n8n/backend-test-utils';
import { mock } from 'vitest-mock-extended';

View File

@ -288,7 +288,7 @@ describe('POST /rest/consent/approve', () => {
expect(redirectUrl.searchParams.get('code')?.length).toBeGreaterThan(32);
expect(redirectUrl.searchParams.get('state')).toBe('test-state');
expect(redirectUrl.searchParams.get('iss')).toBe(
Container.get(UrlService).getInstanceBaseUrl(),
new URL(Container.get(UrlService).getInstanceBaseUrl()).href,
);
});
@ -310,7 +310,7 @@ describe('POST /rest/consent/approve', () => {
expect(redirectUrl.searchParams.get('error_description')).toBeTruthy();
expect(redirectUrl.searchParams.get('state')).toBe('test-state');
expect(redirectUrl.searchParams.get('iss')).toBe(
Container.get(UrlService).getInstanceBaseUrl(),
new URL(Container.get(UrlService).getInstanceBaseUrl()).href,
);
});

View File

@ -345,7 +345,7 @@ describe('OAuthConsentService', () => {
'error_description=User+denied+the+authorization+request',
);
expect(result.redirectUrl).toContain('state=state-xyz');
expect(new URL(result.redirectUrl).searchParams.get('iss')).toBe(issuer);
expect(new URL(result.redirectUrl).searchParams.get('iss')).toBe(new URL(issuer).href);
expect(logger.info).toHaveBeenCalledWith('Consent denied', {
clientId: 'client-123',
userId: 'user-123',
@ -379,7 +379,7 @@ describe('OAuthConsentService', () => {
expect(result.redirectUrl).toContain('code=generated-auth-code');
expect(result.redirectUrl).toContain('state=state-xyz');
expect(new URL(result.redirectUrl).searchParams.get('iss')).toBe(issuer);
expect(new URL(result.redirectUrl).searchParams.get('iss')).toBe(new URL(issuer).href);
expect(userConsentRepository.upsert).toHaveBeenCalledWith(
{
userId: 'user-123',

View File

@ -1,7 +1,4 @@
import {
InvalidGrantError,
InvalidTargetError,
} from '@modelcontextprotocol/sdk/server/auth/errors.js';
import { InvalidGrantError, InvalidTargetError } from '@modelcontextprotocol/server-legacy/auth';
import { Logger, type ModuleRegistry } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';

View File

@ -63,10 +63,13 @@ describe('GET /.well-known/oauth-authorization-server', () => {
} = response.body;
expect(issuer).toMatch(/^https?:\/\//);
expect(authorization_endpoint).toBe(`${issuer}/mcp-oauth/authorize`);
expect(token_endpoint).toBe(`${issuer}/mcp-oauth/token`);
expect(registration_endpoint).toBe(`${issuer}/mcp-oauth/register`);
expect(revocation_endpoint).toBe(`${issuer}/mcp-oauth/revoke`);
// `issuer` is the canonical RFC 9207 form (trailing slash for an origin-only
// base URL); the endpoints are built from the bare base URL, so trim it.
const origin = issuer.replace(/\/$/, '');
expect(authorization_endpoint).toBe(`${origin}/mcp-oauth/authorize`);
expect(token_endpoint).toBe(`${origin}/mcp-oauth/token`);
expect(registration_endpoint).toBe(`${origin}/mcp-oauth/register`);
expect(revocation_endpoint).toBe(`${origin}/mcp-oauth/revoke`);
});
test('should include all required OAuth 2.1 fields', async () => {
@ -461,7 +464,7 @@ describe('GET /mcp-oauth/authorize', () => {
expect(redirectUrl.origin + redirectUrl.pathname).toBe('https://example.com/callback');
expect(redirectUrl.searchParams.get('error')).toBe('invalid_request');
expect(redirectUrl.searchParams.get('iss')).toBe(
Container.get(UrlService).getInstanceBaseUrl(),
new URL(Container.get(UrlService).getInstanceBaseUrl()).href,
);
});
});
@ -672,7 +675,7 @@ describe('Full authorization-code flow (PKCE)', () => {
expect(code).toBeTruthy();
expect(redirectUrl.searchParams.get('state')).toBe('flow-state');
expect(redirectUrl.searchParams.get('iss')).toBe(
Container.get(UrlService).getInstanceBaseUrl(),
new URL(Container.get(UrlService).getInstanceBaseUrl()).href,
);
// 4. Token exchange

View File

@ -1,4 +1,4 @@
import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients.js';
import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/server-legacy/auth';
import { mockInstance } from '@n8n/backend-test-utils';
import type { Request, Response } from 'express';
import { mock } from 'vitest-mock-extended';
@ -108,7 +108,8 @@ describe('OAuthController', () => {
expect(res.json).toHaveBeenCalledWith({
resource: 'https://n8n.test/mcp-server/http',
bearer_methods_supported: ['header'],
authorization_servers: ['https://n8n.test'],
// Canonical issuer form (trailing slash for an origin-only base URL).
authorization_servers: ['https://n8n.test/'],
scopes_supported: scopes,
});
});

View File

@ -185,37 +185,22 @@ describe('OAuthHelpers', () => {
});
});
describe('setIssuerParam', () => {
it('should set iss on an absolute URL without one', () => {
const result = OAuthHelpers.setIssuerParam('https://example.com/callback', issuer);
expect(new URL(result).searchParams.get('iss')).toBe(issuer);
});
it('should preserve existing query parameters', () => {
const result = OAuthHelpers.setIssuerParam(
'https://example.com/callback?error=invalid_request&state=state-xyz',
issuer,
describe('canonicalIssuer', () => {
it('appends a trailing slash to an origin-only base URL', () => {
expect(OAuthHelpers.canonicalIssuer('https://n8n.example.com')).toBe(
'https://n8n.example.com/',
);
const url = new URL(result);
expect(url.searchParams.get('error')).toBe('invalid_request');
expect(url.searchParams.get('state')).toBe('state-xyz');
expect(url.searchParams.get('iss')).toBe(issuer);
});
it('should replace a pre-existing iss parameter with the server issuer', () => {
const result = OAuthHelpers.setIssuerParam(
'https://example.com/callback?iss=https%3A%2F%2Fother.example.com',
issuer,
it('leaves a base URL that already has a path untouched', () => {
expect(OAuthHelpers.canonicalIssuer('https://example.com/n8n')).toBe(
'https://example.com/n8n',
);
expect(new URL(result).searchParams.get('iss')).toBe(issuer);
expect(new URL(result).searchParams.getAll('iss')).toHaveLength(1);
});
it('should return relative URLs unchanged', () => {
expect(OAuthHelpers.setIssuerParam('/oauth/consent', issuer)).toBe('/oauth/consent');
it('matches the href form the authorize handler stamps as iss', () => {
const baseUrl = 'https://n8n.example.com';
expect(OAuthHelpers.canonicalIssuer(baseUrl)).toBe(new URL(baseUrl).href);
});
});
});

View File

@ -1,4 +1,4 @@
import { InvalidGrantError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
import { InvalidGrantError } from '@modelcontextprotocol/server-legacy/auth';
import { Time } from '@n8n/constants';
import { Service } from '@n8n/di';
import { randomBytes } from 'node:crypto';

View File

@ -160,7 +160,9 @@ export class OAuthConsentService {
throw new UserError('Invalid or expired session');
}
const issuer = this.urlService.getInstanceBaseUrl();
// Same canonical `href` form the authorize handler and metadata use, so
// the RFC 9207 `iss` on the consent redirect matches the advertised issuer.
const issuer = OAuthHelpers.canonicalIssuer(this.urlService.getInstanceBaseUrl());
if (!approved) {
const redirectUrl = OAuthHelpers.buildErrorRedirectUrl(

View File

@ -1,4 +1,4 @@
import { InvalidGrantError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
import { InvalidGrantError } from '@modelcontextprotocol/server-legacy/auth';
import { Time } from '@n8n/constants';
import { Service } from '@n8n/di';
import { UserError, type N8nOAuth2FlowResult } from 'n8n-workflow';

View File

@ -1,18 +1,15 @@
import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients.js';
import {
InvalidGrantError,
InvalidTargetError,
} from '@modelcontextprotocol/sdk/server/auth/errors.js';
import type {
AuthorizationParams,
OAuthServerProvider,
} from '@modelcontextprotocol/sdk/server/auth/provider.js';
import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
import type {
AuthInfo,
OAuthClientInformationFull,
OAuthTokens,
OAuthTokenRevocationRequest,
} from '@modelcontextprotocol/sdk/shared/auth.js';
} from '@modelcontextprotocol/server';
import { InvalidGrantError, InvalidTargetError } from '@modelcontextprotocol/server-legacy/auth';
import type {
AuthorizationParams,
OAuthRegisteredClientsStore,
OAuthServerProvider,
} from '@modelcontextprotocol/server-legacy/auth';
import type { McpClientConnectedPeriod, McpClientTypeFilter } from '@n8n/api-types';
import { getMcpClientType, MCP_CLIENT_TYPE_FILTER_BUCKETS } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';

View File

@ -1,6 +1,5 @@
import { InvalidGrantError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
import { OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
import type { AuthInfo, OAuthTokens } from '@modelcontextprotocol/server';
import { InvalidGrantError } from '@modelcontextprotocol/server-legacy/auth';
import { Logger } from '@n8n/backend-common';
import { Time } from '@n8n/constants';
import { TransactionRunner, UserRepository } from '@n8n/db';

View File

@ -1,7 +1,9 @@
import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize.js';
import { clientRegistrationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/register.js';
import { revocationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/revoke.js';
import { tokenHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/token.js';
import {
authorizationHandler,
clientRegistrationHandler,
revocationHandler,
tokenHandler,
} from '@modelcontextprotocol/server-legacy/auth';
import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { Time } from '@n8n/constants';
@ -57,32 +59,22 @@ const oauthClientLimitGuard: RequestHandler = async (_req, res, next) => {
next();
};
/**
* The SDK's authorization handler redirects request-validation errors (e.g.
* missing `code_challenge`) back to the client without the RFC 9207 `iss`
* parameter, while our metadata advertises
* `authorization_response_iss_parameter_supported`. Wrap `res.location`
* (which `res.redirect` sets its target through) so every absolute-URL
* redirect from the authorize route carries `iss` matching the advertised
* issuer. Internal relative redirects (consent screen) pass through
* untouched. Remove once `@modelcontextprotocol/sdk` ships
* `authorizationHandler({ issuerUrl })` (on upstream main, unreleased as of
* 1.29.0) and the catalog version is bumped past it.
*/
const rfc9207IssuerParam: RequestHandler = (_req, res, next) => {
const originalLocation = res.location.bind(res);
res.location = (url: string) =>
originalLocation(OAuthHelpers.setIssuerParam(url, urlService.getInstanceBaseUrl()));
next();
};
// Built once and mounted under both the legacy `/mcp-oauth/*` paths (existing
// DCR clients hold them in their stored discovery metadata) and the neutral
// `/oauth/*` paths that future, non-MCP protected resources will advertise.
const registerRouter = clientRegistrationHandler({
clientsStore: oauthServerService.clientsStore,
}) as Router;
const authorizeRouter = authorizationHandler({ provider: oauthServerService }) as Router;
// `issuerUrl` makes the handler stamp the RFC 9207 `iss` parameter onto every
// redirect it drives back to the client's validated `redirect_uri` — including
// its own request-validation error redirects (e.g. missing `code_challenge`),
// which is what our metadata's `authorization_response_iss_parameter_supported`
// promises. Success redirects run through the consent flow, which sets `iss`
// itself.
const authorizeRouter = authorizationHandler({
provider: oauthServerService,
issuerUrl: new URL(urlService.getInstanceBaseUrl()),
}) as Router;
const tokenRouter = tokenHandler({ provider: oauthServerService }) as Router;
const revokeRouter = revocationHandler({ provider: oauthServerService }) as Router;
@ -101,7 +93,6 @@ const sharedEndpointRouters = (basePath: '/mcp-oauth' | '/oauth'): StaticRouterM
path: `${basePath}/authorize`,
router: authorizeRouter,
skipAuth: true,
middlewares: [rfc9207IssuerParam],
ipRateLimit: createIpRateLimit(
oauthServerConfig.rateLimitAuthorize,
5 * Time.minutes.toMilliseconds,
@ -182,7 +173,9 @@ export class OAuthController {
const baseUrl = this.urlService.getInstanceBaseUrl();
const allScopes = this.resourceRegistry.getAllScopes();
const metadata: Record<string, unknown> = {
issuer: baseUrl,
// Canonical `href` form so it matches the RFC 9207 `iss` the authorize
// handler stamps onto redirects (see OAuthHelpers.canonicalIssuer).
issuer: OAuthHelpers.canonicalIssuer(baseUrl),
authorization_endpoint: `${baseUrl}/mcp-oauth/authorize`,
token_endpoint: `${baseUrl}/mcp-oauth/token`,
registration_endpoint: `${baseUrl}/mcp-oauth/register`,
@ -294,7 +287,9 @@ export class OAuthController {
const metadata: Record<string, unknown> = {
resource: resource.getResourceUrl(),
bearer_methods_supported: ['header'],
authorization_servers: [baseUrl],
// Canonical issuer form so it matches the `issuer` in the
// authorization-server metadata a client cross-checks it against.
authorization_servers: [OAuthHelpers.canonicalIssuer(baseUrl)],
};
if (resource.scopes.length > 0) {

View File

@ -1,4 +1,4 @@
import { ServerError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
import { ServerError } from '@modelcontextprotocol/server-legacy/auth';
import { AuthError } from '@/errors/response-errors/auth.error';

View File

@ -2,6 +2,20 @@
* Static utility functions for OAuth URL building
*/
export class OAuthHelpers {
/**
* Canonical RFC 8414/9207 issuer identifier for a given instance base URL.
*
* The `server-legacy/auth` authorize handler derives the RFC 9207 `iss`
* parameter from `issuerUrl.href`, so an origin-only base URL comes back with
* a trailing slash (`https://host` → `https://host/`). RFC 9207 requires the
* `iss` on every authorization response to be byte-identical to the `issuer`
* advertised in metadata, so the metadata document and the consent redirects
* must use this same `href` normalization rather than the bare base URL.
*/
static canonicalIssuer(baseUrl: string): string {
return new URL(baseUrl).href;
}
/**
* Build success redirect URL with authorization code
* Used when user approves consent
@ -47,19 +61,4 @@ export class OAuthHelpers {
targetUrl.searchParams.set('iss', issuer);
return targetUrl.toString();
}
/**
* Set the RFC 9207 `iss` parameter on an absolute redirect URL, replacing
* any pre-existing value (e.g. one baked into the client's registered
* redirect URI) so the response always asserts this server's identity.
* Relative URLs (internal redirects, e.g. to the consent screen) are
* returned unchanged.
*/
static setIssuerParam(url: string, issuer: string): string {
if (!URL.canParse(url)) return url;
const targetUrl = new URL(url);
targetUrl.searchParams.set('iss', issuer);
return targetUrl.toString();
}
}

View File

@ -150,6 +150,9 @@ catalogs:
'@modelcontextprotocol/server':
specifier: 2.0.0
version: 2.0.0
'@modelcontextprotocol/server-legacy':
specifier: 2.0.0
version: 2.0.0
'@mozilla/readability':
specifier: ^0.6.0
version: 0.6.0
@ -4084,12 +4087,12 @@ importers:
'@modelcontextprotocol/node':
specifier: 'catalog:'
version: 2.0.0(@modelcontextprotocol/server@2.0.0)(hono@4.12.27)
'@modelcontextprotocol/sdk':
specifier: 'catalog:'
version: 1.26.0(zod@3.25.76)
'@modelcontextprotocol/server':
specifier: 'catalog:'
version: 2.0.0
'@modelcontextprotocol/server-legacy':
specifier: 'catalog:'
version: 2.0.0(express@5.1.0)
'@mozilla/readability':
specifier: 'catalog:'
version: 0.6.0
@ -9999,6 +10002,16 @@ packages:
'@cfworker/json-schema':
optional: true
'@modelcontextprotocol/server-legacy@2.0.0':
resolution: {integrity: sha512-LnffC1BSqFMHtMQxEz92lqDpHWma+ErV3ghdHDgdkCyYzVcCYKcUT5loq4kflty+Bf9C9qjJqbnphyBWyCqo8Q==}
engines: {node: '>=20'}
deprecated: This package is a frozen copy of v1's SSE transport and OAuth Authorization Server helpers for migration purposes only. Use StreamableHTTP from @modelcontextprotocol/server and a dedicated OAuth server in production. Will not receive new features.
peerDependencies:
express: ^4.18.0 || ^5.0.0
peerDependenciesMeta:
express:
optional: true
'@modelcontextprotocol/server@2.0.0':
resolution: {integrity: sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==}
engines: {node: '>=20'}
@ -27343,6 +27356,18 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@modelcontextprotocol/server-legacy@2.0.0(express@5.1.0)':
dependencies:
'@modelcontextprotocol/core': 2.0.0
content-type: 1.0.5
cors: 2.8.5
express-rate-limit: 8.2.2(express@5.1.0)
pkce-challenge: 5.0.0(patch_hash=651e785d0b7bbf5be9210e1e895c39a16dc3ce8a5a3843b4819565fb6e175b90)
raw-body: 3.0.0
zod: 3.25.76
optionalDependencies:
express: 5.1.0
'@modelcontextprotocol/server@2.0.0':
dependencies:
'@modelcontextprotocol/core': 2.0.0

View File

@ -58,6 +58,7 @@ catalog:
'@modelcontextprotocol/node': 2.0.0
'@modelcontextprotocol/sdk': 1.26.0
'@modelcontextprotocol/server': 2.0.0
'@modelcontextprotocol/server-legacy': 2.0.0
'@mozilla/readability': ^0.6.0
'@n8n_io/ai-assistant-sdk': 1.25.0
'@openrouter/ai-sdk-provider': ^3.0.0