soul.demarkus.io:6309/plans/universe-onboarding-pr4.md/v1 draft reader meta

Plan: Universe Onboarding — PR4 (Broker refresh tokens)

Sub-plan for plan §PR4 of /plans/universe-onboarding.md. Picked up after PR3 (#137, broker device flow) merged 2026-05-15. Ready to start cold next session: this doc plus /plans/universe-onboarding.md + /journal/2026-05-15.md is sufficient context.

Goal

After PR4 lands, a device-flow client that completed /device/token once can mint fresh id_token + access_token from the broker indefinitely (up to the refresh-token TTL, default 90 days) without re-running the device-code dance. Concretely:

  1. POST /device/token success body now carries refresh_token (32-byte opaque, hex-encoded).
  2. POST /device/token with grant_type=refresh_token exchanges the refresh token for a fresh ID-token + access-token pair. Existing grant_type=urn:ietf:params:oauth:grant-type:device_code keeps working unchanged.
  3. POST /token/revoke (RFC 7009) drops a refresh token from the broker. Called by /soul-leave (PR7) and on user-IdP-disable detection.
  4. Refresh tokens live in a broker-namespace Kubernetes Secret (demarkus-broker-refresh-tokens) keyed by hash — same shape as the issuances Secret. Survives broker restarts (unlike the device-flow grants).
  5. Existing sweeper picks up expired refresh tokens on its tick.

PR5 (/me/install) consumes the broker-minted id_token as a bearer to fetch the per-world install bundle. PR6 (tools/demarkus-join) is the binary that drives all of this. Each PR keeps reviewable in isolation.

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 6749 §6 (refresh token grant) + RFC 7009 (revocation). Real Secret-backed storage from day one; do not stash in-memory.
  • Single broker. Refresh tokens persisted in a single broker-namespace Secret. Multi-replica reads/writes use the same optimistic-concurrency Secret pattern the issuances store uses (see mutateSecret).

Out of Scope (explicit, for PR4 specifically)

  • Refresh-token rotation. OAuth 2.1 best practice rotates the refresh token on each use. PR4 returns the SAME refresh_token on each successful refresh. Rotation adds a "previous-refresh-token grace window" + replay-detection that's a clean +200 LOC follow-up; defer unless a customer asks for it.
  • Broker-signed id_tokens. The plan's §PR4 row mentions "re-fetches user info from IdP via stored refresh-on-IdP token, OR re-uses cached claims with a TTL." PR4 takes the cached-claims path: store the verified Claims at device-code bind time, return them verbatim on refresh. Broker-signed id_tokens (with broker-hosted JWKS) is PR4-adjacent work but adds key-management surface (rotation, JWKS endpoint) that doubles PR4's scope. Defer to a later PR; document the gap in /architecture.md.
  • Refresh against the IdP. The broker does NOT carry an IdP refresh token. Storing one would require the broker to be a registered confidential client at the IdP with a long-lived token — meaningful additional risk surface. The cached-claims approach means a fired employee's IdP-side disable does not immediately invalidate the broker's refresh — that's what the broker's /token/revoke + admin tooling is for (called by an out-of-band webhook or audit-log scrape; not in PR4).
  • /me/install consumption flow. PR5.
  • Plugin / binary work. PR6 + PR7.

Architecture

┌──────────────┐  /device/token + refresh_token  ┌────────────┐
│  client      │ ──────────────────────────────► │            │
│ (curl,       │ ◄────────────────────────────── │   broker   │
│  plugin)     │   {access_token, id_token,      │            │
│              │    token_type, expires_in}      │   refreshStore (Secret-backed)
│              │                                 │   ─────────────────────────────
│  /token/revoke + refresh_token                 │   key:   sha256(refresh_token)
│ ──────────────────────────────────────────────►│   value: refreshTokenRecord
│  204 No Content                                │            ├ email
│ ◄──────────────────────────────────────────────│            ├ subject (hashed for logs)
└──────────────┘                                 │            ├ claims (full, JSON-serialized)
                                                 │            ├ issuedAt
                                                 │            ├ expiresAt
                                                 │            └ universe (broker URL)
                                                 │
                                                 │  POST /device/token entry points:
                                                 │   1. grant_type=device_code (existing) — also mints refresh now
                                                 │   2. grant_type=refresh_token (new) — uses refreshStore
                                                 └────────────┘

Layer responsibilities

Component Owns Does NOT own
refreshStore Hashed-key map of sha256(refresh_token) → refreshTokenRecord. Persisted to a single broker-namespace Secret via the existing mutateSecret pattern. Methods: Issue, Refresh, Revoke, Sweep. State transitions: active, revoked (tombstone for sweeper), expired (lazy at refresh). OIDC. HTTP. The id_token itself (the refresh dispatch in /device/token reconstructs the claims-based response from the stored record).
device.go::deviceToken (extended) Two grant_type branches: device_code (existing PR3 path, now mints refresh) and refresh_token (new). Anything Secret-backed (delegated to refreshStore).
device.go::tokenRevoke (new) POST /token/revoke per RFC 7009. Drops the record from refreshStore. Idempotent (RFC 7009 §2.2: revocation of an invalid token is not an error). Anything beyond Secret-key deletion.
Existing Sweeper Now sweeps expired refresh tokens alongside expired issuances. Tiny addition to the existing per-tick loop. refreshStore correctness (the store owns its own consistency).

Pre-Flight Tasks

None. Unlike PR3's Verifier.Exchange refactor, PR4 builds entirely on top of the PR3 surface. The ExchangeResult from PR3 already plumbs the Claims that the refresh-grant response needs to reconstruct.

Routes To Register / Modify

In server.Routes():

Method Path Middleware Notes
POST /device/token ipRateLimit (existing) Same route, new grant_type branch. Handler reads grant_type, dispatches device_code (existing) vs refresh_token (new).
POST /token/revoke ipRateLimit (new) RFC 7009. Reads token form param + optional token_type_hint. Returns 204.

No modifications to /device/authorize, GET /device, POST /device, or /auth/callback — those are device-flow-only and refresh tokens live downstream.

Sub-Tasks (sequenced)

Step 1 — refreshStore (~200 lines + ~200 tests)

  • File: tools/demarkus-broker/internal/broker/refresh.go.
  • refreshTokenRecord struct:
    type refreshTokenRecord struct {
        KeyHash    string    `json:"keyHash"`    // sha256 hex of the token, the actual map key
        Email      string    `json:"email"`
        Subject    string    `json:"subject"`    // verbatim sub claim (not hashed — needed for Mint)
        Claims     Claims    `json:"claims"`     // full claims snapshot for the refresh response
        IssuedAt   time.Time `json:"issuedAt"`
        ExpiresAt  time.Time `json:"expiresAt"`
        LastUsedAt time.Time `json:"lastUsedAt,omitempty"`
    }
    
  • refreshStore struct: holds a kubernetes.Interface, namespace, Secret name, optional clock + sweeper hooks. Mirrors Issuer's shape.
  • Methods:
    • Issue(ctx, claims Claims, ttl time.Duration) (rawToken string, record refreshTokenRecord, err error) — generates 32 bytes from crypto/rand, hex-encodes, hashes, stores. Returns the raw token to give to the client; never logs it.
    • Refresh(ctx, rawToken string, now time.Time) (refreshTokenRecord, error) — looks up by sha256, validates not-expired, updates LastUsedAt, returns the record. Returns ErrRefreshTokenInvalid for unknown / expired / revoked tokens.
    • Revoke(ctx, rawToken string) error — deletes the record. Idempotent.
    • Sweep(ctx, now time.Time) — invoked by the existing Sweeper. Removes records where now > ExpiresAt. Same lazy-vs-eager posture as the issuances sweep.
  • Secret storage: one Secret per broker namespace, key = refresh_tokens.json, body = JSON map {keyHash → record}. Uses the existing mutateSecret helper from issuer.go for optimistic-concurrency Read-Modify-Write.
  • Critical: the raw token is NEVER stored anywhere. Only its sha256 hash lives in the Secret. Compromise of the Secret does not let an attacker mint tokens — they'd have to brute-force pre-image sha256 of 32-byte random values (infeasible).
  • Tests:
    • Issue → Refresh round-trip returns the same Claims.
    • Refresh with unknown / tampered / expired token returns ErrRefreshTokenInvalid.
    • Revoke makes a previously-valid token reject.
    • Revoke of an unknown token is a no-op (no error).
    • Sweep removes expired entries without touching active ones.
    • Concurrent Issue + Refresh under -race using the existing fake k8s clientset.
    • Secret round-trip: write, read, parse — confirms JSON shape.

Step 2 — Mint refresh on device-code completion (~50 lines + ~50 tests)

  • File: tools/demarkus-broker/internal/broker/device.go.
  • Extend deviceTokenSuccess:
    type deviceTokenSuccess struct {
        AccessToken  string `json:"access_token"`
        IDToken      string `json:"id_token"`
        RefreshToken string `json:"refresh_token,omitempty"`
        TokenType    string `json:"token_type"`
        ExpiresIn    int    `json:"expires_in"`
    }
    
  • In the statusComplete branch of deviceToken, call s.refreshStore.Issue(...) after the Poll result is non-nil. Wire cfg.Server.RefreshTokenTTL as the TTL.
  • Tests:
    • PR3's TestDeviceTokenStates/complete_returns_tokens extended: assert refresh_token is non-empty and ~64 hex chars.
    • Issue failure (mock the store to error) results in 500, not a token leak.

Step 3 — grant_type=refresh_token branch (~120 lines + ~120 tests)

  • File: tools/demarkus-broker/internal/broker/device.go.
  • Dispatch in deviceToken:
    switch r.PostFormValue("grant_type") {
    case deviceGrantType:       s.deviceTokenDeviceFlow(w, r)
    case refreshGrantType:      s.deviceTokenRefresh(w, r)
    default:                    writeJSON(w, 400, deviceTokenError{Error: "unsupported_grant_type"})
    }
    
  • deviceTokenRefresh:
    • Reads refresh_token form param. Missing → invalid_request.
    • Calls s.refreshStore.Refresh(...). Errors → invalid_grant (RFC 6749 §5.2).
    • Reconstructs the ExchangeResult-shaped response from the stored Claims. RawIDToken is regenerated by re-signing — wait, no, PR4 keeps cached claims and skips broker re-signing. So the response carries the ORIGINAL id_token from the device-flow completion. Problem: the original id_token has its own expiry that's already passed (otherwise why refresh?).
    • Decision point for next session: either (a) PR4 returns the cached id_token verbatim and trusts the bearer-validation downstream to handle the time skew, OR (b) PR4 does the bare-minimum re-signing (broker mints a JWT with the cached claims + fresh exp, signed with a broker-side key, with JWKS at /.well-known/jwks.json). (b) is the larger lift but cleaner.
    • Lean: (a) for PR4, document the gap, do (b) in a follow-up PR titled "PR4.5 — broker-signed id_tokens" once /me/install (PR5) makes the gap user-visible. The bearer validation today at Verifier.VerifyIDToken will reject expired id_tokens, so this would be a known broken state — meaning PR5 will need to call into the broker via the bearer regardless. So actually we need (b) for PR4 to be useful end-to-end. Re-lean: (b), broker re-signs at refresh time.
    • Add Cache-Control: no-store + Pragma: no-cache (same as the device-code success path).
  • For (b) — broker re-signing — additional surface:
    • Broker-side key material: a static signing key (ECDSA P-256) in the broker config (Secret-mounted, ENV override). New cfg.OIDC.BrokerSigningKey field. Rotation deferred to a follow-up.
    • JWKS endpoint: GET /.well-known/jwks.json serving the broker's public key in JWK format. Public, unauthenticated, no rate limit (same posture as /.well-known/openid-configuration).
    • Update Discovery.Override to point jwks_uri at the broker (currently this is intentionally left at the IdP per PR2's design note — but now we need to swap to broker-signed). Will break: any third-party JWKS client that was relying on IdP-signed tokens. Mitigation: PR4 broker still accepts BOTH broker-signed AND IdP-signed tokens during a transition window (verify with broker key first, fall back to IdP JWKS).
  • Tests:
    • Refresh with valid token → 200 + fresh access/id tokens + same refresh_token returned.
    • Refresh with unknown / revoked / expired token → 400 invalid_grant.
    • Refresh response has Cache-Control: no-store.
    • Cross-grant-type isolation: device_code-grant doesn't accept a refresh_token; refresh_token-grant doesn't accept a device_code.
    • Broker-signed id_token verification: Verifier.VerifyIDToken accepts the freshly-signed token.
    • JWKS endpoint serves a parseable JWK with the broker's public key.

Step 4 — POST /token/revoke (~80 lines + ~80 tests)

  • File: tools/demarkus-broker/internal/broker/device.go (or refresh.go; co-locate with the handler that owns it).
  • RFC 7009 surface: reads token form param, optional token_type_hint (ignored — we only support refresh tokens at this endpoint). Calls refreshStore.Revoke. Always returns 204 (idempotent — RFC 7009 §2.2: server MUST respond with 200/204 even for an invalid token).
  • Route: POST /token/revoke under ipRateLimit.
  • Tests:
    • Valid token → 204 + subsequent refresh fails.
    • Unknown token → 204 (no leak; RFC-conformant).
    • Missing token form param → 400 invalid_request.

Step 5 — Sweeper integration (~30 lines + ~30 tests)

  • File: tools/demarkus-broker/internal/broker/sweeper.go.
  • Sweeper gains a refreshStore *refreshStore field (optional; only sweeps when non-nil — keeps existing sweeper tests unchanged).
  • Per-tick: call s.refreshStore.Sweep(ctx, now) after the existing issuance sweep. Log the count of swept refresh tokens.
  • Tests: add a refresh token with ExpiresAt in the past, run one sweep, assert removal.

Step 6 — Config + wiring (~50 lines + ~30 tests)

  • tools/demarkus-broker/internal/broker/config.go:
    • ServerConfig.RefreshTokenTTL time.Duration yaml:"refreshTokenTTL"`` — default 90d, must be > 0.
    • ServerConfig.RefreshTokensSecret string yaml:"refreshTokensSecret"`` — required, no default (operator-visible name).
    • OIDCConfig.BrokerSigningKey string yaml:"brokerSigningKey"`` — base64-encoded ECDSA P-256 private key. Required when broker re-signing is enabled (always for PR4).
    • Validation: extract to applyRefreshDefaults to keep validate() inside the gocyclo budget (same pattern as applyDeviceFlowDefaults).
  • tools/demarkus-broker/internal/broker/server.go: NewServer wires the refreshStore. Routes() registers /token/revoke and /.well-known/jwks.json.
  • tools/demarkus-broker/main.go: standard plumbing.
  • Helm chart:
    • deploy/helm/demarkus-broker/values.yaml: new server.refreshTokenTTL, server.refreshTokensSecret, oidc.brokerSigningKey examples + comments.
    • deploy/helm/demarkus-broker/templates/secret-config.yaml: render the new fields.
    • deploy/helm/demarkus-broker/templates/secret-broker-signing-key.yaml (new): mounts the operator-supplied signing key.
    • deploy/helm/demarkus-broker/templates/rbac.yaml: extend RBAC to allow get/patch on the refresh tokens Secret.
    • deploy/helm/demarkus-broker/tests/: helm-unittest coverage for each new field + the new Secret template.

Scope Estimate

Step Code Tests
1. refreshStore 200 200
2. Mint on device-code 50 50
3. refresh_token grant + JWKS 250 220
4. /token/revoke 80 80
5. Sweeper integration 30 30
6. Config + chart wiring 130 80
Total ~740 ~660

Parent plan §PR4 estimated "~400 lines + tests, ~1.5 day PR." We're over by ~340 prod lines because broker-signed id_tokens (the JWKS path) wasn't in the original line estimate. Revised: ~2.5-3 day PR. Worth opening a Fritz check-in before starting if 3 days is over budget.

Open Questions To Resolve Before/During PR4

  1. Broker re-signing vs cached-id_token-verbatim. Lean (b): broker re-signs at refresh time, hosts JWKS, eats the +200 LOC. Rationale: without it, PR5's /me/install can't actually consume the bearer (the cached id_token's exp is already past — that's the whole point of refresh). Re-confirm before starting.
  2. Signing-key rotation strategy. PR4 ships a single static key. Rotation is real operational pain (must publish both old + new in JWKS during rollover; must track which key signed which token). Lean: static key for PR4, dedicated rotation PR later. Document in /architecture.md.
  3. Refresh-token rotation per OAuth 2.1. Skip per the §Out of Scope above unless someone asks.
  4. Per-user refresh-token cap. Should one user be able to hold N concurrent refresh tokens (e.g., one per device)? Lean: yes, unlimited — the Secret can hold ~5000 entries before the etcd limit, which is plenty. Per-user limits are a phase-7+ thing.
  5. Refresh against the IdP for fresh claims (groups, email_verified). Skip per the §Out of Scope. The broker uses the cached claims from device-code completion. Means: if a user's group membership changes at the IdP, the broker doesn't reflect that until re-login. Document in /architecture.md as a known trade-off.
  6. JWKS endpoint authentication. Public, unauthenticated, per OIDC. No rate limit either (same as /.well-known/openid-configuration).
  7. What happens on broker-signing-key compromise? Attacker can forge bearer tokens for any user. Mitigation: rotate the key + force-revoke all refresh tokens + force-revoke all per-world tokens (the world's Verifier rejects them once issuances are dropped). Document the incident-response playbook in /architecture.md.
  8. Verifier interface impact. Current Verifier.VerifyIDToken validates against the IdP's JWKS. PR4 needs it to ALSO accept broker-signed tokens. Two implementation shapes: (a) two-Verifier composition (broker-first, IdP-fallback), or (b) extend the existing oidcVerifier to multiplex by iss claim. Lean: (a) — cleaner abstraction, ~30 LOC adapter. Worth confirming before Step 3.

Risks Specific To PR4

  • Secret-storage scaling. Refresh tokens at 90-day TTL: a 1000-user broker holds 1000 records continuously, ~250KB JSON, well under the 1MB Secret limit. A 10k-user broker is the wall. Mitigation: document in /architecture.md; phase-7 fix is sharded Secrets or a CRD-backed store.
  • mutateSecret contention at scale. Every refresh + revoke RMWs the same Secret. Under heavy refresh load (e.g., a chart rollout that triggers every user's plugin to refresh on next session), the optimistic-concurrency retries could thunder. Mitigation: same as the issuances Secret today — it has not been an issue, would be a real signal worth instrumenting if it becomes one.
  • Broker-signing-key in a Kubernetes Secret. A cluster-admin compromise is full broker compromise. Documented risk, identical to the current OIDC.ClientSecret storage. Not unique to PR4.
  • Cross-grant-type confusion. Easy to accidentally accept a refresh_token at the device_code branch or vice versa. Strict grant_type dispatch + cross-grant tests in Step 3 guard.
  • Old tests must pass after the refresh_token,omitempty addition. PR3's TestDeviceTokenStates/complete_returns_tokens decodes into deviceTokenSuccess; the new field is omitempty, so the existing assertion holds. Worth a quick re-run before assuming.

Next-Session Resume Steps

  1. git fetch && git log --oneline -5 — confirm PR3 (#137) on main, nothing else conflicts.
  2. mark_fetch /index.md + /patterns.md + /guidelines.md per preflight.
  3. mark_fetch /plans/universe-onboarding-pr4.md (this doc).
  4. mark_fetch /journal/2026-05-15.md for the full PR3 review-lessons context.
  5. Decide on Open Question 1 (broker re-signing yes/no) — lean (b), but confirm before starting. This is the biggest scope swing.
  6. Cut a fresh branch (feat-tools-broker-refresh-tokens or similar). Start at Step 1 (refreshStore) — keep that as its own commit so the rest builds on a green Secret-backed store baseline.
  7. After Step 1: go test -race + bash pre-commit.sh green before touching Step 2. The Secret-marshaling tests are the most-likely-to-bite layer.

Touch Points With Later PRs

  • PR5 (/me/install) consumes the broker-signed id_token as a bearer. PR4 broker-signing is what makes that bearer verifiable; otherwise /me/install's requireAuth would reject a refresh-renewed token whose IdP-side exp is in the past.
  • PR6 (tools/demarkus-join) drives the refresh subcommand. Standard RFC 6749 §6 grant, standard refresh_token field — any OIDC client library handles it. The Go binary writes ~/.config/demarkus/refresh_token mode 0600.
  • PR7 (plugin slash commands) invokes tools/demarkus-join refresh on session start. The plugin doesn't know about refresh tokens directly; it shells out.
  • PR8 (docs) covers the operator-facing "what is the broker signing key, why do I need to rotate it" story. Not PR4's burden.

Done When

  • PR4 opens with all six sub-steps' commits, each individually testable.
  • go test -race ./... green inside tools/demarkus-broker/.
  • helm unittest . green (chart-side changes assert refresh-tokens Secret + signing-key Secret + RBAC).
  • pre-commit.sh green.
  • Manual end-to-end via curl: device-flow completes → refresh succeeds → revoke works → refresh after revoke fails.
  • Journal entry on /journal/<date>.md with design decisions that landed differently from this plan.
trail
  1. soul.demarkus.io:6309 v1