Plan: Universe Onboarding — PR3 (Broker device flow)
Sub-plan for plan §PR3 of /plans/universe-onboarding.md. Picked up after PR2 (#136, broker discovery doc) merged 2026-05-15. Ready to start cold tomorrow: this doc plus /plans/universe-onboarding.md + /journal/2026-05-15.md is sufficient context.
Goal
Broker hosts the RFC 8628 device flow end to end. After PR3 lands, a curl client can drive:
POST /device/authorize→ receivesdevice_code+user_code+verification_uri.- User opens
verification_uriin a browser, typesuser_code, completes OIDC. - Curl polls
POST /device/tokenwithdevice_codeand eventually gets a success response containing the IdP's rawid_token+access_token.
That id_token can already be used as a bearer against the broker's existing /tokens endpoint via the verifier infra — no plugin work yet, but the device-flow surface is fully operational. PR4 layers refresh tokens on top of the same response shape; PR5 adds /me/install; PR6 is the plugin binary.
Non-Negotiables Inherited
- No protocol changes. Same as the parent plan.
- No demarkus-server changes. All work in
tools/demarkus-broker/. - No MVPs, no shortcuts. Full RFC 8628 — all error codes,
slow_down, expiry, denial. - Single broker. State lives in-memory; no Lease, no Secret-backed persistence. Plan §"Single broker only for now."
Out of Scope (explicit, for PR3 specifically)
- Broker-signed id_tokens. PR3 forwards the IdP's
id_tokenverbatim. Broker re-signing is PR4's job. - Refresh tokens. PR3's
/device/tokensuccess response omitsrefresh_token. PR4 adds it. /me/install. Tokens minted from device flow are consumable via the existing/tokensendpoint in PR3. PR5 adds the install flow on top.- Plugin work. PR6 is the
tools/demarkus-joinbinary; PR7 is the Claude Code plugin slash commands. - Multi-replica device store. In-memory map only; multi-replica routing is out of scope. The plan locked single-broker for now; if a customer ever asks for HA broker, the device store becomes a stateful piece needing a backing store (Redis / Secret / DB) — not now.
Architecture
┌───────────┐ POST /device/authorize ┌────────────┐
│ client │ ──────────────────────► │ │
│ (curl, │ ◄────────────────────── │ broker │
│ plugin) │ {device_code, │ │
│ │ user_code, │ deviceStore (in-mem map)
│ │ verification_uri, │ ────────────────────
│ │ expires_in, │ key: device_code
│ │ interval} │ value: deviceCodeState
│ │ │ ├ user_code
│ │ │ ├ status: pending |
│ │ │ │ complete | expired |
│ │ │ │ denied
│ │ │ ├ createdAt + expiresAt
│ │ │ ├ lastPolledAt (slow_down)
│ │ │ └ on success:
│ │ │ • claims
│ │ │ • raw id_token
│ │ │ • raw access_token
│ │ │ • IdP token expiry
│ │ │
│ │ │ /device → HTML form
│ │ GET /device │ POST /device → set cookie, 302 /auth/login
│ │ ──────────────────────► │ GET /auth/callback (existing, plus
│ │ (user types user_code, │ new branch on device_code cookie)
│ │ completes OIDC) │
│ │ │
│ │ POST /device/token │ Returns RFC 8628 error codes
│ │ (poll loop) │ while pending; success with
│ │ ──────────────────────► │ raw id_token + access_token
│ │ ◄────────────────────── │ on complete.
└───────────┘ └────────────┘
Layer responsibilities
| Component | Owns | Does NOT own |
|---|---|---|
deviceStore |
Thread-safe map of device_code → deviceCodeState. Lookup by device_code (poll) and by user_code (form submit). State transitions: Authorize, Bind (associate IdP exchange result), Poll (returns status + drives slow_down), Deny (user-cancels branch — deferred, see Open Questions), Expire (lazy at poll time + periodic janitor). |
OIDC, HTTP, anything HTTP-specific. Pure state machine. |
device.go handlers |
The four routes: POST /device/authorize, GET /device, POST /device, POST /device/token. HTML form rendering. The device-code cookie set + clear. |
OIDC. Cross-route state (sits in deviceStore). |
Modified /auth/callback |
Two branches: existing browser-flow callback (mints world tokens, returns JSON), and new device-flow callback (looks for device cookie, calls deviceStore.Bind, renders done page). |
Anything device-store specific beyond Bind. |
Verifier.Exchange (refactored) |
OAuth code exchange + ID token verification, returning ExchangeResult { Claims, RawIDToken, AccessToken, Expiry }. |
Anything device-flow specific. |
Pre-Flight Interface Refactor
Verifier.Exchange currently returns (Claims, error) and throws away the raw id_token + access_token. The device-flow POST /device/token success response needs both raw tokens to forward to the plugin.
Decision (open for confirmation tomorrow): change the interface signature, update three call sites. Parallel-method alternative (ExchangeWithTokens) duplicates the OAuth dance for no real benefit and we'd refactor in PR4 anyway.
type ExchangeResult struct {
Claims Claims
RawIDToken string // forwarded into /device/token success body
AccessToken string // ditto; OAuth access_token from IdP
Expiry time.Time // when the IdP-issued tokens expire
}
type Verifier interface {
AuthCodeURL(state string) string
Exchange(ctx context.Context, code string) (ExchangeResult, error) // was (Claims, error)
VerifyIDToken(ctx context.Context, raw string) (Claims, error)
}
Call sites needing update:
tools/demarkus-broker/internal/broker/oidc.go—(*oidcVerifier).Exchangeimplementation.tools/demarkus-broker/internal/broker/server.go—authCallbackreads.Claimsfrom the result.tools/demarkus-broker/internal/broker/server_test.go—fakeVerifier.Exchange(the test double).
Routes To Register
In server.Routes():
| Method | Path | Middleware | Notes |
|---|---|---|---|
POST |
/device/authorize |
ipRateLimit |
Same protection shape as /auth/login (unauth'd endpoint). |
GET |
/device |
none | Server-rendered HTML form. Optional ?user_code=X query prefills. |
POST |
/device |
none | Submits user_code, sets device cookie, 302 to /auth/login. |
POST |
/device/token |
ipRateLimit |
Polling endpoint; RFC 8628 slow_down is the in-band rate limiter, ipRateLimit is the IP-level backstop. |
Plus modify the existing GET /auth/callback handler (no route-table change, just add a device-cookie branch at the top).
Sub-Tasks (sequenced)
Step 1 — Verifier interface refactor (~80 lines)
oidc.go: introduceExchangeResult, changeVerifier.Exchangereturn type, update implementation to populateRawIDToken+AccessToken+Expiryfrom the OAuth2 token (tok.Extra("id_token")for the raw ID token;tok.AccessToken+tok.Expiryfor the rest).server.go:authCallbackdoesresult, err := s.verifier.Exchange(...)thenclaims := result.Claims.server_test.go:fakeVerifier.ExchangereturnsExchangeResult(the existing tests don't care about the new fields — they assert against claims).- Pre-commit + go test should still pass before any device-flow code lands.
Step 2 — deviceStore (~150 lines + ~150 tests)
- File:
tools/demarkus-broker/internal/broker/device_store.go(or co-locate indevice.go— judgment call when writing). deviceStatusenum:statusPending,statusComplete,statusExpired,statusDenied.deviceCodeStatestruct with mutex-friendly access — design: state value-by-value, mutate under store's mutex.deviceStorestruct:sync.Mutex,map[string]*deviceCodeState, secondarymap[string]stringforuser_code → device_codelookup,clock func() time.Time,expiresIn,pollInterval.- Methods:
Authorize(now) (deviceCode, userCode string, expiresAt time.Time)— generates new entry.LookupByUserCode(userCode) (deviceCode, ok)— for /device POST handler.Bind(deviceCode, result ExchangeResult, now) error— called by /auth/callback on device branch.Poll(deviceCode, now) PollResult— drives state machine and slow_down. Returns a sealed-shape struct so handler dispatches by status.Sweep(now)— expires aged-out entries; called by janitor.
- User code generator: Crockford-style 32-char alphabet (no I, L, O, 0, 1, U), 8 chars, hyphen between groups of 4 for readability (e.g.
WDJB-MJHT). Validation: case-insensitive on input, normalize at lookup. - Device code generator: 32 bytes from crypto/rand, hex-encoded.
- Janitor: a goroutine started by
Server, ticks at half-expiry interval, callsstore.Sweep. Lifecycle hung off a context cancel — same shape as the existing sweeper's RunLeaderElected pattern but trivially non-leader (single-broker invariant).
Step 3 — Device flow handlers (~250 lines + ~200 tests)
- File:
tools/demarkus-broker/internal/broker/device.go. deviceAuthorize(POST /device/authorize): callsstore.Authorize, returns RFC 8628 JSON:{ "device_code": "<hex>", "user_code": "WDJB-MJHT", "verification_uri": "https://broker.example.com/device", "verification_uri_complete": "https://broker.example.com/device?user_code=WDJB-MJHT", "expires_in": 600, "interval": 5 }deviceFormGet(GET /device): renders HTML with a form for user_code. If?user_code=query param is present, prefill the input.deviceFormPost(POST /device): reads user_code from form body, normalizes (strip hyphens, uppercase), looks up viastore.LookupByUserCode. On valid: set device cookie (Path=/,HttpOnly,Secure=!InsecureCookies,SameSite=Lax, MaxAge=expires_in), 302 to/auth/login. On invalid: re-render form with error.deviceToken(POST /device/token): readsdevice_codeandgrant_typefrom form. Validatesgrant_type=urn:ietf:params:oauth:grant-type:device_code. Callsstore.Poll, dispatches:statusPending→{"error": "authorization_pending"}, HTTP 400.slow_down(interval violated) →{"error": "slow_down"}, HTTP 400.statusExpired→{"error": "expired_token"}, HTTP 400.statusDenied→{"error": "access_denied"}, HTTP 400.statusComplete→{"access_token":"<raw>","id_token":"<raw>","token_type":"Bearer","expires_in":<int>}, HTTP 200.
- HTML templates: two pages.
device_form.html(form + optional error message).device_done.html("you can close this tab"). Useembed.FS+html/template.
Step 4 — Modify /auth/callback (~50 lines + ~80 tests)
- Top of handler: check for the device cookie.
- If present:
- Validate cookie value points to a pending device_code (use store's
LookupByDeviceCode— may need to add). - Run the existing OAuth exchange.
- Call
store.Bind(deviceCode, result, now). - Clear the device cookie.
- Render
device_done.html. - Do NOT call
s.issuer.Mint— that's only for the existing browser-flow path.
- Validate cookie value points to a pending device_code (use store's
- If absent: existing behavior, unchanged.
- Tests: assert the device branch never mints world tokens; assert the bind-then-success path lands tokens in the store; assert clearing the cookie works.
Step 5 — Wiring + janitor lifecycle (~30 lines)
Servergains adeviceStore *deviceStorefield, set byNewServer.Routes()registers the four new routes.- A new method
StartJanitors(ctx)onServer(or inline inmain.go) starts the device-store sweeper goroutine. main.gocalls it post-NewServer, wires lifecycle to the existing shutdown context.
Step 6 — Integration test (~100 lines)
- End-to-end against the in-process broker via httptest:
- POST /device/authorize → assert response shape.
- POST /device/token → assert authorization_pending.
- POST /device → set cookie.
- Drive callback flow with the cookie set (use the existing fakeVerifier).
- POST /device/token → assert success with the expected raw tokens.
- Plus a slow_down test (poll twice in quick succession).
- Plus an expiry test (advance the clock past expires_in).
Scope Estimate
| Step | Code | Tests |
|---|---|---|
| 1. Verifier refactor | 80 | 0 (existing tests cover it) |
| 2. deviceStore | 150 | 150 |
| 3. Device flow handlers | 250 | 200 |
| 4. /auth/callback branch | 50 | 80 |
| 5. Wiring + janitor | 30 | 30 |
| 6. Integration test | 0 | 100 |
| Total | ~560 | ~560 |
Plan §PR3 estimated "~600 lines + tests, ~2 day PR." On track.
Open Questions To Resolve Before/During PR3
- User-denial branch. RFC 8628 allows the user to deny consent at the IdP. In our flow, denial happens at the IdP side and the IdP redirects back to
/auth/callbackwith anerrorquery param. Should/auth/callback's device branch translate that intostatusDenied, or just let the polling client time out? Lean: translate. Cleaner UX. ~20 lines. - Cookie scope for device_code. Path=
/is simplest (cookie travels with /auth/callback and /device). Path=/auth/callbackwould be tighter but then /device POST can't read it on validation. Lean: Path=/. Same SameSite=Lax + HttpOnly + Secure-when-not-InsecureCookies as the state cookie. - Cookie name collision risk.
broker_device_codefor the device cookie, alongside the existingbroker_oidc_state. No collision but worth pinning the name in a constant. slow_downpolicy increment. RFC 8628 §3.5: server MAY require client to increase polling interval by 5 seconds on each slow_down. Lean: not for PR3 — RFC says MAY. Just enforce the configured minimum interval; if poll arrives early, return slow_down without bumping the rate. Revisit if a customer reports IdP abuse.- Device code TTL default. RFC 8628 has no required default; example shows 1800 seconds (30 min). Most IdPs (Google, Auth0) use 600 (10 min). Lean: 600. Configurable via chart later if needed.
- What does
/device/donelook like? Minimal HTML: title, body "You can close this tab and return to your editor.", maybe a checkmark. Server-rendered, no JS, no CSS framework. Aim: < 50 lines of template. - Should
/device/authorizerequireclient_idlike real IdP device endpoints? RFC 8628 §3.1 requires it. Since the broker is the only "client" (the plugin is talking to the broker, not multiplexing client identities), we can accept any client_id or omit the check entirely. Lean: accept-but-ignore; log the value at debug for observability. Matches the spec without putting weight on a registration check that has no security value here. - HTML template embedding.
embed.FSis the obvious choice. One question: live-reload during dev? No — broker has never had hot-reload, and the templates are 50-line static HTML. Embed and forget.
Risks Specific To PR3
- Cross-cookie path confusion. State cookie is Path=
/auth/callback, device cookie is Path=/. /auth/callback receives both during device flow. Pre-existing /auth/callback tests must still pass after the modification — guard by writing the device-branch test FIRST, asserting "no device cookie → existing behavior unchanged" explicitly. - In-memory store loss on broker restart. Acceptable for PR3 (plan's single-broker invariant). Document in
device.godoc comment and in /journal so future-me doesn't forget. A user mid-device-flow during a broker rolling restart seesexpired_tokenon next poll → restarts the flow. Tolerable for a 10-minute window during HA migrations. - User code collisions. 8 chars from a 32-char alphabet = 32^8 ≈ 10^12 codes. With device_code TTL of 600s and authorize rate well under 1/s in any realistic deployment, birthday collision is negligible. Add a generate-and-retry-on-collision loop anyway (3 attempts; fail authorize on the third). Cheap insurance.
- Verifier interface refactor breakage. The signature change touches three files. Tests-pass-before-and-after is the gate. If anything else in the broker package starts depending on
Verifier.Exchangebetween now and tomorrow, the refactor scope grows; verify withrg 'Verifier.*Exchange\(|\.Exchange\(' tools/demarkus-broker/before starting.
Tomorrow Morning Resume Steps
git fetch && git log --oneline -3— confirm #136 (PR2) is on main; nothing else conflicts.mark_fetch /index.md+mark_fetch /patterns.md+mark_fetch /guidelines.mdper preflight.mark_fetch /plans/universe-onboarding-pr3.md(this doc).mark_fetch /journal/2026-05-15.mdfor PR1+PR2 context.- Decide on the two open design questions (interface change shape, slow_down policy) — both have leans documented above; either confirm or override.
- Cut a fresh branch (
feat-tools-broker-device-flowor similar), start at Step 1 (Verifier refactor) — keep that as its own commit so the device-flow code lands on top of a green refactor baseline. - After Step 1: pre-commit + go test must be green before touching Step 2. If they aren't, the refactor is fighting something unexpected — investigate before proceeding.
Touch Points With Later PRs
- PR4 (refresh tokens) will extend
deviceCodeStatewith a refresh_token field, modify/device/tokensuccess response to include it, and addPOST /device/tokenwithgrant_type=refresh_token. PR3's struct fields should leave room for that (or PR4 adds them — judgment call when writing PR3). - PR5 (/me/install) consumes the
id_tokenreturned by/device/tokenas a bearer. PR3 forwarding the IdP's raw id_token meansVerifier.VerifyIDTokenalready accepts it — no extra wiring needed in PR3 for that path. - PR6 (
tools/demarkus-join) will be the device-flow client. PR3's RFC 8628 conformance is what makes a standard OIDC client library work against the broker; design choices here are observable through the eventual binary.
Done When
- PR3 PR opens with all six sub-steps' commits, each individually testable.
go test ./...green insidetools/demarkus-broker/, including-race.helm unittest .green (no chart changes expected; assertion is regression-safe).pre-commit.shgreen.- Integration test in
device_flow_test.godrives the full happy path + slow_down + expiry against an in-process broker. - Journal entry on
/journal/2026-05-16.md(or whatever date PR3 actually ships) with the design decisions that landed differently from this plan.