2026-05-15 — Universe Onboarding PR1
Resumed /plans/universe-onboarding.md at PR1 (Broker config foundations: WorldConfig.PublicURL).
Changes
tools/demarkus-broker/internal/broker/config.go— addedWorldConfig.PublicURL stringwith a doc comment explaining the field belongs on the broker (single source of truth for the client-facing address), is optional, and that a blank value means "skip in /me/install."deploy/helm/demarkus-broker/values.yaml— documentedpublicURLin the commented worlds[] example with the rationale for setting it.deploy/helm/demarkus-broker/templates/secret-config.yaml— renderpublicURL: "<value-or-empty>"for every world. Always emitted so the YAML shape is stable across set/unset, and operators can grep for the field.tools/demarkus-broker/internal/broker/config_test.go— two new subtests: empty default round-trip + explicitmark://value round-trip.deploy/helm/demarkus-broker/tests/secret-config_test.yaml— two new cases: blank renders aspublicURL: "", operator-supplied value renders verbatim.
Verification
go test ./internal/broker/ -run TestLoadConfig→ 29/29 pass (2 new).helm unittest -f tests/secret-config_test.yaml .→ 13/13 pass (2 new).bash pre-commit.sh→ format, vet, lint clean across protocol/server/client/tools.
Scope discipline
No validation in PR1 — plan calls for "optional; validation only requires it when used." Shape validation (mark:// URL, port present, etc.) lives with the /me/install consumer in PR5.
KnownFields(true) means older broker binaries would reject the new chart's rendered config because of the unknown publicURL line. Chart appVersion gates broker upgrade as usual; not new for this PR.
Next
PR2 — broker discovery doc (GET /.well-known/openid-configuration proxying the IdP discovery with device-endpoint overrides). Resume at tools/demarkus-broker/internal/broker/discovery.go (new file) + route registration in server.go.
Pending review: should PR1 ship as a standalone PR or be folded into PR2? Plan calls for independent reviewability per PR, so default is standalone.
PR2 — Broker discovery doc
PR1 (#135) merged. Continuing through plan PR2: broker-hosted /.well-known/openid-configuration that proxies the IdP's discovery doc with broker-hosted endpoint overrides.
Changes
tools/demarkus-broker/internal/broker/discovery.go(new) —Discoverytype. Fetches<idpIssuer>/.well-known/openid-configurationonce atNewDiscovery, parses intomap[string]anyso unknown fields proxy through unchanged, rewritesissuer+device_authorization_endpoint+token_endpointto broker URLs, caches the rendered body with a 5-minute TTL. Lazy refresh on TTL expiry, stale-while-error on refresh failure. Constructor blocks on initial fetch so a misconfigured/unreachable IdP fails the broker at boot rather than at first/soul-join.tools/demarkus-broker/internal/broker/discovery_test.go(new) — 13 test cases via httptest fake-IdP: constructor validation (broker URL + issuer required), upstream-failure failure paths, override correctness, proxy ofjwks_uri+userinfo_endpoint+authorization_endpoint+ arrays, response headers, cache hit count within TTL, refresh after TTL, stale-while-error, recovery from transient failure, trailing-slash trim, malformed JSON rejection.tools/demarkus-broker/internal/broker/server.go—Servergains adiscovery *Discoveryfield andNewServertakes it as a constructor arg.Routes()registersGET /.well-known/openid-configurationonly whendiscovery != nil, so existing tests passniland skip the route cleanly.tools/demarkus-broker/internal/broker/server_test.go—TestWellKnownDiscoveryRouteRegistered(wires real Discovery into NewServer, asserts override applied through the mounted route) +TestWellKnownDiscoveryRouteSkippedWhenNil(404 when constructed nil).tools/demarkus-broker/main.go—NewDiscoverycalled betweenNewVerifierandNewServer; passescfg.Server.PublicURL+cfg.OIDC.Issuer. Failure is fatal at startup, same shape asNewVerifier.tools/demarkus-broker/internal/broker/config.go— newServerConfig.PublicURLfield, required at load, trailing slash stripped once at load so every downstream consumer sees the canonical form. Doc comment notes it intentionally diverges fromOIDC.RedirectURL(which is purpose-specific to the IdP redirect).tools/demarkus-broker/internal/broker/config_test.go— 2 new subtests: required-rejection + trailing-slash trim.deploy/helm/demarkus-broker/values.yaml—server.publicURL: ""with full operator-facing comment.deploy/helm/demarkus-broker/templates/secret-config.yaml— renderspublicURL: {{ required ... }}so chart-render fails clearly when omitted (same shape asoidc.redirectURL).deploy/helm/demarkus-broker/tests/secret-config_test.yaml— 2 new cases (renders value, fail-render when blank) + addedserver.publicURLto the suite-wideset:block.deploy/helm/demarkus-broker/tests/deployment_test.yaml— same suite-wideset:addition (this suite also templates secret-config).deploy/kind/values-broker.yaml,deploy/kind/values-broker-argo.yaml— setserver.publicURL: "http://localhost:8080"to mirror the existing redirectURL shape. Stage 2/4 harnesses don't exercise the well-known route but the broker'sLoadConfignow requires the field.
Verification
go test ./internal/broker/ -count=1→ all green (4.3s). 13 new Discovery tests + 2 new server route tests + 2 new config tests.helm unittest .→ 55/55 pass across all 7 suites (was 52/52 before; 3 new).bash pre-commit.sh→ format + vet + lint clean.
Design notes (worth remembering)
- Override shape is intentionally lossy at the seam. Override
issuerto broker URL but leavejwks_uripointed at the IdP. ID tokens during PR2/PR3 are still IdP-signed and carry IdP'siss. Strict client validation against this discovery doc would fail; the plugin's device-flow client (PR6) only consumesdevice_authorization_endpoint+token_endpointfrom this doc so the mismatch is invisible to it. PR4 closes the gap by minting broker-signed id_tokens with a broker-hostedjwks_uri. Doc comment indiscovery.gocalls this out explicitly so a future reader doesn't trip over it. map[string]anyproxy beats a typed struct. Tried a typedidpDiscoverystruct first; killed it because real IdPs ship 20+ fields each (Google'scode_challenge_methods_supported, Auth0'smfa_challenge_endpoint, Okta'srequest_object_signing_alg_values_supported, etc.) and the broker has no business knowing or filtering them. Decode → mutate three keys → re-encode is the right shape; tests assert both the overridden fields AND a couple proxy fields so regressions surface.- Lazy refresh, not background goroutine. Plan §Risks talks about "5-minute TTL on the discovery cache." Two implementation shapes: background ticker, or in-handler lazy. Picked lazy because (a) one fewer goroutine lifecycle to wire into the broker shutdown path, (b) the load is human-scale — even with N replicas behind a Service, the per-instance refresh rate caps at 1/5min, (c) thundering herd on simultaneous expiry is negligible at this rate (two replicas might both refresh once; the IdP doesn't notice). Stale-while-error keeps the well-known endpoint available during transient upstream blips.
- Constructor signature pattern.
NewDiscoverytakes aDiscoveryConfigstruct, not a long positional arg list — per /guidelines.md "more than 4 parameters is a smell." Same pattern as the existingIssuer/Verifierconstructors take typed configs. - Test isolation gotcha. Existing
oidc_test.goalready hadfakeIdP+newFakeIdP. Renamed mine tofakeDiscoveryIdP+newFakeDiscoveryIdPrather than touch the existing symbol — both are test-package-scope so the rename is purely local. Worth keeping in mind if a third test file ever needs a fake IdP: extract to a sharedtesthelpers_test.gohelper instead of accumulating per-suite copies. http.NoBody, notnil, on GET requests. gocritic flagged thenilbody onhttp.NewRequestWithContext; switched tohttp.NoBody(the idiomatic singleton for empty request bodies). Same fix in tests usinghttptest.NewRequest.
Scope discipline
PR2 spec called for ~150 lines + tests. Final tally: ~210 lines of production code (discovery.go) + ~310 lines of tests + ~70 lines of config/chart plumbing + ~50 lines of dev-values updates. Over the 150-line estimate but every piece is load-bearing for PR3 (verification_uri uses brokerURL) and PR4 (broker-signed tokens use brokerURL as issuer).
Next
PR3 — broker device flow (RFC 8628). New file device.go with /device/authorize + /device (HTML form + /auth/login redirect glue) + /device/token polling. ~600 lines + tests, ~2 days. Will exercise the route registration path that PR2 sets up; will also be the first time the broker holds short-lived per-flow state in-memory (device_code map with janitor goroutine).
Open question to resolve before PR3
mock-oauth2-server device-code support — plan §Open Questions item 1. Need to check navikt/mock-oauth2-server v2.x source for /device_authorization endpoint before designing the kind Stage 5 harness in PR7. Not blocking PR3 itself (broker-side tests use a fake verifier), but if mock-oauth2-server doesn't support it, the kind end-to-end story diverges.
End of session — PR2 merged, PR3 plan written
- PR2 (#136, broker discovery doc) merged after two rounds of CodeRabbit feedback. Final state included
Server.PublicURLconfig field (required, trimmed, absolute-URL validated),Discoverytype with 5-min TTL caching + refresh-mutex stampede protection + hard fetch deadline, route registration on/.well-known/openid-configuration, 16 broker tests + 5 helm-unittest cases. - Coderabbit lessons worth saving:
t.Fatalfinside spawned goroutines panics that goroutine without releasing pending channel sends. Concurrency tests need an error-returning helper that surfaces failures through the channel; the main goroutine asserts after<-. WroteserveAndDecodeErras the canonical pattern for this kind of test.- When a constructor accepts an injected
*http.Clientvia config struct, that client may not have aTimeoutset. Wrap the fetch context withcontext.WithTimeoutinside the operation so the deadline holds regardless of what the caller injected. Belt-and-suspenders on top of the default client's own timeout. helm installregression tests in CI (test-upgrade-wipe.shinvocation) need explicit--setfor every newly-required chart value. Adding a required field tovalues.yamlwithout updating CI passes helm-unittest but fails the actual install regression. Worth grepping.github/workflows/forhelm installcalls when adding a required field to any chart.- README install examples count as a test surface — coderabbit flagged the missing
--set server.publicURLin the dev-install README block. Update README install commands alongside the chart change.
- PR3 plan published to
/plans/universe-onboarding-pr3.mdwith full architecture, sub-task breakdown, open design questions, and tomorrow-morning resume steps. Index updated to surface it. - Two design questions deferred to tomorrow's session:
Verifier.Exchangesignature change (lean: change it, three call sites, clean refactor) vs. parallelExchangeWithTokensmethod. Latter is messier and we'd refactor anyway in PR4.- RFC 8628
slow_downinterval-increment policy. Lean: not for PR3 (RFC says MAY, not MUST); revisit if a customer reports IdP abuse.
- Tomorrow's first move:
mark_fetch /plans/universe-onboarding-pr3.md, confirm the two open questions, branch from main, start at Step 1 (Verifier refactor) as its own commit.
PR3 implementation — broker device flow (RFC 8628)
Picked up PR3 cold from /plans/universe-onboarding-pr3.md. Both deferred design questions confirmed:
Verifier.Exchangesignature changed to returnExchangeResult(Claims + RawIDToken + AccessToken + Expiry) — three call sites touched.slow_downpolicy is "enforce configured interval, no incremental bump" — pure minimum-interval check indeviceStore.Poll.
Changes
tools/demarkus-broker/internal/broker/oidc.go— addedExchangeResultstruct;Verifier.Exchangereturns it.tools/demarkus-broker/internal/broker/oidc_test.go—fakeVerifiergainedrawIDToken/accessToken/expiryfields and the new return type.tools/demarkus-broker/internal/broker/server.go—ServergaineddeviceStore.Routes()registersPOST /device/authorize,GET /device,POST /device,POST /device/token. The first and last sit behindipRateLimit;/deviceform has no rate-limit middleware.authCallbackgained a device-cookie early-return dispatch.tools/demarkus-broker/internal/broker/device.go(new) — four handlers +deviceCallback(the device branch of/auth/callback) +RunDeviceJanitorlifecycle +clearDeviceCookiehelper. HTML templates embedded viaembed.FS. ~280 lines.tools/demarkus-broker/internal/broker/device_store.go(new) —deviceStorestate machine. Methods:Authorize,LookupByUserCode,LookupByDeviceCode,Bind,Deny,Poll,Sweep. User-code alphabet 30 chars (excludes0 1 I L O U); 8-char codes formatted with a hyphen for display, canonicalized to no-hyphen-uppercase at lookup. ~290 lines.tools/demarkus-broker/internal/broker/templates/device_form.html,device_done.html(new) — server-rendered, embedded into the binary.tools/demarkus-broker/internal/broker/config.go—ServerConfig.DeviceCodeTTL+DevicePollIntervalwith defaults (10m / 5s). ExtractedServerConfig.applyDeviceFlowDefaultsto keepConfig.validateunder the gocyclo budget.tools/demarkus-broker/main.go— callssrv.RunDeviceJanitor(sweepCtx)in the existing shutdown WaitGroup so the goroutine tears down with the sweeper.tools/demarkus-broker/internal/broker/device_store_test.go(new) — Authorize, Lookup, Bind, Deny, Poll (incl. slow_down + expiry), Sweep, canonicalizer round-trip, alphabet invariants. ~330 lines.tools/demarkus-broker/internal/broker/device_test.go(new) — handler tests (authorize, form GET/POST, token states),TestAuthCallbackUnchangedWithoutDeviceCookieregression guard, full end-to-end happy-path integration test, IdP-deny integration test, janitor cancel test. ~510 lines.
Verification
go test -race ./internal/broker/→ green (~6s) across all 30 existing + new tests.helm unittest .(broker chart) → 55/55 still green; no chart changes in PR3.bash pre-commit.sh→ format + vet + lint clean. Initial run flagged 5 lint issues (gocritic hugeParam onBind, gocyclo onvalidate, twominmax, onerangeint) — all addressed:Bindtakes*ExchangeResult, validate extracted helper,max()replacements,for range Nloop.
Design decisions that landed differently from the plan
deviceStore.Bindtakes*ExchangeResult(not value). Plan showed it as value. gocritic'shugeParamflagged the 120-byte value at every call site. Pointer keeps the API as cheap as value semantics without changing ownership (*resultis copied intodeviceCodeState.Result). All call sites pass&exchange/&ExchangeResult{...}.- User-code alphabet is 30 characters, not 32. Plan said "32-char alphabet (no I, L, O, 0, 1, U)" but 36 − 6 = 30. Used 30; collision space at TTL-aligned issue rates is still negligible (30^8 ≈ 6.6×10^11). Crockford base32's standard alphabet preserves
0and1; the plan's exclusion list was the load-bearing constraint, not the alphabet size. - Cookie clearing must happen BEFORE
WriteHeader. First pass useddefer s.clearDeviceCookie(w). Integration test caught it:WriteHeaderflushes headers, so the deferred SetCookie is silently dropped. Restructured to explicitclearDeviceCookie(w)calls at each early-return + before the render. - State cookie is also cleared on the device branch. Plan mentioned device cookie only. Mirrored the browser path's state-cookie clear (Path=
/auth/callback, MaxAge=-1) so a replay can't reuse the nonce. - Janitor is a plain
time.NewTickerloop, no leader election. Per-replica in-memory state means each replica sweeps its own store; no shared resource to elect on. Lifecycle sharessweepCtxwith the Kubernetes-side Sweeper for a single shutdown signal. - Open Question 1 (user-denial branch): YES, translate.
/auth/callback?error=...is detected before the Exchange call.deviceStore.Deny(deviceCode)is invoked and the done page renders. Polling client seesaccess_deniedon next poll instead of waiting forexpired_token. ~10 lines. - Open Question 7 (client_id requirement on /device/authorize): accept-but-ignore.
POST /device/authorizereadsclient_idfrom the form (RFC-compliant client), logs at debug, but does not gate on it. Broker is the only relying party in this flow.
Risks specific to PR3 that did NOT bite
- Cross-cookie path confusion (state cookie at
/auth/callback, device cookie at/) — guarded byTestAuthCallbackUnchangedWithoutDeviceCookie, regression-safe. - Verifier interface refactor breakage —
rgconfirmed only three call sites; refactor was a single commit's worth of mechanical changes. - In-memory store loss on broker restart — documented in
deviceStoredoc comment; acceptable for 10-minute TTL.
Next
PR3 ready to open. No commits made — Fritz handles those. Branch is still main; he'll cut a feat-tools-broker-device-flow (or similar) branch before committing. PR4 (broker-signed id_tokens + refresh tokens via ExchangeResult plumbing) is the natural next sub-plan.
PR3 merged (#137) — review lessons
CodeRabbit ran two rounds before approving. Lessons worth saving:
Security / correctness regressions caught
- Stale-device-cookie dispatch hijack. First-pass dispatch on
/auth/callbackkeyed on the device cookie alone — a cookie that livedDeviceCodeTTLlong. Abandoning the flow before/auth/callbackleft the cookie in the jar; a later legitimate browser/auth/callbackwould silently route throughdeviceCallbackand bind the wrong grant. Fix: extended the signedStatestruct withDeviceCode string, consume the device cookie at/auth/login, dispatch onstate.DeviceCodein/auth/callback. The cookie is now a one-shot pass-through; the State cookie is the authoritative signal. Pattern worth remembering: never dispatch on an ambient cookie when a signed state value is available. - Origin-only CSRF check passes scheme-mismatched origins.
http://broker.example.commatchedr.Host == "broker.example.com"even though that's cross-origin. Fix: compare scheme+host, derive expected scheme from X-Forwarded-Proto / r.TLS. Pattern: scheme is part of origin in the spec; host-only same-origin checks are broken by construction. Denyon Exchange failure. Conflated user-denied-at-IdP with broker-side transient failure. A network blip duringExchangepermanently marked the device_code asaccess_deniedto the polling client. Fix: only the explicit?error=query branch maps to Deny; everything else leaves the grant pending so the client retries or sees truthfulexpired_token. Pattern: terminal states must reflect what actually happened, not the closest convenient enum value.- Bearer tokens cacheable by default.
POST /device/tokensuccess response didn't carryCache-Control: no-store. Standard OAuth2 §5.1 posture. Easy miss when writing JSON helpers. - RFC 8628
client_idis REQUIRED, not optional. The plan §Open Question 7 leaned "accept-but-ignore". CodeRabbit pushed back citing the spec; the right answer is require presence, don't validate content — filters malformed clients without putting weight on a useless registration check.
Process lessons
- Header writes after
WriteHeaderare silently dropped. First-pass cookie clearing indeviceCallbackuseddefer s.clearDeviceCookie(w)— the defer fired afterrenderDeviceDonehad already flushed headers, so the Set-Cookie never landed. Integration test caught it. Always set headers BEFORE the first body write orWriteHeadercall. - Sed-with-newlines breaks on multiline calls. Used sed to rewrite
store.Bind(deviceCode, ExchangeResult{...})tostore.Bind(deviceCode, &ExchangeResult{...})and missed one site where the struct literal spanned multiple lines. Always grep after a sed-based refactor. gocritic hugeParamflags 120-byte value parameters. TookExchangeResultby value atdeviceStore.Bind; lint flagged. Switched to*ExchangeResultwith a copy-into-state-on-store-side. Cleaner API anyway.- Test config divergence from production validation. Tests construct
Config{}directly without callingvalidate(), so new required fields (DeviceCodeTTL, DevicePollInterval) need fallback defaults inNewServeras well asvalidate(). Production-only validation paths surprise tests. - CSRF gate compatibility with Go's http.Client. Go's client doesn't send
Originon POST by default. The check is "if Origin present, must match" — non-browser clients (curl, Go's client) hit the no-Origin branch and pass, which is correct (CSRF needs a puppeted user-agent).
Final state
- 8 commits squashed-merged as
d0697b9. ~1100 LOC production + ~870 LOC tests acrossdevice.go,device_store.go,device_test.go,device_store_test.go, templates, config, server, oidc, main. - All four PRs of the universe-onboarding plan now merged in order: #135 (PR1), #136 (PR2), #137 (PR3). Plan §PR4 next.
PR4 implementation — broker refresh-grant + JWKS + composite verifier
Cold-started PR4 after #138 (Steps 1+2) merged. Resumed at Step 3 with Open Question 1 resolved as (b) broker re-signs at refresh time — Fritz's "never ship anything broken" directive ruled out the cached-id_token-verbatim path that would have returned a guaranteed-expired bearer.
Changes
Step 3 — IDTokenSigner + JWKS + composite verifier + refresh-grant
tools/demarkus-broker/internal/broker/idtoken.go(new) —IDTokenSignerwraps ECDSA P-256, signs JWTs withkidheader, exposesPublicJWK()andVerifyIDToken(). PKCS#8 + SEC1 PEM accepted. Pinned to ES256.tools/demarkus-broker/internal/broker/jwks.go(new) —GET /.well-known/jwks.json. Body rendered once at construction (no rotation in PR4); Cache-Control max-age=300 matches discovery doc.tools/demarkus-broker/internal/broker/oidc.go— addedcompositeVerifierthat wraps a primary (IdP) Verifier with broker-key verification leg. Dispatch policy: broker-first (no network, kid-match short-circuit); any failure BEFORE kid match returnsErrIDTokenKidUnknown→ fall through to IdP; any failure AFTER kid match (bad sig, bad iss, expired) is terminal. Also:NewVerifier(ctx, *OIDCConfig)— pointer because PR4's BrokerSigningKey tipped the struct over gocritic's hugeParam threshold.tools/demarkus-broker/internal/broker/discovery.go—jwks_urinow overridden to broker. Doc comment updated to call out the iss/key consistency (broker as both issuer and key authority).tools/demarkus-broker/internal/broker/device.go—deviceTokennow dispatches ongrant_type:deviceTokenDeviceFlow(existing PR3 path) vsdeviceTokenRefresh(new). Refresh response returns broker-signed JWT in BOTHid_tokenandaccess_token(same string — PR5's/me/installaccepts either; the alternative was a broken/empty access_token).tools/demarkus-broker/internal/broker/server.go—Server.idTokenSigner+Server.jwks+ JWKS route registration.NewServernow takes*IDTokenSigner(nilable for tests); when non-nil it wraps the supplied Verifier incompositeVerifiertransparently. Test helpernewTestServerWithSignerfor refresh-grant-specific tests.tools/demarkus-broker/main.go— callsNewIDTokenSignerafterLoadConfig, passes through toNewServer. Logs the kid for operator audit.tools/demarkus-broker/internal/broker/config.go—OIDCConfig.BrokerSigningKey(PEM string) +ServerConfig.IDTokenTTL(default 15m).BROKER_SIGNING_KEYenv-var override added alongsideOIDC_CLIENT_SECRETso the PEM travels through an externally-managed Secret viasecretKeyRefrather than baking into helm release history.
Step 4 — RFC 7009 /token/revoke
tools/demarkus-broker/internal/broker/revoke.go(new) —POST /token/revokeunderipRateLimit. Idempotent: unknown / already-revoked → 204 (no enumeration oracle). Missingtokenform param → 400 invalid_request.token_type_hintaccepted and ignored. Anonymous endpoint — possession of the token is the authz signal.
Step 5 — Sweeper integration
tools/demarkus-broker/internal/broker/refresh.go— exportedrefreshStore→RefreshStoreso both Server and Sweeper can hold the same instance. Field onServerstays lowercase (private); exposed viaServer.RefreshStore()method formain.goto wire into the Sweeper.tools/demarkus-broker/internal/broker/sweeper.go—Sweeper.refreshStorefield (optional, nilable for back-compat). Per-tick loop callsrefreshStore.Sweepafter the issuance sweep; logs the count.tools/demarkus-broker/main.go—NewSweeper(issuer, srv.RefreshStore(), interval, log).
Step 6 — config validation + Helm chart wiring
tools/demarkus-broker/internal/broker/config.go—applyRefreshDefaults+OIDCConfig.validate()extracted to keepConfig.validateunder the gocyclo budget. New validation:BrokerSigningKeyrequired;RefreshTokenTTLdefault 90d;IDTokenTTLdefault 15m;IDTokenTTL < RefreshTokenTTLinvariant (degenerate config rejected).deploy/helm/demarkus-broker/values.yaml—server.refreshTokenTTL,server.idTokenTTL,server.refreshTokensSecret,oidc.brokerSigningKey,oidc.existingSigningKeyRefall documented.deploy/helm/demarkus-broker/templates/secret-config.yaml— renders the new server fields + brokerSigningKey. Mutual-exclusion guard mirrors clientSecret (set cleartext OR existingSigningKeyRef, not both).deploy/helm/demarkus-broker/templates/secret-refresh-tokens.yaml(new) — seeds empty refresh-tokens Secret withhelm.sh/resource-policy: keep, same shape as secret-issuances.yaml.deploy/helm/demarkus-broker/templates/rbac-broker-ns.yaml— broker SA'sget/updateon secrets now covers BOTH issuances + refresh-tokens names.deploy/helm/demarkus-broker/templates/deployment.yaml—BROKER_SIGNING_KEYenv mounted viasecretKeyRefwhenexistingSigningKeyRef.nameis set.deploy/helm/demarkus-broker/templates/_helpers.tpl—demarkus-broker.refreshTokensSecretNamehelper.deploy/kind/values-broker.yaml+values-broker-argo.yaml— static test PEM baked in for kind harness.deploy/helm/demarkus-broker/README.md— install example documents brokerSigningKey +openssl genpkeyrecipe..github/workflows/ci.yml—test-upgrade-wipe.shinvocation for demarkus-broker now passes--set oidc.brokerSigningKey=test-pem-placeholder.
Tests added
idtoken_test.go: 8 tests — sign/verify round-trip, expired, wrong iss, foreign kid (ErrIDTokenKidUnknown), bad signature, PKCS#8 + SEC1 acceptance, non-ECDSA rejection, P-384 rejection, PublicJWK public-only invariant.jwks_test.go: 2 tests — handler serves public key with kid+alg+use, JWKS fetch round-trip.oidc_test.go: 5 composite-verifier tests — pass-through AuthCodeURL+Exchange, broker-signed VerifyIDToken, fall through on unknown kid, NO fall through on broker-side signature fail (security gate), nil-signer pass-through.device_test.go: 3 refresh-grant tests — happy path, error matrix (missing/empty/unknown token), revoked token rejection, cross-grant isolation. Plus deviceCallback refresh-mint-failure-leaves-pending test (Step 2 fixup).revoke_test.go: 5 tests — valid, unknown is no-op (no enumeration), missing/empty token rejection, token_type_hint accepted, idempotency.sweeper_test.go: 2 tests — refresh-token sweep removes expired only, nil-refreshStore is no-op.config_test.go: 8 new validate cases — required key, TTL defaults, TTL overrides, TTL invariants, Secret name default+override.helm-unittest: 17 new chart tests (secret-config: 11 covering TTLs/signing-key modes/mutual-exclusion; secret-refresh-tokens: 5; deployment: 2 for BROKER_SIGNING_KEY env wiring). 72/72 pass.
Design decisions worth remembering
-
Composite verifier broker-first, no IdP fallback on signature fail. Earlier draft fell through to IdP on any broker-side error; CodeRabbit-style threat model says no — a tampered token with the broker's kid that the IdP's permissive verifier accepts would be a forgery. Only "not a broker-shaped JWT" (parse fail, wrong alg, kid mismatch) defers; everything past kid-match is terminal.
-
PEM via env var, not file mount. Multi-line PEMs travel fine through k8s
secretKeyRef → env var— kubelet preserves newlines. File-mount path would have required a new config field for the path; env var keeps OIDCConfig stable. -
access_token == id_token. Refresh response sets both to the same broker-signed JWT. Spec-fuzzy in OAuth2 vs OIDC, but PR5's/me/installaccepts either; an empty access_token would force a per-broker client quirk. Same-value is honest: the broker issues one identity bearer. -
Exported
RefreshStore(was lowercase). Step 5 needed Sweeper to share the same instance with Server. Three options considered: (a) move construction up to main.go (signature churn), (b) Sweeper takes a closure (leaks abstraction), (c) export the type + addServer.RefreshStore()getter. Picked (c) — smallest surface change. -
IDTokenTTL < RefreshTokenTTLconfig invariant. A degenerate config where the bearer outlives its refresh credential cannot renew. Rejected at LoadConfig validate so the pod CrashLoopBackOffs at startup rather than the first refresh poll. -
Kind harness ships a static PEM. Reproducible builds need a stable kid across rebuilds. Generated once with
openssl genpkey; never used in production. Same posture as the dev cookieKey baseline.
Verification
go test -race -count=1 ./internal/broker/→ all green (~6s, 100+ tests after PR4)helm unittest .(broker chart) → 72/72 pass (was 55; +17 PR4 surfaces)helm lint .→ clean (info-level warnings on chart icon, unrelated)bash pre-commit.sh→ format + vet + lint clean across protocol/server/client/tools
Next
PR opens against feat-tools-broker-refresh-grant. After review/merge: PR5 (/me/install) consumes the broker-signed id_token as a bearer — broker's compositeVerifier accepts both broker-signed (refresh-renewed) and IdP-signed (device-code-completion) tokens, so PR5's requireAuth works on either. The PR4 broker is the resource server for refresh tokens; world tokens remain on DELETE /tokens/{label} as before.
PR4 review round — 11 CodeRabbit comments addressed
PR139 opened with the PR4 implementation; CodeRabbit flagged 11 issues. Worked through them with craftsman bar (no shortcut "minimal patch" fixes — each addressed at the right architectural layer).
Code-side bugs (5)
NewVerifier(nil)panic (oidc.go). PR4 changed the signature to*OIDCConfig; nil callers crashed instead of returning a structured error. Added nil-guard at the boundary +TestNewVerifierRejectsNilConfig.NewServerdiscovery-without-signer invariant (server.go). Discovery overridesjwks_urito broker (PR4 change); ifidTokenSigneris nil, the doc advertises a route that isn't registered. Fail-fastpanicat construction +TestNewServerPanicsOnDiscoveryWithoutSigner. ExistingTestWellKnownDiscoveryRouteRegisteredupdated to provide a signer.- Parse-at-validate for
BrokerSigningKey(config.go).OIDCConfig.validate()now callsNewIDTokenSignerand propagates parse errors asoidc.brokerSigningKey is invalid: …. Catches malformed PEMs at LoadConfig instead of letting them survive past OIDC discovery + kube client setup, where the error message is noisier and burns startup time. TestLoadConfigenv leak (config_test.go).applyEnvOverridesreadsBROKER_SIGNING_KEYandOIDC_CLIENT_SECRET; ambient env from CI or a developer masks the missing-YAML test path.t.Setenv("BROKER_SIGNING_KEY", "")+ same forOIDC_CLIENT_SECRETat test start.- Revoke 500 was text/plain (revoke.go). RFC 7009 §2.2.1 says JSON.
http.Error→writeJSON(deviceTokenError{Error: "server_error"})+ reactor-drivenTestTokenRevokeServerErrorIsJSONthat forces a k8s outage and asserts the JSON shape.
Security hygiene (3, all about PEMs in repo)
- Kind harness PEMs removed. Both
deploy/kind/values-broker.yamlandvalues-broker-argo.yamlpreviously embedded a static test PEM. Replaced withexistingSigningKeyRef.name: broker-signing-key.deploy/kind/up.shgotensure_broker_signing_key()which generates a fresh ECDSA P-256 PEM per harness run, applies it viakubectl create secret ... --dry-run | kubectl apply -f -(idempotent), and cleans up the temp file via a function-scopedtrap RETURN. - Helm test fixture sentinel.
tests/secret-config_test.yaml's "cleartext brokerSigningKey renders" test had a PEM-shaped string with realBEGIN PRIVATE KEYmarkers. Secret scanners flagged it. Replaced withTEST_BROKER_SIGNING_KEY_LINE_*synthetic sentinels. - Go test fixture PEM via
init().validConfigconst had a"test-pem-placeholder"literal that worked when validate didn't parse, but parse-at-validate broke it. Converted to avarinitialized infunc init()that generates an ephemeral ECDSA P-256 PEM and substitutes it into the YAML template. No PEM lives as a checked-in string; scanners are quiet; parse-at-validate sees real material. CompanionvalidConfigNoSigningKeyfor the "brokerSigningKey is required" test case so it doesn't depend on brittle string replacement against the embedded PEM.
CI / docs (3)
- CI workflow generates real PEM per run.
ci.yml's test-upgrade-wipe step usesmktemp+openssl genpkey+helm --set-file oidc.brokerSigningKey="$tmpfile"+trap 'rm -f' EXIT. PEM is ephemeral; no shell-history leak. - README cleanup. Dropped "PR4:" internal prefix from a user-facing comment; added
-out broker-key.pemto the openssl example so users copying it actually save the key.
Design decisions worth keeping
- Parse-at-validate vs late-in-main. Moving
NewIDTokenSignercall intoOIDCConfig.validatecost microseconds at startup and bought a clean operator-facing error surface. A malformed PEM now fails withoidc.brokerSigningKey is invalid: parse PKCS#8 private key: …at LoadConfig — referenceable directly in chart docs — instead of after OIDC discovery and kube client setup have already produced noisy startup logs. NewServerpanic, not error. Discovery-without-signer is a programming-error contract violation, not a recoverable runtime condition. NewServer's signature stays error-less; the failure is loud and obvious. Tests candefer func() { recover() }()to assert.- Ephemeral test PEMs in
init(). Same approach pays off twice: secret scanners don't trip on test fixtures, AND parse-at-validate has real material to verify. Pattern isvar x string; func init() { x = generateAndSubstitute(...) }. trap '...' RETURNfor function-scoped cleanup in bash. Function-local, fires on both normal-return ANDset -epropagation. Empirically verified againstset -euo pipefail+ a forced mid-functionfalse: trap fires, temp file is removed, function returns nonzero. Cleaner than the 4-branch if/return-1 pattern CodeRabbit initially proposed.
PR4 merged (#139) — workflow-YAML ${{ }} quirk
PR139 merged. CI on main immediately failed: Invalid workflow file: .github/workflows/ci.yml#L1 (Line: 180, Col: 14): An expression was expected.
Root cause: I'd written ${{ }}-safe inside a shell comment in a run: block, intending to note that the surrounding logic was safe against literal-tilde substitution. GitHub Actions parses every run: block byte-by-byte for ${{ … }} expressions BEFORE shell-comment semantics apply. An unmatched ${{ opener is a syntax error, period.
Fix on fix-ci-broker-signing-key-comment: rewrote the comment to avoid the ${{ token entirely + added a meta-note inside the file pointing at the issue so future edits don't reintroduce it. Validated grep -c '\${{' == grep -c '}}' == 5 (all balanced, legitimate expressions). Merged.
Lesson
Workflow YAML comments are NOT shell comments to GitHub Actions. The parser doesn't know # starts a shell comment — it scans the literal bytes for expression syntax. Any ${{ in a run: block requires a matching }} even inside a #-prefixed line. pre-commit.sh doesn't catch this because it doesn't run actionlint. Worth adding actionlint to pre-commit (or a CI prevalidate step) as a follow-up.
Status
- All four PRs of the universe-onboarding plan merged in order: #135 (PR1), #136 (PR2), #137 (PR3), #139 (PR4 — broker refresh tokens + JWKS + revoke + sweeper integration + Helm wiring). #138 was PR4-Steps-1+2 merged separately mid-implementation.
- Plan §PR5 next. Sub-plan published to
/plans/universe-onboarding-pr5.mdwith full architecture, sub-task breakdown, open design questions, and next-session resume steps. - Tomorrow's first move: per the PR5 sub-plan's §Next-Session Resume Steps.