diff --git a/.changeset/eight-bananas-serve.md b/.changeset/eight-bananas-serve.md new file mode 100644 index 0000000..e888f43 --- /dev/null +++ b/.changeset/eight-bananas-serve.md @@ -0,0 +1,5 @@ +--- +"@vercel/mcp-adapter": patch +--- + +Add more robust error handling to auth logic diff --git a/src/next/auth-wrapper.ts b/src/next/auth-wrapper.ts index 75e835d..307d24e 100644 --- a/src/next/auth-wrapper.ts +++ b/src/next/auth-wrapper.ts @@ -1,6 +1,13 @@ import { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types"; +import { InvalidTokenError, InsufficientScopeError, ServerError } from "@modelcontextprotocol/sdk/server/auth/errors"; import { withAuthContext } from "./auth-context"; +declare global { + interface Request { + auth?: AuthInfo; + } +} + export function withMcpAuth( handler: (req: Request) => Response | Promise, verifyToken: ( @@ -9,57 +16,92 @@ export function withMcpAuth( ) => AuthInfo | undefined | Promise, { required = false, - oauthResourcePath = "/.well-known/oauth-protected-resource", + resourceMetadataPath = "/.well-known/oauth-protected-resource", + requiredScopes, }: { required?: boolean; - oauthResourcePath?: string; + resourceMetadataPath?: string; + requiredScopes?: string[]; } = {} ) { return async (req: Request) => { - const origin = new URL(req.url).origin; + try { + const authHeader = req.headers.get("Authorization"); + const [type, token] = authHeader?.split(" ") || []; - const authHeader = req.headers.get("Authorization"); - const [type, token] = authHeader?.split(" ") || []; + // Only support bearer token as per the MCP spec + // https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#2-6-1-token-requirements + const bearerToken = type?.toLowerCase() === "bearer" ? token : undefined; - // Only support bearer token as per the MCP spec - // https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#2-6-1-token-requirements - const bearerToken = type?.toLowerCase() === "bearer" ? token : undefined; + const authInfo = await verifyToken(req, bearerToken); + + if (required && !authInfo) { + throw new InvalidTokenError("No authorization provided"); + } - const authInfo = await verifyToken(req, bearerToken); + if (!authInfo) { + return handler(req); + } - if (required && !authInfo) { - return new Response( - JSON.stringify({ - error: "unauthorized_client", - error_description: "No authorization provided", - }), - { - status: 401, - headers: { - "WWW-Authenticate": `Bearer resource_metadata=${origin}${oauthResourcePath}`, - }, + // Check if token has the required scopes (if any) + if (requiredScopes?.length) { + const hasAllScopes = requiredScopes.every(scope => + authInfo.scopes.includes(scope) + ); + + if (!hasAllScopes) { + throw new InsufficientScopeError("Insufficient scope"); } - ); - } + } - if (!authInfo) { - return handler(req); - } + // Check if the token is expired + if (authInfo.expiresAt && authInfo.expiresAt < Date.now() / 1000) { + throw new InvalidTokenError("Token has expired"); + } + + // Set auth info on the request object after successful verification + req.auth = authInfo; - if (authInfo.expiresAt && authInfo.expiresAt < Date.now() / 1000) { - return new Response( - JSON.stringify({ - error: "invalid_token", - error_description: "Authorization expired", - }), - { + return withAuthContext(authInfo, () => handler(req)); + } catch (error) { + const origin = new URL(req.url).origin; + const resourceMetadataUrl = `${origin}${resourceMetadataPath}`; + + if (error instanceof InvalidTokenError) { + return new Response(JSON.stringify(error.toResponseObject()), { status: 401, headers: { - "WWW-Authenticate": `Bearer error="invalid_token", error_description="Authorization expired", resource_metadata=${origin}${oauthResourcePath}`, - }, - } - ); + "WWW-Authenticate": `Bearer error="${error.errorCode}", error_description="${error.message}", resource_metadata="${resourceMetadataUrl}"`, + "Content-Type": "application/json" + } + }); + } else if (error instanceof InsufficientScopeError) { + return new Response(JSON.stringify(error.toResponseObject()), { + status: 403, + headers: { + "WWW-Authenticate": `Bearer error="${error.errorCode}", error_description="${error.message}", resource_metadata="${resourceMetadataUrl}"`, + "Content-Type": "application/json" + } + }); + } else if (error instanceof ServerError) { + return new Response(JSON.stringify(error.toResponseObject()), { + status: 500, + headers: { + "Content-Type": "application/json" + } + }); + } else { + console.error("Unexpected error authenticating bearer token:", error); + const serverError = new ServerError("Internal Server Error"); + return new Response(JSON.stringify(serverError.toResponseObject()), { + status: 500, + headers: { + "Content-Type": "application/json" + } + }); + } } - return withAuthContext(authInfo, () => handler(req)); }; } + + diff --git a/src/next/mcp-api-handler.ts b/src/next/mcp-api-handler.ts index 0219073..f6976bb 100644 --- a/src/next/mcp-api-handler.ts +++ b/src/next/mcp-api-handler.ts @@ -322,7 +322,9 @@ export function initializeMcpApiHandler( url: req.url, headers: Object.fromEntries(req.headers), body: bodyContent, + auth: req.auth, // Use the auth info that should already be set by withMcpAuth }); + // Create a response that will emit events const wrappedRes = new EventEmittingResponse( @@ -666,7 +668,7 @@ interface FakeIncomingMessageOptions { // Create a fake IncomingMessage function createFakeIncomingMessage( options: FakeIncomingMessageOptions = {} -): IncomingMessage { +): IncomingMessage & { auth?: AuthInfo } { const { method = "GET", url = "/", @@ -696,7 +698,7 @@ function createFakeIncomingMessage( } // Create the IncomingMessage instance - const req = new IncomingMessage(socket); + const req = new IncomingMessage(socket) as IncomingMessage & { auth?: AuthInfo }; // Set the properties req.method = method;