T14: /mcp behind workers-oauth-provider (DCR + consent) #13

Merged
dzenda merged 70 commits from t14-mcp-oauth into t13-aggregates 2026-11-14 17:50:15 +01:00
Owner

Stacked on #12 (t13-aggregates) → … → #1. Merge those first.

Architecture

  • Custom Worker entry src/server.ts: OAuthProvider in front of TanStack Start. wrangler.jsonc main now points here.
    • /mcp with a valid MCP access token → MCP handler.
    • /oauth/token, /oauth/register (dynamic client registration), /.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource/mcp (RFC 9728) → the provider.
    • Everything else (website, /authorize, Better Auth) → TanStack.
  • Login linking: /authorize requires the normal website session (FIT OAuth via Better Auth, T6). Anonymous users go to /login?next=<full authorize URL> and come back after the FIT login.
    • Approving the consent page calls completeAuthorization with userId = our users.id and props { userId }. MCP calls therefore act as the same user, with that user's encrypted upstream tokens.
    • No upstream token is stored in the grant.
    • Because we reuse the website session, we don't need a second upstream login keyed by state. The single FIT callback (/auth/callback) stays Better Auth's.
  • Consent page (src/server/consent-page.ts, en/cs), built on the provider's describeConsent / beginConsent / approveConsent / denyConsent:
    • It shows the client name (flagged as unverified for DCR clients), the redirect host, and a warning for loopback redirects. Every client string is HTML-escaped.
    • The handle is bound to the browser, single use and expiring. On top of that, an HMAC binds it to the user it was shown to.
    • CSP is frame-ancestors 'none', default-src 'none', and form-action 'self' plus the validated redirect origin or scheme. X-Frame-Options: DENY. The POST requires the SameSite=Lax session.
    • Validated authorization errors (e.g. missing PKCE) redirect back to the client with error, state and iss. Unknown clients and bad redirect URIs are shown locally.
  • /mcp: the stateless createMcpHandler from agents/mcp/server with @modelcontextprotocol/server v2, one server per request. allowedHostnames is the PUBLIC_URL host plus localhost. No tools yet; T15 adds search/describe/read/call.
  • Open-redirect guard safeNext() for /login?next: parsed like a browser would, same origin only, re-checked after normalisation.
  • wrangler.jsonc:
    • OAUTH_KV binding (placeholder id; local only, no Cloudflare resources created).
    • New compat flag global_fetch_strictly_public, which restricts outbound fetch to the public internet (an SSRF guard) and enables Client ID Metadata Document clients.
  • Tests: an in-memory KV (test/helpers/kv.ts), cloudflare:* stubs aliased in vitest.config.ts for the agents package, and the fake-FIT login moved to test/helpers/auth.ts (shared with the T6 tests).

Verified

  • bun run lint && bun run typecheck && bun run test: 183 tests. 12 of them are new end-to-end MCP OAuth tests through the real OAuthProvider:
    • Happy path: 401 + WWW-Authenticate without a token; anonymous user → /login?next=…; the full register → FIT login → consent → code → token (PKCE) → MCP initialize chain. The grant is listed under our users.id, and KV holds no upstream tokens.
    • Rejections: wrong PKCE verifier and bogus token; deny → access_denied; POST without a session, from a different browser, with a bad decision, or with a reused handle; user switched in another tab; tampered binding.
    • Errors and redirects: error redirect for missing PKCE; unknown client and unregistered redirect URI; the form-action source; safeNext bypass vectors.
  • bun run dev + curl:
    • The health check and home page load. Both well-known documents and the AS metadata (S256 only) are correct, and /mcp without a token returns 401 with the right challenge.
    • DCR works. /authorize sends anonymous users to /login with the full next, an unknown client gets 400, and a missing-PKCE request redirects with invalid_request. Better Auth accepts /authorize?… as a post-login callbackURL.
  • Not yet tried in a real MCP client (ChatGPT or Claude). That's T16, which needs a public URL.

Astra review (gpt-6-astra), 2 rounds

Fixed:

  1. (P1) safeNext("/a/..//evil.example") normalised to //evil.example → re-checked after serialisation.
  2. (P1) form-action 'self' would make Chromium block the post-consent redirect to the client → the validated redirect origin or scheme is allowed.
  3. (P2) A consent page shown to user A could be approved after switching to user B → HMAC(handle, userId) binding, checked before the handle is consumed.
  4. (P2) Validated authorization errors were rendered locally → redirected to the client.

Round 2: "No remaining concrete defects found."

🤖 Generated with Claude Code

