diff --git a/src/authentication.test.ts b/src/authentication.test.ts index 1cfaff7..da9ff85 100644 --- a/src/authentication.test.ts +++ b/src/authentication.test.ts @@ -103,15 +103,90 @@ describe("AuthenticationMiddleware", () => { expect(body.id).toBe(null); }); - it("should have consistent format regardless of configuration", () => { + it("should have consistent body format regardless of configuration", () => { const middleware1 = new AuthenticationMiddleware({}); const middleware2 = new AuthenticationMiddleware({ apiKey: "test" }); const response1 = middleware1.getUnauthorizedResponse(); const response2 = middleware2.getUnauthorizedResponse(); - expect(response1.headers).toEqual(response2.headers); expect(response1.body).toEqual(response2.body); }); + + it("should not include WWW-Authenticate header without OAuth config", () => { + const middleware = new AuthenticationMiddleware({ apiKey: "test" }); + const response = middleware.getUnauthorizedResponse(); + + expect(response.headers["WWW-Authenticate"]).toBeUndefined(); + }); + + it("should include WWW-Authenticate header with OAuth config", () => { + const middleware = new AuthenticationMiddleware({ + apiKey: "test", + oauth: { + protectedResource: { + resource: "https://example.com", + }, + }, + }); + const response = middleware.getUnauthorizedResponse(); + + expect(response.headers["WWW-Authenticate"]).toBe( + 'Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource"', + ); + }); + + it("should handle OAuth config with trailing slash in resource URL", () => { + const middleware = new AuthenticationMiddleware({ + apiKey: "test", + oauth: { + protectedResource: { + resource: "https://example.com/", + }, + }, + }); + const response = middleware.getUnauthorizedResponse(); + + expect(response.headers["WWW-Authenticate"]).toBe( + 'Bearer resource_metadata="https://example.com//.well-known/oauth-protected-resource"', + ); + }); + + it("should not include WWW-Authenticate header when OAuth config is empty", () => { + const middleware = new AuthenticationMiddleware({ + apiKey: "test", + oauth: {}, + }); + const response = middleware.getUnauthorizedResponse(); + + expect(response.headers["WWW-Authenticate"]).toBeUndefined(); + }); + + it("should not include WWW-Authenticate header when protectedResource is empty", () => { + const middleware = new AuthenticationMiddleware({ + apiKey: "test", + oauth: { + protectedResource: {}, + }, + }); + const response = middleware.getUnauthorizedResponse(); + + expect(response.headers["WWW-Authenticate"]).toBeUndefined(); + }); + + it("should include WWW-Authenticate header with OAuth config but no apiKey", () => { + const middleware = new AuthenticationMiddleware({ + oauth: { + protectedResource: { + resource: "https://example.com", + }, + }, + }); + const response = middleware.getUnauthorizedResponse(); + + expect(response.headers["WWW-Authenticate"]).toBe( + 'Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource"', + ); + }); }); }); \ No newline at end of file diff --git a/src/authentication.ts b/src/authentication.ts index 2784c6a..a66a80f 100644 --- a/src/authentication.ts +++ b/src/authentication.ts @@ -2,12 +2,26 @@ import type { IncomingMessage } from "http"; export interface AuthConfig { apiKey?: string; + oauth?: { + protectedResource?: { + resource?: string; + }; + }; } export class AuthenticationMiddleware { constructor(private config: AuthConfig = {}) {} - getUnauthorizedResponse() { + getUnauthorizedResponse(): { 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"`; + } + return { body: JSON.stringify({ error: { @@ -17,9 +31,7 @@ export class AuthenticationMiddleware { id: null, jsonrpc: "2.0", }), - headers: { - "Content-Type": "application/json", - }, + headers, }; } diff --git a/src/index.ts b/src/index.ts index 8d60682..96e5f3c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,5 @@ +export type { AuthConfig } from "./authentication.js"; +export { AuthenticationMiddleware } from "./authentication.js"; export { InMemoryEventStore } from "./InMemoryEventStore.js"; export { proxyServer } from "./proxyServer.js"; export { startHTTPServer } from "./startHTTPServer.js"; diff --git a/src/startHTTPServer.ts b/src/startHTTPServer.ts index 58f4b3a..2e216c0 100644 --- a/src/startHTTPServer.ts +++ b/src/startHTTPServer.ts @@ -8,7 +8,7 @@ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import http from "http"; import { randomUUID } from "node:crypto"; -import { AuthenticationMiddleware } from "./authentication.js"; +import { AuthConfig, AuthenticationMiddleware } from "./authentication.js"; import { InMemoryEventStore } from "./InMemoryEventStore.js"; export type SSEServer = { @@ -49,6 +49,17 @@ const createJsonRpcErrorResponse = (code: number, message: string) => { }); }; +// Helper function to get WWW-Authenticate header value +const getWWWAuthenticateHeader = ( + oauth?: AuthConfig["oauth"], +): string | undefined => { + if (!oauth?.protectedResource?.resource) { + return undefined; + } + + return `Bearer resource_metadata="${oauth.protectedResource.resource}/.well-known/oauth-protected-resource"`; +}; + // Helper function to handle Response errors and send appropriate HTTP response const handleResponseError = ( error: unknown, @@ -98,6 +109,7 @@ const handleStreamRequest = async ({ enableJsonResponse, endpoint, eventStore, + oauth, onClose, onConnect, req, @@ -113,6 +125,7 @@ const handleStreamRequest = async ({ enableJsonResponse?: boolean; endpoint: string; eventStore?: EventStore; + oauth?: AuthConfig["oauth"]; onClose?: (server: T) => Promise; onConnect?: (server: T) => Promise; req: http.IncomingMessage; @@ -148,6 +161,13 @@ const handleStreamRequest = async ({ : "Unauthorized: Authentication failed"; res.setHeader("Content-Type", "application/json"); + + // Add WWW-Authenticate header if OAuth config is available + const wwwAuthHeader = getWWWAuthenticateHeader(oauth); + if (wwwAuthHeader) { + res.setHeader("WWW-Authenticate", wwwAuthHeader); + } + res.writeHead(401).end( JSON.stringify({ error: { @@ -165,6 +185,13 @@ const handleStreamRequest = async ({ 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); + if (wwwAuthHeader) { + res.setHeader("WWW-Authenticate", wwwAuthHeader); + } + res.writeHead(401).end( JSON.stringify({ error: { @@ -242,6 +269,13 @@ const handleStreamRequest = async ({ if (isAuthError) { res.setHeader("Content-Type", "application/json"); + + // Add WWW-Authenticate header if OAuth config is available + const wwwAuthHeader = getWWWAuthenticateHeader(oauth); + if (wwwAuthHeader) { + res.setHeader("WWW-Authenticate", wwwAuthHeader); + } + res.writeHead(401).end(JSON.stringify({ error: { code: -32000, @@ -294,6 +328,13 @@ const handleStreamRequest = async ({ if (isAuthError) { res.setHeader("Content-Type", "application/json"); + + // Add WWW-Authenticate header if OAuth config is available + const wwwAuthHeader = getWWWAuthenticateHeader(oauth); + if (wwwAuthHeader) { + res.setHeader("WWW-Authenticate", wwwAuthHeader); + } + res.writeHead(401).end(JSON.stringify({ error: { code: -32000, @@ -549,6 +590,7 @@ export const startHTTPServer = async ({ enableJsonResponse, eventStore, host = "::", + oauth, onClose, onConnect, onUnhandledRequest, @@ -563,6 +605,7 @@ export const startHTTPServer = async ({ enableJsonResponse?: boolean; eventStore?: EventStore; host?: string; + oauth?: AuthConfig["oauth"]; onClose?: (server: T) => Promise; onConnect?: (server: T) => Promise; onUnhandledRequest?: ( @@ -584,7 +627,7 @@ export const startHTTPServer = async ({ } > = {}; - const authMiddleware = new AuthenticationMiddleware({ apiKey }); + const authMiddleware = new AuthenticationMiddleware({ apiKey, oauth }); /** * @author https://dev.classmethod.jp/articles/mcp-sse/ @@ -647,6 +690,7 @@ export const startHTTPServer = async ({ enableJsonResponse, endpoint: streamEndpoint, eventStore, + oauth, onClose, onConnect, req,