Implement MCP 2026-07-28 (#2286)

Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com>
Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Felix Weinberger 2026-06-26 14:01:10 +01:00 • committed by GitHub
commit 1823aae891
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 69131 additions and 9001 deletions

View file

@ -1,4 +1,4 @@
import type { RequestHandler } from 'express';
import type { RequestHandler, Response } from 'express';
import express from 'express';
import type { Options as RateLimitOptions } from 'express-rate-limit';
import { rateLimit } from 'express-rate-limit';
@ -6,10 +6,19 @@ import * as z from 'zod/v4';
import { InvalidClientError, InvalidRequestError, OAuthError, ServerError, TooManyRequestsError } from '../errors';
import { allowedMethods } from '../middleware/allowedMethods';
import type { OAuthServerProvider } from '../provider';
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- AuthorizationParams referenced in JSDoc {@linkcode}
import type { AuthorizationParams, OAuthServerProvider } from '../provider';
export type AuthorizationHandlerOptions = {
provider: OAuthServerProvider;
/**
* The authorization server's issuer identifier. When set, the handler appends it as the
* `iss` query parameter (RFC 9207) to any redirect — success or error — that targets the
* client's validated `redirect_uri`, and also supplies it to the provider as
* {@linkcode AuthorizationParams.issuer}. `mcpAuthRouter` always sets this from its
* `issuerUrl`.
*/
issuerUrl?: URL;
/**
* Rate limiting configuration for the authorization endpoint.
* Set to false to disable rate limiting for this endpoint.
@ -69,7 +78,8 @@ const RequestAuthorizationParamsSchema = z.object({
resource: z.string().url().optional()
});
export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: AuthorizationHandlerOptions): RequestHandler {
export function authorizationHandler({ provider, issuerUrl, rateLimit: rateLimitConfig }: AuthorizationHandlerOptions): RequestHandler {
const issuer = issuerUrl?.href;
// Create a router to apply middleware
const router = express.Router();
router.use(allowedMethods(['GET', 'POST']));
@ -158,7 +168,11 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A
requestedScopes = scope.split(' ');
}
// All validation passed, proceed with authorization
// All validation passed, proceed with authorization. RFC 9207: the metadata
// advertises `authorization_response_iss_parameter_supported`, so make that claim
// true from SDK code by appending `iss` to whatever redirect the provider issues
// back to the client's validated redirect_uri — the provider need not do anything.
// Redirects elsewhere (e.g. to an upstream authorize endpoint) are left untouched.
await provider.authorize(
client,
{
@ -166,17 +180,18 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A
scopes: requestedScopes,
redirectUri: redirect_uri!,
codeChallenge: code_challenge,
resource: resource ? new URL(resource) : undefined
resource: resource ? new URL(resource) : undefined,
issuer
},
res
issuer ? withIssOnCallbackRedirect(res, redirect_uri!, issuer) : res
);
} catch (error) {
// Post-redirect errors - redirect with error parameters
if (error instanceof OAuthError) {
res.redirect(302, createErrorRedirect(redirect_uri!, error, state));
res.redirect(302, createErrorRedirect(redirect_uri!, error, state, issuer));
} else {
const serverError = new ServerError('Internal Server Error');
res.redirect(302, createErrorRedirect(redirect_uri!, serverError, state));
res.redirect(302, createErrorRedirect(redirect_uri!, serverError, state, issuer));
}
}
});
@ -184,10 +199,43 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A
return router;
}
/**
* Wraps `res.redirect` so that when the provider redirects to the client's validated
* `redirect_uri` (i.e. the OAuth authorization response), `iss` is appended per RFC 9207.
* Only redirects whose origin and path match `redirectUri` are touched; an `iss` already
* set by the provider is preserved. This is what backs the
* `authorization_response_iss_parameter_supported: true` metadata claim without requiring
* `OAuthServerProvider.authorize()` implementations to change.
*/
function withIssOnCallbackRedirect(res: Response, redirectUri: string, issuer: string): Response {
const cb = new URL(redirectUri);
const appendIss = (url: string): string => {
let target: URL;
try {
target = new URL(url);
} catch {
return url;
}
if (target.origin === cb.origin && target.pathname === cb.pathname && !target.searchParams.has('iss')) {
target.searchParams.set('iss', issuer);
return target.href;
}
return url;
};
const original = res.redirect.bind(res) as (...args: unknown[]) => void;
res.redirect = ((statusOrUrl: number | string, maybeUrl?: string | number): void => {
if (typeof statusOrUrl === 'number') original(statusOrUrl, appendIss(String(maybeUrl)));
// Express 4 still accepts the deprecated reversed form `res.redirect(url, status)`.
else if (typeof maybeUrl === 'number') original(appendIss(statusOrUrl), maybeUrl);
else original(appendIss(statusOrUrl));
}) as Response['redirect'];
return res;
}
/**
* Helper function to create redirect URL with error parameters
*/
function createErrorRedirect(redirectUri: string, error: OAuthError, state?: string): string {
function createErrorRedirect(redirectUri: string, error: OAuthError, state?: string, issuer?: string): string {
const errorUrl = new URL(redirectUri);
errorUrl.searchParams.set('error', error.errorCode);
errorUrl.searchParams.set('error_description', error.message);
@ -197,5 +245,9 @@ function createErrorRedirect(redirectUri: string, error: OAuthError, state?: str
if (state) {
errorUrl.searchParams.set('state', state);
}
if (issuer) {
// RFC 9207 §2: the iss parameter is required on error responses too.
errorUrl.searchParams.set('iss', issuer);
}
return errorUrl.href;
}

View file

@ -10,6 +10,15 @@ export type AuthorizationParams = {
codeChallenge: string;
redirectUri: string;
resource?: URL;
/**
* The authorization server's own issuer identifier (the `issuerUrl` configured on
* `mcpAuthRouter`). Informational: the bundled `authorizationHandler` already appends
* this as the `iss` query parameter (RFC 9207 §2) to any `res.redirect(...)` your
* `authorize()` issues to {@linkcode AuthorizationParams.redirectUri | redirectUri}. You
* only need to append it yourself when the final callback redirect is issued from a
* different response (e.g. after a separate consent-page POST).
*/
issuer?: string;
};
/**
@ -27,6 +36,14 @@ export interface OAuthServerProvider {
* This server must eventually issue a redirect with an authorization response or an error response to the given redirect URI. Per OAuth 2.1:
* - In the successful case, the redirect MUST include the `code` and `state` (if present) query parameters.
* - In the error case, the redirect MUST include the `error` query parameter, and MAY include an optional `error_description` query parameter.
*
* RFC 9207: the bundled `authorizationHandler` appends `iss` **only** to `res.redirect(...)` calls you issue
* on the supplied `res` to `params.redirectUri`, so an implementation that redirects that way requires no
* change. If you emit the `Location` header another way (e.g. `res.writeHead(302, { Location: ... })`), or
* issue the final callback redirect from a different response (e.g. after a separate consent step), append
* {@linkcode AuthorizationParams.issuer | params.issuer} as `iss` yourself, or set
* {@linkcode OAuthServerProvider.authorizationResponseIssParameterSupported} to `false` so the metadata does
* not over-claim.
*/
authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void>;
@ -63,6 +80,16 @@ export interface OAuthServerProvider {
*/
revokeToken?(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise<void>;
/**
* Whether this provider's authorization responses carry the RFC 9207 `iss` parameter.
* Drives the `authorization_response_iss_parameter_supported` metadata field. Defaults to
* `true` — the bundled `authorizationHandler` appends `iss` to redirects it issues to the
* client's `redirect_uri`. Set to `false` when the callback is issued by an upstream
* authorization server this provider delegates to (e.g. `ProxyOAuthServerProvider`), so the
* published metadata does not over-claim support.
*/
authorizationResponseIssParameterSupported?: boolean;
/**
* Whether to skip local PKCE validation.
*

View file

@ -47,6 +47,17 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider {
skipLocalPkceValidation = true;
/**
* The proxy redirects the browser to the upstream AS's authorize endpoint with
* `redirect_uri = params.redirectUri`, so the upstream — not this proxy — issues the
* callback. The proxy cannot append its own `iss`, and any `iss` the upstream emits is the
* upstream's issuer, not `issuerUrl`. Advertise `false` so the metadata does not over-claim —
* a callback *without* `iss` then passes validation. Note: an upstream that *does* emit its
* own `iss` will still mismatch this proxy's issuer and be rejected by RFC 9207 clients
* regardless of this flag.
*/
authorizationResponseIssParameterSupported = false;
revokeToken?: (client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest) => Promise<void>;
constructor(options: ProxyOptions) {

View file

@ -61,7 +61,7 @@ export type AuthRouterOptions = {
resourceServerUrl?: URL;
// Individual options per route
authorizationOptions?: Omit<AuthorizationHandlerOptions, 'provider'>;
authorizationOptions?: Omit<AuthorizationHandlerOptions, 'provider' | 'issuerUrl'>;
clientRegistrationOptions?: Omit<ClientRegistrationHandlerOptions, 'clientsStore'>;
revocationOptions?: Omit<RevocationHandlerOptions, 'provider'>;
tokenOptions?: Omit<TokenHandlerOptions, 'provider'>;
@ -114,7 +114,14 @@ export const createOAuthMetadata = (options: {
revocation_endpoint: revocation_endpoint ? new URL(revocation_endpoint, baseUrl || issuer).href : undefined,
revocation_endpoint_auth_methods_supported: revocation_endpoint ? ['client_secret_post'] : undefined,
registration_endpoint: registration_endpoint ? new URL(registration_endpoint, baseUrl || issuer).href : undefined
registration_endpoint: registration_endpoint ? new URL(registration_endpoint, baseUrl || issuer).href : undefined,
// RFC 9207: the bundled authorize handler appends `iss` to any redirect the provider
// issues via `res.redirect(...)` to the client's validated redirect_uri, so the default
// is `true`. Providers whose callback is issued by an upstream AS (e.g. the proxy
// provider) override this to `false` so we don't over-claim — SEP-2468 clients reject
// a callback that omits `iss` when support is advertised.
authorization_response_iss_parameter_supported: options.provider.authorizationResponseIssParameterSupported ?? true
};
return metadata;
@ -139,7 +146,7 @@ export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler {
router.use(
new URL(oauthMetadata.authorization_endpoint).pathname,
authorizationHandler({ provider: options.provider, ...options.authorizationOptions })
authorizationHandler({ provider: options.provider, issuerUrl: options.issuerUrl, ...options.authorizationOptions })
);
router.use(new URL(oauthMetadata.token_endpoint).pathname, tokenHandler({ provider: options.provider, ...options.tokenOptions }));

View file

@ -397,4 +397,78 @@ describe('Authorization Handler', () => {
expect(location.searchParams.has('code')).toBe(true);
});
});
describe('RFC 9207 iss parameter', () => {
const ISSUER = 'https://auth.example.com/';
let issApp: express.Express;
beforeEach(() => {
issApp = express();
issApp.use('/authorize', authorizationHandler({ provider: mockProvider, issuerUrl: new URL(ISSUER) }));
});
it("appends iss to the provider's success redirect and supplies issuer to provider.authorize()", async () => {
// mockProvider.authorize() does NOT set `iss` itself — the handler must add it.
const spy = vi.spyOn(mockProvider, 'authorize');
const response = await supertest(issApp).get('/authorize').query({
client_id: 'valid-client',
redirect_uri: 'https://example.com/callback',
response_type: 'code',
code_challenge: 'challenge123',
code_challenge_method: 'S256'
});
expect(response.status).toBe(302);
const location = new URL(response.header.location!);
expect(location.searchParams.get('code')).toBe('mock_auth_code');
expect(location.searchParams.get('iss')).toBe(ISSUER);
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ issuer: ISSUER }), expect.anything());
spy.mockRestore();
});
it('leaves redirects to non-callback targets untouched', async () => {
// A provider that hops to an upstream authorize endpoint (proxy pattern) — the
// handler must not append `iss` to that hop.
const upstream = 'https://upstream.example.com/authorize?client_id=x';
const proxyLike: OAuthServerProvider = {
...mockProvider,
async authorize(_client, _params, res) {
res.redirect(upstream);
}
};
const proxyApp = express();
proxyApp.use('/authorize', authorizationHandler({ provider: proxyLike, issuerUrl: new URL(ISSUER) }));
const response = await supertest(proxyApp).get('/authorize').query({
client_id: 'valid-client',
redirect_uri: 'https://example.com/callback',
response_type: 'code',
code_challenge: 'challenge123',
code_challenge_method: 'S256'
});
expect(response.status).toBe(302);
expect(response.header.location).toBe(upstream);
});
it('appends iss to error redirects', async () => {
const response = await supertest(issApp).get('/authorize').query({
client_id: 'valid-client',
redirect_uri: 'https://example.com/callback',
response_type: 'token' // invalid → error redirect
});
expect(response.status).toBe(302);
const location = new URL(response.header.location!);
expect(location.searchParams.get('error')).toBe('invalid_request');
expect(location.searchParams.get('iss')).toBe(ISSUER);
});
it('omits iss when issuerUrl is not configured', async () => {
const response = await supertest(app).get('/authorize').query({
client_id: 'valid-client',
redirect_uri: 'https://example.com/callback',
response_type: 'token'
});
expect(response.status).toBe(302);
const location = new URL(response.header.location!);
expect(location.searchParams.has('iss')).toBe(false);
});
});
});

View file

@ -106,6 +106,14 @@ describe('Proxy OAuth Server Provider', () => {
expect(mockResponse.redirect).toHaveBeenCalledWith(expectedUrl.toString());
});
it('reports authorizationResponseIssParameterSupported = false (upstream issues the callback)', () => {
// The proxy cannot guarantee its own `iss` on the callback, so the metadata flag
// derived from the provider must be false — otherwise RFC 9207 clients reject any
// callback that arrives *without* `iss`. (A present upstream `iss` still mismatches
// the proxy issuer regardless of this flag.)
expect(provider.authorizationResponseIssParameterSupported).toBe(false);
});
});
describe('token exchange', () => {

View file

@ -218,6 +218,22 @@ describe('MCP Auth Router', () => {
// Verify optional fields
expect(response.body.service_documentation).toBe('https://docs.example.com/');
// RFC 9207: the bundled authorize handler emits `iss`, so the metadata advertises it.
expect(response.body.authorization_response_iss_parameter_supported).toBe(true);
});
it('derives authorization_response_iss_parameter_supported from the provider', async () => {
const optOutApp = express();
optOutApp.use(
mcpAuthRouter({
provider: { ...mockProvider, authorizationResponseIssParameterSupported: false },
issuerUrl: new URL('https://auth.example.com')
})
);
const response = await supertest(optOutApp).get('/.well-known/oauth-authorization-server');
expect(response.status).toBe(200);
expect(response.body.authorization_response_iss_parameter_supported).toBe(false);
});
it('returns minimal metadata for minimal router', async () => {