--- 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 both `UserStore` and `ChallengesStore` (so the per-user board cache is never leaked to the next signed-in user), 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 `challengesStore.reset()` and closes `changePasswordOpen` / `userMenuOpen` modal signals. The challenges reset guarantees the next signed-in user does not briefly see the previous user's `solvedByMe` flags on `/challenges`. * 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) - [Challenges Board](/guides/challenges-board.md) - [Per-User `solvedByMe` Across the Session Boundary](/guides/challenges-per-user-state.md) - [Frontend Structure](/architecture/frontend-structure.md) - [REST API Overview](/api/rest-overview.md) - [Auth Endpoints](/api/auth.md)