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
5 changes: 5 additions & 0 deletions .changeset/fresh-mice-battle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Fix MCP OAuth discovery for unauthenticated POST requests.
64 changes: 44 additions & 20 deletions packages/core/src/astro/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,38 @@ declare global {

// Role level constants (matching @emdash-cms/auth)
const ROLE_ADMIN = 50;
const MCP_ENDPOINT_PATH = "/_emdash/api/mcp";

Comment on lines +54 to +55
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MCP_ENDPOINT_PATH is introduced, but the file still contains hard-coded "/_emdash/api/mcp" comparisons elsewhere (e.g., the invalid-token WWW-Authenticate branch and the scope rule). To avoid drift, prefer using the constant consistently in this module.

Copilot uses AI. Check for mistakes.
function isUnsafeMethod(method: string): boolean {
return method !== "GET" && method !== "HEAD" && method !== "OPTIONS";
}

function csrfRejectedResponse(): Response {
return new Response(
JSON.stringify({ error: { code: "CSRF_REJECTED", message: "Missing required header" } }),
{
status: 403,
headers: { "Content-Type": "application/json", ...MW_CACHE_HEADERS },
},
);
}

function mcpUnauthorizedResponse(
url: URL,
config?: Parameters<typeof getPublicOrigin>[1],
): Response {
const origin = getPublicOrigin(url, config);
return Response.json(
{ error: { code: "NOT_AUTHENTICATED", message: "Not authenticated" } },
{
status: 401,
headers: {
"WWW-Authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`,
...MW_CACHE_HEADERS,
},
},
);
}

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

const isTokenAuth = bearerResult === "authenticated";

// MCP discovery/tooling is bearer-only. Session/external auth should never
// be consulted for this endpoint, and unauthenticated requests must return
// the OAuth discovery-style 401 response.
const method = context.request.method.toUpperCase();
const isMcpEndpoint = url.pathname === MCP_ENDPOINT_PATH;
if (isMcpEndpoint && !isTokenAuth) {
return mcpUnauthorizedResponse(url, context.locals.emdash?.config);
}
Comment on lines +218 to +225
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new MCP short-circuit runs before the CSRF check, so any non-token request to /_emdash/api/mcp (including browser requests carrying session cookies but missing X-EmDash-Request: 1) will now return the OAuth-discovery 401 instead of 403 CSRF_REJECTED. This doesn’t match the PR description’s goal of preserving CSRF rejection for session/cookie-backed MCP POSTs. If that behavior is still desired, gate this early-return to only apply to truly anonymous discovery (e.g., no Cookie/session cookie present), and add a regression test covering the cookie-present + missing-CSRF-header case.

Copilot uses AI. Check for mistakes.

// CSRF protection: require X-EmDash-Request header on state-changing requests.
// Skip for token-authenticated requests (tokens aren't ambient credentials).
// Browsers block cross-origin custom headers, so this prevents CSRF without tokens.
// OAuth authorize consent is exempt: it's a standard HTML form POST that can't
// include custom headers. The consent flow is protected by session + single-use codes.
const method = context.request.method.toUpperCase();
const isOAuthConsent = url.pathname.startsWith("/_emdash/oauth/authorize");
if (
isApiRoute &&
!isTokenAuth &&
!isOAuthConsent &&
method !== "GET" &&
method !== "HEAD" &&
method !== "OPTIONS" &&
isUnsafeMethod(method) &&
!isPublicApiRoute
) {
const csrfHeader = context.request.headers.get("X-EmDash-Request");
if (csrfHeader !== "1") {
return new Response(
JSON.stringify({ error: { code: "CSRF_REJECTED", message: "Missing required header" } }),
{
status: 403,
headers: { "Content-Type": "application/json", ...MW_CACHE_HEADERS },
},
);
return csrfRejectedResponse();
}
}

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

if (!sessionUser?.id) {
// Not authenticated
if (isApiRoute) {
const headers: Record<string, string> = { ...MW_CACHE_HEADERS };
// Add WWW-Authenticate on MCP endpoint 401s to trigger OAuth discovery
if (url.pathname === "/_emdash/api/mcp") {
const origin = getPublicOrigin(url, emdash?.config);
headers["WWW-Authenticate"] =
`Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`;
}
return Response.json(
{ error: { code: "NOT_AUTHENTICATED", message: "Not authenticated" } },
{ status: 401, headers },
{ status: 401, headers: MW_CACHE_HEADERS },
);
}
const loginUrl = new URL("/_emdash/admin/login", getPublicOrigin(url, emdash?.config));
Expand Down
187 changes: 187 additions & 0 deletions packages/core/tests/unit/auth/mcp-discovery-post.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("virtual:emdash/auth", () => ({ authenticate: vi.fn() }));
vi.mock("astro:middleware", () => ({
defineMiddleware: (handler: unknown) => handler,
}));
vi.mock("@emdash-cms/auth", () => ({
TOKEN_PREFIXES: {},
generatePrefixedToken: vi.fn(),
hashPrefixedToken: vi.fn(),
VALID_SCOPES: [],
validateScopes: vi.fn(),
hasScope: vi.fn(() => false),
computeS256Challenge: vi.fn(),
Role: { ADMIN: 50 },
}));
vi.mock("@emdash-cms/auth/adapters/kysely", () => ({
createKyselyAdapter: vi.fn(() => ({
getUserById: vi.fn(async (id: string) => ({
id,
email: "admin@test.com",
name: "Admin",
role: 50,
disabled: 0,
})),
getUserByEmail: vi.fn(),
})),
}));

type AuthMiddlewareModule = typeof import("../../../src/astro/middleware/auth.js");

let onRequest: AuthMiddlewareModule["onRequest"];

beforeAll(async () => {
({ onRequest } = await import("../../../src/astro/middleware/auth.js"));
});

beforeEach(() => {
vi.clearAllMocks();
});

afterEach(() => {
vi.clearAllMocks();
});
Comment on lines +38 to +44
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

vi.clearAllMocks() is called in both beforeEach and afterEach. One of these is redundant and removing the duplicate will reduce noise in the test setup while keeping the same isolation behavior.

Copilot uses AI. Check for mistakes.

async function runAuthMiddleware(opts: {
pathname: string;
method?: string;
headers?: HeadersInit;
sessionUserId?: string | null;
siteUrl?: string;
}) {
const url = new URL(opts.pathname, "https://example.com");
const session = {
get: vi.fn().mockResolvedValue(opts.sessionUserId ? { id: opts.sessionUserId } : null),
set: vi.fn(),
destroy: vi.fn(),
};
const next = vi.fn(async () => new Response("ok"));
const response = await onRequest(
{
url,
request: new Request(url, {
method: opts.method ?? "POST",
headers: opts.headers,
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: { name: "debug", version: "1.0" },
},
}),
}),
locals: {
emdash: {
db: {},
config: opts.siteUrl ? { siteUrl: opts.siteUrl } : {},
},
},
session,
redirect: (location: string) =>
new Response(null, {
status: 302,
headers: { Location: location },
}),
} as Parameters<AuthMiddlewareModule["onRequest"]>[0],
next,
);

