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
195 changes: 195 additions & 0 deletions src/authentication.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
});
});
45 changes: 45 additions & 0 deletions src/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>; statusCode: number } {
const headers: Record<string, string> = {
"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;
Expand Down
41 changes: 40 additions & 1 deletion src/startHTTPServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -267,6 +289,7 @@ const applyCorsHeaders = (
const handleStreamRequest = async <T extends ServerLike>({
activeTransports,
authenticate,
authMiddleware,
createServer,
enableJsonResponse,
endpoint,
Expand All @@ -283,6 +306,7 @@ const handleStreamRequest = async <T extends ServerLike>({
{ server: T; transport: StreamableHTTPServerTransport }
>;
authenticate?: (request: http.IncomingMessage) => Promise<unknown>;
authMiddleware: AuthenticationMiddleware;
createServer: (request: http.IncomingMessage) => Promise<T>;
enableJsonResponse?: boolean;
endpoint: string;
Expand All @@ -298,6 +322,7 @@ const handleStreamRequest = async <T extends ServerLike>({
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]
Expand All @@ -307,7 +332,7 @@ const handleStreamRequest = async <T extends ServerLike>({

let server: T;

const body = await getBody(req);
body = await getBody(req);

// Per-request authentication in stateless mode
if (stateless && authenticate) {
Expand Down Expand Up @@ -566,6 +591,19 @@ const handleStreamRequest = async <T extends ServerLike>({

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");
Expand Down Expand Up @@ -858,6 +896,7 @@ export const startHTTPServer = async <T extends ServerLike>({
(await handleStreamRequest({
activeTransports: activeStreamTransports,
authenticate,
authMiddleware,
createServer,
enableJsonResponse,
endpoint: streamEndpoint,
Expand Down