AI Implementation feature(881): Authenticated Shell, Quick Tabs and Change Password 1.05 #20
@@ -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.
|
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
---
|
||||||
|
type: guide
|
||||||
|
title: Cross-Tab Authenticated Shell Invalidation
|
||||||
|
description: How logging out in one tab, or receiving an unauthorized SSE response, instantly logs out and redirects every other open tab of the SPA.
|
||||||
|
tags: [guide, auth, session, broadcast-channel, storage-event, sse, tester]
|
||||||
|
timestamp: 2026-07-22T10:32:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# What this feature does
|
||||||
|
|
||||||
|
Before this change, `sessionStorage` was tab-scoped, so logging out in **tab A**
|
||||||
|
left any other open tabs (`/challenges`, `/scoreboard`, `/blog`, `/admin`)
|
||||||
|
**still rendering authenticated content** until they were individually closed or
|
||||||
|
reloaded. The same gap existed when the server returned `401` to the
|
||||||
|
authenticated `/api/v1/events/status` SSE stream: the offending tab knew it was
|
||||||
|
unauthenticated, but the others did not.
|
||||||
|
|
||||||
|
This change adds a same-browser **cross-tab session-invalidation channel**:
|
||||||
|
|
||||||
|
1. `AuthService` now owns a single `BroadcastChannel('hipctf.auth.v1')` plus a
|
||||||
|
`localStorage` `storage`-event fallback, registered in its constructor and
|
||||||
|
torn down on `DestroyRef`.
|
||||||
|
2. Every confirmed local session invalidation (manual `logout()`, SSE
|
||||||
|
`401`/`403`) clears state **and publishes** a fixed-shape
|
||||||
|
`session-invalidated` message containing only the broadcasting tab's
|
||||||
|
`origin` (random per-tab UUID) and a `ts` timestamp. No tokens, users, or
|
||||||
|
storage contents are broadcast.
|
||||||
|
3. Each tab listens for the message. If the `origin` differs from its own, it
|
||||||
|
treats the notification as a peer-logout: it clears its in-memory and
|
||||||
|
`sessionStorage` state **without** rebroadcasting, then notifies
|
||||||
|
subscribers.
|
||||||
|
4. `HomeComponent` subscribes to the new peer-invalidation event, resets
|
||||||
|
`UserStore`, closes any open change-password / user-menu overlays, and
|
||||||
|
navigates to `/login`. A re-entrancy flag prevents duplicate navigations
|
||||||
|
when the originating tab already navigated.
|
||||||
|
5. `AuthenticatedEventSourceService` was extended to dispatch a new
|
||||||
|
`'unauthorized'` event when the SSE fetch returns `401` or `403`. Generic
|
||||||
|
network failures still dispatch `'error'` and do **not** invalidate.
|
||||||
|
|
||||||
|
# Why a new concept doc
|
||||||
|
|
||||||
|
The change spans the auth core, the SSE transport, the event-status store, and
|
||||||
|
the shell component. It introduces a new pure module
|
||||||
|
(`auth-session-events.pure.ts`) and a new transport event type
|
||||||
|
(`'unauthorized'` on `EventSourceLike`). A tester reading the
|
||||||
|
[Authenticated Shell](/guides/authenticated-shell.md) guide should not have to
|
||||||
|
re-derive the cross-tab propagation rules, and a developer wiring a new
|
||||||
|
service should not have to read the auth service diff to learn about the
|
||||||
|
broadcast contract.
|
||||||
|
|
||||||
|
# Tester flows
|
||||||
|
|
||||||
|
> Open the SPA in **two browser tabs** of the same browser profile, signed in
|
||||||
|
> as the same user. Both tabs must be on authenticated routes (`/`,
|
||||||
|
> `/challenges`, `/scoreboard`, `/blog`, or `/admin`).
|
||||||
|
|
||||||
|
## Peer logout (manual)
|
||||||
|
|
||||||
|
1. Sign in once and navigate to `/challenges` in **Tab A**.
|
||||||
|
2. Duplicate the tab — **Tab B** opens at `/scoreboard`.
|
||||||
|
3. In **Tab A**, open the user menu (`[data-testid="user-menu-trigger"]`)
|
||||||
|
and click **Logout**.
|
||||||
|
4. **Tab A** navigates to `/login` (unchanged behavior).
|
||||||
|
5. **Tab B** clears its shell state without any user interaction and navigates
|
||||||
|
to `/login`. The event LED, countdown, and quick-tab strip are no longer
|
||||||
|
visible — the landing/login view is shown.
|
||||||
|
6. Confirm in DevTools → Application → Session Storage of **Tab B** that the
|
||||||
|
`hipctf.auth.v1` key has been removed.
|
||||||
|
|
||||||
|
## Peer logout (SSE unauthorized)
|
||||||
|
|
||||||
|
1. Sign in once and open the SPA in two tabs.
|
||||||
|
2. In **Tab A** (server side, via an admin-only tool or by manually
|
||||||
|
revoking the refresh cookie), invalidate the refresh-cookie/session.
|
||||||
|
3. Wait for `EventStatusStore` to reconnect — the SSE fetch returns `401`.
|
||||||
|
4. **Tab A** clears state and navigates to `/login`.
|
||||||
|
5. **Tab B** (still on `/challenges`) clears state and navigates to `/login`
|
||||||
|
without any SSE retry happening locally — it received the broadcast from
|
||||||
|
Tab A.
|
||||||
|
|
||||||
|
> If you instead trigger the unauthorized response in **Tab B** directly
|
||||||
|
> (e.g. by expiring the refresh cookie for that tab only), Tab B navigates
|
||||||
|
> to `/login`; Tab A is unaffected.
|
||||||
|
|
||||||
|
## Negative cases
|
||||||
|
|
||||||
|
| Action | Expected |
|
||||||
|
|---|---|
|
||||||
|
| Open the SPA in a private/incognito window in addition to a normal window | Logging out in one does **not** affect the other. `BroadcastChannel` and the `storage` event are scoped to the browser profile. |
|
||||||
|
| Trigger a generic network failure on `/api/v1/events/status` | The store dispatches `'error'`, retries, and **does not** invalidate. The shell keeps the last known event state. |
|
||||||
|
| A non-HIPCTF tab on the same browser profile writes `hipctf.auth.invalidate.v1` to `localStorage` with garbage | The payload validator (`isCrossTabInvalidationEvent`) ignores it — no state change. |
|
||||||
|
| Same tab echoes its own broadcast (test environment that delivers self-messages) | The `origin === crossTabOrigin` guard prevents self-processing and rebroadcast loops. |
|
||||||
|
|
||||||
|
# Visual / interactive elements
|
||||||
|
|
||||||
|
There are **no new UI controls**. Existing selectors continue to work and
|
||||||
|
should be exercised after a peer invalidation:
|
||||||
|
|
||||||
|
| Element | Selector | After peer invalidation |
|
||||||
|
|---|---|---|
|
||||||
|
| Shell header | `[data-testid="shell-header"]` | Removed from DOM by `Router` (route changed to `/login`). |
|
||||||
|
| Quick tabs | `[data-testid="quick-tabs"]` | Removed from DOM by `Router`. |
|
||||||
|
| User menu | `[data-testid="user-menu-trigger"]` | Removed from DOM by `Router`. |
|
||||||
|
| Event LED / countdown | `[data-testid="shell-led"]`, `[data-testid="shell-countdown"]` | Removed from DOM by `Router`. |
|
||||||
|
| Login form | `[data-testid="login-submit"]` | Becomes visible (already-tested flow). |
|
||||||
|
|
||||||
|
# How it works (developer map)
|
||||||
|
|
||||||
|
## Pure contract module
|
||||||
|
|
||||||
|
`frontend/src/app/core/services/auth-session-events.pure.ts` is a
|
||||||
|
framework-light, testable module that defines:
|
||||||
|
|
||||||
|
| Export | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `CROSS_TAB_CHANNEL_NAME = 'hipctf.auth.v1'` | Stable `BroadcastChannel` name. |
|
||||||
|
| `CROSS_TAB_STORAGE_KEY = 'hipctf.auth.invalidate.v1'` | `localStorage` fallback key. |
|
||||||
|
| `CROSS_TAB_EVENT_TYPE = 'hipctf.session.invalidated'` | Marker used by the discriminated-union guard. |
|
||||||
|
| `CrossTabInvalidationMessage` | `{ type: 'session-invalidated'; origin: string; ts: number }`. |
|
||||||
|
| `encodeInvalidationMessage(origin)` | Builds a message stamped with `Date.now()`. |
|
||||||
|
| `isCrossTabInvalidationMessage(value)` | Type-guard used by both `BroadcastChannel` and `storage` listeners. |
|
||||||
|
| `isStorageInvalidationEvent(event)` | Guards `storage` events: only accepts the namespaced key with a parseable payload. |
|
||||||
|
|
||||||
|
The payload intentionally carries **no** access token, refresh token, user id,
|
||||||
|
or `sessionStorage` content.
|
||||||
|
|
||||||
|
## `AuthService` wiring
|
||||||
|
|
||||||
|
`frontend/src/app/core/services/auth.service.ts`
|
||||||
|
|
||||||
|
* Constructor calls `installCrossTabListener()`, which:
|
||||||
|
* Feature-detects `BroadcastChannel`; if present, opens it on
|
||||||
|
`CROSS_TAB_CHANNEL_NAME` and subscribes to `'message'`.
|
||||||
|
* Always subscribes a `'storage'` listener filtered through
|
||||||
|
`isStorageInvalidationEvent`.
|
||||||
|
* `clearAndBroadcast()` is the new public invalidation entry point: calls the
|
||||||
|
existing `clear()` (which wipes `sessionStorage`), and if
|
||||||
|
`suppressBroadcast` is `false`, publishes a fresh `encodeInvalidationMessage`
|
||||||
|
on both transports.
|
||||||
|
* `logout()` now ends with `clearAndBroadcast()` instead of `clear()`.
|
||||||
|
* `handleSseUnauthorized()` calls `clearAndBroadcast()` and then synchronously
|
||||||
|
notifies peer-invalidation subscribers with the reason
|
||||||
|
`'sse-unauthorized'` so the originating tab can navigate immediately.
|
||||||
|
* `onPeerInvalidation(cb)` registers a subscriber; returns an unsubscribe
|
||||||
|
closure. Used by `HomeComponent`.
|
||||||
|
* `handlePeerInvalidation()` is the single fan-in for both transport sources:
|
||||||
|
it parses the payload, drops self-origin messages, drops messages when the
|
||||||
|
tab is already unauthenticated, and runs `clear()` under
|
||||||
|
`suppressBroadcast = true` so the listener does not echo.
|
||||||
|
* `DestroyRef.onDestroy` calls `teardownCrossTabListener()` which removes the
|
||||||
|
`'message'` and `'storage'` listeners and closes the channel.
|
||||||
|
|
||||||
|
## Authenticated SSE: `unauthorized` event
|
||||||
|
|
||||||
|
`frontend/src/app/core/services/authenticated-event-source.service.ts`
|
||||||
|
|
||||||
|
The fetch path now inspects `res.status` *before* the `ok`/`body` check. If
|
||||||
|
the status is `401` or `403`, the service dispatches a new `'unauthorized'`
|
||||||
|
`Event` and returns; the response body is **not** read and no frame parsing
|
||||||
|
runs.
|
||||||
|
|
||||||
|
`frontend/src/app/core/services/event-status.pure.ts` widens
|
||||||
|
`EventSourceLike.addEventListener` to accept `'open' | 'message' | 'error' |
|
||||||
|
'unauthorized'`.
|
||||||
|
|
||||||
|
`frontend/src/app/core/services/event-status.store.ts`
|
||||||
|
|
||||||
|
`EventStatusStore.start(createSource, onUnauthorized?)` now accepts an
|
||||||
|
optional second argument. When provided, the store attaches an
|
||||||
|
`'unauthorized'` listener that invokes `onUnauthorized()` and is detached by
|
||||||
|
`stop()` (which is already idempotent). Generic `'error'` events remain
|
||||||
|
unhandled by the store; the SPA does not navigate on transport failure.
|
||||||
|
|
||||||
|
## Shell reaction
|
||||||
|
|
||||||
|
`frontend/src/app/features/home/home.component.ts`
|
||||||
|
|
||||||
|
* `ngOnInit()` passes a second callback to `eventStatus.start(...)` that
|
||||||
|
delegates to `handleSessionInvalidated('sse-unauthorized')`.
|
||||||
|
* It also subscribes to `auth.onPeerInvalidation(...)` and stores the
|
||||||
|
unsubscribe handle on `peerInvalidationUnsubscribe`.
|
||||||
|
* `ngOnDestroy()` calls `eventStatus.stop()` and unsubscribes from
|
||||||
|
`onPeerInvalidation`.
|
||||||
|
* `handleSessionInvalidated(reason)`:
|
||||||
|
* Calls `userStore.reset()` and closes `changePasswordOpen` /
|
||||||
|
`userMenuOpen` modal signals.
|
||||||
|
* Guards re-entry with `navigatingToLogin` and short-circuits when the
|
||||||
|
router is already on `/login`.
|
||||||
|
* Otherwise awaits `router.navigateByUrl('/login')`.
|
||||||
|
|
||||||
|
## Test coverage
|
||||||
|
|
||||||
|
| File | What it pins down |
|
||||||
|
|---|---|
|
||||||
|
| `tests/frontend/auth-session-events.spec.ts` | Pure payload validation, `storage`-event filtering, `encodeInvalidationMessage` shape. |
|
||||||
|
| `tests/frontend/authenticated-event-source.spec.ts` | Extended: `401`/`403` dispatches `'unauthorized'`; generic errors still dispatch `'error'`; no body is read on the unauthorized path. |
|
||||||
|
|
||||||
|
# See also
|
||||||
|
|
||||||
|
- [Authenticated Shell](/guides/authenticated-shell.md)
|
||||||
|
- [Session Restoration on Reload](/guides/session-restoration.md)
|
||||||
|
- [Frontend Structure](/architecture/frontend-structure.md)
|
||||||
|
- [REST API Overview](/api/rest-overview.md)
|
||||||
|
- [Auth Endpoints](/api/auth.md)
|
||||||
+4
-1
@@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin
|
|||||||
controls.
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user