AI Implementation feature(881): Authenticated Shell, Quick Tabs and Change Password 1.05 (#20)
This commit was merged in pull request #20.
This commit is contained in:
@@ -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-<state>` 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
+4
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user