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:
@@ -0,0 +1,160 @@
|
||||
# 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](../../docs/FUNCTIONAL.md).
|
||||
|
||||
## Surface
|
||||
|
||||
- `ui/frontend/src/api/auth.ts` — `sendEmailCode`,
|
||||
`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`)
|
||||
|
||||
```text
|
||||
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](../../docs/FUNCTIONAL.md)). 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`](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
|
||||
|
||||
- **Vitest** — `tests/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.
|
||||
- **Playwright** — `tests/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.
|
||||
+4
-2
@@ -41,8 +41,10 @@ Browsers older than the baseline above will fail at the first
|
||||
`NotSupportedError`. Phase 6 deliberately does not ship a JavaScript
|
||||
fallback (e.g. `@noble/ed25519`) — keeping the keystore on WebCrypto
|
||||
is what gives us non-extractable storage on every supported engine.
|
||||
The Phase 7 login UI surfaces a clear "browser not supported"
|
||||
message instead.
|
||||
The Phase 7 root layout runs a one-time probe on boot and switches
|
||||
to a "browser not supported" page (described in
|
||||
[`auth-flow.md`](auth-flow.md)) when the probe rejects, instead of
|
||||
attempting the keystore generate.
|
||||
|
||||
### WebKit non-determinism note
|
||||
|
||||
|
||||
Reference in New Issue
Block a user