## T14: `/mcp` behind workers-oauth-provider (DCR, consent, grants bound to our users) **Stacked on #12 (`t13-aggregates`) → … → #1.** Merge those first. ### Architecture - **Custom Worker entry `src/server.ts`:** `OAuthProvider` in front of TanStack Start. `wrangler.jsonc` `main` now points here. - `/mcp` with a valid MCP access token → MCP handler. - `/oauth/token`, `/oauth/register` (dynamic client registration), `/.well-known/oauth-authorization-server` and `/.well-known/oauth-protected-resource/mcp` (RFC 9728) → the provider. - Everything else (website, `/authorize`, Better Auth) → TanStack. - **Login linking:** `/authorize` requires the normal **website session** (FIT OAuth via Better Auth, T6). Anonymous users go to `/login?next=<full authorize URL>` and come back after the FIT login. - Approving the consent page calls `completeAuthorization` with `userId` = our `users.id` and props `{ userId }`. MCP calls therefore act as the same user, with that user's encrypted upstream tokens. - No upstream token is stored in the grant. - Because we reuse the website session, we don't need a second upstream login keyed by `state`. The single FIT callback (`/auth/callback`) stays Better Auth's. - **Consent page** (`src/server/consent-page.ts`, en/cs), built on the provider's `describeConsent` / `beginConsent` / `approveConsent` / `denyConsent`: - It shows the client name (flagged as unverified for DCR clients), the redirect host, and a warning for loopback redirects. Every client string is HTML-escaped. - The handle is bound to the browser, single use and expiring. On top of that, an HMAC binds it to the **user it was shown to**. - CSP is `frame-ancestors 'none'`, `default-src 'none'`, and `form-action 'self'` plus the validated redirect origin or scheme. `X-Frame-Options: DENY`. The POST requires the SameSite=Lax session. - Validated authorization errors (e.g. missing PKCE) redirect back to the client with `error`, `state` and `iss`. Unknown clients and bad redirect URIs are shown locally. - **`/mcp`:** the stateless `createMcpHandler` from `agents/mcp/server` with `@modelcontextprotocol/server` v2, one server per request. `allowedHostnames` is the `PUBLIC_URL` host plus localhost. **No tools yet**; T15 adds `search`/`describe`/`read`/`call`. - **Open-redirect guard `safeNext()`** for `/login?next`: parsed like a browser would, same origin only, re-checked after normalisation. - `wrangler.jsonc`: - `OAUTH_KV` binding (placeholder id; local only, no Cloudflare resources created). - New compat flag `global_fetch_strictly_public`, which restricts outbound fetch to the public internet (an SSRF guard) and enables Client ID Metadata Document clients. - **Tests:** an in-memory KV (`test/helpers/kv.ts`), `cloudflare:*` stubs aliased in `vitest.config.ts` for the `agents` package, and the fake-FIT login moved to `test/helpers/auth.ts` (shared with the T6 tests). ### Verified - `bun run lint && bun run typecheck && bun run test`: 183 tests. 12 of them are new end-to-end MCP OAuth tests through the real `OAuthProvider`: - **Happy path:** 401 + `WWW-Authenticate` without a token; anonymous user → `/login?next=…`; the full **register → FIT login → consent → code → token (PKCE) → MCP `initialize`** chain. The grant is listed under our `users.id`, and KV holds no upstream tokens. - **Rejections:** wrong PKCE verifier and bogus token; deny → `access_denied`; POST without a session, from a different browser, with a bad decision, or with a reused handle; **user switched in another tab**; tampered binding. - **Errors and redirects:** error redirect for missing PKCE; unknown client and unregistered redirect URI; the `form-action` source; `safeNext` bypass vectors. - `bun run dev` + curl: - The health check and home page load. Both well-known documents and the AS metadata (S256 only) are correct, and `/mcp` without a token returns 401 with the right challenge. - DCR works. `/authorize` sends anonymous users to `/login` with the full `next`, an unknown client gets 400, and a missing-PKCE request redirects with `invalid_request`. Better Auth accepts `/authorize?…` as a post-login `callbackURL`. - Not yet tried in a real MCP client (ChatGPT or Claude). That's T16, which needs a public URL. ### Astra review (gpt-6-astra), 2 rounds **Fixed:** 1. *(P1)* `safeNext("/a/..//evil.example")` normalised to `//evil.example` → re-checked after serialisation. 2. *(P1)* `form-action 'self'` would make Chromium block the post-consent redirect to the client → the validated redirect origin or scheme is allowed. 3. *(P2)* A consent page shown to user A could be approved after switching to user B → HMAC(handle, userId) binding, checked before the handle is consumed. 4. *(P2)* Validated authorization errors were rendered locally → redirected to the client. Round 2: "No remaining concrete defects found." 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dzenda merged commit b62ccf58ec into t13-aggregates 2026-11-14 17:50:14 +01:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
dzenda/os!13
No description provided.