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:
parent
e4227d135c
commit
1823aae891
4 changed files with 69131 additions and 9001 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
|
|
|
|||
Loading…
Reference in a new issue