2 Commits

Author SHA1 Message Date
Elie Habib
51f7b7cf6d feat(mcp): full OAuth 2.1 compliance — Authorization Code + PKCE + DCR (#2432)
* feat(mcp): full OAuth 2.1 compliance — Authorization Code + PKCE + DCR

Adds the complete OAuth 2.1 flow required by claude.ai (and any MCP
2025-03-26 client) on top of the existing client_credentials path.

New endpoints:
- POST /oauth/register — Dynamic Client Registration (RFC 7591)
  Strict allowlist: claude.ai/claude.com callbacks + localhost only.
  90-day sliding TTL on client records (no unbounded Redis growth).

- GET/POST /oauth/authorize — Consent page + authorization code issuance
  CSRF nonce binding, X-Frame-Options: DENY, HTML-escapes all metadata,
  shows exact redirect hostname, Origin validation (must be our domain).
  Stores full SHA-256 of API key (not fingerprint) in auth code.

- authorization_code + refresh_token grants in /oauth/token
  Both use GETDEL for atomic single-use consumption (no race condition).
  Refresh tokens carry family_id for future family invalidation.
  Returns 401 invalid_client when DCR client is expired (triggers re-reg).

Security improvements:
- verifyPkceS256() validates code_verifier format before any SHA-256 work;
  returns null=invalid_request vs false=invalid_grant.
- Full SHA-256 (64 hex) stored for new OAuth tokens; legacy
  client_credentials keeps 16-char fingerprint (backward compat).
- Discovery doc: only authorization_code + refresh_token advertised.
- Protected resource metadata: /.well-known/oauth-protected-resource
- WWW-Authenticate headers include resource_metadata param.
- HEAD /mcp returns 200 (Anthropic probe compatibility).
- Origin validation on POST /mcp: claude.ai/claude.com + absent allowed.
- ping method + tool annotations (readOnlyHint, openWorldHint).
- api/oauth/ subdir added to edge-function module isolation scan.

* fix(oauth): distinguish Redis unavailable from key-miss; fix retry nonce; extend client TTL on token use

P1: redisGetDel and redisGet in token.js and authorize.js now throw on
transport/HTTP errors instead of swallowing them as null. Callers catch
and return 503 so clients know to retry, not discard valid codes/tokens.
Key-miss (result=null from Redis) still returns null as before.

P2a: Invalid API key retry path now generates and stores a fresh nonce
before re-rendering the consent form. Previously the new nonce was never
persisted, causing the next submit to fail with "Session Expired" and
forcing the user to restart the entire OAuth flow on a single typo.

P2b: token.js now extends the client TTL (EXPIRE, fire-and-forget) after
a successful client lookup in both authorization_code and refresh_token
paths. CLIENT_TTL_SECONDS was defined but unused — clients that only
refresh tokens would expire after 90 days despite continuous use.

* fix(oauth): atomic nonce consumption via GETDEL; fail closed on nonce storage failure

P2a: Nonce storage result is now checked before rendering the consent page
(both initial GET and invalid-key retry path). If redisSet returns false
(storage unavailable), we return a 503-style error page instead of
rendering a form the user cannot submit successfully.

P2b: CSRF nonce is now consumed atomically via GETDEL instead of a
read-then-fire-and-forget-delete. Two concurrent POST submits can no
longer both pass validation before the delete lands, and the delete is
no longer vulnerable to edge runtime isolate teardown.
2026-03-28 18:40:53 +04:00
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