import { Injectable, signal, computed, inject, DestroyRef } from '@angular/core'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; import { readStoredSession, writeStoredSession, clearStoredSession, StoredSession, } from './auth.session-storage'; import { CROSS_TAB_CHANNEL_NAME, CROSS_TAB_STORAGE_KEY, CROSS_TAB_ROLE_CHANNEL_NAME, CROSS_TAB_ROLE_STORAGE_KEY, CrossTabInvalidationMessage, CrossTabRoleChangeMessage, encodeInvalidationMessage, encodeRoleChangeMessage, isCrossTabInvalidationMessage, isCrossTabRoleChangeMessage, isStorageInvalidationEvent, isStorageRoleChangeEvent, } from './auth-session-events.pure'; export interface CurrentUser { id: string; username: string; role: 'admin' | 'player'; rank?: number | null; points?: number; } interface RefreshResponse { accessToken: string; expiresIn: number; user: CurrentUser; } export interface MeResponse { id: string; username: string; role: 'admin' | 'player'; rank: number | null; points: number; } export interface LoginSuccess { ok: true; accessToken: string; expiresIn: number; user: CurrentUser; } export interface LoginFailure { ok: false; status: number; code: string; message: string; } export type LoginResult = LoginSuccess | LoginFailure; export interface RegisterSuccess { ok: true; accessToken: string; expiresIn: number; user: CurrentUser; } export interface RegisterFailure { ok: false; status: number; code: string; message: string; } export type RegisterResult = RegisterSuccess | RegisterFailure; export interface ChangePasswordSuccess { ok: true; } export interface ChangePasswordFailure { ok: false; status: number; code: string; message: string; } 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 roleChannel: BroadcastChannel | null = null; private storageListener: ((ev: StorageEvent) => void) | null = null; private suppressBroadcast = false; private peerInvalidationListeners = new Set<(reason: 'peer-logout' | 'sse-unauthorized') => void>(); private peerRoleChangeListeners = new Set<(msg: CrossTabRoleChangeMessage) => void>(); constructor(http?: HttpClient) { this.http = http ?? inject(HttpClient); this.installCrossTabListener(); this.destroyRef.onDestroy(() => this.teardownCrossTabListener()); } private accessToken = signal(null); private user = signal(null); readonly hydrated = signal(false); private hydrateWaiters: Array<() => void> = []; readonly currentUser = computed(() => this.user()); readonly isAuthenticated = computed(() => !!this.user()); async ensureCsrf(): Promise { try { await firstValueFrom( this.http.get('/api/v1/auth/csrf', { withCredentials: true }), ); } catch { // middleware will still mint the cookie on the next response } } async login(creds: { username: string; password: string }): Promise { await this.ensureCsrf(); try { const res = await firstValueFrom( this.http.post<{ accessToken: string; expiresIn: number; user: CurrentUser }>( '/api/v1/auth/login', creds, { withCredentials: true, observe: 'response' }, ), ); const success: LoginSuccess = { ok: true, accessToken: res.body!.accessToken, expiresIn: res.body!.expiresIn, user: res.body!.user, }; return success; } catch (e) { return mapAuthError(e as HttpErrorResponse); } } async register(creds: { username: string; password: string; passwordConfirm: string; }): Promise { await this.ensureCsrf(); try { const res = await firstValueFrom( this.http.post<{ accessToken: string; expiresIn: number; user: CurrentUser }>( '/api/v1/auth/register', creds, { withCredentials: true, observe: 'response' }, ), ); const success: RegisterSuccess = { ok: true, accessToken: res.body!.accessToken, expiresIn: res.body!.expiresIn, user: res.body!.user, }; return success; } catch (e) { return mapAuthError(e as HttpErrorResponse) as RegisterFailure; } } async logout(): Promise { await this.ensureCsrf(); try { await firstValueFrom( this.http.post('/api/v1/auth/logout', {}, { withCredentials: true, observe: 'response' }), ); } catch { // ignore network/auth errors; we still clear local state } this.clearAndBroadcast(); } async me(): Promise { const res = await firstValueFrom( this.http.get('/api/v1/auth/me', { withCredentials: true }), ); const current = this.user(); if (current && current.id === res.id) { this.user.set({ ...current, rank: res.rank, points: res.points }); } return res; } async changePassword(dto: { oldPassword: string; newPassword: string; confirmNewPassword: string; }): Promise { await this.ensureCsrf(); try { await firstValueFrom( this.http.post( '/api/v1/auth/change-password', dto, { withCredentials: true, observe: 'response' }, ), ); return { ok: true }; } catch (e) { return mapAuthError(e as HttpErrorResponse); } } setSession(token: string, user: CurrentUser): void { this.accessToken.set(token); this.user.set(user); writeStoredSession(this.sessionStorage(), { token, user }); } clear(): void { this.accessToken.set(null); this.user.set(null); clearStoredSession(this.sessionStorage()); } clearAndBroadcast(): void { this.clear(); if (this.suppressBroadcast) return; const message = encodeInvalidationMessage(this.crossTabOrigin); this.publishInvalidation(message); } /** * Forcibly invalidate the local session (and broadcast peer * invalidation) — used after a destructive server-initiated action such * as a successful restore that revokes access. Same as a normal logout * from the client's perspective. */ forceServerInvalidation(): void { this.suppressBroadcast = false; this.clearAndBroadcast(); } 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); } onPeerRoleChange(cb: (msg: CrossTabRoleChangeMessage) => void): () => void { this.peerRoleChangeListeners.add(cb); return () => this.peerRoleChangeListeners.delete(cb); } applyPeerRoleChange(newRole: 'admin' | 'player'): void { const current = this.user(); if (!current) return; if (current.role === newRole) return; const updated = { ...current, role: newRole }; this.user.set(updated); if (typeof window !== 'undefined') { try { writeStoredSession(this.sessionStorage(), { token: this.accessToken() ?? '', user: updated }); } catch { // ignore storage failures } } } publishRoleChange(userId: string, newRole: 'admin' | 'player'): void { const message = encodeRoleChangeMessage(this.crossTabOrigin, userId, newRole); if (this.roleChannel) { try { this.roleChannel.postMessage(message); } catch { // ignore serialization failures; storage fallback still fires } } if (typeof window !== 'undefined' && window.localStorage) { try { window.localStorage.setItem(CROSS_TAB_ROLE_STORAGE_KEY, JSON.stringify(message)); } catch { // ignore quota / serialization errors } } } 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; } try { this.roleChannel = new BroadcastChannel(CROSS_TAB_ROLE_CHANNEL_NAME); this.roleChannel.addEventListener('message', this.onRoleBroadcastMessage); } catch { this.roleChannel = null; } } this.storageListener = (ev: StorageEvent) => { if (isStorageInvalidationEvent(ev)) { this.handlePeerInvalidation(ev.newValue, 'peer-logout'); return; } if (isStorageRoleChangeEvent(ev)) { this.handlePeerRoleChange(ev.newValue); } }; 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.roleChannel) { try { this.roleChannel.removeEventListener('message', this.onRoleBroadcastMessage); this.roleChannel.close(); } catch { // ignore } this.roleChannel = null; } if (this.storageListener && typeof window !== 'undefined') { try { window.removeEventListener('storage', this.storageListener); } catch { // ignore } this.storageListener = null; } this.peerInvalidationListeners.clear(); this.peerRoleChangeListeners.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 readonly onRoleBroadcastMessage = (ev: MessageEvent) => { if (!isCrossTabRoleChangeMessage(ev.data)) return; if (ev.data.origin === this.crossTabOrigin) return; this.handlePeerRoleChange(JSON.stringify(ev.data)); }; 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 handlePeerRoleChange(raw: string | null): void { if (!raw) return; let parsed: CrossTabRoleChangeMessage | null = null; try { const candidate = JSON.parse(raw) as unknown; if (!isCrossTabRoleChangeMessage(candidate)) return; if (candidate.origin === this.crossTabOrigin) return; parsed = candidate; } catch { return; } if (!parsed) return; const me = this.user(); if (!me) return; if (me.id !== parsed.userId) return; this.applyPeerRoleChange(parsed.newRole); for (const cb of this.peerRoleChangeListeners) { try { cb(parsed); } catch { // ignore listener errors } } } private sessionStorage(): Storage { return typeof window !== 'undefined' ? window.sessionStorage : (undefined as unknown as Storage); } private restoreFromStorage(): void { const stored = readStoredSession(this.sessionStorage()); if (stored) { this.accessToken.set(stored.token); this.user.set(stored.user as CurrentUser); } } private markHydrated(): void { if (this.hydrated()) return; this.hydrated.set(true); const waiters = this.hydrateWaiters; this.hydrateWaiters = []; for (const resolve of waiters) resolve(); } waitUntilHydrated(): Promise { if (this.hydrated()) return Promise.resolve(); return new Promise((resolve) => { this.hydrateWaiters.push(resolve); }); } async restoreSession(): Promise { this.restoreFromStorage(); try { const res = await firstValueFrom( this.http.post( '/api/v1/auth/refresh', {}, { withCredentials: true }, ), ); if (res && res.accessToken && res.user) { this.setSession(res.accessToken, res.user); this.markHydrated(); return true; } this.clear(); this.markHydrated(); return false; } catch { this.clear(); this.markHydrated(); return false; } } } function mapAuthError(err: HttpErrorResponse): LoginFailure { const body = err.error as { code?: string; message?: string } | null; return { ok: false, status: err.status ?? 0, code: body?.code ?? 'INTERNAL', message: body?.message ?? err.message ?? 'Authentication failed', }; }