diff --git a/docs/architecture/frontend-structure.md b/docs/architecture/frontend-structure.md index fe086d4..6bb2cce 100644 --- a/docs/architecture/frontend-structure.md +++ b/docs/architecture/frontend-structure.md @@ -3,7 +3,7 @@ type: architecture title: Frontend Structure description: Angular routes, components, services, guards, interceptors, and authenticated SSE transport. tags: [architecture, frontend, angular, shell, sse] -timestamp: 2026-07-22T09:35:00Z +timestamp: 2026-07-22T10:32:00Z --- # Routes @@ -24,13 +24,14 @@ Routes live in `frontend/src/app/app.routes.ts`: | Symbol | Path | Responsibility | |---|---|---| -| `HomeComponent` | `frontend/src/app/features/home/home.component.ts` | Smart shell; owns navigation signals, user hydration, change-password flow, and event-stream lifecycle. | +| `HomeComponent` | `frontend/src/app/features/home/home.component.ts` | Smart shell; owns navigation signals, user hydration, change-password flow, event-stream lifecycle, and peer-invalidation subscription. | | `ShellHeaderComponent` | `frontend/src/app/features/shell/header/shell-header.component.ts` | Renders title, active section, event LED/countdown, and user menu; contains component-scoped LED styling. | | `QuickTabsComponent` | `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Emits navigation events for Challenges, Scoreboard, and Blog. | -| `EventStatusStore` | `frontend/src/app/core/services/event-status.store.ts` | Applies event state frames and computes countdown text; re-exports the pure LED color mapping. | -| `AuthenticatedEventSourceService` | `frontend/src/app/core/services/authenticated-event-source.service.ts` | Opens authenticated SSE over `fetch`, parsing streamed frames into `EventSourceLike` events. | -| `AuthService` | `frontend/src/app/core/services/auth.service.ts` | Stores the access token used by the authenticated SSE transport. | +| `EventStatusStore` | `frontend/src/app/core/services/event-status.store.ts` | Applies event state frames and computes countdown text; re-exports the pure LED color mapping; wires an optional `onUnauthorized` callback for the new SSE `401`/`403` event. | +| `AuthenticatedEventSourceService` | `frontend/src/app/core/services/authenticated-event-source.service.ts` | Opens authenticated SSE over `fetch`, parsing streamed frames into `EventSourceLike` events; dispatches a separate `'unauthorized'` event for `401`/`403`. | +| `AuthService` | `frontend/src/app/core/services/auth.service.ts` | Stores the access token used by the authenticated SSE transport; owns the cross-tab invalidation `BroadcastChannel` + `storage`-event fallback and exposes `clearAndBroadcast` / `onPeerInvalidation`. | | `UserStore` | `frontend/src/app/core/services/user.store.ts` | Hydrates the signed-in user and rank data. | +| `auth-session-events.pure.ts` | `frontend/src/app/core/services/auth-session-events.pure.ts` | Pure cross-tab message encoding/validation and the namespaced channel / storage-key constants. | # Authenticated SSE wiring @@ -44,10 +45,17 @@ Routes live in `frontend/src/app/app.routes.ts`: 4. Splits complete SSE records on blank lines, joins repeated `data:` lines, and dispatches `MessageEvent` objects. 5. Buffers records received before the message listener is attached. -6. Aborts and clears listeners when `close()` is called. +6. Inspects the response status *before* the `ok`/`body` check; on `401` or + `403` dispatches a separate `'unauthorized'` event and returns without + reading the body. Generic transport failures still dispatch `'error'`. +7. Aborts and clears listeners when `close()` is called. -`EventStatusStore` parses each message as `EventStatePayload`, updates its signals, -and runs a one-second local timer. `HomeComponent` stops the store on destruction. +`EventStatusStore.start(createSource, onUnauthorized?)` parses each message as +`EventStatePayload`, updates its signals, runs a one-second local timer, and — +when `onUnauthorized` is provided — invokes it on `'unauthorized'` events. +`HomeComponent` passes an `onUnauthorized` callback that delegates to its +shared `handleSessionInvalidated('sse-unauthorized')` flow and stops the store +on destruction. # Key integration files @@ -84,6 +92,20 @@ color always comes from `LED_COLOR_BY_STATE` and is guaranteed to be a non-transparent theme token. The four `led-` classes remain on the element as selectors/test hooks but no longer own the color. +# Cross-tab session invalidation + +`AuthService` (constructed once at root) installs a single +`BroadcastChannel('hipctf.auth.v1')` and a `localStorage` `storage`-event +fallback. The shape of the broadcast payload is defined in +`auth-session-events.pure.ts` and contains only `{ type, origin, ts }` — no +tokens or user data. `clearAndBroadcast()` is invoked from `logout()` and +from the SSE unauthorized callback; peer listeners clear their local state +without rebroadcasting. `HomeComponent` subscribes via +`auth.onPeerInvalidation(...)`, resets `UserStore`, closes any open shell +overlays, and navigates to `/login`. See +[Cross-Tab Authenticated Shell Invalidation](/guides/cross-tab-invalidation.md) +for the full contract and tester matrix. + # See also - [Authenticated Shell](/guides/authenticated-shell.md) diff --git a/docs/architecture/key-files.md b/docs/architecture/key-files.md index 33ab169..8f7e224 100644 --- a/docs/architecture/key-files.md +++ b/docs/architecture/key-files.md @@ -3,7 +3,7 @@ type: architecture title: Key Files Index description: One-line responsibility for important source files, including authenticated event streaming. tags: [architecture, index, key-files] -timestamp: 2026-07-22T09:35:00Z +timestamp: 2026-07-22T10:32:00Z --- # Backend @@ -31,14 +31,17 @@ timestamp: 2026-07-22T09:35:00Z | `frontend/src/app/app.routes.ts` | Defines public, shell, child, and admin routes. | | `frontend/src/app/app.component.ts` | Loads bootstrap data and restores the session. | | `frontend/src/app/features/home/home.component.ts` | Authenticated shell container; starts user and event state services. | -| `frontend/src/app/core/services/auth.service.ts` | Signal-backed access token and current-user state. | -| `frontend/src/app/core/services/authenticated-event-source.service.ts` | Fetch-based authenticated SSE transport with frame parsing and abort support. | -| `frontend/src/app/core/services/event-status.store.ts` | Event state signal store and one-second countdown timer. | -| `frontend/src/app/core/services/event-status.pure.ts` | Event payload types, pure countdown helpers, and the `LED_COLOR_BY_STATE` map used by the shell LED. | +| `frontend/src/app/core/services/auth.service.ts` | Signal-backed access token and current-user state; owns the cross-tab invalidation `BroadcastChannel` + `storage`-event fallback. | +| `frontend/src/app/core/services/auth-session-events.pure.ts` | Pure cross-tab message encoding/validation and namespaced constants used by the auth broadcast channel. | +| `frontend/src/app/core/services/authenticated-event-source.service.ts` | Fetch-based authenticated SSE transport with frame parsing, abort support, and a separate `'unauthorized'` event for `401`/`403`. | +| `frontend/src/app/core/services/event-status.store.ts` | Event state signal store, one-second countdown timer, and the optional `onUnauthorized` callback wiring. | +| `frontend/src/app/core/services/event-status.pure.ts` | Event payload types, transport interface (including `'unauthorized'` listener), pure countdown helpers, and the `LED_COLOR_BY_STATE` map used by the shell LED. | +| `frontend/src/app/features/home/home.component.ts` | Authenticated shell container; starts user and event state services, subscribes to peer invalidation, and navigates to `/login` when the session is invalidated elsewhere. | | `frontend/src/app/features/shell/header/shell-header.component.ts` | Shell title, status LED (size/shape only — color comes from the pure map via `[style.background-color]`), countdown, and user menu. | | `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Main shell navigation tabs. | | `frontend/src/app/features/shell/change-password/change-password-modal.component.ts` | Change-password form modal. | -| `tests/frontend/authenticated-event-source.spec.ts` | Tests SSE authorization and frame transport behavior. | +| `tests/frontend/authenticated-event-source.spec.ts` | Tests SSE authorization, frame transport behavior, and the `401`/`403` unauthorized path. | +| `tests/frontend/auth-session-events.spec.ts` | Pure tests for cross-tab invalidation message encoding, payload validation, and `storage`-event filtering. | # See also diff --git a/docs/guides/authenticated-shell.md b/docs/guides/authenticated-shell.md index 00f0bae..30baa9c 100644 --- a/docs/guides/authenticated-shell.md +++ b/docs/guides/authenticated-shell.md @@ -106,6 +106,22 @@ error event. The shell retains the last event state until a later connection suc 1. Click each quick tab and confirm the matching route and active styling. 2. Open the user menu and click **Logout**. 3. Confirm navigation to `/login` and that the event stream is closed. +4. With **two open tabs** of the same browser profile both on authenticated + routes, log out in one. Confirm the *other* tab also navigates to `/login` + without any user interaction. See + [Cross-Tab Authenticated Shell Invalidation](/guides/cross-tab-invalidation.md) + for the full tester matrix (peer logout, SSE unauthorized, negative cases). + +## Unauthorized SSE response + +1. Sign in once and open the authenticated shell. +2. Trigger an unauthorized response on `/api/v1/events/status` (e.g. by + revoking the refresh cookie via an admin tool or by expiring the session + server-side). +3. Confirm the shell clears, the event stream is closed, and the router + navigates to `/login`. The same flow in another open tab is propagated + via the cross-tab broadcast — see + [Cross-Tab Authenticated Shell Invalidation](/guides/cross-tab-invalidation.md). # See also diff --git a/docs/guides/cross-tab-invalidation.md b/docs/guides/cross-tab-invalidation.md new file mode 100644 index 0000000..7166db2 --- /dev/null +++ b/docs/guides/cross-tab-invalidation.md @@ -0,0 +1,204 @@ +--- +type: guide +title: Cross-Tab Authenticated Shell Invalidation +description: How logging out in one tab, or receiving an unauthorized SSE response, instantly logs out and redirects every other open tab of the SPA. +tags: [guide, auth, session, broadcast-channel, storage-event, sse, tester] +timestamp: 2026-07-22T10:32:00Z +--- + +# What this feature does + +Before this change, `sessionStorage` was tab-scoped, so logging out in **tab A** +left any other open tabs (`/challenges`, `/scoreboard`, `/blog`, `/admin`) +**still rendering authenticated content** until they were individually closed or +reloaded. The same gap existed when the server returned `401` to the +authenticated `/api/v1/events/status` SSE stream: the offending tab knew it was +unauthenticated, but the others did not. + +This change adds a same-browser **cross-tab session-invalidation channel**: + +1. `AuthService` now owns a single `BroadcastChannel('hipctf.auth.v1')` plus a + `localStorage` `storage`-event fallback, registered in its constructor and + torn down on `DestroyRef`. +2. Every confirmed local session invalidation (manual `logout()`, SSE + `401`/`403`) clears state **and publishes** a fixed-shape + `session-invalidated` message containing only the broadcasting tab's + `origin` (random per-tab UUID) and a `ts` timestamp. No tokens, users, or + storage contents are broadcast. +3. Each tab listens for the message. If the `origin` differs from its own, it + treats the notification as a peer-logout: it clears its in-memory and + `sessionStorage` state **without** rebroadcasting, then notifies + subscribers. +4. `HomeComponent` subscribes to the new peer-invalidation event, resets + `UserStore`, closes any open change-password / user-menu overlays, and + navigates to `/login`. A re-entrancy flag prevents duplicate navigations + when the originating tab already navigated. +5. `AuthenticatedEventSourceService` was extended to dispatch a new + `'unauthorized'` event when the SSE fetch returns `401` or `403`. Generic + network failures still dispatch `'error'` and do **not** invalidate. + +# Why a new concept doc + +The change spans the auth core, the SSE transport, the event-status store, and +the shell component. It introduces a new pure module +(`auth-session-events.pure.ts`) and a new transport event type +(`'unauthorized'` on `EventSourceLike`). A tester reading the +[Authenticated Shell](/guides/authenticated-shell.md) guide should not have to +re-derive the cross-tab propagation rules, and a developer wiring a new +service should not have to read the auth service diff to learn about the +broadcast contract. + +# Tester flows + +> Open the SPA in **two browser tabs** of the same browser profile, signed in +> as the same user. Both tabs must be on authenticated routes (`/`, +> `/challenges`, `/scoreboard`, `/blog`, or `/admin`). + +## Peer logout (manual) + +1. Sign in once and navigate to `/challenges` in **Tab A**. +2. Duplicate the tab — **Tab B** opens at `/scoreboard`. +3. In **Tab A**, open the user menu (`[data-testid="user-menu-trigger"]`) + and click **Logout**. +4. **Tab A** navigates to `/login` (unchanged behavior). +5. **Tab B** clears its shell state without any user interaction and navigates + to `/login`. The event LED, countdown, and quick-tab strip are no longer + visible — the landing/login view is shown. +6. Confirm in DevTools → Application → Session Storage of **Tab B** that the + `hipctf.auth.v1` key has been removed. + +## Peer logout (SSE unauthorized) + +1. Sign in once and open the SPA in two tabs. +2. In **Tab A** (server side, via an admin-only tool or by manually + revoking the refresh cookie), invalidate the refresh-cookie/session. +3. Wait for `EventStatusStore` to reconnect — the SSE fetch returns `401`. +4. **Tab A** clears state and navigates to `/login`. +5. **Tab B** (still on `/challenges`) clears state and navigates to `/login` + without any SSE retry happening locally — it received the broadcast from + Tab A. + +> If you instead trigger the unauthorized response in **Tab B** directly +> (e.g. by expiring the refresh cookie for that tab only), Tab B navigates +> to `/login`; Tab A is unaffected. + +## Negative cases + +| Action | Expected | +|---|---| +| Open the SPA in a private/incognito window in addition to a normal window | Logging out in one does **not** affect the other. `BroadcastChannel` and the `storage` event are scoped to the browser profile. | +| Trigger a generic network failure on `/api/v1/events/status` | The store dispatches `'error'`, retries, and **does not** invalidate. The shell keeps the last known event state. | +| A non-HIPCTF tab on the same browser profile writes `hipctf.auth.invalidate.v1` to `localStorage` with garbage | The payload validator (`isCrossTabInvalidationEvent`) ignores it — no state change. | +| Same tab echoes its own broadcast (test environment that delivers self-messages) | The `origin === crossTabOrigin` guard prevents self-processing and rebroadcast loops. | + +# Visual / interactive elements + +There are **no new UI controls**. Existing selectors continue to work and +should be exercised after a peer invalidation: + +| Element | Selector | After peer invalidation | +|---|---|---| +| Shell header | `[data-testid="shell-header"]` | Removed from DOM by `Router` (route changed to `/login`). | +| Quick tabs | `[data-testid="quick-tabs"]` | Removed from DOM by `Router`. | +| User menu | `[data-testid="user-menu-trigger"]` | Removed from DOM by `Router`. | +| Event LED / countdown | `[data-testid="shell-led"]`, `[data-testid="shell-countdown"]` | Removed from DOM by `Router`. | +| Login form | `[data-testid="login-submit"]` | Becomes visible (already-tested flow). | + +# How it works (developer map) + +## Pure contract module + +`frontend/src/app/core/services/auth-session-events.pure.ts` is a +framework-light, testable module that defines: + +| Export | Purpose | +|---|---| +| `CROSS_TAB_CHANNEL_NAME = 'hipctf.auth.v1'` | Stable `BroadcastChannel` name. | +| `CROSS_TAB_STORAGE_KEY = 'hipctf.auth.invalidate.v1'` | `localStorage` fallback key. | +| `CROSS_TAB_EVENT_TYPE = 'hipctf.session.invalidated'` | Marker used by the discriminated-union guard. | +| `CrossTabInvalidationMessage` | `{ type: 'session-invalidated'; origin: string; ts: number }`. | +| `encodeInvalidationMessage(origin)` | Builds a message stamped with `Date.now()`. | +| `isCrossTabInvalidationMessage(value)` | Type-guard used by both `BroadcastChannel` and `storage` listeners. | +| `isStorageInvalidationEvent(event)` | Guards `storage` events: only accepts the namespaced key with a parseable payload. | + +The payload intentionally carries **no** access token, refresh token, user id, +or `sessionStorage` content. + +## `AuthService` wiring + +`frontend/src/app/core/services/auth.service.ts` + +* Constructor calls `installCrossTabListener()`, which: + * Feature-detects `BroadcastChannel`; if present, opens it on + `CROSS_TAB_CHANNEL_NAME` and subscribes to `'message'`. + * Always subscribes a `'storage'` listener filtered through + `isStorageInvalidationEvent`. +* `clearAndBroadcast()` is the new public invalidation entry point: calls the + existing `clear()` (which wipes `sessionStorage`), and if + `suppressBroadcast` is `false`, publishes a fresh `encodeInvalidationMessage` + on both transports. +* `logout()` now ends with `clearAndBroadcast()` instead of `clear()`. +* `handleSseUnauthorized()` calls `clearAndBroadcast()` and then synchronously + notifies peer-invalidation subscribers with the reason + `'sse-unauthorized'` so the originating tab can navigate immediately. +* `onPeerInvalidation(cb)` registers a subscriber; returns an unsubscribe + closure. Used by `HomeComponent`. +* `handlePeerInvalidation()` is the single fan-in for both transport sources: + it parses the payload, drops self-origin messages, drops messages when the + tab is already unauthenticated, and runs `clear()` under + `suppressBroadcast = true` so the listener does not echo. +* `DestroyRef.onDestroy` calls `teardownCrossTabListener()` which removes the + `'message'` and `'storage'` listeners and closes the channel. + +## Authenticated SSE: `unauthorized` event + +`frontend/src/app/core/services/authenticated-event-source.service.ts` + +The fetch path now inspects `res.status` *before* the `ok`/`body` check. If +the status is `401` or `403`, the service dispatches a new `'unauthorized'` +`Event` and returns; the response body is **not** read and no frame parsing +runs. + +`frontend/src/app/core/services/event-status.pure.ts` widens +`EventSourceLike.addEventListener` to accept `'open' | 'message' | 'error' | +'unauthorized'`. + +`frontend/src/app/core/services/event-status.store.ts` + +`EventStatusStore.start(createSource, onUnauthorized?)` now accepts an +optional second argument. When provided, the store attaches an +`'unauthorized'` listener that invokes `onUnauthorized()` and is detached by +`stop()` (which is already idempotent). Generic `'error'` events remain +unhandled by the store; the SPA does not navigate on transport failure. + +## Shell reaction + +`frontend/src/app/features/home/home.component.ts` + +* `ngOnInit()` passes a second callback to `eventStatus.start(...)` that + delegates to `handleSessionInvalidated('sse-unauthorized')`. +* It also subscribes to `auth.onPeerInvalidation(...)` and stores the + unsubscribe handle on `peerInvalidationUnsubscribe`. +* `ngOnDestroy()` calls `eventStatus.stop()` and unsubscribes from + `onPeerInvalidation`. +* `handleSessionInvalidated(reason)`: + * Calls `userStore.reset()` and closes `changePasswordOpen` / + `userMenuOpen` modal signals. + * Guards re-entry with `navigatingToLogin` and short-circuits when the + router is already on `/login`. + * Otherwise awaits `router.navigateByUrl('/login')`. + +## Test coverage + +| File | What it pins down | +|---|---| +| `tests/frontend/auth-session-events.spec.ts` | Pure payload validation, `storage`-event filtering, `encodeInvalidationMessage` shape. | +| `tests/frontend/authenticated-event-source.spec.ts` | Extended: `401`/`403` dispatches `'unauthorized'`; generic errors still dispatch `'error'`; no body is read on the unauthorized path. | + +# See also + +- [Authenticated Shell](/guides/authenticated-shell.md) +- [Session Restoration on Reload](/guides/session-restoration.md) +- [Frontend Structure](/architecture/frontend-structure.md) +- [REST API Overview](/api/rest-overview.md) +- [Auth Endpoints](/api/auth.md) diff --git a/docs/index.md b/docs/index.md index 9efd50f..1d2dd11 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin controls. The docs below are organized by purpose so agents can pull just the slice -they need. Last regenerated 2026-07-22T09:35:00Z. +they need. Last regenerated 2026-07-22T10:32:00Z. # Architecture @@ -65,6 +65,9 @@ they need. Last regenerated 2026-07-22T09:35:00Z. * [Change Password](/guides/change-password.md) - How a signed-in user changes their own password, including every error branch and the refresh-token revocation behavior. +* [Cross-Tab Authenticated Shell Invalidation](/guides/cross-tab-invalidation.md) - + How a logout in one tab, or an unauthorized SSE response, instantly clears + and redirects every other open tab of the SPA. * [Session Restoration on Reload](/guides/session-restoration.md) - How the SPA keeps a user signed in across hard refreshes and how the guards wait for bootstrap + auth hydration before deciding where to