Files
HIPCTF2/docs/guides/session-restoration.md
T

7.0 KiB

type, title, description, tags, timestamp
type title description tags timestamp
guide Session Restoration on Reload How the SPA keeps a user signed in across page reloads and how the guards wait for bootstrap + auth hydration before deciding where to route.
guide
auth
session
restoration
refresh
tester
2026-07-21T15:31:00Z

What this feature does

Before this change, refreshing the tab while signed in logged the user out (because the access JWT was only held in memory). Now the SPA:

  1. Writes the access token + current user to sessionStorage on every setSession() / clear().
  2. On every app boot, attempts to silently refresh the session by POST /api/v1/auth/refresh (the rt HttpOnly cookie is sent automatically).
  3. Guards (authGuard, adminGuard) wait until both the bootstrap payload and the auth hydration step finish before deciding where to redirect — so refreshing on a deep link no longer sends the user to /bootstrap or /login while data is still in flight.

Where the user sees it (tester perspective)

Scenario Expected outcome
Reload the tab while signed in as an admin The home shell (/) re-renders, the admin nav link is visible, no redirect through /login or /bootstrap.
Reload while signed in as a player The home shell (/) re-renders, no admin nav link.
Reload while on /admin as an admin The admin user list renders without redirecting away.
Reload while signed in but the refresh cookie expired The stored session is cleared, the user is redirected to /login.
Reload while on a deep link (e.g. /admin) The app waits for bootstrap + auth hydration, then navigates directly. No flash of /login or /bootstrap.
Open /bootstrap in the URL once an admin exists The setup component detects initialized === true and redirects away (to / when authenticated, otherwise /login).

Visual / interactive elements

There are no new UI controls. The existing flows (login, home shell, admin nav) simply stop flickering through /bootstrap or /login when the session is still valid.

Key selectors that should still work (regression targets):

Element Selector Notes
Admin nav link [data-testid="nav-admin"] Re-renders after hydration; not visible on /login or /bootstrap.
Login form [data-testid="login-*"] Reachable only when initialized && !isAuthenticated.
Setup modal [data-testid="setup-submit"] etc. Reachable only when !initialized.

How it works (developer map)

On every successful auth (setSession)

AuthService.setSession() (frontend/src/app/core/services/auth.service.ts:42) now also writes { token, user } to sessionStorage under the key hipctf.auth.v1 via writeStoredSession() from frontend/src/app/core/services/auth.session-storage.ts. clear() removes the key via clearStoredSession().

On app boot (AppComponent.ngOnInit)

AppComponent.ngOnInit() (frontend/src/app/app.component.ts:13) performs:

await this.bootstrap.load();
await this.auth.restoreSession();

AuthService.restoreSession():

  1. Rehydrates the in-memory signals from sessionStorage (restoreFromStorage()) — the UI can render immediately.
  2. POST /api/v1/auth/refresh with { withCredentials: true } so the rt cookie is sent.
  3. On success: replaces the stored session with the fresh token + user, flips hydrated.set(true), resolves any waitUntilHydrated() callers.
  4. On failure (network or 401): calls clear() (which also wipes sessionStorage) and still flips hydrated.set(true) so guards can unblock and route the user to /login.

Guard synchronization (race fix)

Both authGuard (frontend/src/app/core/guards/auth.guard.ts) and adminGuard (frontend/src/app/core/guards/admin.guard.ts) are now async and await:

await bootstrap.ready();      // deduplicated in-flight promise
await auth.waitUntilHydrated(); // resolves once restoreSession() finishes
  • BootstrapService.load() and BootstrapService.ready() (frontend/src/app/core/services/bootstrap.service.ts) share a single loadPromise so concurrent callers don't double-fetch /api/v1/bootstrap.
  • AuthService.waitUntilHydrated() returns a promise that resolves when hydrated() flips to true (or immediately if already hydrated).

Pure decision functions

  • decideAuthRedirect (frontend/src/app/core/guards/auth.guard.decision.ts) is the pure unit-testable predicate matching the existing decideAdminGuard pattern. Inputs: { initialized, isAuthenticated }. Outputs: true or a router UrlTree.
  • The CanActivateFn authGuard now just calls the pure function.

Setup component redirect

SetupCreateAdminComponent (frontend/src/app/features/setup/setup-create-admin.component.ts:36) runs an Angular effect() watching bootstrap.initialized(). If a user navigates directly to /bootstrap on an already-initialized instance, the effect pushes them to / (when authenticated) or /login (otherwise).

Tests

File What it pins down
tests/frontend/auth-restore.spec.ts auth.session-storage round-trips; refresh-success writes new token; refresh-failure clears storage.
tests/frontend/guard-bootstrap-race.spec.ts decideAuthRedirect redirects to /bootstrap while uninitialized, to /login when init'd but unauth'd, and allows when both init + auth are true (no /bootstrap redirect).

See also