feat(server): runtime-neutral requireBearerAuth for web-standard hosts (#2420)

This commit is contained in:
Felix Weinberger 2026-07-02 19:35:21 +01:00 • committed by GitHub
commit 7635115d01
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 595 additions and 129 deletions

View file

@ -1,120 +1,47 @@
import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
import type { BearerAuthOptions } from '@modelcontextprotocol/server';
import { bearerAuthChallengeResponse, OAuthError, OAuthErrorCode, verifyBearerToken } from '@modelcontextprotocol/server';
import type { RequestHandler } from 'express';
import type { OAuthTokenVerifier } from './types';
/**
* Options for {@link requireBearerAuth}.
*/
export interface BearerAuthMiddlewareOptions {
/**
* A verifier used to validate access tokens.
*/
verifier: OAuthTokenVerifier;
/**
* Optional scopes that the token must have. When any are missing the
* middleware responds with `403 insufficient_scope`.
*/
requiredScopes?: string[];
/**
* Optional Protected Resource Metadata URL to advertise in the
* `WWW-Authenticate` header on 401/403 responses, per
* {@link https://datatracker.ietf.org/doc/html/rfc9728 | RFC 9728}.
*
* Typically built with `getOAuthProtectedResourceMetadataUrl`.
*/
resourceMetadataUrl?: string;
}
function buildWwwAuthenticateHeader(
errorCode: string,
description: string,
requiredScopes: string[],
resourceMetadataUrl: string | undefined
): string {
let header = `Bearer error="${errorCode}", error_description="${description}"`;
if (requiredScopes.length > 0) {
header += `, scope="${requiredScopes.join(' ')}"`;
}
if (resourceMetadataUrl) {
header += `, resource_metadata="${resourceMetadataUrl}"`;
}
return header;
}
export type BearerAuthMiddlewareOptions = BearerAuthOptions;
/**
* Express middleware that requires a valid Bearer token in the `Authorization`
* header.
*
* The token is validated via the supplied {@link OAuthTokenVerifier} and the
* resulting `AuthInfo` (from `@modelcontextprotocol/server`) is attached
* to `req.auth`. The MCP Streamable HTTP transport reads `req.auth` and
* surfaces it to handlers as `ctx.http.authInfo`.
* The Express adapter over the runtime-neutral core in
* `@modelcontextprotocol/server` (`verifyBearerToken` /
* `bearerAuthChallengeResponse` — or `requireBearerAuth` from that package for
* web-standard `fetch(request)` hosts). The token is validated via the
* supplied `OAuthTokenVerifier` and the resulting `AuthInfo` is attached to
* `req.auth`. The MCP Streamable HTTP transport reads `req.auth` and surfaces
* it to handlers as `ctx.http.authInfo`.
*
* On failure the middleware sends a JSON OAuth error body and a
* `WWW-Authenticate: Bearer …` challenge that includes the configured
* `resource_metadata` URL so clients can discover the Authorization Server.
*/
export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetadataUrl }: BearerAuthMiddlewareOptions): RequestHandler {
export function requireBearerAuth(options: BearerAuthMiddlewareOptions): RequestHandler {
// Destructure at creation so a plain-JS caller passing undefined or
// malformed options crashes at startup, not on the first request.
const { verifier, requiredScopes = [], resourceMetadataUrl } = options;
const resolved = { verifier, requiredScopes, resourceMetadataUrl };
return async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader) {
throw new OAuthError(OAuthErrorCode.InvalidToken, 'Missing Authorization header');
}
const [type, token] = authHeader.split(' ');
if (type?.toLowerCase() !== 'bearer' || !token) {
throw new OAuthError(OAuthErrorCode.InvalidToken, "Invalid Authorization header format, expected 'Bearer TOKEN'");
}
const authInfo = await verifier.verifyAccessToken(token);
// Check if token has the required scopes (if any)
if (requiredScopes.length > 0) {
const hasAllScopes = requiredScopes.every(scope => authInfo.scopes.includes(scope));
if (!hasAllScopes) {
throw new OAuthError(OAuthErrorCode.InsufficientScope, 'Insufficient scope');
}
}
// Check if the token is set to expire or if it is expired
if (typeof authInfo.expiresAt !== 'number' || Number.isNaN(authInfo.expiresAt)) {
throw new OAuthError(OAuthErrorCode.InvalidToken, 'Token has no expiration time');
} else if (authInfo.expiresAt < Date.now() / 1000) {
throw new OAuthError(OAuthErrorCode.InvalidToken, 'Token has expired');
}
req.auth = authInfo;
req.auth = await verifyBearerToken(req.headers.authorization, resolved);
next();
} catch (error) {
if (error instanceof OAuthError) {
const challenge = buildWwwAuthenticateHeader(error.code, error.message, requiredScopes, resourceMetadataUrl);
switch (error.code) {
case OAuthErrorCode.InvalidToken: {
res.set('WWW-Authenticate', challenge);
res.status(401).json(error.toResponseObject());
break;
}
case OAuthErrorCode.InsufficientScope: {
res.set('WWW-Authenticate', challenge);
res.status(403).json(error.toResponseObject());
break;
}
case OAuthErrorCode.ServerError: {
res.status(500).json(error.toResponseObject());
break;
}
default: {
res.status(400).json(error.toResponseObject());
}
}
} else {
const serverError = new OAuthError(OAuthErrorCode.ServerError, 'Internal Server Error');
res.status(500).json(serverError.toResponseObject());
// The core Response supplies status and challenge; the body is
// derived directly rather than parsed back out of the Response.
const response = bearerAuthChallengeResponse(error, resolved);
const challenge = response.headers.get('WWW-Authenticate');
if (challenge !== null) {
res.set('WWW-Authenticate', challenge);
}
const body = error instanceof OAuthError ? error : new OAuthError(OAuthErrorCode.ServerError, 'Internal Server Error');
res.status(response.status).json(body.toResponseObject());
}
};
}

