import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { BehaviorSubject, Observable, of } from 'rxjs'; import { catchError, map, tap } from 'rxjs/operators'; import { ApiConfigService } from '../../core/services/api-config.service'; // ── Public interfaces ───────────────────────────────────────────────────────── /** Matches the C# UserInfo DTO exactly. */ export interface UserInfo { id: string; email: string; roles: string[]; languagePreference: string; } /** Matches the C# LoginResponse DTO exactly. */ export interface ApiLoginResponse { accessToken: string; expiresIn: number; user: UserInfo; } export interface LoginCredentials { email: string; password: string; /** Reserved for future MFA support — ignored by the current API. */ mfaCode?: string; } export enum LoginResultType { Success = 'Success', /** Kept dormant — the current API has no MFA endpoint. */ MfaRequired = 'MfaRequired', InvalidCredentials = 'InvalidCredentials', Error = 'Error' } export interface LoginResult { result: LoginResultType; responseData?: UserInfo; message?: string; } export interface TokenVerificationResult { isValid: boolean; /** Constructed from JWT claims when using secret-link login. */ user?: UserInfo; message?: string; expiresAt?: Date; requiresMfa?: boolean; } // ── Service ─────────────────────────────────────────────────────────────────── @Injectable({ providedIn: 'root' }) export class AuthService { /** In-memory only — never written to localStorage. */ accessToken$ = new BehaviorSubject(null); currentUser$ = new BehaviorSubject(null); /** Observable stream of the current user (null = not authenticated). */ public currentUser = this.currentUser$.asObservable(); private redirectUrl = '/dashboard'; constructor( private http: HttpClient, private apiConfig: ApiConfigService ) {} // ── Auth API calls ────────────────────────────────────────────────────── /** * Authenticate with email + password. * On success, stores the access token and user in memory and returns * LoginResultType.Success. Never throws — errors are mapped to LoginResult. */ login(credentials: LoginCredentials): Observable { return this.http.post( `${this.apiConfig.authUrl}/login`, { email: credentials.email, password: credentials.password }, { withCredentials: true } ).pipe( tap(response => { this.accessToken$.next(response.accessToken); this.currentUser$.next(response.user); }), map(response => ({ result: LoginResultType.Success, responseData: response.user } as LoginResult)), catchError(error => { if (error.status === 401) { return of({ result: LoginResultType.InvalidCredentials, message: error.error?.message || 'Invalid email or password' } as LoginResult); } return of({ result: LoginResultType.Error, message: error.error?.message || 'An error occurred during login' } as LoginResult); }) ); } /** * Silently exchange the HttpOnly `rolac_rt` cookie for a new access token. * Returns true on success, false if the cookie is absent or expired. * Never throws. */ refresh(): Observable { return this.http.post( `${this.apiConfig.authUrl}/refresh`, {}, { withCredentials: true } ).pipe( tap(response => { this.accessToken$.next(response.accessToken); this.currentUser$.next(response.user); }), map(() => true), catchError(() => of(false)) ); } /** * Clears in-memory auth state immediately, then fires a fire-and-forget * POST to revoke the server-side refresh token cookie. */ logout(): void { this.accessToken$.next(null); this.currentUser$.next(null); this.http.post( `${this.apiConfig.authUrl}/logout`, {}, { withCredentials: true } ).pipe( catchError(() => of(null)) ).subscribe(); } /** * Called by APP_INITIALIZER on every page load. * Attempts to restore the session via the refresh token cookie. * Always resolves — never rejects — so it cannot block app bootstrap. */ initializeFromRefreshToken(): Promise { return new Promise(resolve => { this.refresh().subscribe(() => resolve()); }); } // ── State accessors ───────────────────────────────────────────────────── getToken(): string | null { return this.accessToken$.value; } isAuthenticated(): boolean { return this.currentUser$.value !== null; } getCurrentUser(): UserInfo | null { return this.currentUser$.value; } /** * Manually set the current user — used by the MFA success callback * and the secret-link token flow. */ setCurrentUser(user: UserInfo): void { this.currentUser$.next(user); } setRedirectUrl(url: string): void { this.redirectUrl = url; } getRedirectUrl(): string { return this.redirectUrl; } // ── Secret-link token helpers (unchanged logic, updated types) ─────────── /** * Verifies a JWT token received as a URL parameter (secret-link login). * Performs local verification only — no API call. * Constructs a UserInfo from the JWT claims (id, email, roles, * languagePreference). */ verifySecretLinkToken(token: string): Observable { try { const tokenData = this.parseJwtToken(token); if (!tokenData) { return of({ isValid: false, message: 'Invalid token format' }); } if (this.isTokenExpired(token)) { return of({ isValid: false, message: 'This link has expired. Please request a new one.' }); } const user: UserInfo = { id: tokenData.userId || tokenData.sub || tokenData.id || '', email: tokenData.email || tokenData.email_address || '', roles: Array.isArray(tokenData.roles) ? tokenData.roles : tokenData.role ? [tokenData.role] : [], languagePreference: tokenData.languagePreference || 'en' }; return of({ isValid: true, user, message: 'Token verified successfully. MFA required.', expiresAt: tokenData.exp ? new Date(tokenData.exp * 1000) : undefined, requiresMfa: true }); } catch (error) { return of({ isValid: false, message: 'Invalid or corrupted token' }); } } /** Returns true if the JWT's `exp` claim is in the past. */ isTokenExpired(token: string): boolean { try { const payload = JSON.parse(atob(token.split('.')[1])); return payload.exp < Math.floor(Date.now() / 1000); } catch { return true; } } private parseJwtToken(token: string): any | null { try { const parts = token.split('.'); if (parts.length !== 3) return null; const decoded = atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')); return JSON.parse(decoded); } catch { return null; } } }