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 — see findings below.
Decision rationale: ADR 0004 — Broker confidential web client + redirect SSO.
Findings that shaped this plan (broker source, 2026-06-11)
/mcpis always bearer-gated (mcp_auth.go:33-65). Login is a hard turnstile; no anonymous reads. (Resolves the long-standing open question.)/oauth/authorizeis loopback-only (oauth_authorize.go:54-60, RFC 8252 §7.3,httpscheme 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/authorizetreatsclient_idas opaque;register.gopinstoken_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; scopesmark.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_idinWebClients. - Found (confidential): validate
redirect_uriby exact match vs the client'sredirectURIs(allow https + non-loopback). SkipisLoopbackRedirectURI. Stamp the pending auth-code entryConfidential=true+ carryclient_id. - Not found: existing loopback-only public-client behavior unchanged.
- Carry
Confidentialthrough the pending entry intoauthCodeStore(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_postform param. - If redeemed entry is
Confidential: require client auth; verifyclient_secretvs registry hash (constant-time). Mismatch →401 invalid_clientWWW-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_idto 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/tokengrant_type=authorization_code+ Basic client auth →{id_token, refresh_token, expires_in}.Refresh(ctx, refreshToken)POSTgrant_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 <session id_token>. - Map:
Fetch(ctx,path)→mark_fetch{url:"mark://<world><path>"};List→mark_list;Versions→mark_versions;Lookup(ctx,scope,query)→mark_lookup{url:"mark://<world><scope>", query}. - Parse tool result →
domain.RawDocument; map world status →ErrNotFound/ErrUnauthorized(mirrorworld.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,oauthto 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: noConfidentialstamp 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-clientscopesfield (declared-not-enforced broker-wide → dead config). Follow-up: chart values surface forwebClients. - Step 2 (library) — not started; 2.2/2.5 next on Fable once Step 1 merges.
Step 1 merged to main 2026-06-11 (squash commit 117e2a6, "feat(broker):
Add confidential OAuth client registration to broker") — broker + chart
webClients rendering + kind smoke web-client leg (verified green on a live kind
cluster pre-merge). Chart follow-up from the original plan is closed; nothing
broker-side remains. Next: library 2.2 (oauth client) + 2.5 (session +
single-flight refresh) on Fable in ~/latebit/demarkus-library.