11 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. |
|
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:
AuthServicenow owns a singleBroadcastChannel('hipctf.auth.v1')plus alocalStoragestorage-event fallback, registered in its constructor and torn down onDestroyRef.- Every confirmed local session invalidation (manual
logout(), SSE401/403) clears state and publishes a fixed-shapesession-invalidatedmessage containing only the broadcasting tab'sorigin(random per-tab UUID) and atstimestamp. No tokens, users, or storage contents are broadcast. - Each tab listens for the message. If the
origindiffers from its own, it treats the notification as a peer-logout: it clears its in-memory andsessionStoragestate without rebroadcasting, then notifies subscribers. HomeComponentsubscribes to the new peer-invalidation event, resets bothUserStoreandChallengesStore(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.AuthenticatedEventSourceServicewas extended to dispatch a new'unauthorized'event when the SSE fetch returns401or403. 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)
- Sign in once and navigate to
/challengesin Tab A. - Duplicate the tab — Tab B opens at
/scoreboard. - In Tab A, open the user menu (
[data-testid="user-menu-trigger"]) and click Logout. - Tab A navigates to
/login(unchanged behavior). - 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. - Confirm in DevTools → Application → Session Storage of Tab B that the
hipctf.auth.v1key has been removed.
Peer logout (SSE unauthorized)
- Sign in once and open the SPA in two tabs.
- In Tab A (server side, via an admin-only tool or by manually revoking the refresh cookie), invalidate the refresh-cookie/session.
- Wait for
EventStatusStoreto reconnect — the SSE fetch returns401. - Tab A clears state and navigates to
/login. - Tab B (still on
/challenges) clears state and navigates to/loginwithout 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 onCROSS_TAB_CHANNEL_NAMEand subscribes to'message'. - Always subscribes a
'storage'listener filtered throughisStorageInvalidationEvent.
- Feature-detects
clearAndBroadcast()is the new public invalidation entry point: calls the existingclear()(which wipessessionStorage), and ifsuppressBroadcastisfalse, publishes a freshencodeInvalidationMessageon both transports.logout()now ends withclearAndBroadcast()instead ofclear().handleSseUnauthorized()callsclearAndBroadcast()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 byHomeComponent.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 runsclear()undersuppressBroadcast = trueso the listener does not echo.DestroyRef.onDestroycallsteardownCrossTabListener()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 toeventStatus.start(...)that delegates tohandleSessionInvalidated('sse-unauthorized').- It also subscribes to
auth.onPeerInvalidation(...)and stores the unsubscribe handle onpeerInvalidationUnsubscribe. ngOnDestroy()callseventStatus.stop()and unsubscribes fromonPeerInvalidation.handleSessionInvalidated(reason):- Calls
userStore.reset()andchallengesStore.reset()and closeschangePasswordOpen/userMenuOpenmodal signals. The challenges reset guarantees the next signed-in user does not briefly see the previous user'ssolvedByMeflags on/challenges. - Guards re-entry with
navigatingToLoginand short-circuits when the router is already on/login. - Otherwise awaits
router.navigateByUrl('/login').
- Calls
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. |