Skip to content

fix(core): return OAuth discovery 401 for unauthenticated MCP POSTs#371

Open
pejmanjohn wants to merge 7 commits intoemdash-cms:mainfrom
pejmanjohn:contrib/emdash-cms-emdash-mcp-post-oauth-discovery
Open

fix(core): return OAuth discovery 401 for unauthenticated MCP POSTs#371
pejmanjohn wants to merge 7 commits intoemdash-cms:mainfrom
pejmanjohn:contrib/emdash-cms-emdash-mcp-post-oauth-discovery

Conversation

@pejmanjohn
Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes OAuth discovery for unauthenticated MCP POST requests on /_emdash/api/mcp.

Some MCP clients, including Codex, probe the MCP endpoint with POST when starting auth discovery. Before this change, unauthenticated POST /_emdash/api/mcp requests without X-EmDash-Request: 1 were rejected by the generic CSRF middleware with 403 CSRF_REJECTED before the MCP auth path could return 401 Unauthorized with the WWW-Authenticate header.

That prevented OAuth-capable MCP clients from starting discovery even though EmDash already exposed valid OAuth metadata endpoints.

This change:

  • allows tokenless MCP discovery POST requests with no authenticated session to reach the existing 401 + WWW-Authenticate response
  • preserves CSRF rejection for session/cookie-backed MCP POST requests without X-EmDash-Request: 1
  • keeps non-MCP API CSRF behavior unchanged
  • adds regression tests for unauthenticated MCP POST, invalid bearer token handling, and the non-regression CSRF cases

Type of change

  • Bug fix
  • Feature (requires approved Discussion)
  • Refactor (no behavior change)
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm --silent lint:json | jq '.diagnostics | length' returns 0
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: https://github.com/emdash-cms/emdash/discussions/...

AI-generated code disclosure

  • This PR includes AI-generated code

Screenshots / test output

Ran:

  • pnpm build
  • pnpm typecheck
  • pnpm typecheck:demos
  • pnpm --filter emdash exec vitest run tests/unit/auth/mcp-discovery-post.test.ts tests/unit/auth/discovery-endpoints.test.ts tests/unit/api/csrf.test.ts tests/unit/mcp/authorization.test.ts

Notes:

  • pnpm --silent lint:json | jq '.diagnostics | length' currently reports 3 existing warnings in unrelated files.

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 8, 2026

🦋 Changeset detected

Latest commit: 56ce798

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/plugin-ai-moderation Patch
@emdash-cms/plugin-atproto Patch
@emdash-cms/plugin-audit-log Patch
@emdash-cms/plugin-color Patch
@emdash-cms/plugin-embeds Patch
@emdash-cms/plugin-forms Patch
@emdash-cms/plugin-webhook-notifier Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pejmanjohn pejmanjohn marked this pull request as ready for review April 8, 2026 04:58
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 9, 2026

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@371

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@371

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@371

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@371

emdash

npm i https://pkg.pr.new/emdash@371

create-emdash

npm i https://pkg.pr.new/create-emdash@371

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@371

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@371

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@371

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@371

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@371

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@371

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@371

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@371

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@371

commit: 56ce798

Copy link
Copy Markdown
Collaborator

@ascorbic ascorbic left a comment

Choose a reason for hiding this comment

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

Thanks for the PR. The fix is in the right area, but I think there's a simpler approach. No MCP client will ever authenticate via session cookies, so we don't need to preserve CSRF for session-backed MCP requests: we can just make MCP bearer-only. I've made a couple of inline suggesitons, but it might need a few more as I've not tested. Basically for MCP requests we want to exempt from CSRF, but only allow token auth.

Comment on lines 212 to 216
const isOAuthConsent = url.pathname.startsWith("/_emdash/oauth/authorize");
if (
isApiRoute &&
!isTokenAuth &&
!isOAuthConsent &&
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
const isOAuthConsent = url.pathname.startsWith("/_emdash/oauth/authorize");
const isMcpEndpoint = url.pathname === "/_emdash/api/mcp";
if (
isApiRoute &&
!isTokenAuth &&
!isOAuthConsent &&
!isMcpEndpoint &&

Copy link
Copy Markdown
Contributor Author

@pejmanjohn pejmanjohn Apr 9, 2026

Choose a reason for hiding this comment

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

@ascorbic Updated this to match the broader bearer-only direction. MCP now short-circuits before session or external auth: requests without a valid bearer token return the discovery-style 401 with WWW-Authenticate, and session-backed MCP requests are no longer accepted even if the CSRF header is present.

I also updated the regression test to assert that MCP POST discovery no longer reads session state at all. Verified with pnpm --filter emdash test tests/unit/auth/mcp-discovery-post.test.ts, pnpm test tests/unit/auth/*.test.ts, pnpm typecheck, and pnpm --silent lint:json | jq .diagnostics | length.

}
return response;
}

Copy link
Copy Markdown
Collaborator

@ascorbic ascorbic Apr 9, 2026

Choose a reason for hiding this comment

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

Suggested change
if (isMcpEndpoint) {
return Response.json(
{ error: { code: "NOT_AUTHENTICATED", message: "Not authenticated" } },
{ status: 401, headers: {
"WWW-Authenticate": `Bearer resource_metadata="${url.origin}/.well-known/oauth-protected-resource"`,
...MW_CACHE_HEADERS,
}},
);
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants