Files
galaxy-game/ui/docs/auth-flow.md
T
Ilia Denisov 22b0710d04 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>
2026-05-07 15:24:21 +02:00

7.5 KiB

Auth Flow (UI)

The Galaxy UI client uses a two-step e-mail-code login: the user submits an e-mail address, receives a six-digit code by mail, then submits the code together with a freshly generated Ed25519 public key. The gateway returns a device_session_id, which the client persists for subsequent visits. This doc covers the UI side; the backend behaviour, throttling, and account-creation rules are authoritative in docs/FUNCTIONAL.md §1.

Surface

  • ui/frontend/src/api/auth.tssendEmailCode, confirmEmailCode, and the AuthError taxonomy.
  • ui/frontend/src/lib/session-store.svelte.ts — singleton reactive state (status, keypair, deviceSessionId) plus init, signIn, signOut.
  • ui/frontend/src/lib/revocation-watcher.ts — minimal SubscribeEvents watcher that triggers signOut("revoked") on any non-aborted stream termination.
  • ui/frontend/src/routes/login/+page.svelte — two-step form.
  • ui/frontend/src/routes/lobby/+page.svelte — placeholder lobby that issues the first authenticated user.account.get.
  • ui/frontend/src/routes/+layout.svelte — route guard plus the browser-not-supported blocker.

State machine (SessionStatus)

                init()
                  │
                  ▼
            ┌─────────────┐
            │   loading   │
            └──┬───────┬──┘
               │       │
               │       ▼
               │  Ed25519 missing → unsupported
               ▼
        device id?
       ┌────┴────┐
       │         │
       ▼         ▼
  anonymous  authenticated
       │         │
   signIn      signOut(*)
       └────────►│
                ▼
           anonymous

signOut("revoked") shares the same observable end state as signOut("user"); the reason exists only for telemetry. Both trigger the layout effect's anonymous → /login redirect.

UX states and error mapping

The send-email-code endpoint deliberately returns a uniform response shape regardless of whether the address is new, existing, throttled, or rate-limited (see docs/FUNCTIONAL.md §1.2). The UI therefore treats every 200 the same and never tries to distinguish those branches.

Condition UI behaviour
200 from send-email-code advance to step code, focus the code input
invalid_request from send stay on step email, surface the gateway message
service_unavailable from send stay on step email, surface "service is temporarily unavailable"
200 from confirm-email-code persist device_session_id, redirect to /lobby
invalid_request from confirm bounce to step email, message: "code expired or already used"
any other error from confirm stay on step code, surface the gateway message

permanent_block and any other authoritative rejection from the backend collapse into the same invalid_request envelope from the UI's perspective; the gateway does not differentiate them externally.

Resend and change-email

  • send a new code — re-issues sendEmailCode for the same address. The backend may throttle by reusing the most recent challenge id; the UI does not need to know about that.
  • change email — clears the in-progress challenge and returns to step email. No backend call.

Persistence and returning users

After confirm-email-code succeeds, session.signIn writes the device_session_id into the IDB cache (namespace=session, key=device-session-id). On the next page load, SessionStore.init reads it back and settles status to authenticated, so the layout effect routes the user straight to /lobby.

The keypair lives next to the id in the same database (object store keypair, key device). Clearing site data wipes both; the next load generates a fresh keypair and the user must log in again. This is the documented re-login path — there is no paired "reissue device session" flow in Phase 7.

Browser support

The keystore relies on WebCrypto Ed25519, which currently lands in Chrome ≥ 137, Firefox ≥ 130, Safari ≥ 17.4 (see storage.md for the rationale). On boot the layout runs a sanity probe (crypto.subtle.generateKey for Ed25519); if it rejects, the layout switches to a browser not supported page instead of rendering /login. Phase 7 deliberately does not ship a JavaScript Ed25519 fallback — see Phase 6's "modern-browser baseline, no JS Ed25519 fallback" decision.

Revocation

The lobby layout opens a long-running SubscribeEvents stream as soon as status becomes authenticated. The watcher does not process individual events in Phase 7 — that arrives in Phase 24. Its only contract is liveness: any non-aborted termination of the stream is treated as a server-side session revocation, the watcher calls session.signOut("revoked"), and the layout effect redirects to /login.

This satisfies the Phase 7 acceptance bar of "session revocation closes the active client within one second": the gateway closes the stream the moment it observes a session_invalidation push event from backend, and the watcher reacts on the next event-loop tick.

Configuration

Build-time environment, read by lib/env.ts:

Variable Format Notes
VITE_GATEWAY_BASE_URL URL string gateway public REST surface and Connect-Web edge listener (same host); defaults to http://localhost:8080
VITE_GATEWAY_RESPONSE_PUBLIC_KEY standard base64, 32 raw Ed25519 bytes response-signing public key; only needed on authenticated routes

For local development against the integration suite, use the public key the gateway container exposes (ResponseSignerPublic in integration/testenv/gateway.go). Playwright derives both halves of the pair from tests/e2e/fixtures/gateway-key.ts and pins the public half through playwright.config.ts's webServer.env.

An empty VITE_GATEWAY_RESPONSE_PUBLIC_KEY does not block app boot; the lobby surfaces an inline error when the first executeCommand would have otherwise been issued.

Testing

  • Vitesttests/auth-api.test.ts, tests/session-store.test.ts, tests/login-page.test.ts cover the wire shape, persistence state machine, and the form behaviours respectively.
  • Playwrighttests/e2e/auth-flow.spec.ts exercises the full happy path, returning-user resume, revocation within one second, and the browser-not-supported blocker. The gateway is mocked via page.route(...); the lobby's user.account.get call is answered with a fixture-signed ExecuteCommandResponse.

The Go-side integration suite (integration/auth_flow_test.go) covers the live wire contract; this UI doc deliberately stops at the boundaries above.