Files
HIPCTF2/docs/guides/cross-tab-invalidation.md
T
2026-07-22 10:35:24 +00:00

10 KiB

type, title, description, tags, timestamp
type title description tags timestamp
guide Cross-Tab Authenticated Shell Invalidation How logging out in one tab, or receiving an unauthorized SSE response, instantly logs out and redirects every other open tab of the SPA.
guide
auth
session
broadcast-channel
storage-event
sse
tester
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 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