Files
HIPCTF2/frontend/src/app/core/services/auth.service.ts
T
2026-07-21 18:34:42 +00:00

207 lines
5.1 KiB
TypeScript

import { Injectable, signal, computed, inject } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import {
readStoredSession,
writeStoredSession,
clearStoredSession,
StoredSession,
} from './auth.session-storage';
export interface CurrentUser {
id: string;
username: string;
role: 'admin' | 'player';
}
interface RefreshResponse {
accessToken: string;
expiresIn: number;
user: CurrentUser;
}
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;
@Injectable({ providedIn: 'root' })
export class AuthService {
private http: HttpClient;
constructor(http?: HttpClient) {
this.http = http ?? inject(HttpClient);
}
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;
}
}
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());
}
getAccessToken(): string | null {
return this.accessToken();
}
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',
};
}