feat: Authenticated Shell, Quick Tabs and Change Password 1.05

This commit is contained in:
OpenVelo Agent
2026-07-22 10:35:25 +00:00
parent 305352b259
commit a8aa0c03ca
10 changed files with 425 additions and 41 deletions
@@ -0,0 +1,43 @@
export const CROSS_TAB_CHANNEL_NAME = 'hipctf.auth.v1';
export const CROSS_TAB_STORAGE_KEY = 'hipctf.auth.invalidate.v1';
export const CROSS_TAB_EVENT_TYPE = 'hipctf.session.invalidated';
export interface CrossTabInvalidationMessage {
type: 'session-invalidated';
origin: string;
ts: number;
}
export function encodeInvalidationMessage(origin: string): CrossTabInvalidationMessage {
return {
type: 'session-invalidated',
origin,
ts: Date.now(),
};
}
export function isCrossTabInvalidationMessage(
value: unknown,
): value is CrossTabInvalidationMessage {
if (!value || typeof value !== 'object') return false;
const v = value as Record<string, unknown>;
return (
v['type'] === 'session-invalidated' &&
typeof v['origin'] === 'string' &&
typeof v['ts'] === 'number'
);
}
export function isStorageInvalidationEvent(
event: { key?: string | null; newValue?: string | null } | null | undefined,
): boolean {
if (!event) return false;
if (event.key !== CROSS_TAB_STORAGE_KEY) return false;
if (event.newValue == null) return false;
try {
const parsed = JSON.parse(event.newValue) as unknown;
return isCrossTabInvalidationMessage(parsed);
} catch {
return false;
}
}
+131 -2
View File
@@ -1,4 +1,4 @@
import { Injectable, signal, computed, inject } from '@angular/core';
import { Injectable, signal, computed, inject, DestroyRef } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { 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<string | null>(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<MeResponse> {
@@ -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<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 {
return typeof window !== 'undefined' ? window.sessionStorage : (undefined as unknown as Storage);
}
@@ -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;
@@ -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;
}
@@ -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);
}