# 2026-06-11 — Broker confidential web-client support (Phase 1b Step 1) Implemented plan Step 1 from [/demarkus-library/plans/phase-1b-web-sso.md](/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).