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 10623x 23x 23x 23x 23x   23x 23x   23x 23x                       23x 56x 56x 56x 56x 56x                   57x 55x   57x       14x 3x     11x 2x     9x       48x 1x   47x       57x               11x 9x   2x     9x 9x     2x 2x 2x         57x       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;
    }
}