# 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: 1. `POST /device/authorize` → receives `device_code` + `user_code` + `verification_uri`. 2. User opens `verification_uri` in a browser, types `user_code`, completes OIDC. 3. Curl polls `POST /device/token` with `device_code` and eventually gets a success response containing the IdP's raw `id_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_token` verbatim. Broker re-signing is PR4's job. - **Refresh tokens.** PR3's `/device/token` success response omits `refresh_token`. PR4 adds it. - **`/me/install`.** Tokens minted from device flow are consumable via the existing `/tokens` endpoint in PR3. PR5 adds the install flow on top. - **Plugin work.** PR6 is the `tools/demarkus-join` binary; 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. ```go 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).Exchange` implementation. - `tools/demarkus-broker/internal/broker/server.go` — `authCallback` reads `.Claims` from 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`: introduce `ExchangeResult`, change `Verifier.Exchange` return type, update implementation to populate `RawIDToken` + `AccessToken` + `Expiry` from the OAuth2 token (`tok.Extra("id_token")` for the raw ID token; `tok.AccessToken` + `tok.Expiry` for the rest). - `server.go`: `authCallback` does `result, err := s.verifier.Exchange(...)` then `claims := result.Claims`. - `server_test.go`: `fakeVerifier.Exchange` returns `ExchangeResult` (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 in `device.go` — judgment call when writing). - `deviceStatus` enum: `statusPending`, `statusComplete`, `statusExpired`, `statusDenied`. - `deviceCodeState` struct with mutex-friendly access — design: state value-by-value, mutate under store's mutex. - `deviceStore` struct: `sync.Mutex`, `map[string]*deviceCodeState`, secondary `map[string]string` for `user_code → device_code` lookup, `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, calls `store.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): calls `store.Authorize`, returns RFC 8628 JSON: ```json { "device_code": "", "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 via `store.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): reads `device_code` and `grant_type` from form. Validates `grant_type=urn:ietf:params:oauth:grant-type:device_code`. Calls `store.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":"","id_token":"","token_type":"Bearer","expires_in":}`, HTTP 200. - HTML templates: two pages. `device_form.html` (form + optional error message). `device_done.html` ("you can close this tab"). Use `embed.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. - 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) - `Server` gains a `deviceStore *deviceStore` field, set by `NewServer`. - `Routes()` registers the four new routes. - A new method `StartJanitors(ctx)` on `Server` (or inline in `main.go`) starts the device-store sweeper goroutine. - `main.go` calls 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: 1. POST /device/authorize → assert response shape. 2. POST /device/token → assert authorization_pending. 3. POST /device → set cookie. 4. Drive callback flow with the cookie set (use the existing fakeVerifier). 5. 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 1. **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/callback` with an `error` query param. Should `/auth/callback`'s device branch translate that into `statusDenied`, or just let the polling client time out? Lean: translate. Cleaner UX. ~20 lines. 2. **Cookie scope for device_code.** Path=`/` is simplest (cookie travels with /auth/callback and /device). Path=`/auth/callback` would 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. 3. **Cookie name collision risk.** `broker_device_code` for the device cookie, alongside the existing `broker_oidc_state`. No collision but worth pinning the name in a constant. 4. **`slow_down` policy 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. 5. **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. 6. **What does `/device/done` look 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. 7. **Should `/device/authorize` require `client_id` like 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. 8. **HTML template embedding.** `embed.FS` is 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.go` doc comment and in /journal so future-me doesn't forget. A user mid-device-flow during a broker rolling restart sees `expired_token` on 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.Exchange` between now and tomorrow, the refactor scope grows; verify with `rg 'Verifier.*Exchange\(|\.Exchange\(' tools/demarkus-broker/` before starting. ## Tomorrow Morning Resume Steps 1. `git fetch && git log --oneline -3` — confirm #136 (PR2) is on main; nothing else conflicts. 2. `mark_fetch /index.md` + `mark_fetch /patterns.md` + `mark_fetch /guidelines.md` per preflight. 3. `mark_fetch /plans/universe-onboarding-pr3.md` (this doc). 4. `mark_fetch /journal/2026-05-15.md` for PR1+PR2 context. 5. Decide on the two open design questions (interface change shape, slow_down policy) — both have leans documented above; either confirm or override. 6. Cut a fresh branch (`feat-tools-broker-device-flow` or 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. 7. 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 `deviceCodeState` with a refresh_token field, modify `/device/token` success response to include it, and add `POST /device/token` with `grant_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_token` returned by `/device/token` as a bearer. PR3 forwarding the IdP's raw id_token means `Verifier.VerifyIDToken` already 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 inside `tools/demarkus-broker/`, including `-race`. - `helm unittest .` green (no chart changes expected; assertion is regression-safe). - `pre-commit.sh` green. - Integration test in `device_flow_test.go` drives 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.