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:
- User adds the broker as an MCP server (via
/knowledge-joinorclaude mcp add). - Claude Code's MCP SDK fetches the broker's discovery doc, sees
authorization_codeingrant_types_supported, and starts an auth-code flow. - SDK hits
/oauth/authorize?response_type=code&client_id=...&code_challenge=...&code_challenge_method=S256&redirect_uri=http://localhost:PORT/callback&state=...&scope=.... - Broker redirects through
/auth/login→ IdP →/auth/callback, exactly as the device-flow shim does today. - Broker mints an authorization code, redirects to
http://localhost:PORT/callback?code=...&state=.... - 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). - SDK uses the access_token as a bearer on all subsequent MCP gateway calls. Refresh via the existing
refresh_tokengrant.
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=S256only. Noplain. 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/...andhttp://localhost:PORT/...with any port. Reject everything else — no public redirect URIs, nourn: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. Holdsclient_id,redirect_uri,state(client-supplied),scope,code_challenge,code_challenge_method,expires_at. Created byBegin()at /oauth/authorize. Resolved intocode-keyed entry byBind()after /auth/callback completes the IdP exchange.codes(keyed by issued authorization code): the bindable result the SDK redeems at /token. Holds the resolvedExchangeResult, 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 becode),client_id,redirect_uri,state,scope,code_challenge,code_challenge_method. - Validate redirect_uri is loopback per RFC 8252 (allow
127.0.0.1andlocalhostwith any port and any path). - Validate
code_challenge_method=S256andcode_challengeis 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 signedState{Nonce, AuthCodeID, ExpiresAt}cookie, set it on/auth/callback, 302 toverifier.AuthCodeURL(nonce).
- Parse
-
server.goauthCallback— add a parallel branch alongside the existing DeviceCode dispatch:if state.AuthCodeID != "" { s.authCodeCallback(w, r, state.AuthCodeID) return }The new
authCodeCallback(inoauth_authorize.goor a newoauth_callback.go) runsverifier.Exchange, callsauthCodeStore.Bind(authCodeID, exchange)which issues a fresh authorization code, then 302s topending.redirect_uri?code=<authCode>&state=<pending.state>. -
device.godeviceToken— extend the grant_type switch to dispatchauthorization_codeto a newdeviceTokenAuthCodehandler. (The file name is misleading now — could rename totoken.golater 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_idandredirect_urimatch what was bound (RFC 6749 §4.1.3). - SHA-256(
code_verifier) base64url-no-pad → compare to storedcode_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, perdevice.go:269-273).
- Reads
-
discovery.goapplyDiscoveryOverrides:- Add
"authorization_code"togrant_types_supported. - Add
response_types_supported: ["code"](currently absent; required for MCP clients to confirm). - Add
code_challenge_methods_supported: ["S256"]. - Leave
authorization_endpointpointing at/oauth/authorize(unchanged URL, real handler now). - Leave
token_endpointpointing at/device/token(unchanged; same endpoint dispatches all three grants bygrant_type).
- Add
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.
issparameter (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/authorizealready sits behindipRateLimit(server.go:238). Add the same wrapping around the newauthorization_codebranch 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):AuthCodeStorewithBegin / Bind / Redeem / janitorand tests. Mirrorsdevice_store.goshape; usesclockinjection for deterministic TTL tests.session.go: addAuthCodeID stringtoState. Update signer tests to cover the new field.server.go: constructs.authCodeStorealongsides.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. AddauthCodeCallback(or newoauth_callback.goif 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.goauthCallback: add thestate.AuthCodeID != ""dispatch branch.device.godeviceToken: addauthorization_codeto the grant_type switch; newdeviceTokenAuthCodemethod.discovery.goapplyDiscoveryOverrides: extend the three lists. Updatediscovery_test.goassertions 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 fakeVerifier, 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 standardauthorization_codeout 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.mdentry capturing one manual/knowledge-joinagainst the livebroker.knowledge.demarkus.ioto confirm the original error is gone. - Scope: ~400 lines integration test + ~150 lines kind smoke + journal. ~0.5 day.
Open questions
- Should the
authorization_endpointURL 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/authorizemeans existingmcp_oauth.go/mcp_auth.goreferences stay intact. - 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. - 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. - The
device.gofilename 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_postorfragment. 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/loginor/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 onRedeemvalidation 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— theunsupported_response_typestub is gone; real/oauth/authorizevalidatesresponse_type=code, loopback-onlyredirect_uri(RFC 8252), required S256code_challenge.authCodeCallbackdispatch (server.go) →deviceTokenAuthCodetoken-endpoint exchange withverifyPKCEconstant-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):
- A kind smoke stage that actually exercises the broker's auth-code flow —
deploy/kind/up.shcurrently drives only the device-flow mint path and never hitsGET /oauth/authorize?response_type=codewith a PKCE verifier end-to-end against a deployed broker. - A journal entry recording one live manual
/knowledge-joinagainstbroker.knowledge.demarkus.ioconfirming the originalunsupported_response_typeerror is gone.
DECISION NEEDED: complete PR3, or descope it (the grant is production-shipped and well-tested) and close. Left OPEN pending that call.
PR3 progress (2026-05-31) — kind smoke implemented + live surface verified
Closing the PR3 gap from the status correction above.
Kind smoke (deliverable 1) — IMPLEMENTED (uncommitted, pending Fritz commit). Added an auth-code + PKCE stage to the --with-mcp-smoke path in deploy/kind/up.sh, after the existing 3 MCP-gateway checks. A new authcode-smoke curl pod drives the full chain against the broker's :8080 OAuth surface:
GET /oauth/authorizewith S256 PKCE → 302 to mock IdP (state cookie set).- Mock IdP (
interactiveLogin=false) auto-approves → broker/auth/callback. - Callback (state cookie replayed, re-targeted at the in-cluster Service) → 302 to the loopback
redirect_uriwith a broker-minted code; assertsstateechoed (CSRF) +isspresent (RFC 9207). - Negative:
POST /device/tokenwith a wrongcode_verifier→400 invalid_grant(proves PKCE is enforced; one-shot code survives a failed verify). - Positive: correct verifier →
200Bearer token (shape-only assertion; no token material in logs). - Replay: re-presenting the consumed code →
400(one-shot).
PKCE verifier/challenge computed host-side via openssl (already a harness requirement) and passed into the pod — the curlimages/curl pod has no guaranteed openssl. Derivation cross-checked against a Python reference (base64url-nopad(sha256(verifier))) → exact match. server.insecureCookies=true set via --set on the local-chart install only (so the Secure state cookie replays over plain HTTP in-pod); plain Stage 2 untouched. bash -n clean; pre-commit.sh green (shell-only change, no Go touched). Not yet executed in a live kind cluster (requires image build + cluster spin-up).
Live verification (deliverable 2) — read-only probe done; interactive login still Fritz's. Probed https://broker.knowledge.demarkus.io (GET-only):
- Discovery advertises
authorization_code,response_types_supported:[code],code_challenge_methods_supported:[S256],authorization_endpoint:/oauth/authorize. /oauth/authorizemissingclient_id→400 invalid_request(real handler).response_type=token+ loopback redirect → 302 witherror=unsupported_response_type(the new two-phase redirect, not the old direct-stub).- Valid PKCE request → 302 to
accounts.google.com(full flow live; stub replaced).
This is strong evidence the original unsupported_response_type stub is gone in prod. The full interactive /knowledge-join (real Google login → token mint) remains a manual confirmation for Fritz; not automatable here.
Remaining to close the plan: commit the up.sh change; optionally run deploy/kind/up.sh --with-broker --with-mcp-smoke once to see the stage green; optionally one manual /knowledge-join. After commit, move this plan to Completed in /index.md.