Files
worldmonitor/todos/078-complete-p2-oauth-token-simplification-remove-dead-code.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.2 KiB

status, priority, issue_id, tags, dependencies
status priority issue_id tags dependencies
complete p2 078
code-review
quality
oauth
simplification

OAuth token files: remove dead code and simplify (parseBasicAuth, JSON body, clientId/issuedAt)

Problem Statement

api/oauth/token.js and api/_oauth-token.js contain ~60% dead or over-engineered code relative to their actual use case (claude.ai client_credentials with form-encoded body). The code-simplicity reviewer identified three YAGNI violations that add maintenance surface without any real-world value.

Findings

  • api/oauth/token.js:43-54parseBasicAuth (12 lines) is never used by any known client. Claude.ai uses client_secret_post form encoding only.
  • api/oauth/token.js:56-76parseBody JSON branch (20 lines). No OAuth 2.0 client sends application/json to a token endpoint (spec uses form-encoded). Zero real-world callers.
  • api/oauth/token.js:33clientId and issuedAt stored in Redis but never read back by _oauth-token.js. Pure dead payload in Redis.
  • api/_oauth-token.js:6-26 — 28 lines duplicating _upstash-json.js Redis boilerplate. Can be replaced with:
import { readJsonFromUpstash } from './_upstash-json.js';
// ...
const entry = await readJsonFromUpstash(`oauth:token:${token}`);
return entry?.apiKey ?? null;
  • Discovery doc advertises only client_secret_post and client_secret_basic — but client_secret_basic is only supported by the dead parseBasicAuth code path
  • Simplicity reviewer: estimated ~57 LOC reduction (80 → 23 across both files)

Proposed Solutions

Option 1: Remove all dead code in one pass

Approach:

  1. Delete parseBasicAuth function and the Basic-auth branch in handler
  2. Replace parseBody with 3 inline lines: const params = new URLSearchParams(await req.text()); const grantType = params.get('grant_type'); const clientSecret = params.get('client_secret');
  3. Remove clientId and issuedAt from storeToken — store only the apiKey string as the Redis value (eliminates JSON.stringify/JSON.parse round-trip)
  4. Rewrite _oauth-token.js to delegate to readJsonFromUpstash:
    import { readJsonFromUpstash } from './_upstash-json.js';
    export async function resolveApiKeyFromBearer(req) {
      const hdr = req.headers.get('Authorization') || '';
      if (!hdr.startsWith('Bearer ')) return null;
      const token = hdr.slice(7).trim();
      if (!token) return null;
      const apiKey = await readJsonFromUpstash(`oauth:token:${token}`);
      return typeof apiKey === 'string' && apiKey ? apiKey : null;
    }
    
  5. Update discovery doc to remove client_secret_basic from token_endpoint_auth_methods_supported since Basic auth is no longer supported

Pros:

  • ~57 LOC reduction
  • _oauth-token.js now reuses existing tested code instead of duplicating Redis boilerplate
  • Redis stores apiKey as plain string (faster GET, no JSON parse)
  • Stored value validates: typeof apiKey === 'string' && apiKey guards against corrupted entries

Cons:

  • Removes client_secret_basic support — fine since no client uses it
  • Need to verify readJsonFromUpstash can return a plain string (not just an object)

Effort: 2-3 hours

Risk: Low (removing unused code paths)


Option 2: Keep Basic auth, only simplify storage

Approach: Keep parseBasicAuth and JSON body branch, but simplify Redis storage (plain string) and rewrite _oauth-token.js to use readJsonFromUpstash.

Pros: More spec coverage

Cons: Keeps ~35 lines of dead code

Effort: 1 hour

Risk: Low

Option 1. The dead code provides no value and creates maintenance burden. The simplification of _oauth-token.js is particularly important — it currently duplicates the Redis boilerplate that _upstash-json.js already handles, including the encodeURIComponent question (which goes away since readJsonFromUpstash handles it).

Technical Details

Affected files:

  • api/oauth/token.js — remove parseBasicAuth, simplify parseBody, simplify storage
  • api/_oauth-token.js — rewrite to delegate to readJsonFromUpstash
  • api/_upstash-json.js — verify it handles plain string values (currently used for JSON objects)
  • public/.well-known/oauth-authorization-server — remove client_secret_basic from auth methods if Basic is removed

Resources

  • PR: #2418
  • Simplicity finding: code-simplicity-reviewer (high confidence)
  • TS finding: item #5 (encodeURIComponent on Redis key fixed by using readJsonFromUpstash)

Acceptance Criteria

  • api/_oauth-token.js delegates to readJsonFromUpstash (no duplicate Redis boilerplate)
  • resolveApiKeyFromBearer validates returned value is a non-empty string before returning
  • Redis stores apiKey as plain string (verify with redis-cli GET oauth:token:<uuid>)
  • Token issuance and Bearer resolution end-to-end still work
  • Tests pass

Work Log

2026-03-28 — Code Review Discovery

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

Actions:

  • Code-simplicity-reviewer identified 3 YAGNI violations and estimated 57 LOC reduction
  • TS reviewer independently noted encodeURIComponent risk (fixed by this simplification)
  • Architecture reviewer noted clientId/issuedAt are dead payload