* feat(mcp): add OAuth 2.0 Authorization Server for claude.ai connector Implements spec-compliant MCP authentication so claude.ai's remote connector (which requires OAuth Client ID + Secret, no custom headers) can authenticate. - public/.well-known/oauth-authorization-server: RFC 8414 discovery document - api/oauth/token.js: client_credentials grant, issues UUID Bearer token in Redis TTL 3600s - api/_oauth-token.js: resolveApiKeyFromBearer() looks up token in Redis - api/mcp.ts: 3-tier auth (Bearer OAuth first, then ?key=, then X-WorldMonitor-Key); switch to getPublicCorsHeaders; surface error messages in catch - vercel.json: rewrite /oauth/token, exclude oauth from SPA, CORS headers - tests: update SPA no-cache pattern Supersedes PR #2417. Usage: URL=worldmonitor.app/mcp, Client ID=worldmonitor, Client Secret=<API key> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: fix markdown lint in OAuth plan (blank lines around lists) * fix(oauth): address all P1+P2 code review findings for MCP OAuth endpoint - Add per-IP rate limiting (10 req/min) to /oauth/token via Upstash slidingWindow - Return HTTP 401 + WWW-Authenticate header when Bearer token is invalid/expired - Add Cache-Control: no-store + Pragma: no-cache to token response (RFC 6749 §5.1) - Simplify _oauth-token.js to delegate to readJsonFromUpstash (removes duplicated Redis boilerplate) - Remove dead code from token.js: parseBasicAuth, JSON body path, clientId/issuedAt fields - Add Content-Type: application/json header for /.well-known/oauth-authorization-server - Remove response_types_supported (only applies to authorization endpoint, not client_credentials) Closes: todos 075, 076, 077, 078, 079 🤖 Generated with claude-sonnet-4-6 via Claude Code (https://claude.ai/claude-code) + Compound Engineering v2.40.0 Co-Authored-By: claude-sonnet-4-6 (200K context) <noreply@anthropic.com> * chore(review): fresh review findings — todos 081-086, mark 075/077/078/079 complete * fix(mcp): remove ?key= URL param auth + mask internal errors - Remove ?key= query param auth path — API keys in URLs appear in Vercel/CF access logs, browser history, Referer headers. OAuth client_credentials (same PR) already covers clients that cannot set custom headers. Only two auth paths remain: Bearer OAuth and X-WorldMonitor-Key header. - Revert err.message disclosure: catch block was accidentally exposing internal service URLs/IPs via err.message. Restore original hardcoded string, add console.error for server-side visibility. Resolves: todos 081, 082 * fix(oauth): resolve all P2/P3 review findings (todos 076, 080, 083-086) - 076: no-credentials path in mcp.ts now returns HTTP 401 + WWW-Authenticate instead of rpcError (200) - 080: store key fingerprint (sha256 first 16 hex chars) in Redis, not plaintext key - 083: replace Array.includes() with timingSafeIncludes() (constant-time HMAC comparison) in token.js and mcp.ts - 084: resolveApiKeyFromBearer uses direct fetch that throws on Redis errors (500 not 401 on infra failure) - 085: token.js imports getClientIp, getPublicCorsHeaders, jsonResponse from shared helpers; removes local duplicates - 086: mcp.ts auth chain restructured to check Bearer header first, passes token string to resolveApiKeyFromBearer (eliminates double header read + unconditional await) * test(mcp): update auth test to expect HTTP 401 for missing credentials Align with todo 076 fix: no-credentials path now returns 401 + WWW-Authenticate instead of JSON-RPC 200 response. Also asserts WWW-Authenticate header presence. * chore: mark todos 076, 080, 083-086 complete * fix(mcp): harden OAuth error paths and fix rate limit cross-user collision - Wrap resolveApiKeyFromBearer() in try/catch in mcp.ts; Redis/network errors now return 503 + Retry-After: 5 instead of crashing the handler - Wrap storeToken() fetch in try/catch in oauth/token.js; network errors return false so the existing if (!stored) path returns 500 cleanly - Re-key token endpoint rate limit by sha256(clientSecret).slice(0,8) instead of IP; prevents cross-user 429s when callers share Anthropic's shared outbound IPs (Claude remote MCP connector) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
3.7 KiB
status, priority, issue_id, tags, dependencies
| status | priority | issue_id | tags | dependencies | ||||
|---|---|---|---|---|---|---|---|---|
| complete | p1 | 081 |
|
Remove ?key= URL query param auth path — API key exposed in access logs
Problem Statement
PR #2418 added ?key= as a third auth fallback in api/mcp.ts for "clients that cannot set headers." This is a security regression: API keys in URL query parameters are logged verbatim by Vercel access logs, Cloudflare access logs, browser history, proxy logs, and Referer headers. Unlike HTTP headers, URL params cannot be stripped at the transport layer. The OAuth client_credentials flow this same PR introduces already solves the "no headers" use case, making ?key= have no remaining justified use case.
Findings
api/mcp.ts:442—const urlKey = new URL(req.url).searchParams.get('key') ?? '';— new path added in this PR- Security sentinel: "This is a new attack surface introduced specifically by this PR. The original
mcp.tsused header-only auth." - Architecture strategist: "The correct solution for clients that cannot set headers is the OAuth flow this PR already provides."
- Pre-PR auth used
validateApiKey(req, { forceKey: true })which was header-only - Any MCP request to
/mcp?key=wm_live_xxxxxpermanently records the API key in Vercel + CF logs - Tool request URLs show up in browser devtools and Referer headers on redirect
Proposed Solutions
Option 1: Remove the ?key= path (recommended)
Delete the urlKey line and its usage. Direct clients that cannot set custom headers to use the OAuth flow (POST /oauth/token → Authorization: Bearer).
// Remove this:
const urlKey = new URL(req.url).searchParams.get('key') ?? '';
const headerKey = req.headers.get('X-WorldMonitor-Key') ?? '';
const candidateKey = urlKey || headerKey;
// Replace with:
const candidateKey = req.headers.get('X-WorldMonitor-Key') ?? '';
Pros: Eliminates credential-in-URL leakage. OAuth already handles the "no custom headers" use case.
Cons: Any existing client using ?key= URL param breaks. (No known clients per PR description.)
Effort: Small (2 lines)
Risk: Low — no documented clients depend on ?key= per PR description.
Option 2: Keep ?key= but gate behind env flag
Add WORLDMONITOR_ALLOW_KEY_QUERY_PARAM=true env var; only enable the ?key= path if explicitly opted in.
Pros: Backward compat if any undocumented client uses it. Cons: Still allows the security risk to exist in production; adds env config complexity. Effort: Small Risk: Low
Option 3: Log a deprecation warning but keep the path
Return the response but add a header like Warning: 299 - "API key in URL is deprecated; use Authorization: Bearer".
Pros: Non-breaking, signals deprecation. Cons: Does not fix the log-exposure problem. Effort: Small Risk: Low (but doesn't fix the actual issue)
Recommended Action
Option 1: Remove immediately. OAuth covers the stated use case. No documented clients depend on ?key=.
Technical Details
Affected files:
api/mcp.ts:440-451— removeurlKeyand changeconst candidateKey = urlKey || headerKeytoconst candidateKey = req.headers.get('X-WorldMonitor-Key') ?? ''
Acceptance Criteria
?key=URL parameter is not present inapi/mcp.tsauth chainX-WorldMonitor-Keyheader path still works- OAuth Bearer path still works
- No test regressions
Work Log
2026-03-28 — Code Review Discovery
By: Claude Code (compound-engineering:ce-review)
Actions:
- Security sentinel and architecture strategist independently flagged as P1 regression
- Pattern confirmed: OAuth
client_credentials(added in same PR) covers the stated use case - No known clients depend on
?key=URL param per PR description