Files
ROLAC/APP/src/app/shared/services/auth.service.ts
T
Chris Chen 62428cd2d4 feat: rewrite AuthService to use ROLAC auth API with in-memory token storage
- Replace GET /api/Token/Create (Basic Auth) with POST /api/Auth/login
- Add refresh() method using HttpOnly cookie (POST /api/Auth/refresh)
- Add initializeFromRefreshToken() for APP_INITIALIZER support
- logout() now fires POST /api/Auth/logout (fire-and-forget)
- Rename User interface to UserInfo (matches C# DTO: id, email, roles, languagePreference)
- All auth state is in-memory only (no localStorage)
- Fix downstream consumers: app.ts, header components, mfa-dialog, token-verification
- Fix tsconfig.spec.json: exclude legacy src/components and src/directives
- Add stub enums.model.ts and fix models/index.ts for pre-existing build errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 20:47:43 -07:00

252 lines
8.4 KiB
TypeScript

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<string | null>(null);
currentUser$ = new BehaviorSubject<UserInfo | null>(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<LoginResult> {
return this.http.post<ApiLoginResponse>(
`${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<boolean> {
return this.http.post<ApiLoginResponse>(
`${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<void> {
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<TokenVerificationResult> {
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;
}
}
}