phase 7: auth flow UI (email-code login + session resume + revocation)

Implements ui/PLAN.md Phase 7 end-to-end:

- /login two-step form (email -> code) over the gateway public REST
  surface; /lobby placeholder issues the first authenticated
  user.account.get and renders the decoded display name.
- SessionStore (Svelte 5 runes) with loading / unsupported / anonymous /
  authenticated states; layout-level route guard, browser-not-supported
  blocker, and a minimal SubscribeEvents revocation watcher that closes
  the active client within 1s on a clean stream end or
  Unauthenticated.
- VITE_GATEWAY_BASE_URL + VITE_GATEWAY_RESPONSE_PUBLIC_KEY config plus
  AuthError taxonomy in api/auth.ts.
- Vitest (auth-api, session-store, login-page) and Playwright e2e
  (auth-flow.spec.ts) on the four configured projects, with a fixture
  Ed25519 keypair forging Connect-Web JSON responses.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-07 15:24:21 +02:00
parent 390ad3196b
commit 22b0710d04
24 changed files with 2125 additions and 48 deletions
+94 -36
View File
@@ -677,54 +677,112 @@ Targeted tests:
including non-deterministic WebKit), and that
`clearDeviceSession` forces a fresh keypair on next load.
## Phase 7. Auth Flow UI
## ~~Phase 7. Auth Flow UI~~
Status: pending.
Status: done.
Goal: implement the full email-code login flow with device session
registration and post-login redirect to a placeholder lobby.
Artifacts:
Decisions taken with the project owner before implementation:
- `ui/frontend/src/routes/login` two-step form (email → code)
- `ui/frontend/src/api/auth.ts` wraps `public.auth.send_email_code` and
`public.auth.confirm_email_code`, registers the public key, persists
via `KeyStore`
- `ui/frontend/src/lib/session-store.ts` Svelte store exposing the
current session state
- `ui/frontend/src/routes/+layout.svelte` redirect to `/login` for
unauthenticated routes; redirect to `/lobby` on successful confirm
- placeholder `ui/frontend/src/routes/lobby/+page.svelte` rendering
`you are logged in`
- topic doc `ui/docs/auth-flow.md` describing error UX, code
resend, expired challenge handling, and re-login on revocation
1. **Playwright e2e against a mocked gateway.** `page.route(...)`
intercepts the public auth REST surface and the Connect-Web
`ExecuteCommand` / `SubscribeEvents` URLs; a fixture Ed25519 key in
`tests/e2e/fixtures/gateway-key.ts` signs the forged responses so
`GalaxyClient.verifyResponse` accepts them under the matching
public key the dev server picks up via
`VITE_GATEWAY_RESPONSE_PUBLIC_KEY`. The wire-contract path is
already covered by the Go integration suite
(`integration/auth_flow_test.go`).
2. **Build-time gateway response public key delivery.** The browser
reads `VITE_GATEWAY_RESPONSE_PUBLIC_KEY` (standard base64 of the
raw 32-byte key) on module load. A future phase may switch to a
`/api/v1/public/well-known/...` endpoint when prod distribution is
wired up; Phase 7 stops at the env-var.
3. **Minimal SubscribeEvents-based revocation watcher.** The lobby
layout opens a long-running stream and treats two outcomes as
revocation: a clean end-of-stream (the gateway closing after a
`session_invalidation` event) and a Connect `Unauthenticated`
error. Network errors and `Canceled` aborts stay silent so a
flaky connection or page navigation does not bounce the user. The
per-event dispatch path lands in Phase 24.
4. **Browser-not-supported blocker.** The root layout runs a one-time
`crypto.subtle.generateKey({name:"Ed25519"}, ...)` probe on boot
and renders a blocker page when the probe rejects. This closes
Phase 6's "no JS Ed25519 fallback" follow-up.
Artifacts (delivered):
- `ui/frontend/src/routes/login/+page.svelte` (+ `+page.ts` with
`prerender = false; ssr = false;`) — two-step form (email → code)
with resend and change-email affordances.
- `ui/frontend/src/routes/lobby/+page.svelte` (+ `+page.ts`) —
placeholder lobby that issues the first authenticated
`user.account.get` through `GalaxyClient` and surfaces the decoded
display name.
- `ui/frontend/src/routes/+layout.svelte` — boot-time session init,
route guard (anonymous → `/login`, authenticated on `/login` →
`/lobby`), browser-not-supported blocker, and the revocation
watcher lifecycle. `+layout.ts` puts the whole tree into SPA mode
(`ssr = false; prerender = false;`).
- `ui/frontend/src/api/auth.ts` — `sendEmailCode`,
`confirmEmailCode`, and the `AuthError` taxonomy over
`/api/v1/public/auth/*`.
- `ui/frontend/src/lib/env.ts` — `GATEWAY_BASE_URL`,
`GATEWAY_RESPONSE_PUBLIC_KEY` (decoded once on module load).
- `ui/frontend/src/lib/session-store.svelte.ts` — `SessionStore`
singleton (Svelte 5 runes); states `loading | unsupported |
anonymous | authenticated`; `init`, `signIn`, `signOut("user" |
"revoked")`.
- `ui/frontend/src/lib/revocation-watcher.ts` — opens
`SubscribeEvents` against the gateway, signs the envelope through
`Core.signRequest`, treats clean stream end / `Unauthenticated` as
revocation.
- `ui/frontend/.env.example` — `VITE_GATEWAY_BASE_URL`,
`VITE_GATEWAY_RESPONSE_PUBLIC_KEY`.
- Topic doc `ui/docs/auth-flow.md`; cross-references from
`ui/docs/storage.md` and `ui/README.md`.
- Vitest: `tests/auth-api.test.ts`, `tests/session-store.test.ts`,
`tests/login-page.test.ts`.
- Playwright: `tests/e2e/auth-flow.spec.ts` (4 cases × 4 projects)
with the fixture key plumbing in
`tests/e2e/fixtures/{gateway-key,canon,sign-response}.ts`.
- Pre-existing `tests/e2e/landing.spec.ts` was deleted; the landing
surface is no longer reachable in the auth-gated app and the
Vitest unit test on `routes/+page.svelte` retains the version
footer assertion.
Dependencies: Phase 6.
Acceptance criteria:
Acceptance criteria (met):
- a fresh browser completes login end-to-end against a local
gateway+backend stack;
- the first authenticated Connect call after login (e.g.
`user.account.read`) succeeds end-to-end through `WasmCore` →
`GalaxyClient` → ConnectRPC → gateway, with the response signature
verified and the payload decoded back to JSON. This bullet
subsumes the gateway-acceptance check originally listed in
Phase 6; the Phase 6 storage layer cannot pass it without
persisting and signing correctly;
- a returning browser resumes the session without re-login;
- gateway-side session revocation closes the active client immediately
and routes back to `/login`.
- A fresh browser completes login end-to-end via the mocked gateway
in all four Playwright projects; the first authenticated Connect
call (`user.account.get`) succeeds end-to-end through `WasmCore` →
`GalaxyClient` → ConnectRPC and the response signature is verified
under `VITE_GATEWAY_RESPONSE_PUBLIC_KEY`. This bullet subsumes the
gateway-acceptance check originally listed in Phase 6.
- A returning browser resumes the session without re-login (covered
by `tests/e2e/auth-flow.spec.ts::"returning user lands on the
lobby without re-login"`).
- Gateway-side session revocation closes the active client within one
second and routes back to `/login` (covered by
`tests/e2e/auth-flow.spec.ts::"server-side revocation closes the
active client within one second"`).
Targeted tests:
Targeted tests (delivered):
- Vitest component tests for the login forms with mocked
`GalaxyClient`;
- Playwright e2e test driving the full flow against a local stack in
desktop and mobile viewports, asserting the first authenticated
Connect call returns successfully after login;
- regression test for revocation: server-side revoke causes client
redirect within one second.
- Vitest component tests for the login form with mocked `auth.ts`
(six cases: email step, error mapping, code step, expired-code
bounce, resend, change-email).
- Vitest tests for `SessionStore` (init, signIn/signOut, support
probe, idempotency) and for the auth REST wrappers (URL/body
shape, base64 public key, `AuthError` mapping).
- Playwright e2e suite (`auth-flow.spec.ts`) on
chromium-desktop / webkit-desktop / chromium-mobile-iphone-13 /
chromium-mobile-pixel-5: fresh login, returning user, revocation
within one second, browser-not-supported blocker.
## Phase 8. Lobby UI