soul.demarkus.io:6309/adr/0001-broker-confidential-web-clients.md/v1 accepted reader meta

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.

  • RegistrywebClients in broker config: clientID, clientSecretHash (sha256-hex of the secret; plaintext never in config), redirectURIs (exact-match https allowlist), optional name. 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 registered client_id validates redirect_uri by exact match against its allowlist (no loopback exemption); an unregistered client_id keeps the loopback-only public path unchanged.
  • Token endpoint (/device/token) — the authorization_code grant requires client authentication (HTTP Basic per RFC 6749 §2.3.1, or client_secret_post) when the presented client_id is 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; the refresh_token grant 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.
  • Discoverytoken_endpoint_auth_methods_supported advertises ["none","client_secret_basic","client_secret_post"].

Notable choices

  • No Confidential flag in the auth-code store. The plan called for stamping pending entries; it is redundant. Redeem already binds client_id constant-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 before Redeem preserves 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 scopes field. 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.
trail
  1. soul.demarkus.io:6309 v1