fix(mcp): make OAuth discovery work for web connector clients#8
Conversation
GET /api/mcp returned 405 with only Allow: POST and no WWW-Authenticate header. That short-circuited Claude.ai's connector discovery probe — the initial unauthenticated GET is exactly how a strict OAuth resource server is supposed to advertise the protected-resource metadata URL, and our 405 left no breadcrumb for the client to follow. Result: "Couldn't reach the MCP server" from the connector form, even though POST was correctly emitting the challenge. Move the auth-check out front. On unauthenticated requests (any method), return 401 with the existing WWW-Authenticate: Bearer resource_metadata=... challenge. On authenticated GET/DELETE, keep the 405 — the leak-fix reasoning (per-request McpServer instances pinned by long-lived SSE streams in stateless mode) only kicks in once the request is past auth. Verified against arin.usedocsyde.com: GET /api/mcp → was 405 Allow:POST, becomes 401 + WWW-Authenticate POST /api/mcp (no auth) → unchanged: 401 + WWW-Authenticate POST /api/mcp (valid bearer) → unchanged: tools/list etc. GET /api/mcp (valid bearer) → unchanged: 405 Allow:POST Co-authored-by: Claude <81847+claude@users.noreply.github.com>
Better Auth mounts under /api/auth and self-identifies its OAuth issuer
as ${APP_URL}/api/auth in the AS metadata document, but our protected-
resource metadata advertised the AS as the bare root ${APP_URL}.
Strict clients (which the Claude.ai connector turns out to be) walk
RFC 8414's metadata-URL construction rules from the advertised issuer
and validate that the discovered document's issuer claim matches the
one they used to discover it. With our previous PRM, the discovered
issuer (.../api/auth) didn't match the advertised one (root), so a
spec-compliant client would either bail or get into a confused state.
Align PRM with what the AS actually reports. Path-aware metadata route
lands in the next commit so RFC 8414 discovery from this issuer string
resolves cleanly.
Co-authored-by: Claude <81847+claude@users.noreply.github.com>
…/auth
RFC 8414 says when the issuer URL has a path component, the metadata
discovery URL is constructed by inserting "/.well-known/oauth-authorization-server"
between the host and the path. Our issuer is ${APP_URL}/api/auth, so
the canonical metadata location is
${APP_URL}/.well-known/oauth-authorization-server/api/auth — and now
the PRM's authorization_servers entry points clients there.
The bare-root route stays in place for back-compat. Same handler at
both mount points; oauthProviderAuthServerMetadata(auth) just delegates
to auth.api.getOAuthServerConfig and doesn't care where it's exposed.
Co-authored-by: Claude <81847+claude@users.noreply.github.com>
The plugin warning was telling us to expose the AS metadata at the path-aware location (.../api/auth) and we silenced it instead. The previous commit actually adds that route, so the warning is now satisfied at the source — drop the silencer. openidConfig stays silenced; OIDC discovery isn't the focus of this fix and most OAuth-only clients (including Claude.ai) don't reach for it. Co-authored-by: Claude <81847+claude@users.noreply.github.com>
Better Auth issues JWTs with iss = baseURL + basePath = ${APP_URL}/api/auth
(see its jwt plugin's signing path: defaultIss = options?.jwt?.issuer
?? baseURLOrigin, and the OAuth provider uses Better Auth's baseURL-
derived issuer). We were verifying against the bare ${APP_URL}, so even
once Claude.ai got past discovery and obtained an audience-bound JWT
the very next /api/mcp POST would have been rejected by jose's iss
check and the user would have seen a token-loop.
Export a MCP_ISSUER constant so tests can reuse it. The existing
"wrong-issuer" test still uses an explicit bogus value and continues
to assert rejection.
Co-authored-by: Claude <81847+claude@users.noreply.github.com>
Three new tests that drive the route handler directly: - GET /api/mcp with no Authorization → 401 + WWW-Authenticate - DELETE /api/mcp with no Authorization → 401 + WWW-Authenticate - GET /api/mcp with a valid service token → 405 + Allow: POST The challenge tests would have caught the original regression (Claude.ai discovery probe getting a 405 with no resource_metadata pointer). The 405 test guards the leak-fix invariant: once a request is past auth, we still refuse GET so the stateless McpServer instance doesn't get pinned by a long-lived SSE stream. Co-authored-by: Claude <81847+claude@users.noreply.github.com>
|
Working on this PR… |
AGENTS.md forbids multi-line comment blocks. The explanations in app/api/mcp/route.ts and app/.well-known/oauth-authorization-server/api/auth/route.ts both ran 3-4 lines. Collapsed each to a single comment. Caught in self-review on PR #8. Co-authored-by: Claude <81847+claude@users.noreply.github.com>
|
Working on this PR… |
There was a problem hiding this comment.
Summary
This PR fixes three layered OAuth discovery bugs that prevented Claude.ai's custom connector form from discovering the MCP endpoint: (1) GET/DELETE on /api/mcp now return 401 with WWW-Authenticate challenge instead of a bare 405, (2) the protected-resource metadata now advertises the path-correct authorization server URL (/api/auth), and (3) the JWT verifier now expects the canonical issuer ${APP_URL}/api/auth matching what Better Auth produces. A new path-aware AS metadata route is added per RFC 8414, and the corresponding plugin warning suppression is removed. Tests cover all new behavior paths.
One P2 maintainability finding: the MCP_RESOURCE audience constant is independently defined in two modules with no shared source of truth, creating a silent breakage risk if the path ever changes. Otherwise the diff is clean, each fix is at the right layer, and the leak-fix invariant (authenticated GET/DELETE still return 405) is preserved.
Mergeability Score: 4/5
Likely mergeable after reviewing the flagged low-risk comments.
| @@ -8,6 +8,7 @@ import { resolveServiceToken } from "@/lib/service-tokens"; | |||
|
|
|||
| const defaultJwks = createRemoteJWKSet(new URL(`${env.APP_URL}/api/auth/jwks`)); | |||
| export const MCP_RESOURCE = `${env.APP_URL}/api/mcp`; | |||
There was a problem hiding this comment.
Duplicated MCP resource constant creates hidden audience-mismatch risk
The string ${env.APP_URL}/api/mcp is defined independently as MCP_RESOURCE in both lib/mcp/auth.ts (line 10, used for JWT audience verification) and lib/auth.ts (line 11, used for validAudiences in the oauthProvider config that controls which audiences are accepted when issuing tokens). If the MCP endpoint path changes and only one copy is updated, JWT authentication silently breaks — Better Auth issues tokens with one audience but jwtVerify rejects them against the stale audience, and the only diagnostic is a [mcp] jwt verify failed log warning with no stack trace to the root cause.
Import MCP_AUDIENCE from @/lib/auth in this module and use it in place of the local MCP_RESOURCE for the audience parameter. No circular dependency exists between the two modules.
There was a problem hiding this comment.
Summary
Security review of OAuth discovery fixes for the MCP endpoint. Reviewed: (1) GET/DELETE on /api/mcp now emit a 401 + WWW-Authenticate challenge instead of a bare 405, enabling OAuth discovery; (2) protected resource metadata now advertises the correct authorization server URL (/api/auth); (3) JWT issuer verification now matches Better Auth's actual issuer; (4) new RFC 8414 path-aware auth server metadata route. No security findings: the authenticate() call added to GET/DELETE reuses the same verified function as POST, the WWW-Authenticate header leaks no secrets, and the issuer check was tightened (not loosened). Safe to merge.
Mergeability Score: 5/5
Safe to merge from this review's perspective.
The string \${env.APP_URL}/api/mcp was defined independently in two
places: lib/auth.ts (the value passed to oauthProvider's validAudiences
when minting tokens) and lib/mcp/auth.ts (the value passed to jose's
audience param when verifying them). If the MCP endpoint path ever
changes and only one site is updated, Better Auth issues tokens with
one audience while the verifier rejects them against the stale one —
silent failure with no diagnostic beyond [mcp] jwt verify failed.
Import MCP_AUDIENCE from lib/auth in the verifier and alias the
existing MCP_RESOURCE export to it so test imports stay valid without
having to touch test files. No circular dep — lib/auth doesn't import
lib/mcp/auth.
Reported in enkii's review on PR #8.
Co-authored-by: Claude <81847+claude@users.noreply.github.com>
|
Working on this PR… |
There was a problem hiding this comment.
Summary
This PR fixes OAuth discovery for MCP web connector clients by addressing three layered issues: (1) adds the RFC 8414 path-aware AS metadata route at /.well-known/oauth-authorization-server/api/auth, (2) corrects the JWT iss verification from env.APP_URL to ${env.APP_URL}/api/auth matching Better Auth's actual issuer, and (3) makes unauthenticated GET/DELETE on /api/mcp return a 401 OAuth challenge instead of 405. The MCP_RESOURCE/MCP_AUDIENCE duplication is eliminated. All cross-references — issuer, audience, WWW-Authenticate header, protected-resource metadata, and AS metadata — are mutually consistent. Tests cover the new route behavior and updated JWT minting. No findings. Safe to merge.
Mergeability Score: 5/5
Safe to merge from this review's perspective.
There was a problem hiding this comment.
Summary
Security review of OAuth discovery and JWT verification fixes for the MCP endpoint. Changes are security-positive: JWT iss verification is tightened to match Better Auth's actual issuer (${APP_URL}/api/auth), the duplicated audience constant is unified via MCP_AUDIENCE import, GET/DELETE now emit proper OAuth challenge responses per RFC 9728, and the RFC 8414 path-aware authorization server metadata route is added. No exploitable vulnerabilities introduced — auth bypass, injection, SSRF, IDOR, and information disclosure paths were all traced and ruled out. Safe to merge.
Mergeability Score: 5/5
Safe to merge from this review's perspective.
Summary
Claude.ai's "Add custom connector" form failed against the live MCP endpoint with "Couldn't reach the MCP server." Live probes against the deployment surfaced three layered issues in the OAuth discovery + JWT verification path. All three are fixed; the route handler, the protected-resource metadata, the path-aware AS metadata, and the JWT verifier are now mutually consistent and discoverable from a single unauthenticated request.
What's Included
app/api/mcp/route.ts— unauthenticated GET/DELETE now return the same401 + WWW-Authenticate: Bearer resource_metadata=…challenge as POST. Authenticated GET/DELETE still return405 Method Not Allowedso the leak-fix invariant (no long-lived SSE streams pinning per-requestMcpServerinstances) is preserved.app/.well-known/oauth-protected-resource/route.ts—authorization_serversnow advertises${APP_URL}/api/auth, matching what Better Auth actually reports as itsissuerin the AS metadata document.app/.well-known/oauth-authorization-server/api/auth/route.ts— new path-aware route, the canonical metadata URL per RFC 8414's discovery rules when the issuer URL has a path component. Same handler as the bare-root route (oauthProviderAuthServerMetadata(auth)), which stays in place for back-compat.lib/auth.ts— dropsilenceWarnings.oauthAuthServerConfig; the plugin warning was telling us to add exactly the path-aware route above.lib/mcp/auth.ts— verify JWTissagainst${APP_URL}/api/auth(matches what Better Auth'sjwt()plugin produces viadefaultIss = baseURLOrigin + basePath), and verifyaudagainst the canonicalMCP_AUDIENCEimported fromlib/auth.ts(was a separate${env.APP_URL}/api/mcpliteral). Exports a newMCP_ISSUERconstant for reuse.Tests
bun run lint: passedbun run typecheck: passedbun test: passed — 112/112 (3 new route-level tests for the GET/DELETE challenge + authenticated 405 path; existing tests updated to mint JWTs with the new canonical issuer)bun run build: passedTest Plan
Curl reproductions against the deployment after this lands:
curl -i GET /api/mcp→ expect401withWWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource", scope="mcp". Previously405 Allow:POSTwith no challenge header.curl -i POST /api/mcp→ expect401with sameWWW-Authenticate. Unchanged.curl -i /.well-known/oauth-protected-resource→ JSON;authorization_serversis now["…/api/auth"].curl -i /.well-known/oauth-authorization-server/api/auth→ 200 JSON withissuer == "…/api/auth", matching what the metadata self-declares.curl -i /.well-known/oauth-authorization-server→ still 200 (back-compat).End-to-end:
${APP_URL}/api/mcp. Walk DCR → sign-in → consent → tool call. Inspect a server log to confirmlib/mcp/auth.tsaccepts the issued JWT (no[mcp] jwt verify failedwarn) and the tool call is scoped to the consented org.Local Review Findings
${env.APP_URL}/api/mcpwas defined independently inlib/auth.ts(for issuing tokens) andlib/mcp/auth.ts(for verifying them) — a divergence between the two would silently fail every JWT with no diagnostic beyond[mcp] jwt verify failed. The verifier now importsMCP_AUDIENCEfromlib/auth.tsas the single source of truth and aliases the existingMCP_RESOURCEexport to it (no test-file changes needed).