Skip to content

Commit 5320321

Browse files
fix(core): return OAuth discovery 401 for unauthenticated MCP POSTs (#371)
* fix(core): return OAuth discovery 401 for MCP POSTs * chore: add changeset for MCP OAuth discovery fix * style: format * fix(core): centralize MCP discovery auth handling * style: format * fix(core): make MCP auth bearer-only * fix(core): use public origin for MCP discovery 401 --------- Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
1 parent 1dc19b0 commit 5320321

3 files changed

Lines changed: 236 additions & 20 deletions

File tree

.changeset/fresh-mice-battle.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"emdash": patch
3+
---
4+
5+
Fix MCP OAuth discovery for unauthenticated POST requests.

packages/core/src/astro/middleware/auth.ts

Lines changed: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,38 @@ declare global {
5151

5252
// Role level constants (matching @emdash-cms/auth)
5353
const ROLE_ADMIN = 50;
54+
const MCP_ENDPOINT_PATH = "/_emdash/api/mcp";
55+
56+
function isUnsafeMethod(method: string): boolean {
57+
return method !== "GET" && method !== "HEAD" && method !== "OPTIONS";
58+
}
59+
60+
function csrfRejectedResponse(): Response {
61+
return new Response(
62+
JSON.stringify({ error: { code: "CSRF_REJECTED", message: "Missing required header" } }),
63+
{
64+
status: 403,
65+
headers: { "Content-Type": "application/json", ...MW_CACHE_HEADERS },
66+
},
67+
);
68+
}
69+
70+
function mcpUnauthorizedResponse(
71+
url: URL,
72+
config?: Parameters<typeof getPublicOrigin>[1],
73+
): Response {
74+
const origin = getPublicOrigin(url, config);
75+
return Response.json(
76+
{ error: { code: "NOT_AUTHENTICATED", message: "Not authenticated" } },
77+
{
78+
status: 401,
79+
headers: {
80+
"WWW-Authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`,
81+
...MW_CACHE_HEADERS,
82+
},
83+
},
84+
);
85+
}
5486

5587
/**
5688
* API routes that skip auth — each handles its own access control.
@@ -183,31 +215,31 @@ export const onRequest = defineMiddleware(async (context, next) => {
183215

184216
const isTokenAuth = bearerResult === "authenticated";
185217

218+
// MCP discovery/tooling is bearer-only. Session/external auth should never
219+
// be consulted for this endpoint, and unauthenticated requests must return
220+
// the OAuth discovery-style 401 response.
221+
const method = context.request.method.toUpperCase();
222+
const isMcpEndpoint = url.pathname === MCP_ENDPOINT_PATH;
223+
if (isMcpEndpoint && !isTokenAuth) {
224+
return mcpUnauthorizedResponse(url, context.locals.emdash?.config);
225+
}
226+
186227
// CSRF protection: require X-EmDash-Request header on state-changing requests.
187228
// Skip for token-authenticated requests (tokens aren't ambient credentials).
188229
// Browsers block cross-origin custom headers, so this prevents CSRF without tokens.
189230
// OAuth authorize consent is exempt: it's a standard HTML form POST that can't
190231
// include custom headers. The consent flow is protected by session + single-use codes.
191-
const method = context.request.method.toUpperCase();
192232
const isOAuthConsent = url.pathname.startsWith("/_emdash/oauth/authorize");
193233
if (
194234
isApiRoute &&
195235
!isTokenAuth &&
196236
!isOAuthConsent &&
197-
method !== "GET" &&
198-
method !== "HEAD" &&
199-
method !== "OPTIONS" &&
237+
isUnsafeMethod(method) &&
200238
!isPublicApiRoute
201239
) {
202240
const csrfHeader = context.request.headers.get("X-EmDash-Request");
203241
if (csrfHeader !== "1") {
204-
return new Response(
205-
JSON.stringify({ error: { code: "CSRF_REJECTED", message: "Missing required header" } }),
206-
{
207-
status: 403,
208-
headers: { "Content-Type": "application/json", ...MW_CACHE_HEADERS },
209-
},
210-
);
242+
return csrfRejectedResponse();
211243
}
212244
}
213245

@@ -562,18 +594,10 @@ async function handlePasskeyAuth(
562594
const sessionUser = await session?.get("user");
563595

564596
if (!sessionUser?.id) {
565-
// Not authenticated
566597
if (isApiRoute) {
567-
const headers: Record<string, string> = { ...MW_CACHE_HEADERS };
568-
// Add WWW-Authenticate on MCP endpoint 401s to trigger OAuth discovery
569-
if (url.pathname === "/_emdash/api/mcp") {
570-
const origin = getPublicOrigin(url, emdash?.config);
571-
headers["WWW-Authenticate"] =
572-
`Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`;
573-
}
574598
return Response.json(
575599
{ error: { code: "NOT_AUTHENTICATED", message: "Not authenticated" } },
576-
{ status: 401, headers },
600+
{ status: 401, headers: MW_CACHE_HEADERS },
577601
);
578602
}
579603
const loginUrl = new URL("/_emdash/admin/login", getPublicOrigin(url, emdash?.config));
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
vi.mock("virtual:emdash/auth", () => ({ authenticate: vi.fn() }));
4+
vi.mock("astro:middleware", () => ({
5+
defineMiddleware: (handler: unknown) => handler,
6+
}));
7+
vi.mock("@emdash-cms/auth", () => ({
8+
TOKEN_PREFIXES: {},
9+
generatePrefixedToken: vi.fn(),
10+
hashPrefixedToken: vi.fn(),
11+
VALID_SCOPES: [],
12+
validateScopes: vi.fn(),
13+
hasScope: vi.fn(() => false),
14+
computeS256Challenge: vi.fn(),
15+
Role: { ADMIN: 50 },
16+
}));
17+
vi.mock("@emdash-cms/auth/adapters/kysely", () => ({
18+
createKyselyAdapter: vi.fn(() => ({
19+
getUserById: vi.fn(async (id: string) => ({
20+
id,
21+
email: "admin@test.com",
22+
name: "Admin",
23+
role: 50,
24+
disabled: 0,
25+
})),
26+
getUserByEmail: vi.fn(),
27+
})),
28+
}));
29+
30+
type AuthMiddlewareModule = typeof import("../../../src/astro/middleware/auth.js");
31+
32+
let onRequest: AuthMiddlewareModule["onRequest"];
33+
34+
beforeAll(async () => {
35+
({ onRequest } = await import("../../../src/astro/middleware/auth.js"));
36+
});
37+
38+
beforeEach(() => {
39+
vi.clearAllMocks();
40+
});
41+
42+
afterEach(() => {
43+
vi.clearAllMocks();
44+
});
45+
46+
async function runAuthMiddleware(opts: {
47+
pathname: string;
48+
method?: string;
49+
headers?: HeadersInit;
50+
sessionUserId?: string | null;
51+
siteUrl?: string;
52+
}) {
53+
const url = new URL(opts.pathname, "https://example.com");
54+
const session = {
55+
get: vi.fn().mockResolvedValue(opts.sessionUserId ? { id: opts.sessionUserId } : null),
56+
set: vi.fn(),
57+
destroy: vi.fn(),
58+
};
59+
const next = vi.fn(async () => new Response("ok"));
60+
const response = await onRequest(
61+
{
62+
url,
63+
request: new Request(url, {
64+
method: opts.method ?? "POST",
65+
headers: opts.headers,
66+
body: JSON.stringify({
67+
jsonrpc: "2.0",
68+
id: 1,
69+
method: "initialize",
70+
params: {
71+
protocolVersion: "2025-03-26",
72+
capabilities: {},
73+
clientInfo: { name: "debug", version: "1.0" },
74+
},
75+
}),
76+
}),
77+
locals: {
78+
emdash: {
79+
db: {},
80+
config: opts.siteUrl ? { siteUrl: opts.siteUrl } : {},
81+
},
82+
},
83+
session,
84+
redirect: (location: string) =>
85+
new Response(null, {
86+
status: 302,
87+
headers: { Location: location },
88+
}),
89+
} as Parameters<AuthMiddlewareModule["onRequest"]>[0],
90+
next,
91+
);
92+
93+
return { response, next, session };
94+
}
95+
96+
describe("MCP discovery auth middleware", () => {
97+
it("returns 401 with discovery metadata for unauthenticated MCP POST requests", async () => {
98+
const { response, next } = await runAuthMiddleware({
99+
pathname: "/_emdash/api/mcp",
100+
headers: { "Content-Type": "application/json" },
101+
});
102+
103+
expect(next).not.toHaveBeenCalled();
104+
expect(response.status).toBe(401);
105+
expect(response.headers.get("WWW-Authenticate")).toBe(
106+
'Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource"',
107+
);
108+
await expect(response.json()).resolves.toEqual({
109+
error: { code: "NOT_AUTHENTICATED", message: "Not authenticated" },
110+
});
111+
});
112+
113+
it("does not read the session for anonymous MCP POST discovery requests", async () => {
114+
const { response, next, session } = await runAuthMiddleware({
115+
pathname: "/_emdash/api/mcp",
116+
headers: { "Content-Type": "application/json" },
117+
});
118+
119+
expect(next).not.toHaveBeenCalled();
120+
expect(response.status).toBe(401);
121+
expect(session.get).not.toHaveBeenCalled();
122+
});
123+
124+
it("uses the configured public origin for anonymous MCP POST discovery responses", async () => {
125+
const { response, next } = await runAuthMiddleware({
126+
pathname: "/_emdash/api/mcp",
127+
headers: { "Content-Type": "application/json" },
128+
siteUrl: "https://public.example.com",
129+
});
130+
131+
expect(next).not.toHaveBeenCalled();
132+
expect(response.status).toBe(401);
133+
expect(response.headers.get("WWW-Authenticate")).toBe(
134+
'Bearer resource_metadata="https://public.example.com/.well-known/oauth-protected-resource"',
135+
);
136+
});
137+
138+
it("returns 401 with discovery metadata for invalid bearer tokens on MCP POST", async () => {
139+
const { response, next } = await runAuthMiddleware({
140+
pathname: "/_emdash/api/mcp",
141+
headers: {
142+
Authorization: "Bearer invalid",
143+
"Content-Type": "application/json",
144+
},
145+
});
146+
147+
expect(next).not.toHaveBeenCalled();
148+
expect(response.status).toBe(401);
149+
expect(response.headers.get("WWW-Authenticate")).toBe(
150+
'Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource"',
151+
);
152+
await expect(response.json()).resolves.toEqual({
153+
error: { code: "INVALID_TOKEN", message: "Invalid or expired token" },
154+
});
155+
});
156+
157+
it("rejects MCP POST requests that only have session auth", async () => {
158+
const { response, next, session } = await runAuthMiddleware({
159+
pathname: "/_emdash/api/mcp",
160+
headers: {
161+
"Content-Type": "application/json",
162+
"X-EmDash-Request": "1",
163+
},
164+
sessionUserId: "user_1",
165+
});
166+
167+
expect(next).not.toHaveBeenCalled();
168+
expect(response.status).toBe(401);
169+
expect(session.get).not.toHaveBeenCalled();
170+
await expect(response.json()).resolves.toEqual({
171+
error: { code: "NOT_AUTHENTICATED", message: "Not authenticated" },
172+
});
173+
});
174+
175+
it("still rejects non-MCP API POST requests without the CSRF header", async () => {
176+
const { response, next } = await runAuthMiddleware({
177+
pathname: "/_emdash/api/content/posts",
178+
headers: { "Content-Type": "application/json" },
179+
});
180+
181+
expect(next).not.toHaveBeenCalled();
182+
expect(response.status).toBe(403);
183+
await expect(response.json()).resolves.toEqual({
184+
error: { code: "CSRF_REJECTED", message: "Missing required header" },
185+
});
186+
});
187+
});

0 commit comments

Comments
 (0)