From a26487d9bbe969e25cf0169c12500153c6db0673 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 3 Jun 2026 07:18:19 -0400 Subject: [PATCH] Add missing WWW-Authenticate header on auth error responses --- server/routes/mcp/index.test.ts | 30 +++++++++++++++++++++++++ server/routes/mcp/index.ts | 39 +++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/server/routes/mcp/index.test.ts b/server/routes/mcp/index.test.ts index 4cdcd8231c..bdcbc6d633 100644 --- a/server/routes/mcp/index.test.ts +++ b/server/routes/mcp/index.test.ts @@ -29,6 +29,36 @@ describe("POST /mcp/", () => { expect(res.status).toEqual(401); }); + it("should include a WWW-Authenticate challenge when auth is missing", async () => { + const { body } = mcpRequest("tools/list"); + const res = await server.post("/mcp/", { + headers: { Accept: "application/json, text/event-stream" }, + body, + }); + expect(res.status).toEqual(401); + + const challenge = res.headers.get("www-authenticate"); + expect(challenge).toContain(`resource_metadata="`); + expect(challenge).toContain(`/.well-known/oauth-protected-resource/mcp"`); + expect(challenge).not.toContain("invalid_token"); + }); + + it("should include an invalid_token challenge for a rejected bearer token", async () => { + const { body } = mcpRequest("tools/list"); + const res = await server.post("/mcp/", { + headers: { + Authorization: "Bearer invalid-token", + Accept: "application/json, text/event-stream", + }, + body, + }); + expect(res.status).toEqual(401); + + const challenge = res.headers.get("www-authenticate"); + expect(challenge).toContain(`/.well-known/oauth-protected-resource/mcp"`); + expect(challenge).toContain(`error="invalid_token"`); + }); + it("should reject JWT authentication", async () => { const user = await buildUser(); const { body } = mcpRequest("tools/list"); diff --git a/server/routes/mcp/index.ts b/server/routes/mcp/index.ts index 36d81e56e3..2cd34de6d7 100644 --- a/server/routes/mcp/index.ts +++ b/server/routes/mcp/index.ts @@ -1,16 +1,19 @@ import Koa from "koa"; +import type { Next } from "koa"; import bodyParser from "koa-body"; import Router from "koa-router"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; import { TeamPreference } from "@shared/types"; +import env from "@server/env"; import { NotFoundError } from "@server/errors"; import Logger from "@server/logging/Logger"; import auth from "@server/middlewares/authentication"; import { rateLimiter } from "@server/middlewares/rateLimiter"; import requestTracer from "@server/middlewares/requestTracer"; import { UserFlag } from "@server/models/User"; +import type { AppContext } from "@server/types"; import { AuthenticationType } from "@server/types"; import { RateLimiterStrategy } from "@server/utils/RateLimiter"; import { attachmentTools } from "@server/tools/attachments"; @@ -66,8 +69,44 @@ function createMcpServer(scopes: string[], guidance?: string): McpServer { return server; } +/** + * Adds the RFC 9728 `WWW-Authenticate` challenge header to 401 responses so that + * OAuth-enabled MCP clients can discover the protected resource metadata URL and + * (re-)enter the authorization flow when their token is missing, invalid, or expired. + * + * @param ctx - the application context. + * @param next - the next middleware in the chain. + */ +async function mcpAuthChallenge(ctx: AppContext, next: Next) { + try { + await next(); + } catch (err) { + if (err?.status === 401) { + // Use the configured URL for self-hosted deployments to preserve the port + // when behind a reverse proxy that may strip the port from the Host header. + const origin = env.isCloudHosted + ? ctx.request.URL.origin + : new URL(env.URL).origin; + const params = [ + `resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp"`, + ]; + // A token was supplied but rejected (invalid or expired) — signal that to + // the client per RFC 6750 so it knows to refresh rather than re-prompt. + if (ctx.request.get("authorization")) { + params.push(`error="invalid_token"`); + } + err.headers = { + ...err.headers, + "WWW-Authenticate": `Bearer ${params.join(", ")}`, + }; + } + throw err; + } +} + router.post( "/", + mcpAuthChallenge, rateLimiter(RateLimiterStrategy.OneThousandPerHour), auth({ type: [