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
13 changed files with 595 additions and 129 deletions

View file

@ -0,0 +1,15 @@
---
'@modelcontextprotocol/server': minor
'@modelcontextprotocol/express': patch
'@modelcontextprotocol/codemod': patch
---
Add runtime-neutral Bearer authentication to `@modelcontextprotocol/server`:
`requireBearerAuth` gates web-standard `fetch(request)` hosts (Cloudflare
Workers, Deno, Bun, Hono), built on the exported `verifyBearerToken` and
`bearerAuthChallengeResponse` pieces, with `OAuthTokenVerifier` now defined
here. The Express middleware adapts the same core and is unchanged in
behavior, except that `WWW-Authenticate` challenge values are now RFC 7235
quoted-string sanitized (quotes and backslashes escaped, control and
non-ASCII characters replaced); `@modelcontextprotocol/express` re-exports
`OAuthTokenVerifier` as before.

View file

@ -64,8 +64,8 @@ The codemod ([`@modelcontextprotocol/codemod`](https://github.com/modelcontextpr
mechanically applies every rename whose mapping is fixed. The mappings are the
**source of truth** — they live in the codemod package and are not reproduced here:
| Mapping | Source file |
| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| Mapping | Source file |
| ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `@modelcontextprotocol/sdk/...` import paths → v2 packages | [`mappings/importMap.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts) |
| Symbol renames (`McpError` → `ProtocolError`, `JSONRPCError` → `JSONRPCErrorResponse`, …) | [`mappings/symbolMap.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/codemod/src/migrations/v1-to-v2/mappings/symbolMap.ts) |
| `setRequestHandler(Schema, …)` → `setRequestHandler('method/string', …)` | [`mappings/schemaToMethodMap.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts) |
@ -395,7 +395,9 @@ A few transports need a decision the codemod can't make:
import path changes.
- **Server auth split.** Resource Server helpers (`requireBearerAuth`,
`mcpAuthMetadataRouter`, `getOAuthProtectedResourceMetadataUrl`, `OAuthTokenVerifier`)
→ `@modelcontextprotocol/express`. Authorization Server helpers (`mcpAuthRouter`,
→ `@modelcontextprotocol/express`; the runtime-neutral core (`requireBearerAuth`
for web-standard `fetch` hosts, `verifyBearerToken`, `bearerAuthChallengeResponse`,
`OAuthTokenVerifier`) is also exported from `@modelcontextprotocol/server`. Authorization Server helpers (`mcpAuthRouter`,
`OAuthServerProvider`, `ProxyOAuthServerProvider`, `allowedMethods`,
`authenticateClient`, `metadataHandler`, `createOAuthMetadata`,
`authorizationHandler` / `tokenHandler` / `revocationHandler` /
@ -1036,7 +1038,8 @@ if (error instanceof OAuthError && error.code === OAuthErrorCode.InvalidClient)
```
⚠ **Token verifiers must throw the v2 `OAuthError`.** `requireBearerAuth` (from
`@modelcontextprotocol/express`) classifies the error your
`@modelcontextprotocol/express`, or from `@modelcontextprotocol/server` on
web-standard hosts) classifies the error your
`OAuthTokenVerifier.verifyAccessToken()` throws: a v2
`OAuthError(OAuthErrorCode.InvalidToken)` produces the proper `401` +
`WWW-Authenticate` challenge, while the legacy `InvalidTokenError` (from

View file

@ -2,6 +2,7 @@
shape: how-to
description: 'Require a bearer token on a server you run: verification, protected-resource metadata, and per-tool scopes.'
---
# Require authorization
Protecting a server you run → this page. Signing a user in from a client you build → [Authenticate a user with OAuth](../clients/oauth.md). No user present → [Authenticate without a user](../clients/machine-auth.md).
@ -42,6 +43,25 @@ A request with a missing, malformed, or expired token gets `401` with the OAuth
The Authorization Server helpers (`mcpAuthRouter`, `ProxyOAuthServerProvider`, …) are frozen in `@modelcontextprotocol/server-legacy/auth`. Use a dedicated identity provider for new servers; this page only covers the resource-server half.
:::
## Require a bearer token on a web-standard host
On hosts whose HTTP surface is a `fetch(request)` handler — Cloudflare Workers, Deno, Bun, Hono — the gate is `requireBearerAuth` from `@modelcontextprotocol/server`: no framework, only web-standard `Request` and `Response`.
```ts source="../../examples/guides/serving/authorization.web.examples.ts#requireBearerAuth_webStandard"
const gate = requireBearerAuth({ verifier, requiredScopes: ['mcp'] });
const handler = createMcpHandler(buildServer);
export default {
async fetch(request: Request): Promise<Response> {
const auth = await gate(request);
if (auth instanceof Response) return auth;
return handler.fetch(request, { authInfo: auth });
}
};
```
The gate resolves to the verified `AuthInfo` — pass it to the handler as `{ authInfo }` and handlers read it as `ctx.http.authInfo` — or to the ready-to-return challenge `Response`. Status codes, error bodies, and the `WWW-Authenticate` challenge (including `resourceMetadataUrl`) are identical to the Express middleware: both are adapters over one core, so a verifier written for one serves the other unchanged.
## Verify tokens your way
`verifyAccessToken` is the one function you supply: take the raw token string, return an `AuthInfo`. Local JWT verification, [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) introspection, or a call to your identity provider all fit behind it.
@ -107,6 +127,7 @@ Responding `403 insufficient_scope` at the HTTP layer instead triggers the clien
## Recap
- `requireBearerAuth` from `@modelcontextprotocol/server` is the same gate for web-standard `fetch` hosts; the Express middleware adapts the same core.
- `requireBearerAuth` plus a `verifyAccessToken` you write turn an Express-mounted MCP route into an OAuth resource server; the SDK never issues tokens.
- Missing, invalid, or expired tokens get `401 invalid_token`; a token missing a `requiredScopes` entry gets `403 insufficient_scope`; both carry a `WWW-Authenticate: Bearer` challenge.
- `mcpAuthMetadataRouter` publishes the RFC 9728 document that challenge points at, plus a mirror of the AS metadata.

View file

@ -2,7 +2,8 @@
/**
* Companion example for `docs/serving/authorization.md`.
*
* Every `ts` fence on that page is synced from a `//#region` in this file
* Every `ts` fence on that page except the web-standard one (sourced from
* `authorization.web.examples.ts`) is synced from a `//#region` in this file
* (`pnpm sync:snippets --check`). The Express middleware needs a listening
* HTTP server and a real authorization server to exercise, so this file only
* typechecks:

View file

@ -0,0 +1,44 @@
// docs: typecheck-only
/**
* Companion example for the web-standard section of
* `docs/serving/authorization.md`.
*
* Lives beside `authorization.examples.ts` in its own module because both
* packages export a `requireBearerAuth`: the Express one is demonstrated
* there, the web-standard one from `@modelcontextprotocol/server` here. Like
* its sibling, this file only typechecks:
*
* pnpm --filter @modelcontextprotocol/examples typecheck
*
* @module
*/
import type { AuthInfo, OAuthTokenVerifier } from '@modelcontextprotocol/server';
import { createMcpHandler, McpServer, OAuthError, OAuthErrorCode, requireBearerAuth } from '@modelcontextprotocol/server';
declare function verifyJwt(token: string): Promise<{ sub: string; scopes: string[]; exp: number }>;
const verifier: OAuthTokenVerifier = {
async verifyAccessToken(token): Promise<AuthInfo> {
const payload = await verifyJwt(token).catch(() => {
throw new OAuthError(OAuthErrorCode.InvalidToken, 'unknown token');
});
return { token, clientId: payload.sub, scopes: payload.scopes, expiresAt: payload.exp };
}
};
function buildServer(): McpServer {
return new McpServer({ name: 'protected-server', version: '1.0.0' });
}
//#region requireBearerAuth_webStandard
const gate = requireBearerAuth({ verifier, requiredScopes: ['mcp'] });
const handler = createMcpHandler(buildServer);
export default {
async fetch(request: Request): Promise<Response> {
const auth = await gate(request);
if (auth instanceof Response) return auth;
return handler.fetch(request, { authInfo: auth });
}
};
//#endregion requireBearerAuth_webStandard

View file

@ -34,8 +34,10 @@ export interface ImportMapping {
}
/**
* Resource-server auth helpers whose maintained v2 home is `@modelcontextprotocol/express`;
* the server-legacy/auth copy they route to by default is a frozen v1 snapshot, so import
* Resource-server auth helpers whose maintained v2 home is `@modelcontextprotocol/express`
* (with the runtime-neutral core — `requireBearerAuth` for web-standard hosts and
* `OAuthTokenVerifier` — also exported from `@modelcontextprotocol/server`); the
* server-legacy/auth copy they route to by default is a frozen v1 snapshot, so import
* and re-export sites get a marker prompting a deliberate re-point.
*/
export const RS_ONLY_AUTH_SYMBOLS: ReadonlySet<string> = new Set([
@ -141,7 +143,8 @@ export const IMPORT_MAP: Record<string, ImportMapping> = {
'@modelcontextprotocol/sdk/server/auth/provider.js': {
target: '@modelcontextprotocol/server-legacy/auth',
status: 'moved',
migrationHint: 'Legacy OAuth AS provider. For RS-only auth, see requireBearerAuth from @modelcontextprotocol/express.'
migrationHint:
'Legacy OAuth AS provider. For RS-only auth, see requireBearerAuth from @modelcontextprotocol/express (or, on web-standard hosts, from @modelcontextprotocol/server).'
},
'@modelcontextprotocol/sdk/server/auth/router.js': {
target: '@modelcontextprotocol/server-legacy/auth',
@ -151,7 +154,8 @@ export const IMPORT_MAP: Record<string, ImportMapping> = {
'@modelcontextprotocol/sdk/server/auth/middleware.js': {
target: '@modelcontextprotocol/server-legacy/auth',
status: 'moved',
migrationHint: 'Legacy OAuth AS middleware. For bearer-only auth, see requireBearerAuth from @modelcontextprotocol/express.'
migrationHint:
'Legacy OAuth AS middleware. For bearer-only auth, see requireBearerAuth from @modelcontextprotocol/express (or, on web-standard hosts, from @modelcontextprotocol/server).'
},
'@modelcontextprotocol/sdk/server/auth/errors.js': {
target: '@modelcontextprotocol/server-legacy/auth',

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();
});
});
// ---------------------------------------------------------------------------

View file

@ -33,6 +33,10 @@ export type {
ToolCallback
} from './server/mcp';
export { McpServer, ResourceTemplate } from './server/mcp';
// Runtime-neutral Bearer authentication for web-standard hosts; the Express
// middleware in @modelcontextprotocol/express adapts the same core.
export type { BearerAuthOptions, OAuthTokenVerifier } from './server/middleware/bearerAuth';
export { bearerAuthChallengeResponse, requireBearerAuth, verifyBearerToken } from './server/middleware/bearerAuth';
export type { HostHeaderValidationResult } from './server/middleware/hostHeaderValidation';
export { hostHeaderValidationResponse, localhostAllowedHostnames, validateHostHeader } from './server/middleware/hostHeaderValidation';
export type { OriginValidationResult } from './server/middleware/originValidation';

View file

@ -0,0 +1,30 @@
/**
* Type-checked examples for `bearerAuth.ts`.
*
* These examples are synced into JSDoc comments via the sync-snippets script.
* Each function's region markers define the code snippet that appears in the docs.
*
* @module
*/
import type { AuthInfo } from '@modelcontextprotocol/core-internal';
import type { McpHttpHandler } from '../createMcpHandler';
import type { OAuthTokenVerifier } from './bearerAuth';
import { requireBearerAuth } from './bearerAuth';
/**
* Example: gating a web-standard fetch handler with a Bearer token.
*/
function requireBearerAuth_fetchGate(verifier: OAuthTokenVerifier, handler: McpHttpHandler) {
//#region requireBearerAuth_fetchGate
const gate = requireBearerAuth({ verifier, requiredScopes: ['mcp'] });
async function fetchHandler(request: Request): Promise<Response> {
const auth: AuthInfo | Response = await gate(request);
if (auth instanceof Response) return auth;
return handler.fetch(request, { authInfo: auth });
}
//#endregion requireBearerAuth_fetchGate
return fetchHandler;
}

View file

@ -0,0 +1,203 @@
import type { AuthInfo } from '@modelcontextprotocol/core-internal';
import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/core-internal';
/**
* 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 the serving entry
* surfaces 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.
*/
export interface OAuthTokenVerifier {
/**
* Verifies an access token and returns information about it.
*
* Implementations should throw an {@link OAuthError} with
* `OAuthErrorCode.InvalidToken` when the token is unknown, revoked, or
* otherwise invalid; the bearer-auth helpers map that to a `401` with a
* `WWW-Authenticate` challenge.
*
* Note: bearer-auth verification 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>;
}
/**
* Options for {@link verifyBearerToken}, {@link requireBearerAuth}, and
* {@link bearerAuthChallengeResponse}.
*/
export interface BearerAuthOptions {
/**
* A verifier used to validate access tokens.
*/
verifier: OAuthTokenVerifier;
/**
* Optional scopes that the token must have. When any are missing the
* request is refused 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` from
* `@modelcontextprotocol/express`.
*/
resourceMetadataUrl?: string;
}
function headerQuotedValue(value: string): string {
// HTTP quoted-string per RFC 7235: escape backslash and double quote, and
// replace characters a header cannot carry (controls, anything beyond
// printable ASCII) so a verifier-authored message can never make the
// challenge Response constructor throw.
return value.replaceAll(/[\\"]/g, String.raw`\$&`).replaceAll(/[^\u0020-\u007E]/g, ' ');
}
function buildWwwAuthenticateHeader(
errorCode: string,
description: string,
requiredScopes: string[],
resourceMetadataUrl: string | undefined
): string {
let header = `Bearer error="${headerQuotedValue(errorCode)}", error_description="${headerQuotedValue(description)}"`;
if (requiredScopes.length > 0) {
header += `, scope="${requiredScopes.join(' ')}"`;
}
if (resourceMetadataUrl) {
header += `, resource_metadata="${resourceMetadataUrl}"`;
}
return header;
}
/**
* Validate a raw `Authorization` header value as a Bearer token and return
* the verified {@link AuthInfo}.
*
* The runtime-neutral core of Bearer authentication: it parses the header,
* runs the verifier, enforces `requiredScopes`, and rejects tokens without an
* expiration or past it. On any failure it throws an {@link OAuthError} —
* pass that to {@link bearerAuthChallengeResponse} for the matching HTTP
* answer, or use {@link requireBearerAuth} to get both steps as one call.
*
* Framework adapters build on this: `requireBearerAuth` from
* `@modelcontextprotocol/express` feeds it `req.headers.authorization`.
*/
export async function verifyBearerToken(authorizationHeader: string | null | undefined, options: BearerAuthOptions): Promise<AuthInfo> {
const { verifier, requiredScopes = [] } = options;
if (!authorizationHeader) {
throw new OAuthError(OAuthErrorCode.InvalidToken, 'Missing Authorization header');
}
const [type, token] = authorizationHeader.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');
}
return authInfo;
}
/**
* Build the HTTP answer for a Bearer authentication failure.
*
* Maps an {@link OAuthError} to its status — `401` for `invalid_token` and
* `403` for `insufficient_scope` (both carrying the `WWW-Authenticate: Bearer …`
* challenge, with `resource_metadata` when configured so clients can discover
* the Authorization Server), `500` for `server_error`, `400` for anything
* else. A non-`OAuthError` value answers `500 server_error`. The body is the
* OAuth error JSON.
*/
export function bearerAuthChallengeResponse(
error: unknown,
options?: Pick<BearerAuthOptions, 'requiredScopes' | 'resourceMetadataUrl'>
): Response {
const { requiredScopes = [], resourceMetadataUrl } = options ?? {};
if (!(error instanceof OAuthError)) {
const serverError = new OAuthError(OAuthErrorCode.ServerError, 'Internal Server Error');
return Response.json(serverError.toResponseObject(), { status: 500 });
}
switch (error.code) {
case OAuthErrorCode.InvalidToken: {
const challenge = buildWwwAuthenticateHeader(error.code, error.message, requiredScopes, resourceMetadataUrl);
return Response.json(error.toResponseObject(), { status: 401, headers: { 'WWW-Authenticate': challenge } });
}
case OAuthErrorCode.InsufficientScope: {
const challenge = buildWwwAuthenticateHeader(error.code, error.message, requiredScopes, resourceMetadataUrl);
return Response.json(error.toResponseObject(), { status: 403, headers: { 'WWW-Authenticate': challenge } });
}
case OAuthErrorCode.ServerError: {
return Response.json(error.toResponseObject(), { status: 500 });
}
default: {
return Response.json(error.toResponseObject(), { status: 400 });
}
}
}
/**
* Require a valid Bearer token on web-standard requests.
*
* The framework-free counterpart of `requireBearerAuth` from
* `@modelcontextprotocol/express`, for hosts whose HTTP surface is a
* `fetch(request)` handler — Cloudflare Workers, Deno, Bun, Hono. The
* returned gate resolves to the verified {@link AuthInfo}, or to the
* ready-to-return challenge `Response` when the request must be refused.
*
* @example
* ```ts source="./bearerAuth.examples.ts#requireBearerAuth_fetchGate"
* const gate = requireBearerAuth({ verifier, requiredScopes: ['mcp'] });
*
* async function fetchHandler(request: Request): Promise<Response> {
* const auth: AuthInfo | Response = await gate(request);
* if (auth instanceof Response) return auth;
* return handler.fetch(request, { authInfo: auth });
* }
* ```
*/
export function requireBearerAuth(options: BearerAuthOptions): (request: Request) => Promise<AuthInfo | Response> {
// 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 request => {
// Outside the try: a wrong-framework misuse (no web-standard Request)
// should throw loudly, not surface as a 500 challenge. Fetch's
// Headers.get comma-joins repeated headers where Node keeps the
// first; take the first segment so both adapters agree (the token68
// alphabet has no comma, so this is lossless).
const [authorizationHeader] = (request.headers.get('authorization') ?? '').split(',');
try {
return await verifyBearerToken(authorizationHeader || undefined, resolved);
} catch (error) {
return bearerAuthChallengeResponse(error, resolved);
}
};
}

View file

@ -0,0 +1,207 @@
import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/core-internal';
import type { AuthInfo } from '@modelcontextprotocol/core-internal';
import { describe, expect, it, vi } from 'vitest';
import type { OAuthTokenVerifier } from '../../src/server/middleware/bearerAuth';
import { bearerAuthChallengeResponse, requireBearerAuth, verifyBearerToken } from '../../src/server/middleware/bearerAuth';
const validAuthInfo: AuthInfo = {
token: 'valid-token',
clientId: 'client-123',
scopes: ['read', 'write'],
expiresAt: Date.now() / 1000 + 3600
};
function verifierReturning(authInfo: AuthInfo): OAuthTokenVerifier {
return { verifyAccessToken: vi.fn().mockResolvedValue(authInfo) };
}
function verifierThrowing(error: unknown): OAuthTokenVerifier {
return { verifyAccessToken: vi.fn().mockRejectedValue(error) };
}
describe('verifyBearerToken', () => {
it('returns the verifier AuthInfo for a valid token', async () => {
const verifier = verifierReturning(validAuthInfo);
const authInfo = await verifyBearerToken('Bearer valid-token', { verifier });
expect(authInfo).toEqual(validAuthInfo);
expect(verifier.verifyAccessToken).toHaveBeenCalledWith('valid-token');
});
it('accepts a case-insensitive Bearer scheme', async () => {
const verifier = verifierReturning(validAuthInfo);
await expect(verifyBearerToken('bEaReR valid-token', { verifier })).resolves.toEqual(validAuthInfo);
});
it('rejects a missing Authorization header with invalid_token', async () => {
await expect(verifyBearerToken(undefined, { verifier: verifierReturning(validAuthInfo) })).rejects.toMatchObject({
code: OAuthErrorCode.InvalidToken,
message: 'Missing Authorization header'
});
await expect(verifyBearerToken(null, { verifier: verifierReturning(validAuthInfo) })).rejects.toMatchObject({
code: OAuthErrorCode.InvalidToken
});
});
it('rejects a non-Bearer or empty-token header as a format error', async () => {
const verifier = verifierReturning(validAuthInfo);
for (const header of ['Basic dXNlcjpwYXNz', 'Bearer', 'Bearer ']) {
await expect(verifyBearerToken(header, { verifier })).rejects.toMatchObject({
code: OAuthErrorCode.InvalidToken,
message: "Invalid Authorization header format, expected 'Bearer TOKEN'"
});
}
expect(verifier.verifyAccessToken).not.toHaveBeenCalled();
});
it('propagates the verifier OAuthError untouched', async () => {
const verifier = verifierThrowing(new OAuthError(OAuthErrorCode.InvalidToken, 'unknown token'));
await expect(verifyBearerToken('Bearer nope', { verifier })).rejects.toMatchObject({
code: OAuthErrorCode.InvalidToken,
message: 'unknown token'
});
});
it('enforces requiredScopes before expiry (matching the Express middleware order)', async () => {
// Expired AND missing a scope: the scope failure wins, as it always has.
const verifier = verifierReturning({ ...validAuthInfo, scopes: ['read'], expiresAt: Date.now() / 1000 - 100 });
await expect(verifyBearerToken('Bearer t', { verifier, requiredScopes: ['read', 'write'] })).rejects.toMatchObject({
code: OAuthErrorCode.InsufficientScope,
message: 'Insufficient scope'
});
});
it('rejects a token without an expiration time', async () => {
const { expiresAt: _dropped, ...withoutExpiry } = validAuthInfo;
const verifier = verifierReturning(withoutExpiry as AuthInfo);
await expect(verifyBearerToken('Bearer t', { verifier })).rejects.toMatchObject({
code: OAuthErrorCode.InvalidToken,
message: 'Token has no expiration time'
});
const nanVerifier = verifierReturning({ ...validAuthInfo, expiresAt: Number.NaN });
await expect(verifyBearerToken('Bearer t', { verifier: nanVerifier })).rejects.toMatchObject({
code: OAuthErrorCode.InvalidToken,
message: 'Token has no expiration time'
});
});
it('rejects an expired token', async () => {
const verifier = verifierReturning({ ...validAuthInfo, expiresAt: Date.now() / 1000 - 100 });
await expect(verifyBearerToken('Bearer t', { verifier })).rejects.toMatchObject({
code: OAuthErrorCode.InvalidToken,
message: 'Token has expired'
});
});
});
describe('bearerAuthChallengeResponse', () => {
it('answers 401 invalid_token with the WWW-Authenticate challenge, resource_metadata last', async () => {
const response = bearerAuthChallengeResponse(new OAuthError(OAuthErrorCode.InvalidToken, 'Token has expired'), {
requiredScopes: ['read', 'write'],
resourceMetadataUrl: 'https://api.example.com/.well-known/oauth-protected-resource'
});
expect(response.status).toBe(401);
expect(response.headers.get('WWW-Authenticate')).toMatch(
/^Bearer error="invalid_token", error_description="Token has expired", scope="read write", resource_metadata="https:\/\/api\.example\.com\/\.well-known\/oauth-protected-resource"$/
);
expect(await response.json()).toMatchObject({ error: 'invalid_token', error_description: 'Token has expired' });
});
it('answers 403 insufficient_scope with the configured scopes in the challenge', async () => {
const response = bearerAuthChallengeResponse(new OAuthError(OAuthErrorCode.InsufficientScope, 'Insufficient scope'), {
requiredScopes: ['read', 'write']
});
expect(response.status).toBe(403);
expect(response.headers.get('WWW-Authenticate')).toContain('scope="read write"');
expect(await response.json()).toMatchObject({ error: 'insufficient_scope' });
});
it('answers 500 server_error without a challenge', async () => {
const response = bearerAuthChallengeResponse(new OAuthError(OAuthErrorCode.ServerError, 'boom'));
expect(response.status).toBe(500);
expect(response.headers.get('WWW-Authenticate')).toBeNull();
});
it('answers 400 without a challenge for any other OAuth error code', async () => {
const response = bearerAuthChallengeResponse(new OAuthError(OAuthErrorCode.InvalidRequest, 'nope'));
expect(response.status).toBe(400);
expect(response.headers.get('WWW-Authenticate')).toBeNull();
expect(await response.json()).toMatchObject({ error: 'invalid_request' });
});
it('answers 500 server_error for a non-OAuthError value', async () => {
const response = bearerAuthChallengeResponse(new Error('boom'));
expect(response.status).toBe(500);
expect(response.headers.get('WWW-Authenticate')).toBeNull();
expect(await response.json()).toMatchObject({ error: 'server_error', error_description: 'Internal Server Error' });
});
});
describe('requireBearerAuth (web-standard)', () => {
it('resolves to AuthInfo for a valid request', async () => {
const gate = requireBearerAuth({ verifier: verifierReturning(validAuthInfo) });
const result = await gate(new Request('https://api.example.com/mcp', { headers: { authorization: 'Bearer valid-token' } }));
expect(result).toEqual(validAuthInfo);
});
it('resolves to the challenge Response for a missing header', async () => {
const gate = requireBearerAuth({
verifier: verifierReturning(validAuthInfo),
resourceMetadataUrl: 'https://api.example.com/.well-known/oauth-protected-resource'
});
const result = await gate(new Request('https://api.example.com/mcp'));
expect(result).toBeInstanceOf(Response);
const response = result as Response;
expect(response.status).toBe(401);
expect(response.headers.get('WWW-Authenticate')).toMatch(/resource_metadata="https:\/\/api\.example\.com/);
});
it('resolves to a 403 Response when scopes are missing', async () => {
const gate = requireBearerAuth({
verifier: verifierReturning({ ...validAuthInfo, scopes: ['read'] }),
requiredScopes: ['read', 'write']
});
const result = await gate(new Request('https://api.example.com/mcp', { headers: { authorization: 'Bearer t' } }));
expect((result as Response).status).toBe(403);
});
});
describe('hardening (review findings)', () => {
it('returns the challenge Response instead of throwing for a non-ASCII verifier message', async () => {
const response = bearerAuthChallengeResponse(new OAuthError(OAuthErrorCode.InvalidToken, 'token invalide \u2026'));
expect(response.status).toBe(401);
expect(response.headers.get('WWW-Authenticate')).toContain('error_description="token invalide "');
});
it('escapes quotes and strips CR/LF in the challenge header', async () => {
const response = bearerAuthChallengeResponse(new OAuthError(OAuthErrorCode.InvalidToken, 'bad "token"\r\nnext'));
const challenge = response.headers.get('WWW-Authenticate') ?? '';
expect(challenge).toContain('error_description="bad \\"token\\" next"');
});
it('resolves (not rejects) when the verifier throws a hostile-message OAuthError', async () => {
const gate = requireBearerAuth({
verifier: {
verifyAccessToken: vi.fn().mockRejectedValue(new OAuthError(OAuthErrorCode.InvalidToken, 'upstream:\nfail \u2026'))
}
});
const result = await gate(new Request('https://api.example.com/mcp', { headers: { authorization: 'Bearer x' } }));
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(401);
});
it('verifies the first token when duplicate Authorization headers are comma-joined', async () => {
const verifier = verifierReturning(validAuthInfo);
const gate = requireBearerAuth({ verifier });
const headers = new Headers();
headers.append('authorization', 'Bearer valid-token');
headers.append('authorization', 'Bearer second-token');
const result = await gate(new Request('https://api.example.com/mcp', { headers }));
expect(result).toEqual(validAuthInfo);
expect(verifier.verifyAccessToken).toHaveBeenCalledWith('valid-token');
});
it('throws at creation time for missing options', () => {
expect(() => requireBearerAuth(undefined as never)).toThrow(TypeError);
});
});