View file

@ -1,30 +1,12 @@
import type { AuthInfo } from '@modelcontextprotocol/server';
/**
* Minimal token-verifier interface for MCP servers acting as an OAuth 2.0
* Resource Server. Implementations introspect or locally validate an access
* token and return the resulting {@link AuthInfo}, which is then attached to
* the Express request and surfaced to MCP request handlers via
* `ctx.http.authInfo`.
*
* This is intentionally narrower than a full OAuth Authorization Server
* provider — it only covers the verification step a Resource Server needs.
* Re-exported from `@modelcontextprotocol/server`, where the runtime-neutral
* Bearer authentication core lives — implement it once and use it with this
* package's Express middleware or with `requireBearerAuth` from the server
* package on web-standard hosts.
*/
export interface OAuthTokenVerifier {
/**
* Verifies an access token and returns information about it.
*
* Implementations should throw an `OAuthError` (from `@modelcontextprotocol/server`)
* with `OAuthErrorCode.InvalidToken` when
* the token is unknown, revoked, or otherwise invalid; `requireBearerAuth`
* maps that to a 401 with a `WWW-Authenticate` challenge.
*
* Note: `requireBearerAuth` rejects tokens whose `AuthInfo.expiresAt` is unset
* (matches v1 behavior). Ensure your verifier populates it (e.g. from RFC 7662
* introspection `exp` or the JWT `exp` claim).
*/
verifyAccessToken(token: string): Promise<AuthInfo>;
}
export type { OAuthTokenVerifier } from '@modelcontextprotocol/server';
declare module 'express-serve-static-core' {
interface Request {

View file

@ -129,6 +129,31 @@ describe('requireBearerAuth middleware', () => {
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'server_error' }));
expect(next).not.toHaveBeenCalled();
});
it('responds 500 without a WWW-Authenticate challenge', async () => {
mockVerifyAccessToken.mockRejectedValue(new OAuthError(OAuthErrorCode.ServerError, 'boom'));
const { req, res, next } = createMockReqResNext('Bearer valid');
const middleware = requireBearerAuth({ verifier: mockVerifier });
await middleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.set).not.toHaveBeenCalled();
expect(next).not.toHaveBeenCalled();
});
it('responds 400 without a WWW-Authenticate challenge for other OAuth error codes', async () => {
mockVerifyAccessToken.mockRejectedValue(new OAuthError(OAuthErrorCode.InvalidRequest, 'nope'));
const { req, res, next } = createMockReqResNext('Bearer valid');
const middleware = requireBearerAuth({ verifier: mockVerifier });
await middleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.set).not.toHaveBeenCalled();
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'invalid_request' }));
expect(next).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------