diff --git a/src/authentication.test.ts b/src/authentication.test.ts index 18626f0..ab26381 100644 --- a/src/authentication.test.ts +++ b/src/authentication.test.ts @@ -327,4 +327,199 @@ describe("AuthenticationMiddleware", () => { expect(header.indexOf('error=')).toBeLessThan(header.indexOf('error_description=')); }); }); + + describe("getScopeChallengeResponse", () => { + it("should return 403 status code", () => { + const middleware = new AuthenticationMiddleware({ + oauth: { + protectedResource: { + resource: "https://example.com", + }, + }, + }); + const response = middleware.getScopeChallengeResponse(["read", "write"]); + + expect(response.statusCode).toBe(403); + }); + + it("should include required scopes in WWW-Authenticate header", () => { + const middleware = new AuthenticationMiddleware({ + oauth: { + protectedResource: { + resource: "https://example.com", + }, + }, + }); + const response = middleware.getScopeChallengeResponse(["read", "write"]); + + expect(response.headers["WWW-Authenticate"]).toContain('error="insufficient_scope"'); + expect(response.headers["WWW-Authenticate"]).toContain('scope="read write"'); + expect(response.headers["WWW-Authenticate"]).toContain('resource_metadata="https://example.com/.well-known/oauth-protected-resource"'); + }); + + it("should include error_description in WWW-Authenticate header", () => { + const middleware = new AuthenticationMiddleware({ + oauth: { + protectedResource: { + resource: "https://example.com", + }, + }, + }); + const response = middleware.getScopeChallengeResponse( + ["admin"], + "Admin access required", + ); + + expect(response.headers["WWW-Authenticate"]).toContain('error_description="Admin access required"'); + }); + + it("should escape quotes in error_description", () => { + const middleware = new AuthenticationMiddleware({ + oauth: { + protectedResource: { + resource: "https://example.com", + }, + }, + }); + const response = middleware.getScopeChallengeResponse( + ["admin"], + 'Requires "admin" scope', + ); + + expect(response.headers["WWW-Authenticate"]).toContain('error_description="Requires \\"admin\\" scope"'); + }); + + it("should include request ID in response body", () => { + const middleware = new AuthenticationMiddleware({ + oauth: { + protectedResource: { + resource: "https://example.com", + }, + }, + }); + const response = middleware.getScopeChallengeResponse(["read"], undefined, 123); + + const body = JSON.parse(response.body); + expect(body.id).toBe(123); + }); + + it("should include required scopes in response body data", () => { + const middleware = new AuthenticationMiddleware({ + oauth: { + protectedResource: { + resource: "https://example.com", + }, + }, + }); + const response = middleware.getScopeChallengeResponse(["read", "write"]); + + const body = JSON.parse(response.body); + expect(body.error.code).toBe(-32001); + expect(body.error.message).toBe("Insufficient scope"); + expect(body.error.data.error).toBe("insufficient_scope"); + expect(body.error.data.required_scopes).toEqual(["read", "write"]); + }); + + it("should use custom error_description in response body message", () => { + const middleware = new AuthenticationMiddleware({ + oauth: { + protectedResource: { + resource: "https://example.com", + }, + }, + }); + const response = middleware.getScopeChallengeResponse( + ["admin"], + "Admin privileges required", + ); + + const body = JSON.parse(response.body); + expect(body.error.message).toBe("Admin privileges required"); + }); + + it("should handle single scope", () => { + const middleware = new AuthenticationMiddleware({ + oauth: { + protectedResource: { + resource: "https://example.com", + }, + }, + }); + const response = middleware.getScopeChallengeResponse(["admin"]); + + expect(response.headers["WWW-Authenticate"]).toContain('scope="admin"'); + const body = JSON.parse(response.body); + expect(body.error.data.required_scopes).toEqual(["admin"]); + }); + + it("should handle multiple scopes", () => { + const middleware = new AuthenticationMiddleware({ + oauth: { + protectedResource: { + resource: "https://example.com", + }, + }, + }); + const response = middleware.getScopeChallengeResponse(["read", "write", "admin"]); + + expect(response.headers["WWW-Authenticate"]).toContain('scope="read write admin"'); + const body = JSON.parse(response.body); + expect(body.error.data.required_scopes).toEqual(["read", "write", "admin"]); + }); + + it("should not include WWW-Authenticate header without OAuth config", () => { + const middleware = new AuthenticationMiddleware({}); + const response = middleware.getScopeChallengeResponse(["admin"]); + + expect(response.headers["WWW-Authenticate"]).toBeUndefined(); + expect(response.headers["Content-Type"]).toBe("application/json"); + }); + + it("should return proper JSON-RPC 2.0 format", () => { + const middleware = new AuthenticationMiddleware({ + oauth: { + protectedResource: { + resource: "https://example.com", + }, + }, + }); + const response = middleware.getScopeChallengeResponse(["read"], "Description", "req-123"); + + const body = JSON.parse(response.body); + expect(body.jsonrpc).toBe("2.0"); + expect(body.id).toBe("req-123"); + expect(body.error).toBeDefined(); + expect(body.error.code).toBe(-32001); + expect(body.error.message).toBe("Description"); + expect(body.error.data).toBeDefined(); + }); + + it("should include Content-Type header", () => { + const middleware = new AuthenticationMiddleware({ + oauth: { + protectedResource: { + resource: "https://example.com", + }, + }, + }); + const response = middleware.getScopeChallengeResponse(["read"]); + + expect(response.headers["Content-Type"]).toBe("application/json"); + }); + + it("should handle empty scopes array", () => { + const middleware = new AuthenticationMiddleware({ + oauth: { + protectedResource: { + resource: "https://example.com", + }, + }, + }); + const response = middleware.getScopeChallengeResponse([]); + + expect(response.headers["WWW-Authenticate"]).toContain('scope=""'); + const body = JSON.parse(response.body); + expect(body.error.data.required_scopes).toEqual([]); + }); + }); }); \ No newline at end of file diff --git a/src/authentication.ts b/src/authentication.ts index ac88270..5312d33 100644 --- a/src/authentication.ts +++ b/src/authentication.ts @@ -17,6 +17,51 @@ export interface AuthConfig { export class AuthenticationMiddleware { constructor(private config: AuthConfig = {}) {} + getScopeChallengeResponse( + requiredScopes: string[], + errorDescription?: string, + requestId?: unknown, + ): { body: string; headers: Record; statusCode: number } { + const headers: Record = { + "Content-Type": "application/json", + }; + + // Build WWW-Authenticate header with all required parameters + if (this.config.oauth?.protectedResource?.resource) { + const parts = [ + "Bearer", + 'error="insufficient_scope"', + `scope="${requiredScopes.join(" ")}"`, + `resource_metadata="${this.config.oauth.protectedResource.resource}/.well-known/oauth-protected-resource"`, + ]; + + if (errorDescription) { + // Escape quotes in description + const escaped = errorDescription.replace(/"/g, '\\"'); + parts.push(`error_description="${escaped}"`); + } + + headers["WWW-Authenticate"] = parts.join(", "); + } + + return { + body: JSON.stringify({ + error: { + code: -32001, // Custom error code for insufficient scope + data: { + error: "insufficient_scope", + required_scopes: requiredScopes, + }, + message: errorDescription || "Insufficient scope", + }, + id: requestId ?? null, + jsonrpc: "2.0", + }), + headers, + statusCode: 403, + }; + } + getUnauthorizedResponse(options?: { error?: string; error_description?: string; diff --git a/src/startHTTPServer.ts b/src/startHTTPServer.ts index 478b3aa..29bc573 100644 --- a/src/startHTTPServer.ts +++ b/src/startHTTPServer.ts @@ -118,6 +118,28 @@ const getWWWAuthenticateHeader = ( return `Bearer ${params.join(", ")}`; }; +// Helper function to detect scope challenge errors +const isScopeChallengeError = (error: unknown): error is { + data: { + error: string; + errorDescription?: string; + requiredScopes: string[]; + }; + name: string; +} => { + return ( + typeof error === "object" && + error !== null && + "name" in error && + error.name === "InsufficientScopeError" && + "data" in error && + typeof error.data === "object" && + error.data !== null && + "error" in error.data && + error.data.error === "insufficient_scope" + ); +}; + // Helper function to handle Response errors and send appropriate HTTP response const handleResponseError = async ( error: unknown, @@ -267,6 +289,7 @@ const applyCorsHeaders = ( const handleStreamRequest = async ({ activeTransports, authenticate, + authMiddleware, createServer, enableJsonResponse, endpoint, @@ -283,6 +306,7 @@ const handleStreamRequest = async ({ { server: T; transport: StreamableHTTPServerTransport } >; authenticate?: (request: http.IncomingMessage) => Promise; + authMiddleware: AuthenticationMiddleware; createServer: (request: http.IncomingMessage) => Promise; enableJsonResponse?: boolean; endpoint: string; @@ -298,6 +322,7 @@ const handleStreamRequest = async ({ req.method === "POST" && new URL(req.url!, "http://localhost").pathname === endpoint ) { + let body: unknown; try { const sessionId = Array.isArray(req.headers["mcp-session-id"]) ? req.headers["mcp-session-id"][0] @@ -307,7 +332,7 @@ const handleStreamRequest = async ({ let server: T; - const body = await getBody(req); + body = await getBody(req); // Per-request authentication in stateless mode if (stateless && authenticate) { @@ -566,6 +591,19 @@ const handleStreamRequest = async ({ return true; } catch (error) { + // Check for scope challenge errors + if (isScopeChallengeError(error)) { + const response = authMiddleware.getScopeChallengeResponse( + error.data.requiredScopes, + error.data.errorDescription, + (body as { id?: unknown })?.id, + ); + + res.writeHead(response.statusCode, response.headers); + res.end(response.body); + return true; + } + console.error("[mcp-proxy] error handling request", error); res.setHeader("Content-Type", "application/json"); @@ -858,6 +896,7 @@ export const startHTTPServer = async ({ (await handleStreamRequest({ activeTransports: activeStreamTransports, authenticate, + authMiddleware, createServer, enableJsonResponse, endpoint: streamEndpoint,