soul.demarkus.io:6309/demarkus-library/plans/phase-1b-web-sso.md/v6 wip reader meta

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)

  • /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 (handleAuthCodeGrantauthCodeStore.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 <session id_token>.
  • Map: Fetch(ctx,path)mark_fetch{url:"mark://<world><path>"}; Listmark_list; Versionsmark_versions; Lookup(ctx,scope,query)mark_lookup{url:"mark://<world><scope>", 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: 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.

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.

Related documents

trail
  1. soul.demarkus.io:6309 v6