return { response, next, session };
}

describe("MCP discovery auth middleware", () => {
it("returns 401 with discovery metadata for unauthenticated MCP POST requests", async () => {
const { response, next } = await runAuthMiddleware({
pathname: "/_emdash/api/mcp",
headers: { "Content-Type": "application/json" },
});

expect(next).not.toHaveBeenCalled();
expect(response.status).toBe(401);
expect(response.headers.get("WWW-Authenticate")).toBe(
'Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource"',
);
await expect(response.json()).resolves.toEqual({
error: { code: "NOT_AUTHENTICATED", message: "Not authenticated" },
});
});

it("does not read the session for anonymous MCP POST discovery requests", async () => {
const { response, next, session } = await runAuthMiddleware({
pathname: "/_emdash/api/mcp",
headers: { "Content-Type": "application/json" },
});

expect(next).not.toHaveBeenCalled();
expect(response.status).toBe(401);
expect(session.get).not.toHaveBeenCalled();
});

it("uses the configured public origin for anonymous MCP POST discovery responses", async () => {
const { response, next } = await runAuthMiddleware({
pathname: "/_emdash/api/mcp",
headers: { "Content-Type": "application/json" },
siteUrl: "https://public.example.com",
});

expect(next).not.toHaveBeenCalled();
expect(response.status).toBe(401);
expect(response.headers.get("WWW-Authenticate")).toBe(
'Bearer resource_metadata="https://public.example.com/.well-known/oauth-protected-resource"',
);
});

it("returns 401 with discovery metadata for invalid bearer tokens on MCP POST", async () => {
const { response, next } = await runAuthMiddleware({
pathname: "/_emdash/api/mcp",
headers: {
Authorization: "Bearer invalid",
"Content-Type": "application/json",
},
});

expect(next).not.toHaveBeenCalled();
expect(response.status).toBe(401);
expect(response.headers.get("WWW-Authenticate")).toBe(
'Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource"',
);
await expect(response.json()).resolves.toEqual({
error: { code: "INVALID_TOKEN", message: "Invalid or expired token" },
});
});

it("rejects MCP POST requests that only have session auth", async () => {
const { response, next, session } = await runAuthMiddleware({
pathname: "/_emdash/api/mcp",
headers: {
"Content-Type": "application/json",
"X-EmDash-Request": "1",
},
sessionUserId: "user_1",
});

expect(next).not.toHaveBeenCalled();
expect(response.status).toBe(401);
expect(session.get).not.toHaveBeenCalled();
await expect(response.json()).resolves.toEqual({
error: { code: "NOT_AUTHENTICATED", message: "Not authenticated" },
});
});

it("still rejects non-MCP API POST requests without the CSRF header", async () => {
const { response, next } = await runAuthMiddleware({
pathname: "/_emdash/api/content/posts",
headers: { "Content-Type": "application/json" },
});

expect(next).not.toHaveBeenCalled();
expect(response.status).toBe(403);
await expect(response.json()).resolves.toEqual({
error: { code: "CSRF_REJECTED", message: "Missing required header" },
});
});
});
Loading