Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 77 additions & 2 deletions src/authentication.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"',
);
});
});
});
20 changes: 16 additions & 4 deletions src/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> } {
const headers: Record<string, string> = {
"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: {
Expand All @@ -17,9 +31,7 @@ export class AuthenticationMiddleware {
id: null,
jsonrpc: "2.0",
}),
headers: {
"Content-Type": "application/json",
},
headers,
};
}

Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
48 changes: 46 additions & 2 deletions src/startHTTPServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -98,6 +109,7 @@ const handleStreamRequest = async <T extends ServerLike>({
enableJsonResponse,
endpoint,
eventStore,
oauth,
onClose,
onConnect,
req,
Expand All @@ -113,6 +125,7 @@ const handleStreamRequest = async <T extends ServerLike>({
enableJsonResponse?: boolean;
endpoint: string;
eventStore?: EventStore;
oauth?: AuthConfig["oauth"];
onClose?: (server: T) => Promise<void>;
onConnect?: (server: T) => Promise<void>;
req: http.IncomingMessage;
Expand Down Expand Up @@ -148,6 +161,13 @@ const handleStreamRequest = async <T extends ServerLike>({
: "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: {
Expand All @@ -165,6 +185,13 @@ const handleStreamRequest = async <T extends ServerLike>({
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: {
Expand Down Expand Up @@ -242,6 +269,13 @@ const handleStreamRequest = async <T extends ServerLike>({

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,
Expand Down Expand Up @@ -294,6 +328,13 @@ const handleStreamRequest = async <T extends ServerLike>({

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,
Expand Down Expand Up @@ -549,6 +590,7 @@ export const startHTTPServer = async <T extends ServerLike>({
enableJsonResponse,
eventStore,
host = "::",
oauth,
onClose,
onConnect,
onUnhandledRequest,
Expand All @@ -563,6 +605,7 @@ export const startHTTPServer = async <T extends ServerLike>({
enableJsonResponse?: boolean;
eventStore?: EventStore;
host?: string;
oauth?: AuthConfig["oauth"];
onClose?: (server: T) => Promise<void>;
onConnect?: (server: T) => Promise<void>;
onUnhandledRequest?: (
Expand All @@ -584,7 +627,7 @@ export const startHTTPServer = async <T extends ServerLike>({
}
> = {};

const authMiddleware = new AuthenticationMiddleware({ apiKey });
const authMiddleware = new AuthenticationMiddleware({ apiKey, oauth });

/**
* @author https://dev.classmethod.jp/articles/mcp-sse/
Expand Down Expand Up @@ -647,6 +690,7 @@ export const startHTTPServer = async <T extends ServerLike>({
enableJsonResponse,
endpoint: streamEndpoint,
eventStore,
oauth,
onClose,
onConnect,
req,
Expand Down