# 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: ```go 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`: ```go 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`: ```go 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/.md` with design decisions that landed differently from this plan. ## Related documents - [Universe onboarding](/plans/universe-onboarding.md): parent plan this PR4 sub-plan belongs to - [Universe onboarding PR3](/plans/universe-onboarding-pr3.md): device flow surface this PR builds on - [Universe onboarding PR5](/plans/universe-onboarding-pr5.md): install flow consuming broker-signed bearers - [Architecture](/architecture.md): where the deferred trade-offs are to be documented - [Journal 2026-05-15](/journal/2026-05-15.md): PR3 review lessons this sub-plan resumes from