# Plan: Broker Authorization Code Grant ## Why The broker today advertises `device_code` + `refresh_token` only and ships a stub `/oauth/authorize` that returns `unsupported_response_type` (see `tools/demarkus-broker/internal/broker/oauth_authorize.go:39`). The stub was a probe: if Claude Code's MCP SDK pivoted to device flow on its own, we were done. Observation as of 2026-05-26: it does not — it opens the browser at the advertised `authorization_endpoint`, gets the JSON 400, and surfaces it as an opaque error to the user. The path forward called out in `oauth_authorize.go:23-28` is the one we now take: implement a real authorization-code grant inside the broker, mediating between the MCP client and the upstream IdP, while continuing to keep IdP details invisible to the MCP client. This is a follow-on to the universe-onboarding sequence (PR3/PR4/PR5 + DCR PR #153). Same layering, same constraints: no protocol or server changes; all work in `tools/demarkus-broker/`. The MCP client only ever sees the broker as its OAuth AS; the IdP only ever sees the broker as its OAuth client. ## Goal After this plan ships, the following sequence works end-to-end with stock Claude Code: 1. User adds the broker as an MCP server (via `/knowledge-join` or `claude mcp add`). 2. Claude Code's MCP SDK fetches the broker's discovery doc, sees `authorization_code` in `grant_types_supported`, and starts an auth-code flow. 3. SDK hits `/oauth/authorize?response_type=code&client_id=...&code_challenge=...&code_challenge_method=S256&redirect_uri=http://localhost:PORT/callback&state=...&scope=...`. 4. Broker redirects through `/auth/login` → IdP → `/auth/callback`, exactly as the device-flow shim does today. 5. Broker mints an authorization code, redirects to `http://localhost:PORT/callback?code=...&state=...`. 6. SDK POSTs to the token endpoint with `grant_type=authorization_code` + `code` + `code_verifier`. Broker validates PKCE, issues access_token + id_token + refresh_token (broker-signed for both). 7. SDK uses the access_token as a bearer on all subsequent MCP gateway calls. Refresh via the existing `refresh_token` grant. The device-flow surface (`/device/*`) stays — it is the right shape for `tools/demarkus-join` and for any non-browser agent. Auth-code is added alongside, not in place of. ## Non-Negotiables - **PKCE required.** `code_challenge_method=S256` only. No `plain`. No bare auth-code flow without PKCE. OAuth 2.1 + the MCP authorization spec both require this; we have no legacy client to coddle. - **redirect_uri MUST be loopback per RFC 8252.** Accept `http://127.0.0.1:PORT/...` and `http://localhost:PORT/...` with any port. Reject everything else — no public redirect URIs, no `urn:ietf:wg:oauth:2.0:oob`, no custom URI schemes (those belong to native mobile clients which are not in scope). The DCR endpoint already rubber-stamps any client; the redirect_uri restriction is the only meaningful constraint on where the code lands. - **No client authentication at /token for auth-code.** Public clients per OAuth 2.1; PKCE is the binding. Matches the device-flow surface which is also unauthenticated by client_id. - **One-shot codes.** Each authorization code is single-use; second use revokes the refresh token issued from it (defense-in-depth per OAuth 2.1 §4.1.3). - **No silent IdP details leaking.** The broker is still the only OAuth AS the MCP client sees. IdP-specific scopes, claims, redirect rules all stay internal to the broker. - **No core changes.** Same precedent as the universe-onboarding work: every change lives under `tools/demarkus-broker/`. ## Architecture The auth-code flow reuses 80% of the device-flow machinery already in `device.go`/`oidc.go`/`server.go`. The differences are: | Aspect | Device flow (existing) | Auth-code flow (new) | | --- | --- | --- | | Entry point | `POST /device/authorize` returns codes + URLs | `GET /oauth/authorize` 302s to `/auth/login` | | User-side rendezvous | Browser hits `/device`, enters user_code | Browser is already at the right URL | | State persistence | `deviceStore.Authorize()` mints + stores | `authCodeStore.Begin()` mints + stores | | Cookie/state binding | `deviceCookieName` cookie + signed `state.DeviceCode` | Signed `state.AuthCodeID` (no separate cookie; the auth-code id is set inside the signed state during /oauth/authorize) | | Callback dispatch | `state.DeviceCode != ""` → `deviceCallback` | `state.AuthCodeID != ""` → `authCodeCallback` | | Code delivery | Polling at `/device/token` | 302 to `redirect_uri?code=...&state=...` | | Token exchange | `grant_type=urn:ietf:params:oauth:grant-type:device_code` | `grant_type=authorization_code` (new branch in `deviceToken` dispatch) | | PKCE | N/A | `code_challenge` stored at /oauth/authorize, `code_verifier` checked at /token | ### Store shape New file `auth_code_store.go`. Two indices, both in-memory with a janitor goroutine like `deviceStore`: - `pending` (keyed by internal opaque id): the in-flight grant before the user authenticates. Holds `client_id`, `redirect_uri`, `state` (client-supplied), `scope`, `code_challenge`, `code_challenge_method`, `expires_at`. Created by `Begin()` at /oauth/authorize. Resolved into `code`-keyed entry by `Bind()` after /auth/callback completes the IdP exchange. - `codes` (keyed by issued authorization code): the bindable result the SDK redeems at /token. Holds the resolved `ExchangeResult`, the PKCE challenge it must satisfy, the issuing client_id + redirect_uri (re-checked at /token per RFC 6749 §4.1.3), and a single-use sentinel. Both have short TTLs: pending = ~10 min (the user might take a while to sign in), codes = 60 seconds (RFC 6749 §4.1.2 says ≤10 min; OAuth 2.1 BCP says ≤1 min — we follow the tighter bound). ### State extension `session.go`'s `State` struct currently holds `Nonce`, `ExpiresAt`, `DeviceCode`. We add `AuthCodeID`. Signed-cookie roundtrip is unchanged; existing tests cover the round-trip — we extend with cases that exercise AuthCodeID. ### Handlers - `oauth_authorize.go` — gutted of the stub, replaced with the real handler: - Parse `response_type` (must be `code`), `client_id`, `redirect_uri`, `state`, `scope`, `code_challenge`, `code_challenge_method`. - Validate redirect_uri is loopback per RFC 8252 (allow `127.0.0.1` and `localhost` with any port and any path). - Validate `code_challenge_method=S256` and `code_challenge` is present. - For any error that happens BEFORE we trust the redirect_uri: return JSON 400 with the OAuth error code (same shape as today's stub). - For any error that happens AFTER redirect_uri is validated: redirect to `redirect_uri?error=...&state=...` per RFC 6749 §4.1.2.1. - On success: `authCodeStore.Begin(...)`, build a signed `State{Nonce, AuthCodeID, ExpiresAt}` cookie, set it on `/auth/callback`, 302 to `verifier.AuthCodeURL(nonce)`. - `server.go` `authCallback` — add a parallel branch alongside the existing DeviceCode dispatch: ```go if state.AuthCodeID != "" { s.authCodeCallback(w, r, state.AuthCodeID) return } ``` The new `authCodeCallback` (in `oauth_authorize.go` or a new `oauth_callback.go`) runs `verifier.Exchange`, calls `authCodeStore.Bind(authCodeID, exchange)` which issues a fresh authorization code, then 302s to `pending.redirect_uri?code=&state=`. - `device.go` `deviceToken` — extend the grant_type switch to dispatch `authorization_code` to a new `deviceTokenAuthCode` handler. (The file name is misleading now — could rename to `token.go` later but that is post-shipping cleanup.) The handler: - Reads `code`, `redirect_uri`, `client_id`, `code_verifier`. - `authCodeStore.Redeem(code)` — single-use; returns the stored exchange + PKCE challenge + issuing client_id/redirect_uri. - Re-checks `client_id` and `redirect_uri` match what was bound (RFC 6749 §4.1.3). - SHA-256(`code_verifier`) base64url-no-pad → compare to stored `code_challenge`. Mismatch → `invalid_grant`. - Mint refresh_token via `refreshStore` (same path as device-flow today). - Mint a broker-signed id_token via `idTokenSigner` (same path as device-flow refresh today; PR4 wiring). - Return `access_token` = id_token (same convention the refresh path uses, per `device.go:269-273`). - `discovery.go` `applyDiscoveryOverrides`: - Add `"authorization_code"` to `grant_types_supported`. - Add `response_types_supported: ["code"]` (currently absent; required for MCP clients to confirm). - Add `code_challenge_methods_supported: ["S256"]`. - Leave `authorization_endpoint` pointing at `/oauth/authorize` (unchanged URL, real handler now). - Leave `token_endpoint` pointing at `/device/token` (unchanged; same endpoint dispatches all three grants by `grant_type`). ### Error mapping | Condition | Surface | Code | | --- | --- | --- | | Missing or unsupported `response_type` | JSON 400 | `unsupported_response_type` | | Missing `client_id`, `redirect_uri`, `code_challenge` | JSON 400 | `invalid_request` | | Non-loopback `redirect_uri` | JSON 400 | `invalid_request` | | `code_challenge_method` ≠ S256 | redirect with error (redirect_uri is trusted at this point) | `invalid_request` | | Unknown / expired / replayed `code` at /token | JSON 400 | `invalid_grant` | | PKCE verifier mismatch | JSON 400 | `invalid_grant` | | `redirect_uri` at /token ≠ `redirect_uri` at /authorize | JSON 400 | `invalid_grant` | | IdP exchange failure during callback | redirect with error | `access_denied` or `server_error` | | User cancels at IdP | redirect with error | `access_denied` | ### Security checklist - **Code-substitution defense.** Authorization codes are 32-byte random base64url; bound to the issuing `client_id` + `redirect_uri`; single-use; replay revokes the issued refresh_token. - **PKCE.** S256 only. Stored challenge compared with constant-time equality (`subtle.ConstantTimeCompare`). - **State cookie integrity.** Reuses the signed-cookie pattern that already gates DeviceCode dispatch — same HMAC, same nonce, same TTL. - **Open-redirect defense.** Pre-redirect validation rejects any redirect_uri that is not a loopback URI. The 302 at the end of the IdP dance can only land on a loopback that we verified before the user ever left. - **Mix-up defense.** `iss` parameter (RFC 9207) added to the redirect query so SDKs that check it (Claude Code's MCP SDK does) accept the response. Cheap, one extra param. - **Rate limiting.** `/oauth/authorize` already sits behind `ipRateLimit` (server.go:238). Add the same wrapping around the new `authorization_code` branch at `/device/token` (already there — `mux.Handle("POST /device/token", s.ipRateLimit(...))`). ## PR sequence Each PR is independently testable and ships a coherent slice. Total estimate: 3 PRs, ~3 working days. ### PR1 — Auth-code store + state extension - `tools/demarkus-broker/internal/broker/auth_code_store.go` (new): `AuthCodeStore` with `Begin / Bind / Redeem / janitor` and tests. Mirrors `device_store.go` shape; uses `clock` injection for deterministic TTL tests. - `session.go`: add `AuthCodeID string` to `State`. Update signer tests to cover the new field. - `server.go`: construct `s.authCodeStore` alongside `s.deviceStore`; janitor goroutine started in the same place. - **Scope:** ~350 lines + ~250 lines tests. ~1 day. ### PR2 — Handlers + discovery + dispatch - `oauth_authorize.go`: replace the stub with the real handler. Add `authCodeCallback` (or new `oauth_callback.go` if it cleans up). Update existing oauth_authorize_test.go: the unsupported_response_type test stays for *missing/invalid* response_type; everything else is new positive + negative cases. - `server.go` `authCallback`: add the `state.AuthCodeID != ""` dispatch branch. - `device.go` `deviceToken`: add `authorization_code` to the grant_type switch; new `deviceTokenAuthCode` method. - `discovery.go` `applyDiscoveryOverrides`: extend the three lists. Update `discovery_test.go` assertions accordingly. - **Scope:** ~550 lines + ~600 lines tests. ~1.5 days. ### PR3 — End-to-end + kind harness coverage - `tools/demarkus-broker/internal/broker/oauth_authorize_test.go` (new file, separate from the unit cases above): full happy-path integration test running the broker in-process against a fake `Verifier`, driving GET /oauth/authorize → simulated /auth/callback → POST /device/token with the correct PKCE verifier. Negative paths: bad PKCE, replayed code, mismatched redirect_uri, expired pending grant, expired code. - `deploy/kind/up.sh`: add a stage that runs an in-cluster smoke driving the auth-code path against the deployed broker against mock-oauth2-server. Reuses the existing mock-oidc fixture; mock-oauth2-server supports standard `authorization_code` out of the box (unlike its device flow which was the PR3 risk). - Plugin smoke: nothing to change in the Claude Code plugin code itself — the SDK drives the flow on Claude Code's side. But add a `journal/2026-MM-DD.md` entry capturing one manual `/knowledge-join` against the live `broker.knowledge.demarkus.io` to confirm the original error is gone. - **Scope:** ~400 lines integration test + ~150 lines kind smoke + journal. ~0.5 day. ## Open questions 1. **Should the `authorization_endpoint` URL change from `/oauth/authorize`?** No — Claude Code (and any MCP client) is already fetching the discovery doc, so the path is irrelevant to clients. Keeping `/oauth/authorize` means existing `mcp_oauth.go` / `mcp_auth.go` references stay intact. 2. **Does Claude Code's MCP SDK respect `iss` (RFC 9207)?** Best to ship the param defensively — it costs nothing if ignored. Verify against the SDK source before final review; if it strictly validates and we get the issuer string wrong, that's a footgun. 3. **Should we rotate refresh tokens on the auth-code path?** Current device-flow path does not rotate (see `device.go:266-267`). Keeping parity is the cheapest choice. If the MCP SDK rotates aggressively and the broker doesn't, no harm — the same refresh_token continues to work. Open to revisit if OAuth 2.1 rotation guidance shifts. 4. **The `device.go` filename becomes misleading once it dispatches three grants.** Defer rename until after shipping; the diff churn isn't worth blocking a PR on. ## Out of scope - **Confidential clients / client_secret.** Public clients + PKCE only. - **`response_mode=form_post` or `fragment`.** Query-string redirects only — that is what RFC 8252 native apps use. - **Custom URI schemes / mobile deep links.** No `claudecode://` redirect_uri. - **Hybrid flow (`response_type=code id_token`).** Not in the OAuth 2.1 catalog; no MCP client needs it. - **Refresh token rotation on this path.** Inherits device-flow behavior; revisit holistically if rotation policy changes. - **UI changes to `/auth/login` or `/device`.** This plan does not touch the user-facing pages. The IdP-browser dance is byte-identical. ## Sequencing & Status Plan v1, 2026-05-26. Pending Fritz review before any code is written. After approval: PR1 → PR2 → PR3 in strict sequence (PR2 depends on the store from PR1; PR3 depends on the handlers from PR2). Each PR runs the standard `bash pre-commit.sh` before commit; `make test` for the `tools/demarkus-broker/` module must be green at every step. When resuming cold: refetch this plan, `/index.md`, `/patterns.md`, `/guidelines.md`. Inspect `git log` for already-shipped PRs against `tools/demarkus-broker/internal/broker/{auth_code_store.go,oauth_authorize.go,discovery.go,device.go,session.go}` and pick up at the next-unshipped PR. ## Status - **PR1 shipped 2026-05-26** as PR #155 (commit `985554d`). `authCodeStore` + `State.AuthCodeID` + janitor wiring landed; ~580 LOC including tests. One CodeRabbit nit addressed in the same PR (documenting consumption semantics on `Redeem` validation failures). - **PR2 in flight** on branch `broker-auth-code-handler`. - **PR3 pending** PR2. ## Status correction (2026-05-31) — core grant SHIPPED, PR3 deliverables outstanding The "PR2 in flight / PR3 pending" status above is stale. **PR1 (#155, `authCodeStore`) and PR2 (#156, wire handlers + discovery) are MERGED** (2026-05-27). The full RFC 6749 `authorization_code` + PKCE/S256 grant is implemented and unit/in-process tested: - `oauth_authorize.go` — the `unsupported_response_type` stub is **gone**; real `/oauth/authorize` validates `response_type=code`, loopback-only `redirect_uri` (RFC 8252), required S256 `code_challenge`. - `authCodeCallback` dispatch (`server.go`) → `deviceTokenAuthCode` token-endpoint exchange with `verifyPKCE` constant-time compare (`device.go`, `auth_code_store.go`). - Discovery advertises `authorization_code`, `response_types_supported: ["code"]`, `code_challenge_methods_supported: ["S256"]`. - 9 tests incl. end-to-end happy path + replay / bad-PKCE / mismatch / expiry negatives, all green. **Outstanding (plan's PR3):** 1. A kind smoke **stage** that actually exercises the broker's auth-code flow — `deploy/kind/up.sh` currently drives only the device-flow mint path and never hits `GET /oauth/authorize?response_type=code` with a PKCE verifier end-to-end against a deployed broker. 2. A journal entry recording one live manual `/knowledge-join` against `broker.knowledge.demarkus.io` confirming the original `unsupported_response_type` error is gone. **DECISION NEEDED:** complete PR3, or descope it (the grant is production-shipped and well-tested) and close. Left OPEN pending that call.