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
4 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 }));