Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
ba11ead
plans: connection-gated auth + local-first path docs
shikokuchuo Jul 15, 2026
a49367a
sync-client: storage-only Repo for local-first (A2, bd-e2qnvb4a)
shikokuchuo Jul 15, 2026
cd1e91c
project-set: local-only creation path (A1, bd-uvtx8qux)
shikokuchuo Jul 15, 2026
de14813
identity: persisted per-browser local actor (A3, bd-gxz6tqbk)
shikokuchuo Jul 15, 2026
db79036
hub-client: connection-gated auth + local-first default (A4, bd-u4p8x…
shikokuchuo Jul 15, 2026
be455d2
hub-client: local-first E2E + user docs (A7v1, bd-a1gpy16v)
shikokuchuo Jul 15, 2026
82b1937
hub-client: prompt sign-in when opening a hub project logged off (bd-…
shikokuchuo Jul 15, 2026
91ee1ff
plan: offline read+write of cached hub projects (epic bd-xxjy9yfp)
shikokuchuo Jul 15, 2026
88abc70
sync-client: offline-cached-hub test scaffolding (B0, bd-ysusqcb3)
shikokuchuo Jul 16, 2026
147d93c
hub-client: open cached hub project offline under local actor (B1, bd…
shikokuchuo Jul 16, 2026
4eb56dc
hub-client: offline authorship + reconnect actor-switch for cached hu…
shikokuchuo Jul 16, 2026
92711ae
scripts: make local-prod's static port configurable via PORT env var
shikokuchuo Jul 17, 2026
0a8003a
hub-client: restore editable Sync Server URL field in Create/Import (…
shikokuchuo Jul 17, 2026
1ede538
hub-client: fix Sync Server URL default breaking local project creati…
shikokuchuo Jul 17, 2026
bd69e9a
hub-client+sync-client: fix flash-and-bounce on logged-off hub opens …
shikokuchuo Jul 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Auth reshape — overview & path (index)

**Status:** proposed (index)
**Date:** 2026-07-06

This is a **thin index**. The work is decomposed into **three self-contained,
independent plans** (plus a deferred adoption follow-on to plan 1); the substance
lives in each. This doc only records the map, the sequencing, and the
cross-cutting facts that let them stay independent.

## The three plans

1. **`2026-07-06-hub-client-connection-gated-local-first.md`** — Connection-gated
auth + local-first documents. The SPA opens with no login gate; documents live
in browser IndexedDB; auth is required only on "Connect to a hub." **v1** ships
local-first + gate removal + a "Connect to a hub" action that opens/creates
**hub-side** projects. **Publishing an existing local project up to a hub
(adoption)** — the actor-switch + sync-up — is a **deferred fast-follow** in
its own plan file (`2026-07-06-hub-client-local-project-adoption.md`), gated
on the D1 durability fix (`bd-10bdjmjb`). *Pure hub-client / sync-client work —
no server-auth changes.*

2. **`2026-07-06-hub-client-auth-unification-pkce.md`** — Unify hub-client ↔
hub-mcp auth on Authorization Code + PKCE. Migrate the SPA from the GIS button
to a **public** PKCE client that obtains a Google ID token and hands it to the
existing server callback (**pattern (i)** — no hub token-exchange). **v1 builds
the SPA provider standalone; extracting shared OAuth-config + PKCE primitives
with hub-mcp is deferred** (only if duplication proves real).

3. **`2026-07-06-hub-server-minted-sliding-sessions.md`** (`bd-ey6jg70f`) —
Server-minted sliding sessions. Validate the Google token once at login, then
mint a hub-signed, compact, HttpOnly cookie with sliding expiry — decoupling
session lifetime from Google's 1 h token and from Google One-Tap. *Adds the
hub's first token-minting capability.* **v1 uses a single session secret;
`kid`/rotation and a revocable store are deferred.**

## The path (sequencing)

- **Plan 1 ships first** (user decision) — it's self-contained and delivers the
local-first value with zero server-auth risk. Its **adoption follow-on**
(publish a local project to a hub) lands afterward, once D1 (`bd-10bdjmjb`)
is fixed — separate plan file, same Epic 1.
- **Plans 2 and 3 are independent follow-ons**, either order. Two 2026-07-06
investigations confirmed plan 2 (PKCE) is **orthogonal** to plan 3 (sessions):
pattern (i) keeps the browser a token *provider*, so no hub minting is needed.
- **One soft dependency:** plan 2's "retire Google One-Tap renewal" phase (B2)
needs a durable renewal path → sequence it after plan 3, or keep One-Tap as the
interim. Everything else is decoupled. **No hard `blocks` between the plans.**
- Plan 3 also has **standalone production value** (it structurally fixes the
closed-but-only-tactically-mitigated bug `bd-3o8zmz46` and the >3800-byte
cookie-drop risk), so it can be prioritized early if desired.
- **Standalone (in no epic):** the Bearer-on-`/auth/actor`+`/auth/me` fix
(`bd-3g0aijb3`) is a small independent server-side bug — a **standalone
strand**, not a phase of plan 2 or plan 3. Fix it whenever; it gates nothing.

## Cross-cutting realities (why the split is clean)

1. **A browser is a public OAuth client** — it cannot bind a loopback listener,
use an OS keychain, or hold a `client_secret`; those hub-mcp mechanisms are
inherently native-only (RFC 8252). Unification is therefore at the IdP /
auth-code+PKCE / shared-primitives level, not identical transport/storage. The
hub *already* bridges the SPA and MCP client IDs via a shared JWKS + audience
allowlist. *(Owned by plan 2.)*
2. **Automerge changes are immutable** — you can redirect *future* changes to a
new actor but never re-attribute *past* ones; reconciliation is the
display-layer `identities` map. This is why the local→connected transition is
a *forward actor switch + display bridge*, never a rewrite. *(Owned by the
deferred adoption follow-on `2026-07-06-hub-client-local-project-adoption.md`;
plan 1 v1 keeps the local actor and never switches.)*
3. **The hub is a stateless validator today** — the cookie value *is* Google's
ID token; the hub mints/signs nothing and has no session store. Plan 3 is what
changes that (adds minting; a session store only in its deferred revocable
variant). Plans 1 and 2 leave it stateless. *(Owned by plan 3.)*

## Shared strand map

Three epics, no hard cross-epic `blocks`; phase sub-strands filed when each epic kicks off:
- **Epic 1 — `bd-o3if4hrm`** (epic, p1) → **v1: A0–A4 + A7v1** (plan 1); **deferred adoption: A5, A6, A7adopt** (documented in `2026-07-06-hub-client-local-project-adoption.md`; `conditional-blocks` on `bd-10bdjmjb` until D1 lands). Related: `bd-10bdjmjb` (D1 — gates adoption only), `bd-3nzyd` (E2E 401 tests), `bd-qxgoti2b` (Epic 2).
- **Epic 2 — `bd-qxgoti2b`** (epic, p2) → **B1, B2, B4**; **B0 deferred** (shared package, only if duplication proves real). Related: `bd-cmp48`/`bd-81cfshmw` (hub-mcp reference), `bd-ra5ypj3s` (client registration), `bd-ey6jg70f` (B2 soft dep).
- **Epic 3 — `bd-ey6jg70f`** (epic, p2) → **C0–C4, C6, C7**; **C5 (revocable store) + C5b (`kid`/rotation) deferred**. Related: `bd-3o8zmz46` (root-cause bug it fixes).
- **Standalone (not in any epic):** `bd-3g0aijb3` — Bearer on `/auth/actor`+`/auth/me`, a small independent server-side fix.
116 changes: 116 additions & 0 deletions claude-notes/plans/2026-07-06-hub-client-auth-unification-pkce.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# hub-client ↔ hub-mcp auth unification (SPA → Authorization Code + PKCE)

**Status:** proposed
**Date:** 2026-07-06
**Part of:** the auth-reshape path — see the umbrella index `claude-notes/plans/2026-07-06-connection-gated-auth-and-auth-unification.md`. Independent of the other two plans (connection-gated local-first; server-minted sliding sessions).

## Overview

Migrate the SPA from the Google Identity Services (GIS) button to **Authorization
Code + PKCE** as a **public client**. The browser obtains a Google ID token and
hands it to the *existing* server callback — **no hub token-exchange, no new
server minting/session capability**. The OAuth-config + PKCE primitives *may
later* be extracted into a shared module with hub-mcp, but **v1 builds the SPA
provider standalone** (inlining the small helpers); the shared package is
deferred (see B0) until duplication proves real.

### Key reality that shapes this plan
**A browser is a public OAuth client.** It cannot bind a `127.0.0.1` loopback
listener, cannot use an OS keychain, and must not hold a `client_secret`. The
hub-mcp flow's loopback + `@napi-rs/keyring` + confidential-client refresh are
**inherently native-only** (RFC 8252). So "both use PKCE + loopback + keychain"
is not literally achievable. What genuinely unifies (and *already* does, at the
hub) is the **IdP (Google) + shared JWKS validation + an audience allowlist
explicitly built "to share one hub instance between the SPA and quarto-hub-mcp"**
(`crates/quarto-hub/src/auth.rs:129-131`). This plan unifies at the IdP /
auth-code+PKCE / shared-primitives level; the native-only transport/storage
stays divergent by necessity — that is correct, not accidental drift.

## Current architecture (map for implementers)

### The SPA auth seam (the extension point)
- `hub-client/src/auth/AuthProvider.tsx` — IdP-agnostic interface consumed via `useAuthProvider`. The seam comment (`:58-66`) already anticipates "a future Code+PKCE provider" whose `loginUri` is "the redirect URI the SPA registers." A `noopAuthProvider` handles auth-disabled builds.
- `hub-client/src/auth/GoogleAuthProvider.tsx` — the only file naming GIS APIs (`<GoogleLogin ux_mode="redirect">`, `useGoogleOneTapLogin`, `googleLogout`). This is one implementation behind the interface; a PKCE provider is a sibling.
- Credential sink already exists: silent renewal → `onCredential(jwt)` (`GoogleAuthProvider.tsx:37-54`, `useAuth.ts:119`) → `refreshToken()` → `POST /auth/refresh` (`hub-client/src/services/authService.ts:109-120`). A PKCE provider drops into the same seam and feeds the same id_token to the same endpoint. (Design rationale: `claude-notes/plans/2026-05-20-auth-provider-interface.md:221-224`.)

### Server connection auth (what the browser talks to)
- Stateless OIDC resource server: validates a Google ID-token JWT locally against cached JWKS. **No session store, no hub-minted JWT** — the cookie value *is* Google's token (1 h `exp`, `AUTH_COOKIE_MAX_AGE=3600`). The hub has **zero OAuth-client capability**: no `client_secret`, no `oauth2`/`openidconnect` crate, no token-endpoint call (`Cargo.toml:29-42`; grep for `client_secret|token_endpoint|authorization_code` in `crates/quarto-hub/src/` is empty).
- Credential accepted as `quarto_hub_token` cookie (browser) **or** `Authorization: Bearer` (MCP); both present → 400; neither → 401 (`server.rs:901-950`, `extract_credential` `:212-247`).
- Endpoints: `/auth/callback` (Google form_post → validate → cookie, `:701-729`), `/auth/refresh` (accepts a fresh JWT → validate → re-set cookie, `:844-879`), `/auth/me`, `/auth/actor`, `/auth/logout`, `/ws`, `/health`, `/api/*` (`:1048-1081`). Both `/auth/callback` and `/auth/refresh` are the *receiving* half of a BFF — "receive an already-minted token → validate → cookie it."
- A **stateless** `CallbackCsrfMode::OidcState` variant exists but is a fail-safe stub (`auth.rs:644-664`, `validate_callback_csrf` returns `false` for it at `server.rs:684-688`) — the standard `state`-cookie CSRF path a redirect flow would use.
- Security invariants to preserve: CSRF via `X-Requested-With` (`server.rs:328-338`), WS-origin check (`:345-367`).
- Browser = Web-app Google client (`VITE_GOOGLE_CLIENT_ID` == server `--oidc-client-id`); MCP = separate Desktop-app Google client; hub bridges the two `aud` values via `--additional-audiences` (`auth.rs:129-131`, applied `:599-603`).

### hub-mcp reference (the flow we align to)
- hub-mcp uses Authorization Code + PKCE (S256) + loopback (RFC 8252), but as a **confidential** Desktop-app client: it retains a `client_secret` for the token exchange + refresh grant, and stores tokens in the OS keyring. Its `exchangeCode` (`ts-packages/quarto-hub-mcp/src/auth/auth-tools.ts:440-478`) uses `ClientSecretPost(clientSecret)` — a browser public client can replicate **none** of this. The reusable parts are `oauth-config.ts` (issuer resolution, OIDC discovery) and `pkce.ts` (S256 verifier/challenge/state via `oauth4webapi`) — pure IdP plumbing, portable to the browser.

## Design

### What unifies vs what stays divergent
- **Unifies:** Google as IdP; Authorization Code + PKCE (S256); the hub's shared JWKS validation + audience allowlist (already present); and — optionally, later — shared OAuth-config + PKCE primitive modules (see B0, deferred).
- **Stays divergent (by necessity):** the SPA is a **public** client → origin `redirect_uri` (not loopback), token lands in the server-set HttpOnly cookie (not an OS keychain), no `client_secret`, no locally-stored refresh grant. hub-mcp keeps loopback + keyring + confidential Desktop-app client.

### Flow shape — pattern (i), public-client / ID-token (recommended)
The browser runs authorize + PKCE + `state`/nonce and obtains a **Google ID
token**, which it hands to the *existing* `/auth/refresh` (or `/auth/callback`)
that validates + cookies it — today's model, different acquisition. This keeps
this plan **independent of any server minting** — no dependency on the
sliding-sessions plan. If the redirect is routed through a hub HTTP callback
(rather than landing in the SPA), implement the existing **stateless**
`OidcState` CSRF stub — this is *not* a session store.

**Rejected — pattern (ii), BFF** (hub exchanges the auth code server-side): gives
the hub a brand-new OAuth-*client* role it entirely lacks today (an `oauth2`
client, the Web-app `client_secret`, a token-endpoint call) and pulls
refresh-token + session handling in. Not entailed by "the browser uses PKCE";
adds surface. Do not adopt without a specific driver. **Decide up front** — it
dictates the Google client registration.

## Phases (TDD-first)

- [ ] **B1 — SPA Authorization Code + PKCE provider (pattern i).** New `AuthProvider` implementation: public client, origin `redirect_uri`, PKCE, `state`/nonce; obtains a Google ID token and feeds it to the existing `/auth/refresh` (or `/auth/callback`). **Built standalone — inline the small PKCE / oauth-config helpers (browser Web Crypto); do not block on a shared package (B0, deferred).** Register the Google Web-application `redirect_uri`. Optionally implement the `OidcState` CSRF stub if routing through a hub callback. Tests: full redirect round-trip against a mock IdP; `state`/PKCE verification; `GoogleAuthProvider` still selectable behind a flag during migration.
- [ ] **B2 — Retire GIS/One-Tap renewal.** Replace `useSilentRenewal` (One-Tap) with the chosen renewal path; make `GoogleAuthProvider` legacy/removable. **Sequencing:** the durable renewal path is the sliding-sessions plan — sequence B2 after that plan lands, or keep One-Tap as the interim. Tests: refresh works where One-Tap is blocked (FedCM/3p-cookie).
- [ ] **B4 — E2E verification + docs.** Both `q2 mcp` and a real browser authenticate against one hub via the unified path; both obtain the correct per-project actor. Record invocations + observed output. Docs.

### Deferred / de-scoped

- [ ] **B0 (deferred) — Extract shared OAuth primitives.** Only once B1 proves the browser needs the same `pkce.ts` (30 lines) / `oauth-config.ts` (155 lines) as the hub-mcp Node bundle *and* the duplication is real. The two runtimes use different crypto backends (browser Web Crypto vs Node), so a cross-runtime package boundary is not obviously worth it up front — extract if duplication proves painful, otherwise leave inlined.
- **B3 — out of scope (standalone strand).** The Bearer-on-`/auth/actor`+`/auth/me` fix (`bd-3g0aijb3`) is a small, independent server-side bug that gates nothing; it is tracked as its **own standalone strand**, not a phase of this epic. (It fixes MCP sessions silently falling back to random actors.)

## Non-goals
- The browser will **not** use a loopback listener, an OS keychain, or a `client_secret`. That is a property of public clients, not a gap to close.
- Does **not** introduce hub token-minting or sessions (that's the sliding-sessions plan). Pattern (i) keeps the hub a pure validator.

## Relationship to the other plans
- **Independent of the connection-gated local-first plan.** That plan's "Connect to a hub" action triggers whatever browser flow exists — GIS today, this plan's PKCE flow after it lands.
- **Soft dependency on server-minted sliding sessions:** B2 (retiring One-Tap) needs a durable renewal path. Either sequence B2 after the sessions plan, or keep One-Tap as the interim. B1 has no such dependency.

## Risks & open questions
- **B1 flow-shape decision (pattern i vs BFF):** adopt pattern (i) to keep this plan independent of server minting; BFF re-couples everything. Decide before registering the Google client.
- **Client-id / redirect-uri registration (B1):** requires a Google Web-app client `redirect_uri` registration change; coordinate with whoever owns the quarto-hub.com OAuth clients (see `bd-ra5ypj3s`).
- **Migration overlap:** keep `GoogleAuthProvider` selectable behind a flag until the PKCE provider is proven in production.

## Braid strand structure
- **Epic `bd-qxgoti2b`** (epic, p2, open). Sub-strands **B1, B2, B4** (parent-child). **B0 deferred** (own strand, opened only if duplication proves real); **B3 removed** — `bd-3g0aijb3` is tracked standalone.
- Related links in place: `bd-3g0aijb3` (standalone Bearer fix — related, no longer a phase), `bd-cmp48` / `bd-81cfshmw` (hub-mcp reference), `bd-ra5ypj3s` (client registration), `bd-ey6jg70f` (B2 soft dependency). No hard `blocks` to the other two plans.

## References
### Plans
- `claude-notes/plans/2026-07-06-connection-gated-auth-and-auth-unification.md` — umbrella / path.
- `claude-notes/plans/2026-07-06-hub-server-minted-sliding-sessions.md` — the durable renewal path B2 depends on.
- `claude-notes/plans/2026-05-20-auth-provider-interface.md` — the `AuthProvider` seam (done).
- `claude-notes/plans/2026-02-26-httponly-cookie-auth.md` — cookie storage (done).
- `claude-notes/plans/2026-05-28-hub-mcp-loopback-pkce.md` — hub-mcp current auth (loopback + PKCE, confidential Desktop-app client).
- `claude-notes/plans/2026-06-11-q2-mcp-hub-auth.md` — `q2 mcp` launcher (inherits TS auth).
- `claude-notes/plans/2026-05-05-hub-mcp-device-flow-implementation.md` — superseded device flow.

### Strands
- `bd-3g0aijb3` — Bearer on `/auth/actor` and `/auth/me` (standalone; not a phase of this epic).
- `bd-cmp48`, `bd-81cfshmw` — hub-mcp auth (reference).
- `bd-ra5ypj3s` — Google client registration issue (B1).
- `bd-ey6jg70f` — server-minted sliding sessions (B2 soft dependency; own plan).

### Key files
- SPA auth seam: `hub-client/src/auth/{AuthProvider,GoogleAuthProvider,MockAuthProvider}.tsx`, `hub-client/src/hooks/useAuth.ts`, `hub-client/src/services/authService.ts`, `hub-client/src/main.tsx` (provider selection).
- Server auth: `crates/quarto-hub/src/server.rs` (212-247, 328-338, 345-367, 662-729, 844-879, 901-950, 1048-1081), `crates/quarto-hub/src/auth.rs` (129-131, 599-603, 644-664).
- hub-mcp auth (reference): `ts-packages/quarto-hub-mcp/src/auth/{oauth-config,pkce,loopback,browser,auth-tools,refresh-manager,credential-store}.ts`, `crates/quarto-mcp-launcher/src/{lib,defaults}.rs`.
Loading
Loading