diff --git a/src/authentication.test.ts b/src/authentication.test.ts index da9ff85..18626f0 100644 --- a/src/authentication.test.ts +++ b/src/authentication.test.ts @@ -132,7 +132,7 @@ describe("AuthenticationMiddleware", () => { const response = middleware.getUnauthorizedResponse(); expect(response.headers["WWW-Authenticate"]).toBe( - 'Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource"', + 'Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource", error="invalid_token", error_description="Unauthorized: Invalid or missing API key"', ); }); @@ -148,21 +148,24 @@ describe("AuthenticationMiddleware", () => { const response = middleware.getUnauthorizedResponse(); expect(response.headers["WWW-Authenticate"]).toBe( - 'Bearer resource_metadata="https://example.com//.well-known/oauth-protected-resource"', + 'Bearer resource_metadata="https://example.com//.well-known/oauth-protected-resource", error="invalid_token", error_description="Unauthorized: Invalid or missing API key"', ); }); - it("should not include WWW-Authenticate header when OAuth config is empty", () => { + it("should include minimal WWW-Authenticate header when OAuth config is empty object", () => { const middleware = new AuthenticationMiddleware({ apiKey: "test", oauth: {}, }); const response = middleware.getUnauthorizedResponse(); - expect(response.headers["WWW-Authenticate"]).toBeUndefined(); + // Even with empty oauth object, default error and error_description are added + expect(response.headers["WWW-Authenticate"]).toBe( + 'Bearer error="invalid_token", error_description="Unauthorized: Invalid or missing API key"', + ); }); - it("should not include WWW-Authenticate header when protectedResource is empty", () => { + it("should include minimal WWW-Authenticate header when protectedResource is empty", () => { const middleware = new AuthenticationMiddleware({ apiKey: "test", oauth: { @@ -171,7 +174,10 @@ describe("AuthenticationMiddleware", () => { }); const response = middleware.getUnauthorizedResponse(); - expect(response.headers["WWW-Authenticate"]).toBeUndefined(); + // Even without resource_metadata, default error and error_description are added + expect(response.headers["WWW-Authenticate"]).toBe( + 'Bearer error="invalid_token", error_description="Unauthorized: Invalid or missing API key"', + ); }); it("should include WWW-Authenticate header with OAuth config but no apiKey", () => { @@ -185,8 +191,140 @@ describe("AuthenticationMiddleware", () => { const response = middleware.getUnauthorizedResponse(); expect(response.headers["WWW-Authenticate"]).toBe( - 'Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource"', + 'Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource", error="invalid_token", error_description="Unauthorized: Invalid or missing API key"', ); }); + + it("should include realm in WWW-Authenticate header when configured", () => { + const middleware = new AuthenticationMiddleware({ + apiKey: "test", + oauth: { + protectedResource: { + resource: "https://example.com", + }, + realm: "example-realm", + }, + }); + const response = middleware.getUnauthorizedResponse(); + + expect(response.headers["WWW-Authenticate"]).toContain('realm="example-realm"'); + expect(response.headers["WWW-Authenticate"]).toContain('resource_metadata="https://example.com/.well-known/oauth-protected-resource"'); + }); + + it("should include custom error in WWW-Authenticate header via options", () => { + const middleware = new AuthenticationMiddleware({ + apiKey: "test", + oauth: { + protectedResource: { + resource: "https://example.com", + }, + }, + }); + const response = middleware.getUnauthorizedResponse({ + error: "insufficient_scope", + error_description: "The request requires higher privileges", + }); + + expect(response.headers["WWW-Authenticate"]).toContain('error="insufficient_scope"'); + expect(response.headers["WWW-Authenticate"]).toContain('error_description="The request requires higher privileges"'); + }); + + it("should include scope in WWW-Authenticate header", () => { + const middleware = new AuthenticationMiddleware({ + apiKey: "test", + oauth: { + protectedResource: { + resource: "https://example.com", + }, + scope: "read write", + }, + }); + const response = middleware.getUnauthorizedResponse(); + + expect(response.headers["WWW-Authenticate"]).toContain('scope="read write"'); + }); + + it("should override config error with options error", () => { + const middleware = new AuthenticationMiddleware({ + apiKey: "test", + oauth: { + error: "invalid_request", + error_description: "Config error description", + protectedResource: { + resource: "https://example.com", + }, + }, + }); + const response = middleware.getUnauthorizedResponse({ + error: "invalid_token", + error_description: "Options error description", + }); + + expect(response.headers["WWW-Authenticate"]).toContain('error="invalid_token"'); + expect(response.headers["WWW-Authenticate"]).toContain('error_description="Options error description"'); + expect(response.headers["WWW-Authenticate"]).not.toContain("invalid_request"); + expect(response.headers["WWW-Authenticate"]).not.toContain("Config error description"); + }); + + it("should include error_uri in WWW-Authenticate header", () => { + const middleware = new AuthenticationMiddleware({ + apiKey: "test", + oauth: { + error_uri: "https://example.com/errors/auth", + protectedResource: { + resource: "https://example.com", + }, + }, + }); + const response = middleware.getUnauthorizedResponse(); + + expect(response.headers["WWW-Authenticate"]).toContain('error_uri="https://example.com/errors/auth"'); + }); + + it("should properly escape quotes in error_description", () => { + const middleware = new AuthenticationMiddleware({ + apiKey: "test", + oauth: { + protectedResource: { + resource: "https://example.com", + }, + }, + }); + const response = middleware.getUnauthorizedResponse({ + error_description: 'Token "abc123" is invalid', + }); + + expect(response.headers["WWW-Authenticate"]).toContain('error_description="Token \\"abc123\\" is invalid"'); + }); + + it("should include all parameters in correct order", () => { + const middleware = new AuthenticationMiddleware({ + apiKey: "test", + oauth: { + error: "invalid_token", + error_description: "Token expired", + error_uri: "https://example.com/errors", + protectedResource: { + resource: "https://example.com", + }, + realm: "my-realm", + scope: "read write", + }, + }); + const response = middleware.getUnauthorizedResponse(); + + const header = response.headers["WWW-Authenticate"]; + expect(header).toContain('realm="my-realm"'); + expect(header).toContain('resource_metadata="https://example.com/.well-known/oauth-protected-resource"'); + expect(header).toContain('error="invalid_token"'); + expect(header).toContain('error_description="Token expired"'); + expect(header).toContain('error_uri="https://example.com/errors"'); + expect(header).toContain('scope="read write"'); + + // Check order: realm, resource_metadata, error, error_description, error_uri, scope + expect(header.indexOf('realm=')).toBeLessThan(header.indexOf('resource_metadata=')); + expect(header.indexOf('resource_metadata=')).toBeLessThan(header.indexOf('error=')); + expect(header.indexOf('error=')).toBeLessThan(header.indexOf('error_description=')); + }); }); }); \ No newline at end of file diff --git a/src/authentication.ts b/src/authentication.ts index a66a80f..ac88270 100644 --- a/src/authentication.ts +++ b/src/authentication.ts @@ -3,30 +3,76 @@ import type { IncomingMessage } from "http"; export interface AuthConfig { apiKey?: string; oauth?: { + error?: string; + error_description?: string; + error_uri?: string; protectedResource?: { resource?: string; }; + realm?: string; + scope?: string; }; } export class AuthenticationMiddleware { constructor(private config: AuthConfig = {}) {} - getUnauthorizedResponse(): { body: string; headers: Record } { + getUnauthorizedResponse(options?: { + error?: string; + error_description?: string; + error_uri?: string; + scope?: string; + }): { body: string; headers: Record } { const headers: Record = { "Content-Type": "application/json", }; - // Add WWW-Authenticate header if OAuth config is available - if (this.config.oauth?.protectedResource?.resource) { - headers["WWW-Authenticate"] = `Bearer resource_metadata="${this.config.oauth.protectedResource.resource}/.well-known/oauth-protected-resource"`; + // Build WWW-Authenticate header if OAuth config is available + if (this.config.oauth) { + const params: string[] = []; + + // Add realm if configured + if (this.config.oauth.realm) { + params.push(`realm="${this.config.oauth.realm}"`); + } + + // Add resource_metadata if configured + if (this.config.oauth.protectedResource?.resource) { + params.push(`resource_metadata="${this.config.oauth.protectedResource.resource}/.well-known/oauth-protected-resource"`); + } + + // Add error from options or config (options takes precedence) + const error = options?.error || this.config.oauth.error || "invalid_token"; + params.push(`error="${error}"`); + + // Add error_description from options or config (options takes precedence) + const error_description = options?.error_description || this.config.oauth.error_description || "Unauthorized: Invalid or missing API key"; + // Escape quotes in error description + const escaped = error_description.replace(/"/g, '\\"'); + params.push(`error_description="${escaped}"`); + + // Add error_uri from options or config (options takes precedence) + const error_uri = options?.error_uri || this.config.oauth.error_uri; + if (error_uri) { + params.push(`error_uri="${error_uri}"`); + } + + // Add scope from options or config (options takes precedence) + const scope = options?.scope || this.config.oauth.scope; + if (scope) { + params.push(`scope="${scope}"`); + } + + if (params.length > 0) { + headers["WWW-Authenticate"] = `Bearer ${params.join(", ")}`; + } } return { body: JSON.stringify({ error: { code: 401, - message: "Unauthorized: Invalid or missing API key", + message: options?.error_description || "Unauthorized: Invalid or missing API key", }, id: null, jsonrpc: "2.0", diff --git a/src/startHTTPServer.test.ts b/src/startHTTPServer.test.ts index b4965e3..b7d8516 100644 --- a/src/startHTTPServer.test.ts +++ b/src/startHTTPServer.test.ts @@ -1585,6 +1585,150 @@ it("returns 500 when createServer throws non-auth error", async () => { await httpServer.close(); }); +it("includes WWW-Authenticate header in 401 response with OAuth config", async () => { + const port = await getRandomPort(); + + const httpServer = await startHTTPServer({ + createServer: async () => { + throw new Error("Invalid JWT token"); + }, + oauth: { + protectedResource: { + resource: "https://example.com", + }, + realm: "mcp-server", + }, + port, + stateless: true, + }); + + const response = await fetch(`http://localhost:${port}/mcp`, { + body: JSON.stringify({ + id: 1, + jsonrpc: "2.0", + method: "initialize", + params: { + capabilities: {}, + clientInfo: { name: "test", version: "1.0.0" }, + protocolVersion: "2024-11-05", + }, + }), + headers: { + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + }, + method: "POST", + }); + + expect(response.status).toBe(401); + + const wwwAuthHeader = response.headers.get("WWW-Authenticate"); + expect(wwwAuthHeader).toBeTruthy(); + expect(wwwAuthHeader).toContain('Bearer'); + expect(wwwAuthHeader).toContain('realm="mcp-server"'); + expect(wwwAuthHeader).toContain('resource_metadata="https://example.com/.well-known/oauth-protected-resource"'); + expect(wwwAuthHeader).toContain('error="invalid_token"'); + expect(wwwAuthHeader).toContain('error_description="Invalid JWT token"'); + + await httpServer.close(); +}); + +it("includes WWW-Authenticate header when authenticate callback fails with OAuth", async () => { + const port = await getRandomPort(); + + const authenticate = vi.fn().mockRejectedValue(new Error("Token signature verification failed")); + + const httpServer = await startHTTPServer({ + authenticate, + createServer: async () => { + const mcpServer = new Server( + { name: "test", version: "1.0.0" }, + { capabilities: {} }, + ); + return mcpServer; + }, + oauth: { + error_uri: "https://example.com/docs/errors", + protectedResource: { + resource: "https://api.example.com", + }, + realm: "example-api", + }, + port, + stateless: true, + }); + + const response = await fetch(`http://localhost:${port}/mcp`, { + body: JSON.stringify({ + id: 1, + jsonrpc: "2.0", + method: "initialize", + params: { + capabilities: {}, + clientInfo: { name: "test", version: "1.0.0" }, + protocolVersion: "2024-11-05", + }, + }), + headers: { + "Accept": "application/json, text/event-stream", + "Authorization": "Bearer expired-token", + "Content-Type": "application/json", + }, + method: "POST", + }); + + expect(response.status).toBe(401); + expect(authenticate).toHaveBeenCalled(); + + const wwwAuthHeader = response.headers.get("WWW-Authenticate"); + expect(wwwAuthHeader).toBeTruthy(); + expect(wwwAuthHeader).toContain('Bearer'); + expect(wwwAuthHeader).toContain('realm="example-api"'); + expect(wwwAuthHeader).toContain('resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"'); + expect(wwwAuthHeader).toContain('error="invalid_token"'); + expect(wwwAuthHeader).toContain('error_description="Token signature verification failed"'); + expect(wwwAuthHeader).toContain('error_uri="https://example.com/docs/errors"'); + + await httpServer.close(); +}); + +it("does not include WWW-Authenticate header in 401 response without OAuth config", async () => { + const port = await getRandomPort(); + + const httpServer = await startHTTPServer({ + createServer: async () => { + throw new Error("Authentication required"); + }, + port, + stateless: true, + }); + + const response = await fetch(`http://localhost:${port}/mcp`, { + body: JSON.stringify({ + id: 1, + jsonrpc: "2.0", + method: "initialize", + params: { + capabilities: {}, + clientInfo: { name: "test", version: "1.0.0" }, + protocolVersion: "2024-11-05", + }, + }), + headers: { + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + }, + method: "POST", + }); + + expect(response.status).toBe(401); + + const wwwAuthHeader = response.headers.get("WWW-Authenticate"); + expect(wwwAuthHeader).toBeNull(); + + await httpServer.close(); +}); + it("succeeds when authenticate returns { authenticated: true } in stateless mode", async () => { const stdioTransport = new StdioClientTransport({ args: ["src/fixtures/simple-stdio-server.ts"], diff --git a/src/startHTTPServer.ts b/src/startHTTPServer.ts index dd74f5c..b161804 100644 --- a/src/startHTTPServer.ts +++ b/src/startHTTPServer.ts @@ -61,22 +61,82 @@ const createJsonRpcErrorResponse = (code: number, message: string) => { // Helper function to get WWW-Authenticate header value const getWWWAuthenticateHeader = ( oauth?: AuthConfig["oauth"], + options?: { + error?: string; + error_description?: string; + error_uri?: string; + scope?: string; + }, ): string | undefined => { - if (!oauth?.protectedResource?.resource) { + if (!oauth) { return undefined; } - return `Bearer resource_metadata="${oauth.protectedResource.resource}/.well-known/oauth-protected-resource"`; + const params: string[] = []; + + // Add realm if configured + if (oauth.realm) { + params.push(`realm="${oauth.realm}"`); + } + + // Add resource_metadata if configured + if (oauth.protectedResource?.resource) { + params.push(`resource_metadata="${oauth.protectedResource.resource}/.well-known/oauth-protected-resource"`); + } + + // Add error from options or config (options takes precedence) + const error = options?.error || oauth.error; + if (error) { + params.push(`error="${error}"`); + } + + // Add error_description from options or config (options takes precedence) + const error_description = options?.error_description || oauth.error_description; + if (error_description) { + // Escape quotes in error description + const escaped = error_description.replace(/"/g, '\\"'); + params.push(`error_description="${escaped}"`); + } + + // Add error_uri from options or config (options takes precedence) + const error_uri = options?.error_uri || oauth.error_uri; + if (error_uri) { + params.push(`error_uri="${error_uri}"`); + } + + // Add scope from options or config (options takes precedence) + const scope = options?.scope || oauth.scope; + if (scope) { + params.push(`scope="${scope}"`); + } + + // Return undefined if no parameters were added + if (params.length === 0) { + return undefined; + } + + return `Bearer ${params.join(", ")}`; }; // Helper function to handle Response errors and send appropriate HTTP response -const handleResponseError = ( +const handleResponseError = async ( error: unknown, res: http.ServerResponse, -): boolean => { - if (error instanceof Response) { +): Promise => { + // Check if it's a Response-like object (duck typing) + // The instanceof check may fail due to different Response implementations across module boundaries + const isResponseLike = error && + typeof error === 'object' && + 'status' in error && + 'headers' in error && + 'statusText' in error; + + if (isResponseLike || error instanceof Response) { + const responseError = error as Response; + + // Convert Headers to http.OutgoingHttpHeaders format const fixedHeaders: http.OutgoingHttpHeaders = {}; - error.headers.forEach((value, key) => { + responseError.headers.forEach((value, key) => { if (fixedHeaders[key]) { if (Array.isArray(fixedHeaders[key])) { (fixedHeaders[key] as string[]).push(value); @@ -87,11 +147,16 @@ const handleResponseError = ( fixedHeaders[key] = value; } }); - res - .writeHead(error.status, error.statusText, fixedHeaders) - .end(error.statusText); + + // Read the body from the Response object + const body = await responseError.text(); + + res.writeHead(responseError.status, responseError.statusText, fixedHeaders); + res.end(body); + return true; } + return false; }; @@ -260,7 +325,10 @@ const handleStreamRequest = async ({ res.setHeader("Content-Type", "application/json"); // Add WWW-Authenticate header if OAuth config is available - const wwwAuthHeader = getWWWAuthenticateHeader(oauth); + const wwwAuthHeader = getWWWAuthenticateHeader(oauth, { + error: "invalid_token", + error_description: errorMessage, + }); if (wwwAuthHeader) { res.setHeader("WWW-Authenticate", wwwAuthHeader); } @@ -278,13 +346,21 @@ const handleStreamRequest = async ({ return true; } } catch (error) { + // Check if error is a Response object with headers already set + if (await handleResponseError(error, res)) { + return true; + } + // Extract error details from thrown errors const errorMessage = error instanceof Error ? error.message : "Unauthorized: Authentication error"; console.error("Authentication error:", error); res.setHeader("Content-Type", "application/json"); // Add WWW-Authenticate header if OAuth config is available - const wwwAuthHeader = getWWWAuthenticateHeader(oauth); + const wwwAuthHeader = getWWWAuthenticateHeader(oauth, { + error: "invalid_token", + error_description: errorMessage, + }); if (wwwAuthHeader) { res.setHeader("WWW-Authenticate", wwwAuthHeader); } @@ -357,6 +433,11 @@ const handleStreamRequest = async ({ try { server = await createServer(req); } catch (error) { + // Check if error is a Response object with headers already set + if (await handleResponseError(error, res)) { + return true; + } + // Detect authentication errors and return HTTP 401 const errorMessage = error instanceof Error ? error.message : String(error); const isAuthError = errorMessage.includes('Authentication') || @@ -368,7 +449,10 @@ const handleStreamRequest = async ({ res.setHeader("Content-Type", "application/json"); // Add WWW-Authenticate header if OAuth config is available - const wwwAuthHeader = getWWWAuthenticateHeader(oauth); + const wwwAuthHeader = getWWWAuthenticateHeader(oauth, { + error: "invalid_token", + error_description: errorMessage, + }); if (wwwAuthHeader) { res.setHeader("WWW-Authenticate", wwwAuthHeader); } @@ -384,10 +468,6 @@ const handleStreamRequest = async ({ return true; } - if (handleResponseError(error, res)) { - return true; - } - res.writeHead(500).end("Error creating server"); return true; @@ -416,6 +496,11 @@ const handleStreamRequest = async ({ try { server = await createServer(req); } catch (error) { + // Check if error is a Response object with headers already set + if (await handleResponseError(error, res)) { + return true; + } + // Detect authentication errors and return HTTP 401 const errorMessage = error instanceof Error ? error.message : String(error); const isAuthError = errorMessage.includes('Authentication') || @@ -427,7 +512,10 @@ const handleStreamRequest = async ({ res.setHeader("Content-Type", "application/json"); // Add WWW-Authenticate header if OAuth config is available - const wwwAuthHeader = getWWWAuthenticateHeader(oauth); + const wwwAuthHeader = getWWWAuthenticateHeader(oauth, { + error: "invalid_token", + error_description: errorMessage, + }); if (wwwAuthHeader) { res.setHeader("WWW-Authenticate", wwwAuthHeader); } @@ -443,10 +531,6 @@ const handleStreamRequest = async ({ return true; } - if (handleResponseError(error, res)) { - return true; - } - res.writeHead(500).end("Error creating server"); return true; @@ -601,7 +685,7 @@ const handleSSERequest = async ({ try { server = await createServer(req); } catch (error) { - if (handleResponseError(error, res)) { + if (await handleResponseError(error, res)) { return true; }