feat(server): runtime-neutral requireBearerAuth for web-standard hosts (#2420)
This commit is contained in:
parent
79dc162efc
commit
7635115d01
13 changed files with 595 additions and 129 deletions
15
.changeset/web-standard-bearer-auth.md
Normal file
15
.changeset/web-standard-bearer-auth.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
44
examples/guides/serving/authorization.web.examples.ts
Normal file
44
examples/guides/serving/authorization.web.examples.ts
Normal 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
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
30
packages/server/src/server/middleware/bearerAuth.examples.ts
Normal file
30
packages/server/src/server/middleware/bearerAuth.examples.ts
Normal 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;
|
||||
}
|
||||
203
packages/server/src/server/middleware/bearerAuth.ts
Normal file
203
packages/server/src/server/middleware/bearerAuth.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
207
packages/server/test/server/bearerAuth.test.ts
Normal file
207
packages/server/test/server/bearerAuth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue