All files / app/services/auth csrf.service.ts

100% Statements 44/44
94.73% Branches 18/19
100% Functions 12/12
100% Lines 41/41

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 10624x 24x 24x 24x 24x   24x 24x   24x 24x                       24x 59x 59x 59x 59x 59x                   60x 58x   60x       14x 3x     11x 2x     9x       51x 1x   50x       60x               11x 9x   2x     9x 9x     2x 2x 2x         60x       1x 1x       2x       1x 1x      
import { environment } from '../../../environments/environment';
import { isPlatformBrowser } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { Injectable, Injector, PLATFORM_ID, inject } from '@angular/core';
import { LoggerService } from '@drevo-web/core';
import { CsrfResponse } from '@drevo-web/shared';
import { Observable, of, throwError } from 'rxjs';
import { map, catchError, tap, timeout, retry, shareReplay } from 'rxjs/operators';
 
const CSRF_TIMEOUT_MS = 10000;
const CSRF_RETRY_COUNT = 3;
 
/**
 * Separate service for CSRF token management to avoid circular dependency
 * between AuthInterceptor and AuthService.
 *
 * Uses Injector to lazily retrieve HttpClient, breaking the circular dependency:
 * HttpClient → AuthInterceptor → CsrfService → HttpClient
 */
@Injectable({
    providedIn: 'root',
})
export class CsrfService {
    private readonly apiUrl = environment.apiUrl;
    private readonly platformId = inject(PLATFORM_ID);
    private readonly isBrowser = isPlatformBrowser(this.platformId);
    private readonly logger = inject(LoggerService).withContext('CsrfService');
    private readonly injector = inject(Injector);
 
    private csrfToken: string | undefined;
    private fetchInProgress$: Observable<string> | undefined;
    private _httpClient: HttpClient | undefined;
 
    /**
     * Lazily get HttpClient to avoid circular dependency
     */
    private get httpClient(): HttpClient {
        if (!this._httpClient) {
            this._httpClient = this.injector.get(HttpClient);
        }
        return this._httpClient;
    }
 
    getCsrfToken(): Observable<string> {
        if (this.csrfToken) {
            return of(this.csrfToken);
        }
 
        if (this.fetchInProgress$) {
            return this.fetchInProgress$;
        }
 
        return this.fetchCsrfToken();
    }
 
    initCsrfToken(): void {
        if (!this.isBrowser || this.csrfToken || this.fetchInProgress$) {
            return;
        }
        this.fetchCsrfToken().subscribe();
    }
 
    private fetchCsrfToken(): Observable<string> {
        this.fetchInProgress$ = this.httpClient
            .get<CsrfResponse>(`${this.apiUrl}/api/auth/csrf`, {
                withCredentials: true,
            })
            .pipe(
                timeout(CSRF_TIMEOUT_MS),
                retry(CSRF_RETRY_COUNT),
                map(response => {
                    if (response.success && response.data?.csrfToken) {
                        return response.data.csrfToken;
                    }
                    throw new Error('Invalid CSRF response');
                }),
                tap(token => {
                    this.csrfToken = token;
                    this.fetchInProgress$ = undefined;
                }),
                catchError(error => {
                    this.logger.error('Failed to fetch CSRF token', error);
                    this.fetchInProgress$ = undefined;
                    return throwError(() => error);
                }),
                shareReplay(1)
            );
 
        return this.fetchInProgress$;
    }
 
    refreshCsrfToken(): Observable<string> {
        this.clearToken();
        return this.fetchCsrfToken();
    }
 
    updateCsrfToken(token: string): void {
        this.csrfToken = token;
    }
 
    private clearToken(): void {
        this.csrfToken = undefined;
        this.fetchInProgress$ = undefined;
    }
}