512 lines
14 KiB
TypeScript
512 lines
14 KiB
TypeScript
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<string | null>(null);
|
|
private user = signal<CurrentUser | null>(null);
|
|
readonly hydrated = signal<boolean>(false);
|
|
private hydrateWaiters: Array<() => void> = [];
|
|
|
|
readonly currentUser = computed(() => this.user());
|
|
readonly isAuthenticated = computed(() => !!this.user());
|
|
|
|
async ensureCsrf(): Promise<void> {
|
|
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<LoginResult> {
|
|
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<RegisterResult> {
|
|
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<void> {
|
|
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<MeResponse> {
|
|
const res = await firstValueFrom(
|
|
this.http.get<MeResponse>('/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<ChangePasswordResult> {
|
|
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<unknown>) => {
|
|
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<unknown>) => {
|
|
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<void> {
|
|
if (this.hydrated()) return Promise.resolve();
|
|
return new Promise<void>((resolve) => {
|
|
this.hydrateWaiters.push(resolve);
|
|
});
|
|
}
|
|
|
|
async restoreSession(): Promise<boolean> {
|
|
this.restoreFromStorage();
|
|
|
|
try {
|
|
const res = await firstValueFrom(
|
|
this.http.post<RefreshResponse>(
|
|
'/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',
|
|
};
|
|
}
|