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:
POST /device/tokensuccess body now carriesrefresh_token(32-byte opaque, hex-encoded).POST /device/tokenwithgrant_type=refresh_tokenexchanges the refresh token for a fresh ID-token + access-token pair. Existinggrant_type=urn:ietf:params:oauth:grant-type:device_codekeeps working unchanged.POST /token/revoke(RFC 7009) drops a refresh token from the broker. Called by/soul-leave(PR7) and on user-IdP-disable detection.- 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). - 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/installconsumption 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. refreshTokenRecordstruct: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"` }refreshStorestruct: holds akubernetes.Interface, namespace, Secret name, optional clock + sweeper hooks. MirrorsIssuer'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 wherenow > 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 existingmutateSecrethelper fromissuer.gofor 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
statusCompletebranch ofdeviceToken, calls.refreshStore.Issue(...)after the Poll result is non-nil. Wirecfg.Server.RefreshTokenTTLas the TTL. - Tests:
- PR3's
TestDeviceTokenStates/complete_returns_tokensextended: assertrefresh_tokenis non-empty and ~64 hex chars. - Issue failure (mock the store to error) results in 500, not a token leak.
- PR3's
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_tokenform 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 atVerifier.VerifyIDTokenwill 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).
- Reads
- 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.BrokerSigningKeyfield. Rotation deferred to a follow-up. - JWKS endpoint:
GET /.well-known/jwks.jsonserving the broker's public key in JWK format. Public, unauthenticated, no rate limit (same posture as/.well-known/openid-configuration). - Update
Discovery.Overrideto pointjwks_uriat 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).
- Broker-side key material: a static signing key (ECDSA P-256) in the broker config (Secret-mounted, ENV override). New
- 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.VerifyIDTokenaccepts 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(orrefresh.go; co-locate with the handler that owns it). - RFC 7009 surface: reads
tokenform param, optionaltoken_type_hint(ignored — we only support refresh tokens at this endpoint). CallsrefreshStore.Revoke. Always returns 204 (idempotent — RFC 7009 §2.2: server MUST respond with 200/204 even for an invalid token). - Route:
POST /token/revokeunderipRateLimit. - 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 *refreshStorefield (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.Durationyaml:"refreshTokenTTL"`` — default 90d, must be > 0.ServerConfig.RefreshTokensSecret stringyaml:"refreshTokensSecret"`` — required, no default (operator-visible name).OIDCConfig.BrokerSigningKey stringyaml:"brokerSigningKey"`` — base64-encoded ECDSA P-256 private key. Required when broker re-signing is enabled (always for PR4).- Validation: extract to
applyRefreshDefaultsto keepvalidate()inside the gocyclo budget (same pattern asapplyDeviceFlowDefaults).
tools/demarkus-broker/internal/broker/server.go: NewServer wires therefreshStore. Routes() registers/token/revokeand/.well-known/jwks.json.tools/demarkus-broker/main.go: standard plumbing.- Helm chart:
deploy/helm/demarkus-broker/values.yaml: newserver.refreshTokenTTL,server.refreshTokensSecret,oidc.brokerSigningKeyexamples + 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 allowget/patchon 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
- 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/installcan't actually consume the bearer (the cached id_token'sexpis already past — that's the whole point of refresh). Re-confirm before starting. - 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. - Refresh-token rotation per OAuth 2.1. Skip per the §Out of Scope above unless someone asks.
- 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.
- 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.mdas a known trade-off. - JWKS endpoint authentication. Public, unauthenticated, per OIDC. No rate limit either (same as
/.well-known/openid-configuration). - 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. Verifierinterface impact. CurrentVerifier.VerifyIDTokenvalidates 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 existingoidcVerifierto multiplex byissclaim. 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. mutateSecretcontention 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.ClientSecretstorage. 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_tokensdecodes intodeviceTokenSuccess; the new field is omitempty, so the existing assertion holds. Worth a quick re-run before assuming.
Next-Session Resume Steps
git fetch && git log --oneline -5— confirm PR3 (#137) on main, nothing else conflicts.mark_fetch /index.md+/patterns.md+/guidelines.mdper preflight.mark_fetch /plans/universe-onboarding-pr4.md(this doc).mark_fetch /journal/2026-05-15.mdfor the full PR3 review-lessons context.- Decide on Open Question 1 (broker re-signing yes/no) — lean (b), but confirm before starting. This is the biggest scope swing.
- Cut a fresh branch (
feat-tools-broker-refresh-tokensor similar). Start at Step 1 (refreshStore) — keep that as its own commit so the rest builds on a green Secret-backed store baseline. - After Step 1:
go test -race+bash pre-commit.shgreen 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'srequireAuthwould reject a refresh-renewed token whose IdP-sideexpis in the past. - PR6 (
tools/demarkus-join) drives the refresh subcommand. Standard RFC 6749 §6 grant, standardrefresh_tokenfield — any OIDC client library handles it. The Go binary writes~/.config/demarkus/refresh_tokenmode 0600. - PR7 (plugin slash commands) invokes
tools/demarkus-join refreshon 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 insidetools/demarkus-broker/.helm unittest .green (chart-side changes assert refresh-tokens Secret + signing-key Secret + RBAC).pre-commit.shgreen.- Manual end-to-end via curl: device-flow completes → refresh succeeds → revoke works → refresh after revoke fails.
- Journal entry on
/journal/<date>.mdwith design decisions that landed differently from this plan.
Related documents
- Universe onboarding: parent plan this PR4 sub-plan belongs to
- Universe onboarding PR3: device flow surface this PR builds on
- Universe onboarding PR5: install flow consuming broker-signed bearers
- Architecture: where the deferred trade-offs are to be documented
- Journal 2026-05-15: PR3 review lessons this sub-plan resumes from