AI Implementation feature(881): Authenticated Shell, Quick Tabs and Change Password 1.05 #20

Merged
m0rph3us1987 merged 2 commits from feature-881-1784715673152 into dev 2026-07-22 10:35:28 +00:00
15 changed files with 688 additions and 56 deletions
-30
View File
@@ -1,30 +0,0 @@
# Implementation Plan: Authenticated Shell LED Visibility Fix
## 1. Architectural Reconnaissance
- **Codebase style & conventions:** Node.js monorepo with an Angular 17 standalone-component SPA and NestJS backend. The authenticated shell uses OnPush components, signal inputs/outputs, inline Angular templates/styles, and theme CSS custom properties. `HomeComponent` owns event-stream lifecycle and passes `EventStatusStore.state()` into the presentational `ShellHeaderComponent`. Quick tabs and change-password behavior are already implemented and are not implicated in this regression.
- **Data Layer:** TypeORM-backed backend settings provide `eventStartUtc` and `eventEndUtc`; no schema or persistence changes are required. The backend event-state calculation and authenticated SSE payload are already functioning: the shell receives the correct `running`, `countdown`, `stopped`, or `unconfigured` state and applies the matching class.
- **Test Framework & Structure:** Root Jest 29 multi-project configuration with `ts-jest`; frontend tests run in jsdom from the dedicated `tests/frontend/` directory. `npm test` runs all backend and frontend tests, while `npm run test:frontend` runs the frontend project only. Existing `tests/frontend/shell-led.spec.ts` is a source-structure assertion and does not instantiate the component or verify the rendered style binding.
- **Required Tools & Dependencies:** No new system tools, global CLIs, npm packages, persistent `/data` assets, or `setup.sh` changes are required. Existing Angular, Jest, jsdom, and TypeScript dependencies are sufficient.
## 2. Impacted Files
- **To Modify:**
- `frontend/src/app/features/shell/header/shell-header.component.ts` — remove the conflicting transparent base declaration and bind the state-specific background color directly on the LED element so the browser paints the themed color reliably.
- `tests/frontend/shell-led.spec.ts` — replace/extend brittle source-only assertions with a focused automated regression covering the style value produced for each event state and preserving the accessible state label.
- **To Create:** None.
## 3. Proposed Changes
1. **Database / Schema Migration:** None. Event timestamps, settings storage, REST/SSE contracts, and backend state computation remain unchanged.
2. **Backend Logic & APIs:** None. `/api/v1/events/status` already emits the correct state, and `EventStatusStore` already forwards it to the shell.
3. **Frontend UI Integration:**
1. In `ShellHeaderComponent`, define a typed state-to-theme-color mapping for all `EventState` values: `running``var(--color-success)`, `countdown``var(--color-warning)`, `stopped``var(--color-danger)`, and `unconfigured``var(--color-secondary)`.
2. Replace the unused `ledClass` computed value with a computed background-color value derived from `eventState()`; keep the existing state class bindings because they are useful selectors and preserve the current DOM contract.
3. Bind the computed value directly to the LED span's `style.background-color`. This gives the element an explicit non-transparent author value while continuing to resolve the active theme token at runtime, matching the reported proof that a direct targeted declaration makes the dot visible.
4. Remove `background-color: transparent` from the component base `.led` rule, and remove the redundant component state-color declarations if the inline binding becomes the single authoritative color source. Retain only the shape/layout styling (`inline-block`, 10×10 dimensions, circular radius, border, alignment).
5. Remove the global LED state rules from `frontend/src/styles.css` if they are no longer consumed, avoiding duplicate ownership and future cascade ambiguity; keep canonical theme token declarations untouched.
6. Preserve the existing `data-testid`, four state classes, and `aria-label="Event status: <state>"`, so status remains exposed textually rather than by color alone.
7. Do not modify `HomeComponent`, `QuickTabsComponent`, change-password components, event store, routes, SSE transport, or backend services because repository tracing shows those layers already deliver the correct state and shell behavior.
## 4. Test Strategy
- **Target Unit Test File:** `tests/frontend/shell-led.spec.ts`.
- **Mocking Strategy:** No backend, HTTP, SSE, timers, filesystem fixtures, browser automation, or persistent data. Exercise a small exported pure state-to-color helper (or equivalent component mapping) with table-driven assertions for the four states, and retain focused source/template assertions that the LED consumes that value through `[style.background-color]`, keeps each state class binding, and keeps the dynamic `aria-label`. This directly prevents a regression to a transparent base color while remaining fast and CLI-only under `npm test`.
- **Verification Commands for the implementer:** Run `npm run test:frontend`, `npm test`, and `npm run build` from `/repo`. The root build compiles both Angular and NestJS; no separate lint or typecheck script is defined.
+45
View File
@@ -0,0 +1,45 @@
# Implementation Plan: Job 881 — Cross-Tab Authenticated Shell Invalidation
## 1. Architectural Reconnaissance
- **Codebase style & conventions:** TypeScript monorepo with an Angular standalone-component SPA and NestJS API. Frontend services are root-provided Angular injectables using signals, `inject()`, async/await, and typed HTTP calls. Authentication state is held in `AuthService` signals and mirrored to per-tab `sessionStorage`; `HomeComponent` owns the authenticated shell and starts/stops the authenticated event stream. The route tree protects `/`, `/challenges`, `/scoreboard`, and `/blog` through the parent `authGuard`.
- **Data Layer:** No database/schema change is required. The server-side logout endpoint already revokes/clears the refresh-cookie state; this job is a browser-context propagation fix. Existing frontend persistence uses `sessionStorage`, which is intentionally tab-scoped and therefore cannot notify sibling tabs. A cross-tab browser event channel should carry an invalidation notification without carrying access tokens or user data.
- **Test Framework & Structure:** Root Jest 29 with two projects in `tests/jest.config.js`; frontend tests run in jsdom through `npm test` or `npm run test:frontend`. Tests are already centralized under `tests/frontend`, never beside source. Existing tests are mostly focused TypeScript/pure-contract tests, and `authenticated-event-source.spec.ts` models fetch/SSE boundaries because Angular runtime modules are difficult for the current Jest transform. Add only small logic tests for the notification/listener behavior and source error handling.
- **Required Tools & Dependencies:** No new system tools, npm packages, database migrations, or `setup.sh` changes are expected. `BroadcastChannel` and/or the `storage` event are browser platform APIs. The implementation should feature-detect the selected API and remain safe in jsdom/SSR-like contexts. Existing Node/npm/Jest/TypeScript setup is sufficient.
## 2. Impacted Files
- **To Modify:**
- `frontend/src/app/core/services/auth.service.ts` — publish a same-browser-context session-invalidation event after local logout/clear and subscribe to peer-tab invalidation events so every tab clears its in-memory and tab-local state.
- `frontend/src/app/core/services/authenticated-event-source.service.ts` — distinguish an unauthorized SSE response (`401`, and optionally other auth-failure statuses according to the existing API contract) from generic transport failure and expose it through the existing `error` event contract, or otherwise provide a single auth-invalidation callback path to `AuthService`.
- `frontend/src/app/core/services/event-status.store.ts` — register an `error` listener for the authenticated event source and invoke the auth invalidation path on unauthorized stream failure; ensure `stop()` removes/neutralizes the listener and remains idempotent.
- `frontend/src/app/features/home/home.component.ts` — react to the centralized auth state becoming unauthenticated, stop/reset shell-owned state as needed, and navigate to `/login` without requiring a quick-tab interaction or reload; avoid duplicate navigation when the local logout already performed it.
- **To Create:**
- `tests/frontend/auth-cross-tab.spec.ts` — focused tests for cross-tab invalidation publication/consumption and duplicate/self-origin behavior using mocked browser channel/storage boundaries.
- If the implementer chooses a pure transport result rather than a direct callback, optionally `frontend/src/app/core/services/auth-session-events.pure.ts` and its corresponding test file; prefer keeping the event-name/payload validation pure and small rather than introducing an unnecessary abstraction.
- **Not to Modify:** Backend controllers, refresh-token schema/migrations, shell markup, quick-tab components, routes, or `setup.sh`; the server invalidation and route protection already exist.
## 3. Proposed Changes
1. **Database / Schema Migration:**
- No migration. Confirm the existing `POST /api/v1/auth/logout` behavior remains the source of truth for server-side refresh-cookie invalidation.
- Do not broadcast JWTs, refresh tokens, usernames, or arbitrary storage contents. Broadcast only a fixed event type (for example, a namespaced `logout`/`session-invalidated` message) and optionally a random per-tab sender identifier if needed to avoid self-processing.
2. **Backend Logic & APIs:**
- No backend endpoint changes. The existing authenticated logout endpoint is called by `AuthService.logout()` before local state is cleared, and hard navigation already proves the server clears the refresh cookie.
- Treat an authenticated SSE response with `401` as evidence that the current tabs session is invalid. Update `AuthenticatedEventSourceService`s internal fetch path so the response status is available to the existing `error` dispatch, without exposing response bodies or credentials. Generic network/stream errors should remain generic unless the response explicitly indicates unauthorized.
- If the transport contract cannot safely distinguish error categories through `Event`, add a minimal typed error event/detail or a separate `unauthorized` listener type in `event-status.pure.ts`; update all implementations and tests consistently. Keep the contract framework-light and compatible with the current custom fetch-based EventSource implementation.
3. **Frontend UI Integration:**
- Centralize cross-tab handling in `AuthService`, because it owns the authoritative `isAuthenticated`, access-token, and session-storage state. Initialize one browser listener for the root singleton, feature-detect `BroadcastChannel`, and provide a fallback using a namespaced `localStorage` key plus the browser `storage` event if BroadcastChannel is unavailable. If both are installed, publish on the primary channel and retain the fallback only if necessary; avoid double-processing identical notifications.
- On a local logout, call the server as today, then clear local state and publish the invalidation signal. Ensure peer-tab handling calls a non-broadcasting local-clear method so the notification does not echo indefinitely. The local tab may also receive its own channel message in some test/browser implementations, so make handling idempotent.
- On a peer invalidation, clear the access token, current user, and `sessionStorage`; make the state transition observable to consumers. If `AuthService.clear()` is reused by refresh failure or other local failures, define whether those paths should broadcast as well; the preferred rule is that any confirmed local session invalidation broadcasts, while a peer-originated clear suppresses rebroadcast.
- Wire the authenticated SSE lifecycle to the same invalidation path. `EventStatusStore.start()` should attach both message and error listeners. When the authenticated event source reports `401`, invoke an injected/session-invalidation callback or call an explicit `AuthService` method; then close the stream. Do not redirect merely for transient network errors, malformed frames, or ordinary stream closure.
- In `HomeComponent`, observe the auth signal or subscribe to a narrow invalidation event and navigate to `/login` after a peer logout/SSE unauthorized result. Reset `UserStore`, close the event stream, close open menus/modals if appropriate, and use a navigation guard/flag so the local `onLogout()` path does not trigger duplicate navigation. The next click on Challenges, Scoreboard, or Blog must not be able to continue rendering authenticated content; the route guard should see `isAuthenticated === false` and return the existing login `UrlTree`.
- Ensure cleanup: close `BroadcastChannel`, remove `storage` listeners, and detach/neutralize SSE listeners during service/component destruction or `stop()`. Preserve the existing event countdown behavior and shell UI when authentication remains valid.
- Keep browser API access guarded for non-browser/test environments. Use the existing Angular DI/signal conventions; do not add a third-party messaging library or move tokens into `localStorage`.
## 4. Test Strategy
- **Target Unit Test File:** `tests/frontend/auth-cross-tab.spec.ts` for the core event protocol and AuthService behavior. Extend `tests/frontend/authenticated-event-source.spec.ts` only with the minimal unauthorized-response contract test if that file remains the established transport boundary. If direct Angular service instantiation is impractical under the current Jest setup, extract a small pure event encoder/decoder and test it directly, while testing the transport contract with the existing fetch mock style.
- **Mocking Strategy:**
- Mock `BroadcastChannel` with an in-memory registry of channel instances, recording `postMessage`, `addEventListener`, `removeEventListener`, and `close`; deliver messages only to peer instances to model browser behavior and separately cover a self-delivery implementation.
- Mock `window.localStorage` and dispatch synthetic `StorageEvent` objects for the fallback path. Assert the implementation only accepts the fixed namespaced key/event shape and ignores malformed or unrelated storage changes.
- Use a minimal fake `AuthService` or explicit test doubles for token/user/session-storage operations where testing `EventStatusStore`; use a fake `EventSourceLike` that records listeners and close calls to verify `401` causes invalidation and generic errors do not.
- Cover only the core success and directly relevant failure paths: local logout publishes once and clears state; peer notification clears state without rebroadcast; fallback storage notification works when BroadcastChannel is absent; malformed/unrelated messages are ignored; unauthorized SSE invalidates and closes; generic SSE error preserves authentication. Run through the existing root `npm test` command; no browser/UI or persistent `/data` fixture is needed.
+30 -8
View File
@@ -3,7 +3,7 @@ type: architecture
title: Frontend Structure title: Frontend Structure
description: Angular routes, components, services, guards, interceptors, and authenticated SSE transport. description: Angular routes, components, services, guards, interceptors, and authenticated SSE transport.
tags: [architecture, frontend, angular, shell, sse] tags: [architecture, frontend, angular, shell, sse]
timestamp: 2026-07-22T09:35:00Z timestamp: 2026-07-22T10:32:00Z
--- ---
# Routes # Routes
@@ -24,13 +24,14 @@ Routes live in `frontend/src/app/app.routes.ts`:
| Symbol | Path | Responsibility | | 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. | | `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. | | `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. | | `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. | | `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. | | `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. | | `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 # 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 4. Splits complete SSE records on blank lines, joins repeated `data:` lines, and
dispatches `MessageEvent` objects. dispatches `MessageEvent` objects.
5. Buffers records received before the message listener is attached. 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, `EventStatusStore.start(createSource, onUnauthorized?)` parses each message as
and runs a one-second local timer. `HomeComponent` stops the store on destruction. `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 # 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 non-transparent theme token. The four `led-<state>` classes remain on the
element as selectors/test hooks but no longer own the color. 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 # See also
- [Authenticated Shell](/guides/authenticated-shell.md) - [Authenticated Shell](/guides/authenticated-shell.md)
+9 -6
View File
@@ -3,7 +3,7 @@ type: architecture
title: Key Files Index title: Key Files Index
description: One-line responsibility for important source files, including authenticated event streaming. description: One-line responsibility for important source files, including authenticated event streaming.
tags: [architecture, index, key-files] tags: [architecture, index, key-files]
timestamp: 2026-07-22T09:35:00Z timestamp: 2026-07-22T10:32:00Z
--- ---
# Backend # 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.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/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/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/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/authenticated-event-source.service.ts` | Fetch-based authenticated SSE transport with frame parsing and abort support. | | `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/event-status.store.ts` | Event state signal store and one-second countdown timer. | | `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.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/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/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/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. | | `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 # See also
+16
View File
@@ -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. 1. Click each quick tab and confirm the matching route and active styling.
2. Open the user menu and click **Logout**. 2. Open the user menu and click **Logout**.
3. Confirm navigation to `/login` and that the event stream is closed. 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 # See also
+204
View File
@@ -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
View File
@@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin
controls. controls.
The docs below are organized by purpose so agents can pull just the slice 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 # 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 * [Change Password](/guides/change-password.md) - How a signed-in user
changes their own password, including every error branch and the changes their own password, including every error branch and the
refresh-token revocation behavior. 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 * [Session Restoration on Reload](/guides/session-restoration.md) - How
the SPA keeps a user signed in across hard refreshes and how the the SPA keeps a user signed in across hard refreshes and how the
guards wait for bootstrap + auth hydration before deciding where to guards wait for bootstrap + auth hydration before deciding where to
@@ -0,0 +1,43 @@
export const CROSS_TAB_CHANNEL_NAME = 'hipctf.auth.v1';
export const CROSS_TAB_STORAGE_KEY = 'hipctf.auth.invalidate.v1';
export const CROSS_TAB_EVENT_TYPE = 'hipctf.session.invalidated';
export interface CrossTabInvalidationMessage {
type: 'session-invalidated';
origin: string;
ts: number;
}
export function encodeInvalidationMessage(origin: string): CrossTabInvalidationMessage {
return {
type: 'session-invalidated',
origin,
ts: Date.now(),
};
}
export function isCrossTabInvalidationMessage(
value: unknown,
): value is CrossTabInvalidationMessage {
if (!value || typeof value !== 'object') return false;
const v = value as Record<string, unknown>;
return (
v['type'] === 'session-invalidated' &&
typeof v['origin'] === 'string' &&
typeof v['ts'] === 'number'
);
}
export function isStorageInvalidationEvent(
event: { key?: string | null; newValue?: string | null } | null | undefined,
): boolean {
if (!event) return false;
if (event.key !== CROSS_TAB_STORAGE_KEY) return false;
if (event.newValue == null) return false;
try {
const parsed = JSON.parse(event.newValue) as unknown;
return isCrossTabInvalidationMessage(parsed);
} catch {
return false;
}
}
+131 -2
View File
@@ -1,4 +1,4 @@
import { Injectable, signal, computed, inject } from '@angular/core'; import { Injectable, signal, computed, inject, DestroyRef } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { import {
@@ -7,6 +7,14 @@ import {
clearStoredSession, clearStoredSession,
StoredSession, StoredSession,
} from './auth.session-storage'; } from './auth.session-storage';
import {
CROSS_TAB_CHANNEL_NAME,
CROSS_TAB_STORAGE_KEY,
CrossTabInvalidationMessage,
encodeInvalidationMessage,
isCrossTabInvalidationMessage,
isStorageInvalidationEvent,
} from './auth-session-events.pure';
export interface CurrentUser { export interface CurrentUser {
id: string; id: string;
@@ -78,9 +86,22 @@ export type ChangePasswordResult = ChangePasswordSuccess | ChangePasswordFailure
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AuthService { export class AuthService {
private http: HttpClient; private http: HttpClient;
private readonly destroyRef = inject(DestroyRef);
private readonly crossTabOrigin =
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `tab-${Math.random().toString(36).slice(2)}-${Date.now()}`;
private channel: BroadcastChannel | null = null;
private storageListener: ((ev: StorageEvent) => void) | null = null;
private suppressBroadcast = false;
private peerInvalidationListeners = new Set<(reason: 'peer-logout' | 'sse-unauthorized') => void>();
constructor(http?: HttpClient) { constructor(http?: HttpClient) {
this.http = http ?? inject(HttpClient); this.http = http ?? inject(HttpClient);
this.installCrossTabListener();
this.destroyRef.onDestroy(() => this.teardownCrossTabListener());
} }
private accessToken = signal<string | null>(null); private accessToken = signal<string | null>(null);
@@ -158,7 +179,7 @@ export class AuthService {
} catch { } catch {
// ignore network/auth errors; we still clear local state // ignore network/auth errors; we still clear local state
} }
this.clear(); this.clearAndBroadcast();
} }
async me(): Promise<MeResponse> { async me(): Promise<MeResponse> {
@@ -204,10 +225,118 @@ export class AuthService {
clearStoredSession(this.sessionStorage()); clearStoredSession(this.sessionStorage());
} }
clearAndBroadcast(): void {
this.clear();
if (this.suppressBroadcast) return;
const message = encodeInvalidationMessage(this.crossTabOrigin);
this.publishInvalidation(message);
}
handleSseUnauthorized(): void {
this.clearAndBroadcast();
this.notifyPeerInvalidation('sse-unauthorized');
}
onPeerInvalidation(cb: (reason: 'peer-logout' | 'sse-unauthorized') => void): () => void {
this.peerInvalidationListeners.add(cb);
return () => this.peerInvalidationListeners.delete(cb);
}
getAccessToken(): string | null { getAccessToken(): string | null {
return this.accessToken(); return this.accessToken();
} }
private publishInvalidation(message: CrossTabInvalidationMessage): void {
if (this.channel) {
try {
this.channel.postMessage(message);
} catch {
// ignore serialization failures; the storage fallback will still fire
}
}
if (typeof window !== 'undefined' && window.localStorage) {
try {
window.localStorage.setItem(CROSS_TAB_STORAGE_KEY, JSON.stringify(message));
} catch {
// ignore quota / serialization errors
}
}
}
private installCrossTabListener(): void {
if (typeof window === 'undefined') return;
if (typeof BroadcastChannel !== 'undefined') {
try {
this.channel = new BroadcastChannel(CROSS_TAB_CHANNEL_NAME);
this.channel.addEventListener('message', this.onBroadcastMessage);
} catch {
this.channel = null;
}
}
this.storageListener = (ev: StorageEvent) => {
if (!isStorageInvalidationEvent(ev)) return;
this.handlePeerInvalidation(ev.newValue, 'peer-logout');
};
window.addEventListener('storage', this.storageListener);
}
private teardownCrossTabListener(): void {
if (this.channel) {
try {
this.channel.removeEventListener('message', this.onBroadcastMessage);
this.channel.close();
} catch {
// ignore
}
this.channel = null;
}
if (this.storageListener && typeof window !== 'undefined') {
try {
window.removeEventListener('storage', this.storageListener);
} catch {
// ignore
}
this.storageListener = null;
}
this.peerInvalidationListeners.clear();
}
private readonly onBroadcastMessage = (ev: MessageEvent<unknown>) => {
if (!isCrossTabInvalidationMessage(ev.data)) return;
if (ev.data.origin === this.crossTabOrigin) return;
this.handlePeerInvalidation(JSON.stringify(ev.data), 'peer-logout');
};
private handlePeerInvalidation(
raw: string | null,
reason: 'peer-logout' | 'sse-unauthorized',
): void {
if (raw) {
try {
const parsed = JSON.parse(raw) as unknown;
if (!isCrossTabInvalidationMessage(parsed)) return;
if (parsed.origin === this.crossTabOrigin) return;
} catch {
return;
}
}
if (!this.isAuthenticated()) return;
this.suppressBroadcast = true;
this.clear();
this.suppressBroadcast = false;
this.notifyPeerInvalidation(reason);
}
private notifyPeerInvalidation(reason: 'peer-logout' | 'sse-unauthorized'): void {
for (const cb of this.peerInvalidationListeners) {
try {
cb(reason);
} catch {
// ignore listener errors
}
}
}
private sessionStorage(): Storage { private sessionStorage(): Storage {
return typeof window !== 'undefined' ? window.sessionStorage : (undefined as unknown as Storage); return typeof window !== 'undefined' ? window.sessionStorage : (undefined as unknown as Storage);
} }
@@ -48,6 +48,11 @@ export class AuthenticatedEventSourceService {
dispatch('error', new Event('error')); dispatch('error', new Event('error'));
}; };
const fireUnauthorized = () => {
if (closed) return;
dispatch('unauthorized', new Event('unauthorized'));
};
const start = async () => { const start = async () => {
try { try {
const res = await fetch(url, { const res = await fetch(url, {
@@ -56,6 +61,10 @@ export class AuthenticatedEventSourceService {
credentials: 'include', credentials: 'include',
signal: controller!.signal, signal: controller!.signal,
}); });
if (res.status === 401 || res.status === 403) {
if (!closed) fireUnauthorized();
return;
}
if (!res.ok || !res.body) { if (!res.ok || !res.body) {
fireError(); fireError();
return; return;
@@ -10,7 +10,10 @@ export interface EventStatePayload {
} }
export interface EventSourceLike { export interface EventSourceLike {
addEventListener(type: 'open' | 'message' | 'error', listener: (ev: MessageEvent | Event) => void): void; addEventListener(
type: 'open' | 'message' | 'error' | 'unauthorized',
listener: (ev: MessageEvent | Event) => void,
): void;
close(): void; close(): void;
} }
@@ -66,7 +66,7 @@ export class EventStatusStore {
this.anchorDeltaMs.set(Date.now() - new Date(payload.serverNowUtc).getTime()); this.anchorDeltaMs.set(Date.now() - new Date(payload.serverNowUtc).getTime());
} }
start(createSource: () => EventSourceLike): void { start(createSource: () => EventSourceLike, onUnauthorized?: () => void): void {
this.stop(); this.stop();
const src = createSource(); const src = createSource();
this.source = src; this.source = src;
@@ -81,6 +81,15 @@ export class EventStatusStore {
// ignore malformed frame // ignore malformed frame
} }
}); });
if (onUnauthorized) {
src.addEventListener('unauthorized', () => {
try {
onUnauthorized();
} catch {
// ignore
}
});
}
if (!this.intervalId) { if (!this.intervalId) {
this.intervalId = setInterval(() => this.tick.update((v) => v + 1), 1000); this.intervalId = setInterval(() => this.tick.update((v) => v + 1), 1000);
} }
@@ -129,11 +129,21 @@ export class HomeComponent implements OnInit, OnDestroy {
ngOnInit(): void { ngOnInit(): void {
this.userStore.hydrateFromAuth(); this.userStore.hydrateFromAuth();
void this.userStore.loadMe(); void this.userStore.loadMe();
this.eventStatus.start(() => this.authenticatedEventSource.open('/api/v1/events/status')); this.eventStatus.start(
() => this.authenticatedEventSource.open('/api/v1/events/status'),
() => this.handleSessionInvalidated('sse-unauthorized'),
);
this.peerInvalidationUnsubscribe = this.auth.onPeerInvalidation((reason) =>
this.handleSessionInvalidated(reason),
);
} }
ngOnDestroy(): void { ngOnDestroy(): void {
this.eventStatus.stop(); this.eventStatus.stop();
if (this.peerInvalidationUnsubscribe) {
this.peerInvalidationUnsubscribe();
this.peerInvalidationUnsubscribe = null;
}
} }
goHome(): void { goHome(): void {
@@ -192,4 +202,23 @@ export class HomeComponent implements OnInit, OnDestroy {
this.userStore.reset(); this.userStore.reset();
await this.router.navigateByUrl('/login'); await this.router.navigateByUrl('/login');
} }
private peerInvalidationUnsubscribe: (() => void) | null = null;
private navigatingToLogin = false;
private async handleSessionInvalidated(
_reason: 'peer-logout' | 'sse-unauthorized',
): Promise<void> {
this.userStore.reset();
this.changePasswordOpen.set(false);
this.userMenuOpen.set(false);
if (this.navigatingToLogin) return;
if (this.router.url.startsWith('/login')) return;
this.navigatingToLogin = true;
try {
await this.router.navigateByUrl('/login');
} finally {
this.navigatingToLogin = false;
}
}
} }
@@ -0,0 +1,93 @@
import {
CROSS_TAB_CHANNEL_NAME,
CROSS_TAB_STORAGE_KEY,
CrossTabInvalidationMessage,
encodeInvalidationMessage,
isCrossTabInvalidationMessage,
isStorageInvalidationEvent,
} from '../../frontend/src/app/core/services/auth-session-events.pure';
describe('auth-session-events.pure', () => {
describe('encodeInvalidationMessage', () => {
it('produces a session-invalidated message with origin and timestamp', () => {
const before = Date.now();
const msg = encodeInvalidationMessage('tab-A');
const after = Date.now();
expect(msg.type).toBe('session-invalidated');
expect(msg.origin).toBe('tab-A');
expect(msg.ts).toBeGreaterThanOrEqual(before);
expect(msg.ts).toBeLessThanOrEqual(after);
});
});
describe('isCrossTabInvalidationMessage', () => {
it('accepts a properly shaped message', () => {
const msg: CrossTabInvalidationMessage = {
type: 'session-invalidated',
origin: 'x',
ts: 1,
};
expect(isCrossTabInvalidationMessage(msg)).toBe(true);
});
it('rejects non-objects', () => {
expect(isCrossTabInvalidationMessage(null)).toBe(false);
expect(isCrossTabInvalidationMessage(undefined)).toBe(false);
expect(isCrossTabInvalidationMessage('string')).toBe(false);
expect(isCrossTabInvalidationMessage(42)).toBe(false);
});
it('rejects objects with wrong type or missing fields', () => {
expect(isCrossTabInvalidationMessage({ type: 'other', origin: 'a', ts: 1 })).toBe(false);
expect(isCrossTabInvalidationMessage({ type: 'session-invalidated', origin: 1, ts: 1 })).toBe(
false,
);
expect(isCrossTabInvalidationMessage({ type: 'session-invalidated', origin: 'a' })).toBe(
false,
);
expect(
isCrossTabInvalidationMessage({ type: 'session-invalidated', origin: 'a', ts: 'now' }),
).toBe(false);
});
});
describe('isStorageInvalidationEvent', () => {
it('accepts a well-formed storage event for the namespaced key', () => {
const ev = {
key: CROSS_TAB_STORAGE_KEY,
newValue: JSON.stringify(encodeInvalidationMessage('tab-X')),
};
expect(isStorageInvalidationEvent(ev)).toBe(true);
});
it('rejects events with a different key', () => {
const ev = {
key: 'other.key',
newValue: JSON.stringify(encodeInvalidationMessage('tab-X')),
};
expect(isStorageInvalidationEvent(ev)).toBe(false);
});
it('rejects events with no newValue (cleared key)', () => {
const ev = { key: CROSS_TAB_STORAGE_KEY, newValue: null };
expect(isStorageInvalidationEvent(ev)).toBe(false);
});
it('rejects malformed JSON payloads', () => {
const ev = { key: CROSS_TAB_STORAGE_KEY, newValue: 'not-json' };
expect(isStorageInvalidationEvent(ev)).toBe(false);
});
it('rejects nullish events', () => {
expect(isStorageInvalidationEvent(null)).toBe(false);
expect(isStorageInvalidationEvent(undefined)).toBe(false);
});
});
describe('constant exports', () => {
it('exposes a stable channel name and storage key', () => {
expect(CROSS_TAB_CHANNEL_NAME).toBe('hipctf.auth.v1');
expect(CROSS_TAB_STORAGE_KEY).toBe('hipctf.auth.invalidate.v1');
});
});
});
@@ -13,7 +13,10 @@ if (typeof (globalThis as any).TextDecoder === 'undefined') {
} }
interface EventSourceLike { interface EventSourceLike {
addEventListener(type: 'open' | 'message' | 'error', listener: (ev: MessageEvent | Event) => void): void; addEventListener(
type: 'open' | 'message' | 'error' | 'unauthorized',
listener: (ev: MessageEvent | Event) => void,
): void;
close(): void; close(): void;
} }
@@ -33,6 +36,13 @@ function makeCaptureableFetch(frames: string[]) {
return { fetchMock, get captured() { return captured; } }; return { fetchMock, get captured() { return captured; } };
} }
function makeUnauthorizedFetch(status: number) {
const fetchMock = jest.fn(async () => {
return { ok: false, status, body: null } as unknown as Response;
});
return fetchMock;
}
// Inline contract for the production AuthenticatedEventSourceService, // Inline contract for the production AuthenticatedEventSourceService,
// kept here because the project's jest config cannot transform Angular's // kept here because the project's jest config cannot transform Angular's
// ESM runtime modules; this contract guards the regression. // ESM runtime modules; this contract guards the regression.
@@ -42,12 +52,20 @@ function openAuthenticatedLike(
fetchImpl: typeof globalThis.fetch, fetchImpl: typeof globalThis.fetch,
): EventSourceLike { ): EventSourceLike {
let handler: ((ev: MessageEvent) => void) | null = null; let handler: ((ev: MessageEvent) => void) | null = null;
let unauthorizedHandler: ((ev: Event) => void) | null = null;
void (async () => { void (async () => {
const headers: Record<string, string> = { Accept: 'text/event-stream' }; const headers: Record<string, string> = { Accept: 'text/event-stream' };
if (token) headers['Authorization'] = `Bearer ${token}`; if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetchImpl(url, { headers, credentials: 'include' }); const res = (await fetchImpl(url, { headers, credentials: 'include' })) as unknown as Response;
if (!res || !(res as any).body) return; const status = (res as { status?: number }).status ?? 0;
const reader = ((res as any).body as ReadableStream<Uint8Array>).getReader(); if (status === 401 || status === 403) {
if (unauthorizedHandler) unauthorizedHandler(new Event('unauthorized'));
return;
}
if (!res || !(res as { body?: unknown }).body) return;
const reader = ((res as { body: ReadableStream<Uint8Array> }).body as ReadableStream<
Uint8Array
>).getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buf = ''; let buf = '';
// eslint-disable-next-line no-constant-condition // eslint-disable-next-line no-constant-condition
@@ -70,11 +88,13 @@ function openAuthenticatedLike(
} }
})(); })();
return { return {
addEventListener(_type, cb) { addEventListener(type, cb) {
handler = cb as (ev: MessageEvent) => void; if (type === 'unauthorized') unauthorizedHandler = cb as (ev: Event) => void;
else handler = cb as (ev: MessageEvent) => void;
}, },
close() { close() {
handler = null; handler = null;
unauthorizedHandler = null;
}, },
}; };
} }
@@ -150,4 +170,38 @@ describe('authenticated event source transport contract', () => {
expect(headers['Authorization']).toBeUndefined(); expect(headers['Authorization']).toBeUndefined();
source.close(); source.close();
}); });
it('fires an unauthorized event on a 401 response', async () => {
const fetchMock = makeUnauthorizedFetch(401);
(globalThis as any).fetch = fetchMock;
const source = openAuthenticatedLike(
'/api/v1/events/status',
'test-jwt-token',
fetchMock as unknown as typeof globalThis.fetch,
);
const received: string[] = [];
source.addEventListener('unauthorized', (ev) => {
received.push(ev.type);
});
await new Promise((r) => setTimeout(r, 30));
expect(received).toEqual(['unauthorized']);
source.close();
});
it('fires an unauthorized event on a 403 response', async () => {
const fetchMock = makeUnauthorizedFetch(403);
(globalThis as any).fetch = fetchMock;
const source = openAuthenticatedLike(
'/api/v1/events/status',
'test-jwt-token',
fetchMock as unknown as typeof globalThis.fetch,
);
const received: string[] = [];
source.addEventListener('unauthorized', (ev) => {
received.push(ev.type);
});
await new Promise((r) => setTimeout(r, 30));
expect(received).toEqual(['unauthorized']);
source.close();
});
}); });