--- type: guide title: Session Restoration on Reload description: 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. tags: [guide, auth, session, restoration, refresh, tester] timestamp: 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: ```ts 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**: ```ts 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 - [Frontend Structure](/architecture/frontend-structure.md) - [REST API Overview](/api/rest-overview.md) - [First-Run Bootstrap](/guides/bootstrap.md) - [Admin Shell & User Management](/guides/admin-shell.md)