# Phase 1b — Web SSO over the Broker (two-repo plan) Concrete implementation plan for the **library card** (the turnstile). Spans two repos: the **broker** (`~/latebit/demarkus/tools/demarkus-broker`) and this library. Supersedes the auth-code/loopback assumption in [roadmap.md](/demarkus-library/roadmap.md) — see findings below. Decision rationale: [ADR 0004 — Broker confidential web client + redirect SSO](/demarkus-library/adr/0004-broker-web-sso.md). ## Findings that shaped this plan (broker source, 2026-06-11) - **`/mcp` is always bearer-gated** (`mcp_auth.go:33-65`). Login is a hard turnstile; **no anonymous reads.** (Resolves the long-standing open question.) - **`/oauth/authorize` is loopback-only** (`oauth_authorize.go:54-60`, RFC 8252 §7.3, `http` scheme only). Built for native/CLI agents (Claude Code's MCP SDK spins up a loopback listener). A **deployed** web app has no loopback → auth-code redirect is a dead end for the server deployment. - The broker has the **full RFC 8628 device flow** (`device.go`): `/device/authorize`, `/device`, `/device/token`. Works without a redirect — but the UX is the TV-login experience (tab hop + code + polling). Rejected as a shortcut (ADR 0004). - **No downstream client registry** today — all clients are public/PKCE (`/oauth/authorize` treats `client_id` as opaque; `register.go` pins `token_endpoint_auth_method=none`). Adding a confidential web client introduces the first registry. - Endpoint corrections vs old roadmap: token endpoint is **`/device/token`**, authorize is **`/oauth/authorize`**. Tokens are JWT; `access_token == id_token`; refresh TTL 90d; scopes `mark.read`/`mark.write` (declared, not enforced); identity = verified email (no org-id in scope). - Token endpoint already dispatches `authorization_code` (`device.go:214,337`) but **PKCE-only, no client-secret check** (`handleAuthCodeGrant` → `authCodeStore.Redeem(code, client_id, redirect_uri, code_verifier)`). ## Step 1 — Broker: confidential web-client support (demarkus repo, **Fable**) Add a *registered confidential web client* class **alongside** the existing native path. Native/CLI agents (incl. Claude Code) must be untouched. **1.1 Config — downstream client registry.** Add `WebClients []WebClientConfig` (yaml `webClients`) to `Config` (`config.go`). Each entry: `clientID`, `clientSecretHash` (hashed — sha256-hex or bcrypt; never plaintext), `redirectURIs []string` (exact-match allowlist; https, non-loopback allowed), optional `name`, `scopes`. Startup validation: non-empty id, ≥1 https redirect, secret present. First-party app — explicit registry, NOT open DCR. **1.2 `/oauth/authorize` — branch on known web client** (`oauth_authorize.go`, after the client_id extraction at line 44): - Look up `client_id` in `WebClients`. - Found (confidential): validate `redirect_uri` by **exact match** vs the client's `redirectURIs` (allow https + non-loopback). **Skip** `isLoopbackRedirectURI`. Stamp the pending auth-code entry `Confidential=true` + carry `client_id`. - Not found: existing loopback-only public-client behavior unchanged. - Carry `Confidential` through the pending entry into `authCodeStore` (new field). **1.3 `/device/token` authorization_code grant — client auth** (`handleAuthCodeGrant`, `device.go:337`): - Parse client creds: HTTP **Basic** (RFC 6749 §2.3.1, preferred) or `client_secret_post` form param. - If redeemed entry is `Confidential`: **require** client auth; verify `client_secret` vs registry hash (constant-time). Mismatch → `401 invalid_client` + `WWW-Authenticate: Basic`. - Keep PKCE enforced for both (defense in depth). Public clients unchanged. **1.4 `refresh_token` grant — bind to confidential client** (`refresh.go`): require + verify client secret for confidential clients (leaked refresh token alone is useless). Bind `client_id` to the stored refresh token. **1.5 Discovery (optional):** advertise `token_endpoint_auth_methods_supported: ["none","client_secret_basic","client_secret_post"]`. `authorization_code` + `S256` already advertised. **1.6 Tests:** authorize accepts registered web client + https redirect; rejects https redirect for unregistered client (still loopback-only); rejects redirect not in allowlist. Token requires+verifies secret for confidential, `invalid_client` on bad secret; public path unchanged. Refresh requires secret for confidential. ADR in demarkus repo. **Security rails:** exact-match redirect only (no wildcard/prefix); constant-time secret compare; hashed at rest; https-only web redirect; loopback path isolated; existing IP rate-limit applies. ## Step 2 — Library: redirect SSO + broker MCP gateway (this repo) New outbound adapters + session + middleware, swapped at the composition root. Reading-room core/web logic unchanged **except** the one signature change in 2.4. **2.1 Config** (`cmd/demarkus-library/config.go` AppConfig + env): `DEMARKUS_BROKER_URL`, `DEMARKUS_CLIENT_ID`, `DEMARKUS_CLIENT_SECRET`, `DEMARKUS_REDIRECT_URI` (`https://lib/auth/callback`), `DEMARKUS_WORLD` (default world name for `mark://` URLs), `DEMARKUS_SCOPES` (default `mark.read`), session cookie secret, `DEMARKUS_TRANSPORT=quic|broker` (composition-root selector; keeps Phase 0/1a demo path). **2.2 `internal/adapter/outbound/oauth/` — broker OAuth client** *(Fable)*: - `Discover()` GET `/.well-known/oauth-authorization-server`, cache (5-min TTL). - `AuthCodeURL(state, challenge)`: `/oauth/authorize?response_type=code&client_id&redirect_uri&scope&state&code_challenge&code_challenge_method=S256`. - `Exchange(ctx, code, verifier)` POST `/device/token` `grant_type=authorization_code` + **Basic** client auth → `{id_token, refresh_token, expires_in}`. - `Refresh(ctx, refreshToken)` POST `grant_type=refresh_token` + Basic auth. - `Revoke(ctx, refreshToken)` POST `/token/revoke`. - PKCE S256 helpers; CSPRNG state. **2.3 `internal/adapter/outbound/broker/` — MCP `WorldGateway`** *(Opus, after 2.2)*: - MCP client over Streamable HTTP to `/mcp` (mcp-go client, already a demarkus dep), `Authorization: Bearer `. - Map: `Fetch(ctx,path)`→`mark_fetch{url:"mark://"}`; `List`→`mark_list`; `Versions`→`mark_versions`; `Lookup(ctx,scope,query)`→`mark_lookup{url:"mark://", query}`. - Parse tool result → `domain.RawDocument`; map world status → `ErrNotFound`/`ErrUnauthorized` (mirror `world.go:78-87`). - Design point for Fable: stateless per-call POST with bearer vs maintained MCP session/`initialize`. Lean stateless (token is per-session); document choice. **2.4 Per-request token threading (the one core touch):** add `context.Context` as first arg to outbound `WorldGateway` AND inbound `ReadingService` methods (`port.go`). Middleware puts the session bearer in ctx; broker gateway reads it; direct-QUIC gateway ignores it (static token). Idiomatic Go + cancellation. *Rejected alternative:* per-request `GatewayFactory(token)` + per-request service. **2.5 Session — `internal/adapter/inbound/web/session/`** *(Fable — refresh races)*: - Cookie = opaque session id (HttpOnly, Secure, SameSite=Lax). Tokens stored **server-side**, keyed by session id, never to the browser (XSS boundary). - In-memory store behind an interface (swap Redis/DB later). - Refresh on near-expiry, **single-flight** per session (`singleflight`/mutex) to kill refresh races. Revoke on logout. **2.6 Middleware + routes + UI** *(Opus)*: - Auth middleware: unauthenticated → 302 `/login` (preserve return-to). - `GET /login` (gen state+PKCE, stash, redirect to broker authorize) · `GET /auth/callback` (validate state, exchange, create session, redirect back) · `POST /logout` (revoke + clear). - Login page degrades without JS — real link/form per ADR 0003. **2.7 Composition root (`cmd`):** `DEMARKUS_TRANSPORT` selects quic vs broker; broker mode wires oauth client + session store + broker gateway + auth middleware. **2.8 Tests:** oauth client vs `httptest` broker (discovery/authorize-URL/exchange/ refresh); session store + single-flight refresh; middleware redirect; broker gateway vs mock MCP server; ctx token threading. ## Sequencing & ownership | # | Work | Repo | Model | Blocks | |---|------|------|-------|--------| | 1 | Broker confidential web client (1.1–1.6) | demarkus | **Fable** | prereq | | 2.2 | OAuth client | demarkus-library | **Fable** | needs 1 | | 2.5 | Session + single-flight refresh | demarkus-library | **Fable** | — | | 2.4 | ctx threading on ports | demarkus-library | Opus | — | | 2.3 | Broker MCP WorldGateway | demarkus-library | Opus | needs 2.2 | | 2.6 | Middleware + routes + login UI | demarkus-library | Opus | needs 2.5, 2.2 | | 2.7 | Composition-root swap | demarkus-library | Opus | needs all | **Start point (Fable, demarkus repo):** Step 1 — broker confidential web client. Prerequisite + correctness-critical. ## Process - Work on a `feature/phase-1b-*` branch. Never auto-commit (Fritz commits). - PR scopes: add `broker`, `auth`, `session`, `oauth` to the existing set. ## Status - **Step 1 (broker) — implemented 2026-06-11** on `feature/phase-1b-broker-web-client` (demarkus repo), awaiting Fritz commit + PR. All of 1.1–1.6 incl. the optional 1.5 discovery advertisement. Two reasoned deviations, recorded in the repo ADR (`docs/adr/0001-broker-confidential-web-clients.md`) and [/journal/2026-06-11.md](/journal/2026-06-11.md): no `Confidential` stamp in the auth-code store (redundant given Redeem's client_id binding + immutable registry; secret verified pre-Redeem so a bad secret never burns the code), and no per-client `scopes` field (declared-not-enforced broker-wide → dead config). Follow-up: chart values surface for `webClients`. - Step 2 (library) — not started; 2.2/2.5 next on Fable once Step 1 merges.