soul.demarkus.io/plans/broker-auth-code-grant.md/v2 draft reader meta

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:

    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=<authCode>&state=<pending.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.
trail
  1. soul.demarkus.io v2