ADR 0001 — Broker confidential web-client registry
Status: accepted (2026-06-11)
Context
The broker's /oauth/authorize endpoint was loopback-only (RFC 8252 §7.3),
built for native/CLI agents whose MCP SDKs spin up a localhost listener. A
deployed web application (the Universe Library reading room) has no loopback,
so the authorization-code redirect flow was a dead end for server-side apps.
The device flow works without a redirect but gives web users the TV-login
experience (tab hop, code entry, polling) — rejected as the primary web UX.
Every client was public/PKCE: /oauth/authorize treated client_id as
opaque, /register (RFC 7591 DCR) pins token_endpoint_auth_method=none,
and the token endpoint never checked a client secret.
The full decision trail lives in the demarkus-soul document
/demarkus-library/adr/0004-broker-web-sso.md; this ADR records the
broker-side mechanics.
Decision
Add a registered confidential web client class alongside the existing native path, as an explicit operator-curated registry — not open DCR.
- Registry —
webClientsin broker config:clientID,clientSecretHash(sha256-hex of the secret; plaintext never in config),redirectURIs(exact-match https allowlist), optionalname. Validated at load: unique ids, 64-hex-char hash, ≥1 redirect, each redirect absolute https with no userinfo/fragment and no loopback host. /oauth/authorize— a registeredclient_idvalidatesredirect_uriby exact match against its allowlist (no loopback exemption); an unregisteredclient_idkeeps the loopback-only public path unchanged.- Token endpoint (
/device/token) — theauthorization_codegrant requires client authentication (HTTP Basic per RFC 6749 §2.3.1, orclient_secret_post) when the presentedclient_idis registered. The secret is verified constant-time against the stored hash before the code is redeemed, so a failed authentication does not burn the code. Failure →401 invalid_client+WWW-Authenticate: Basic. - Refresh binding — refresh tokens minted through a confidential exchange
record the
clientID; therefresh_tokengrant then requires the same client to authenticate. A leaked bound refresh token alone mints nothing. Tokens from the device flow and the loopback auth-code path stay unbound and refresh as before. - Discovery —
token_endpoint_auth_methods_supportedadvertises["none","client_secret_basic","client_secret_post"].
Notable choices
- No
Confidentialflag in the auth-code store. The plan called for stamping pending entries; it is redundant.Redeemalready bindsclient_idconstant-time and the registry is immutable for the process lifetime, so "entry's client_id is registered" is exactly "entry was issued confidential". The token handler's registry lookup of the presented client_id is therefore authoritative, and doing the secret check beforeRedeempreserves the store's keep-code-on-client-error retry semantics. - sha256, not bcrypt, for the secret hash. The secret is operator-generated high-entropy randomness, not a human password — there is no low-entropy input for a fast hash to endanger, and it avoids a new direct dependency.
- No per-client
scopesfield. Scopes are declared-not-enforced broker-wide today; a per-client list would be dead config. Add it when scope enforcement exists. - PKCE stays required for confidential clients — defense in depth; the authorize handler enforces S256 for both client classes.
Consequences
- Native/CLI clients (Claude Code MCP SDK, demarkus-join) are untouched: no registry entry → identical behavior on every endpoint.
- The Universe Library (and future first-party web apps) can run standard redirect SSO against the broker with a deploy-time client registration.
- Operators must generate the client secret out of band and put only its
sha256 into config; the chart needs a values surface for
webClients(follow-up, not in this change). - A client deregistered from config invalidates its bound refresh tokens at the refresh gate until re-registered or users re-authenticate.