diff --git a/.kilo/plans/880.md b/.kilo/plans/880.md deleted file mode 100644 index b5d78b0..0000000 --- a/.kilo/plans/880.md +++ /dev/null @@ -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: "`, 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. diff --git a/.kilo/plans/881.md b/.kilo/plans/881.md new file mode 100644 index 0000000..f1ba835 --- /dev/null +++ b/.kilo/plans/881.md @@ -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 tab’s 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. diff --git a/docs/architecture/frontend-structure.md b/docs/architecture/frontend-structure.md index fe086d4..6bb2cce 100644 --- a/docs/architecture/frontend-structure.md +++ b/docs/architecture/frontend-structure.md @@ -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-` 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) diff --git a/docs/architecture/key-files.md b/docs/architecture/key-files.md index 33ab169..8f7e224 100644 --- a/docs/architecture/key-files.md +++ b/docs/architecture/key-files.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 diff --git a/docs/guides/authenticated-shell.md b/docs/guides/authenticated-shell.md index 00f0bae..30baa9c 100644 --- a/docs/guides/authenticated-shell.md +++ b/docs/guides/authenticated-shell.md @@ -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 diff --git a/docs/guides/cross-tab-invalidation.md b/docs/guides/cross-tab-invalidation.md new file mode 100644 index 0000000..7166db2 --- /dev/null +++ b/docs/guides/cross-tab-invalidation.md @@ -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) diff --git a/docs/index.md b/docs/index.md index 9efd50f..1d2dd11 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 diff --git a/frontend/src/app/core/services/auth-session-events.pure.ts b/frontend/src/app/core/services/auth-session-events.pure.ts new file mode 100644 index 0000000..b14f98a --- /dev/null +++ b/frontend/src/app/core/services/auth-session-events.pure.ts @@ -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; + 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; + } +} diff --git a/frontend/src/app/core/services/auth.service.ts b/frontend/src/app/core/services/auth.service.ts index 61fe496..58c591f 100644 --- a/frontend/src/app/core/services/auth.service.ts +++ b/frontend/src/app/core/services/auth.service.ts @@ -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 { firstValueFrom } from 'rxjs'; import { @@ -7,6 +7,14 @@ import { clearStoredSession, StoredSession, } 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 { id: string; @@ -78,9 +86,22 @@ export type ChangePasswordResult = ChangePasswordSuccess | ChangePasswordFailure @Injectable({ providedIn: 'root' }) export class AuthService { 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) { this.http = http ?? inject(HttpClient); + this.installCrossTabListener(); + this.destroyRef.onDestroy(() => this.teardownCrossTabListener()); } private accessToken = signal(null); @@ -158,7 +179,7 @@ export class AuthService { } catch { // ignore network/auth errors; we still clear local state } - this.clear(); + this.clearAndBroadcast(); } async me(): Promise { @@ -204,10 +225,118 @@ export class AuthService { 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 { 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) => { + 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 { return typeof window !== 'undefined' ? window.sessionStorage : (undefined as unknown as Storage); } diff --git a/frontend/src/app/core/services/authenticated-event-source.service.ts b/frontend/src/app/core/services/authenticated-event-source.service.ts index e726a68..ec0a726 100644 --- a/frontend/src/app/core/services/authenticated-event-source.service.ts +++ b/frontend/src/app/core/services/authenticated-event-source.service.ts @@ -48,6 +48,11 @@ export class AuthenticatedEventSourceService { dispatch('error', new Event('error')); }; + const fireUnauthorized = () => { + if (closed) return; + dispatch('unauthorized', new Event('unauthorized')); + }; + const start = async () => { try { const res = await fetch(url, { @@ -56,6 +61,10 @@ export class AuthenticatedEventSourceService { credentials: 'include', signal: controller!.signal, }); + if (res.status === 401 || res.status === 403) { + if (!closed) fireUnauthorized(); + return; + } if (!res.ok || !res.body) { fireError(); return; diff --git a/frontend/src/app/core/services/event-status.pure.ts b/frontend/src/app/core/services/event-status.pure.ts index a3cb986..65dbbcb 100644 --- a/frontend/src/app/core/services/event-status.pure.ts +++ b/frontend/src/app/core/services/event-status.pure.ts @@ -10,7 +10,10 @@ export interface EventStatePayload { } 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; } diff --git a/frontend/src/app/core/services/event-status.store.ts b/frontend/src/app/core/services/event-status.store.ts index 379e59a..2a077b8 100644 --- a/frontend/src/app/core/services/event-status.store.ts +++ b/frontend/src/app/core/services/event-status.store.ts @@ -66,7 +66,7 @@ export class EventStatusStore { this.anchorDeltaMs.set(Date.now() - new Date(payload.serverNowUtc).getTime()); } - start(createSource: () => EventSourceLike): void { + start(createSource: () => EventSourceLike, onUnauthorized?: () => void): void { this.stop(); const src = createSource(); this.source = src; @@ -81,6 +81,15 @@ export class EventStatusStore { // ignore malformed frame } }); + if (onUnauthorized) { + src.addEventListener('unauthorized', () => { + try { + onUnauthorized(); + } catch { + // ignore + } + }); + } if (!this.intervalId) { this.intervalId = setInterval(() => this.tick.update((v) => v + 1), 1000); } diff --git a/frontend/src/app/features/home/home.component.ts b/frontend/src/app/features/home/home.component.ts index 64a4d20..e5adc18 100644 --- a/frontend/src/app/features/home/home.component.ts +++ b/frontend/src/app/features/home/home.component.ts @@ -129,11 +129,21 @@ export class HomeComponent implements OnInit, OnDestroy { ngOnInit(): void { this.userStore.hydrateFromAuth(); 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 { this.eventStatus.stop(); + if (this.peerInvalidationUnsubscribe) { + this.peerInvalidationUnsubscribe(); + this.peerInvalidationUnsubscribe = null; + } } goHome(): void { @@ -192,4 +202,23 @@ export class HomeComponent implements OnInit, OnDestroy { this.userStore.reset(); await this.router.navigateByUrl('/login'); } + + private peerInvalidationUnsubscribe: (() => void) | null = null; + private navigatingToLogin = false; + + private async handleSessionInvalidated( + _reason: 'peer-logout' | 'sse-unauthorized', + ): Promise { + 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; + } + } } diff --git a/tests/frontend/auth-session-events.spec.ts b/tests/frontend/auth-session-events.spec.ts new file mode 100644 index 0000000..9c1fce9 --- /dev/null +++ b/tests/frontend/auth-session-events.spec.ts @@ -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'); + }); + }); +}); diff --git a/tests/frontend/authenticated-event-source.spec.ts b/tests/frontend/authenticated-event-source.spec.ts index b452867..2bd7b38 100644 --- a/tests/frontend/authenticated-event-source.spec.ts +++ b/tests/frontend/authenticated-event-source.spec.ts @@ -13,7 +13,10 @@ if (typeof (globalThis as any).TextDecoder === 'undefined') { } 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; } @@ -33,6 +36,13 @@ function makeCaptureableFetch(frames: string[]) { 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, // kept here because the project's jest config cannot transform Angular's // ESM runtime modules; this contract guards the regression. @@ -42,12 +52,20 @@ function openAuthenticatedLike( fetchImpl: typeof globalThis.fetch, ): EventSourceLike { let handler: ((ev: MessageEvent) => void) | null = null; + let unauthorizedHandler: ((ev: Event) => void) | null = null; void (async () => { const headers: Record = { Accept: 'text/event-stream' }; if (token) headers['Authorization'] = `Bearer ${token}`; - const res = await fetchImpl(url, { headers, credentials: 'include' }); - if (!res || !(res as any).body) return; - const reader = ((res as any).body as ReadableStream).getReader(); + const res = (await fetchImpl(url, { headers, credentials: 'include' })) as unknown as Response; + const status = (res as { status?: number }).status ?? 0; + 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 }).body as ReadableStream< + Uint8Array + >).getReader(); const decoder = new TextDecoder(); let buf = ''; // eslint-disable-next-line no-constant-condition @@ -70,11 +88,13 @@ function openAuthenticatedLike( } })(); return { - addEventListener(_type, cb) { - handler = cb as (ev: MessageEvent) => void; + addEventListener(type, cb) { + if (type === 'unauthorized') unauthorizedHandler = cb as (ev: Event) => void; + else handler = cb as (ev: MessageEvent) => void; }, close() { handler = null; + unauthorizedHandler = null; }, }; } @@ -150,4 +170,38 @@ describe('authenticated event source transport contract', () => { expect(headers['Authorization']).toBeUndefined(); 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(); + }); });