Files
worldmonitor/todos/076-complete-p1-mcp-auth-failure-must-return-http-401.md
Elie Habib 14a31c4283 feat(mcp): OAuth 2.0 Authorization Server for claude.ai connector (#2418)
* 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>
2026-03-28 14:53:32 +04:00

5.1 KiB

status, priority, issue_id, tags, dependencies
status priority issue_id tags dependencies
complete p1 076
code-review
security
oauth
mcp
agent-native

MCP auth failures must return HTTP 401 not HTTP 200

Problem Statement

rpcError in api/mcp.ts always returns HTTP 200, including for authentication errors (-32001). RFC 6750 requires HTTP 401 + WWW-Authenticate: Bearer header on auth failures. Claude.ai's OAuth connector watches for HTTP 401 to trigger token re-authentication. With HTTP 200, an expired token causes a silent tool failure that agents cannot self-heal from.

Findings

  • api/mcp.ts:379rpcError hardcodes 200 as HTTP status for all JSON-RPC errors
  • api/mcp.ts:439,443 — auth failure returns -32001 via rpcError → HTTP 200
  • Agent-native reviewer: "Must fix — HTTP 200 on auth errors blocks automatic re-auth loop"
  • RFC 6750 §3.1: server MUST return HTTP 401 + WWW-Authenticate: Bearer realm=..., error=... on token errors
  • claude.ai connector specifically monitors HTTP 401 to re-fetch an OAuth token
  • A client sending only Authorization: Bearer <expired> gets 200 + JSON error — indistinguishable from a tool result to non-parsing callers

Proposed Solutions

Option 1: Special-case -32001 in rpcError

Approach: Add an optional httpStatus parameter to rpcError, default 200. Callers that pass auth errors explicitly set 401. In the handler, when rpcError(null, -32001, ...) is called via the auth chain, construct the response manually with 401 + WWW-Authenticate header.

Pros:

  • Minimal change — only auth errors get 401
  • Other JSON-RPC errors stay HTTP 200 (correct per JSON-RPC spec)
  • Clean separation

Cons:

  • Two call sites for auth errors (lines 439 + 443)

Effort: 30 minutes

Risk: Low


Option 2: Detect Bearer presence and return proper 401

Approach: After Bearer lookup fails (returns null), if a Bearer header was present, return a proper HTTP 401 response immediately (not via rpcError). This distinguishes "token expired/invalid" from "no credentials at all".

const bearerHeader = req.headers.get('Authorization');
const bearerApiKey = await resolveApiKeyFromBearer(req);
if (bearerHeader?.startsWith('Bearer ') && !bearerApiKey) {
  return new Response(
    JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Invalid or expired token' } }),
    { status: 401, headers: { 'WWW-Authenticate': 'Bearer realm="worldmonitor", error="invalid_token"', ...corsHeaders } }
  );
}

Pros:

  • Correctly distinguishes expired token from missing credentials
  • WWW-Authenticate header tells clients exactly what to do
  • claude.ai re-auth loop fires on the right condition

Cons:

  • Slightly more code in the auth chain

Effort: 1 hour

Risk: Low

Use Option 2. Distinguishing "Bearer present but invalid" from "no auth" is important for agent self-healing. The WWW-Authenticate header is RFC 6750 mandatory and claude.ai uses it to trigger re-auth.

Technical Details

Affected files:

  • api/mcp.ts:429-446 — auth chain section

RFC references:

  • RFC 6750 §3.1 — The use of Bearer tokens: WWW-Authenticate: Bearer realm="..." required
  • RFC 6750 §3.1 — error="invalid_token" for expired/revoked tokens, error="invalid_request" for malformed header

Resources

  • PR: #2418
  • Agent-native finding: CRITICAL (agent-native-reviewer)
  • Security finding: related to H-4 (RFC compliance)

Acceptance Criteria

  • POST /mcp with expired/unknown Bearer token returns HTTP 401 (not 200)
  • HTTP 401 response includes WWW-Authenticate: Bearer realm="worldmonitor", error="invalid_token"
  • POST /mcp with no credentials returns HTTP 200 with JSON-RPC -32001 error (existing behavior for non-OAuth clients)
  • POST /mcp with valid Bearer token works normally
  • curl test confirms: curl -si -X POST /mcp -H "Authorization: Bearer invalid" | head -5 shows HTTP/1.1 401

Work Log

2026-03-28 — Code Review Discovery

By: Claude Code (compound-engineering:ce-review)

Actions:

  • Agent-native reviewer flagged as CRITICAL for agent re-auth loop
  • Security sentinel independently flagged RFC 6750 non-compliance
  • Identified rpcError always returns 200 as the root cause

2026-03-28 — Partial Fix Applied (commit a2cf0df3b)

Bearer-present-but-invalid path now returns 401:

} else if (bearerHeader.startsWith('Bearer ')) {
  return new Response(
    JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32001, message: '...' } }),
    { status: 401, headers: { 'WWW-Authenticate': 'Bearer realm="worldmonitor", error="invalid_token"', ...corsHeaders } }
  );

Still pending: The "no credentials at all" path and "invalid direct key" path still return HTTP 200 via rpcError. For claude.ai OAuth clients specifically this is acceptable (they always send a Bearer header), but it is a RFC 6750 non-compliance for any client that calls the endpoint without any auth. Full fix requires either: (a) special-casing -32001 in rpcError to return 401, or (b) manually constructing the 401 response for the "no candidateKey" and "invalid candidateKey" branches.