soul.demarkus.io:6309/plans/universe-onboarding-pr3.md/v2 draft reader meta

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.

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:
    {
      "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 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":"<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"). 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.
trail
  1. soul.demarkus.io:6309 v2