|
| 1 | +import { InvalidTokenError, InsufficientScopeError, ServerError } from '@modelcontextprotocol/sdk/server/auth/errors'; |
| 2 | +import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types'; |
| 3 | + |
| 4 | +export interface McpAuthOptions { |
| 5 | + /** |
| 6 | + * Optional, scopes that the token must have. |
| 7 | + */ |
| 8 | + requiredScopes?: string[]; |
| 9 | + |
| 10 | + /** |
| 11 | + * Optional, resource metadata path to include in WWW-Authenticate header. |
| 12 | + */ |
| 13 | + resourceMetadataPath?: string; |
| 14 | +} |
| 15 | + |
1 | 16 | export function withMcpAuth( |
2 | 17 | handler: (req: Request) => Promise<Response>, |
3 | | - verifyToken: (req: Request, token: string) => Promise<boolean>, |
4 | | - oauthResourcePath = "/.well-known/oauth-protected-resource" |
| 18 | + verifyToken: (req: Request, token: string) => Promise<AuthInfo>, |
| 19 | + options: McpAuthOptions = { |
| 20 | + resourceMetadataPath: "/.well-known/oauth-protected-resource" |
| 21 | + } |
5 | 22 | ) { |
6 | 23 | return async (req: Request) => { |
7 | | - const origin = new URL(req.url).origin; |
8 | | - |
9 | | - if (!req.headers.get("Authorization")) { |
10 | | - return new Response(null, { |
11 | | - status: 401, |
12 | | - headers: { |
13 | | - "WWW-Authenticate": `Bearer resource_metadata=${origin}${oauthResourcePath}`, |
14 | | - }, |
15 | | - }); |
16 | | - } |
| 24 | + try { |
| 25 | + if (!req.headers.get("Authorization")) { |
| 26 | + throw new InvalidTokenError("Missing Authorization header"); |
| 27 | + } |
17 | 28 |
|
18 | | - const authHeader = req.headers.get("Authorization"); |
19 | | - const token = authHeader?.split(" ")[1]; |
| 29 | + const authHeader = req.headers.get("Authorization"); |
| 30 | + const [type, token] = authHeader?.split(" ") || []; |
20 | 31 |
|
21 | | - if (!token) { |
22 | | - throw new Error( |
23 | | - `Invalid authorization header value, expected Bearer <token>, received ${authHeader}` |
24 | | - ); |
25 | | - } |
| 32 | + if (type?.toLowerCase() !== "bearer" || !token) { |
| 33 | + throw new InvalidTokenError("Invalid Authorization header format, expected 'Bearer TOKEN'"); |
| 34 | + } |
26 | 35 |
|
27 | | - const isAuthenticated = await verifyToken(req, token); |
| 36 | + const authInfo = await verifyToken(req, token); |
28 | 37 |
|
29 | | - if (!isAuthenticated) { |
30 | | - return new Response(JSON.stringify({ error: "Unauthorized" }), { |
31 | | - status: 401, |
32 | | - headers: { |
33 | | - "WWW-Authenticate": `Bearer resource_metadata=${origin}${oauthResourcePath}`, |
34 | | - }, |
35 | | - }); |
36 | | - } |
| 38 | + // Check if token has the required scopes (if any) |
| 39 | + if (options.requiredScopes?.length) { |
| 40 | + const hasAllScopes = options.requiredScopes.every(scope => |
| 41 | + authInfo.scopes.includes(scope) |
| 42 | + ); |
| 43 | + |
| 44 | + if (!hasAllScopes) { |
| 45 | + throw new InsufficientScopeError("Insufficient scope"); |
| 46 | + } |
| 47 | + } |
| 48 | + |
| 49 | + // Check if the token is expired |
| 50 | + if (authInfo.expiresAt && authInfo.expiresAt < Date.now() / 1000) { |
| 51 | + throw new InvalidTokenError("Token has expired"); |
| 52 | + } |
37 | 53 |
|
38 | | - return handler(req); |
| 54 | + // Set auth info on the request object after successful verification |
| 55 | + (req as any).auth = authInfo; |
| 56 | + |
| 57 | + return handler(req); |
| 58 | + } catch (error) { |
| 59 | + const origin = new URL(req.url).origin; |
| 60 | + const resourceMetadataUrl = options.resourceMetadataPath || `${origin}/.well-known/oauth-protected-resource`; |
| 61 | + if (error instanceof InvalidTokenError) { |
| 62 | + return new Response(JSON.stringify(error.toResponseObject()), { |
| 63 | + status: 401, |
| 64 | + headers: { |
| 65 | + "WWW-Authenticate": `Bearer error="${error.errorCode}", error_description="${error.message}", resource_metadata="${resourceMetadataUrl}"`, |
| 66 | + "Content-Type": "application/json" |
| 67 | + } |
| 68 | + }); |
| 69 | + } else if (error instanceof InsufficientScopeError) { |
| 70 | + return new Response(JSON.stringify(error.toResponseObject()), { |
| 71 | + status: 403, |
| 72 | + headers: { |
| 73 | + "WWW-Authenticate": `Bearer error="${error.errorCode}", error_description="${error.message}", resource_metadata="${resourceMetadataUrl}"`, |
| 74 | + "Content-Type": "application/json" |
| 75 | + } |
| 76 | + }); |
| 77 | + } else if (error instanceof ServerError) { |
| 78 | + return new Response(JSON.stringify(error.toResponseObject()), { |
| 79 | + status: 500, |
| 80 | + headers: { |
| 81 | + "Content-Type": "application/json" |
| 82 | + } |
| 83 | + }); |
| 84 | + } else { |
| 85 | + console.error("Unexpected error authenticating bearer token:", error); |
| 86 | + const serverError = new ServerError("Internal Server Error"); |
| 87 | + return new Response(JSON.stringify(serverError.toResponseObject()), { |
| 88 | + status: 500, |
| 89 | + headers: { |
| 90 | + "Content-Type": "application/json" |
| 91 | + } |
| 92 | + }); |
| 93 | + } |
| 94 | + } |
39 | 95 | }; |
40 | 96 | } |
0 commit comments