soul.demarkus.io:6309/journal/2026-06-11.md/v5 draft reader meta

2026-06-11 — Broker confidential web-client support

Implemented plan Step 1 from /demarkus-library/plans/phase-1b-web-sso.md on branch feature/phase-1b-broker-web-client (demarkus repo, broker).

What landed

  • webClients registry in broker config: clientID, clientSecretHash (sha256-hex, normalized + shape-validated at load), exact-match https redirectURIs (no loopback, no userinfo, no fragment), optional name.
  • /oauth/authorize branches on registry membership: registered client → allowlist exact match; unregistered → existing loopback-only path untouched.
  • authorization_code grant requires client auth (Basic per RFC 6749 §2.3.1 with form-urlencoded halves, or client_secret_post) when the presented client_id is registered. Verified constant-time before Redeem so a bad secret never burns the code. 401 invalid_client + WWW-Authenticate: Basic.
  • Refresh tokens minted through a confidential exchange carry clientID (omitempty — old Secret records decode as unbound); the refresh grant requires the bound client to authenticate. Deregistered client → bound tokens stop minting.
  • Discovery now advertises token_endpoint_auth_methods_supported: ["none","client_secret_basic","client_secret_post"].
  • New client_auth.go (parse + verify + 401 helper), web_client_test.go (~7 test funcs incl. code-preserved-after-401 pin), ADR at docs/adr/0001-broker-confidential-web-clients.md (first repo ADR).

Deviations from the plan (reasoned, recorded in the ADR)

  • No Confidential stamp in the auth-code store. Redundant: Redeem already binds client_id constant-time and the registry is immutable for the process lifetime, so registry lookup of the presented client_id is authoritative. Checking the secret pre-Redeem also preserves the store's keep-code-on-client-error semantics.
  • No per-client scopes field. Scopes are declared-not-enforced broker-wide; the field would be dead config (conventions: question opt-in knobs).

Gotcha

deviceTokenRefresh verifies client binding after refreshStore.Refresh (the record is the source of the requirement), so a failed client auth still bumps LastUsedAt. Deliberate: observability metadata only, no token minted. Documented in the handler comment.

Follow-ups

  • Helm chart values surface for webClients (config is additive; chart not touched in this change).
  • /token/revoke does not require client auth for bound tokens — RFC 7009 would suggest it; revocation-by-holder is fail-safe so left as is.
  • Tests + bash pre-commit.sh green. Fritz to commit + PR.

Addendum — chart + kind smoke (same session)

Fritz pointed at the kind smoke; turned out the feature wasn't deployable: the broker chart renders config.yaml field-by-field, so webClients never reached the binary. Grew the PR per the never-ship-broken rule:

  • Chart: webClients: [] in values.yaml (documented example), explicit render in secret-config.yaml with required guards (half-registered client fails at render, not as a CrashLoop), 5 new helm unittests (109 total pass).
  • LoadConfig YAML round-trip tests added — KnownFields(true) would have rejected a yaml-tag typo the direct validateWebClients tests can't see.
  • deploy/kind/up.sh --with-mcp-smoke: new webclient-smoke pod after the auth-code leg. Registers library-web-smoke via --set (per-run secret, sha256 on host, .invalid redirect host — only the 302 Location is parsed). Nine checks: discovery advertises client_secret_basic; unregistered redirect 400; authorize→IdP→callback→code on the registered https redirect; secretless exchange 401 invalid_client + Basic challenge; Basic exchange 200 AND proves the failed attempt preserved the one-shot code; unauthenticated refresh of the bound token 401; authenticated refresh 200.
  • Ran it for real: full up.sh --with-broker --with-mcp-smoke green on a fresh kind cluster — loopback leg unchanged, web-client leg end-to-end OK. Cluster left up (deploy/kind/down.sh to tear down).
trail
  1. soul.demarkus.io:6309 v5