|
1 | | -import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; |
| 1 | +import type { BearerAuthOptions } from '@modelcontextprotocol/server'; |
| 2 | +import { bearerAuthChallengeResponse, OAuthError, OAuthErrorCode, verifyBearerToken } from '@modelcontextprotocol/server'; |
2 | 3 | import type { RequestHandler } from 'express'; |
3 | 4 |
|
4 | | -import type { OAuthTokenVerifier } from './types'; |
5 | | - |
6 | 5 | /** |
7 | 6 | * Options for {@link requireBearerAuth}. |
8 | 7 | */ |
9 | | -export interface BearerAuthMiddlewareOptions { |
10 | | - /** |
11 | | - * A verifier used to validate access tokens. |
12 | | - */ |
13 | | - verifier: OAuthTokenVerifier; |
14 | | - |
15 | | - /** |
16 | | - * Optional scopes that the token must have. When any are missing the |
17 | | - * middleware responds with `403 insufficient_scope`. |
18 | | - */ |
19 | | - requiredScopes?: string[]; |
20 | | - |
21 | | - /** |
22 | | - * Optional Protected Resource Metadata URL to advertise in the |
23 | | - * `WWW-Authenticate` header on 401/403 responses, per |
24 | | - * {@link https://datatracker.ietf.org/doc/html/rfc9728 | RFC 9728}. |
25 | | - * |
26 | | - * Typically built with `getOAuthProtectedResourceMetadataUrl`. |
27 | | - */ |
28 | | - resourceMetadataUrl?: string; |
29 | | -} |
30 | | - |
31 | | -function buildWwwAuthenticateHeader( |
32 | | - errorCode: string, |
33 | | - description: string, |
34 | | - requiredScopes: string[], |
35 | | - resourceMetadataUrl: string | undefined |
36 | | -): string { |
37 | | - let header = `Bearer error="${errorCode}", error_description="${description}"`; |
38 | | - if (requiredScopes.length > 0) { |
39 | | - header += `, scope="${requiredScopes.join(' ')}"`; |
40 | | - } |
41 | | - if (resourceMetadataUrl) { |
42 | | - header += `, resource_metadata="${resourceMetadataUrl}"`; |
43 | | - } |
44 | | - return header; |
45 | | -} |
| 8 | +export type BearerAuthMiddlewareOptions = BearerAuthOptions; |
46 | 9 |
|
47 | 10 | /** |
48 | 11 | * Express middleware that requires a valid Bearer token in the `Authorization` |
49 | 12 | * header. |
50 | 13 | * |
51 | | - * The token is validated via the supplied {@link OAuthTokenVerifier} and the |
52 | | - * resulting `AuthInfo` (from `@modelcontextprotocol/server`) is attached |
53 | | - * to `req.auth`. The MCP Streamable HTTP transport reads `req.auth` and |
54 | | - * surfaces it to handlers as `ctx.http.authInfo`. |
| 14 | + * The Express adapter over the runtime-neutral core in |
| 15 | + * `@modelcontextprotocol/server` (`verifyBearerToken` / |
| 16 | + * `bearerAuthChallengeResponse` — or `requireBearerAuth` from that package for |
| 17 | + * web-standard `fetch(request)` hosts). The token is validated via the |
| 18 | + * supplied `OAuthTokenVerifier` and the resulting `AuthInfo` is attached to |
| 19 | + * `req.auth`. The MCP Streamable HTTP transport reads `req.auth` and surfaces |
| 20 | + * it to handlers as `ctx.http.authInfo`. |
55 | 21 | * |
56 | 22 | * On failure the middleware sends a JSON OAuth error body and a |
57 | 23 | * `WWW-Authenticate: Bearer …` challenge that includes the configured |
58 | 24 | * `resource_metadata` URL so clients can discover the Authorization Server. |
59 | 25 | */ |
60 | | -export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetadataUrl }: BearerAuthMiddlewareOptions): RequestHandler { |
| 26 | +export function requireBearerAuth(options: BearerAuthMiddlewareOptions): RequestHandler { |
| 27 | + // Destructure at creation so a plain-JS caller passing undefined or |
| 28 | + // malformed options crashes at startup, not on the first request. |
| 29 | + const { verifier, requiredScopes = [], resourceMetadataUrl } = options; |
| 30 | + const resolved = { verifier, requiredScopes, resourceMetadataUrl }; |
61 | 31 | return async (req, res, next) => { |
62 | 32 | try { |
63 | | - const authHeader = req.headers.authorization; |
64 | | - if (!authHeader) { |
65 | | - throw new OAuthError(OAuthErrorCode.InvalidToken, 'Missing Authorization header'); |
66 | | - } |
67 | | - |
68 | | - const [type, token] = authHeader.split(' '); |
69 | | - if (type?.toLowerCase() !== 'bearer' || !token) { |
70 | | - throw new OAuthError(OAuthErrorCode.InvalidToken, "Invalid Authorization header format, expected 'Bearer TOKEN'"); |
71 | | - } |
72 | | - |
73 | | - const authInfo = await verifier.verifyAccessToken(token); |
74 | | - |
75 | | - // Check if token has the required scopes (if any) |
76 | | - if (requiredScopes.length > 0) { |
77 | | - const hasAllScopes = requiredScopes.every(scope => authInfo.scopes.includes(scope)); |
78 | | - if (!hasAllScopes) { |
79 | | - throw new OAuthError(OAuthErrorCode.InsufficientScope, 'Insufficient scope'); |
80 | | - } |
81 | | - } |
82 | | - |
83 | | - // Check if the token is set to expire or if it is expired |
84 | | - if (typeof authInfo.expiresAt !== 'number' || Number.isNaN(authInfo.expiresAt)) { |
85 | | - throw new OAuthError(OAuthErrorCode.InvalidToken, 'Token has no expiration time'); |
86 | | - } else if (authInfo.expiresAt < Date.now() / 1000) { |
87 | | - throw new OAuthError(OAuthErrorCode.InvalidToken, 'Token has expired'); |
88 | | - } |
89 | | - |
90 | | - req.auth = authInfo; |
| 33 | + req.auth = await verifyBearerToken(req.headers.authorization, resolved); |
91 | 34 | next(); |
92 | 35 | } catch (error) { |
93 | | - if (error instanceof OAuthError) { |
94 | | - const challenge = buildWwwAuthenticateHeader(error.code, error.message, requiredScopes, resourceMetadataUrl); |
95 | | - switch (error.code) { |
96 | | - case OAuthErrorCode.InvalidToken: { |
97 | | - res.set('WWW-Authenticate', challenge); |
98 | | - res.status(401).json(error.toResponseObject()); |
99 | | - break; |
100 | | - } |
101 | | - case OAuthErrorCode.InsufficientScope: { |
102 | | - res.set('WWW-Authenticate', challenge); |
103 | | - res.status(403).json(error.toResponseObject()); |
104 | | - break; |
105 | | - } |
106 | | - case OAuthErrorCode.ServerError: { |
107 | | - res.status(500).json(error.toResponseObject()); |
108 | | - break; |
109 | | - } |
110 | | - default: { |
111 | | - res.status(400).json(error.toResponseObject()); |
112 | | - } |
113 | | - } |
114 | | - } else { |
115 | | - const serverError = new OAuthError(OAuthErrorCode.ServerError, 'Internal Server Error'); |
116 | | - res.status(500).json(serverError.toResponseObject()); |
| 36 | + // The core Response supplies status and challenge; the body is |
| 37 | + // derived directly rather than parsed back out of the Response. |
| 38 | + const response = bearerAuthChallengeResponse(error, resolved); |
| 39 | + const challenge = response.headers.get('WWW-Authenticate'); |
| 40 | + if (challenge !== null) { |
| 41 | + res.set('WWW-Authenticate', challenge); |
117 | 42 | } |
| 43 | + const body = error instanceof OAuthError ? error : new OAuthError(OAuthErrorCode.ServerError, 'Internal Server Error'); |
| 44 | + res.status(response.status).json(body.toResponseObject()); |
118 | 45 | } |
119 | 46 | }; |
120 | 47 | } |
0 commit comments