Files
ROLAC/APP/docs/superpowers/plans/2026-05-26-login-api-integration.md
T
Chris Chen aa0c5403a1 docs: login API integration implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 20:29:33 -07:00

34 KiB

Login API Integration — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Wire the Angular login page to the ROLAC C# auth API (POST /api/auth/login, /refresh, /logout) using secure in-memory token storage and an auto-refreshing HTTP interceptor.

Architecture: AuthService owns all auth state in two BehaviorSubjects (access token + user); it never touches localStorage. An HttpInterceptorFn attaches the Bearer token and silently retries on 401 via /api/auth/refresh. An APP_INITIALIZER calls refresh() on startup to restore sessions from the HttpOnly rolac_rt cookie.

Tech Stack: Angular 20 standalone, Karma/Jasmine, HttpClientTestingModule, HttpTestingController


File Map

File Action
src/app/shared/services/auth.service.ts Full rewrite
src/app/shared/services/auth.service.spec.ts Create (new tests)
src/app/core/interceptors/auth.interceptor.ts Update
src/app/core/interceptors/auth.interceptor.spec.ts Create (new tests)
src/app/app.config.ts Add APP_INITIALIZER

Task 1: Write failing tests for AuthService

Files:

  • Create: src/app/shared/services/auth.service.spec.ts

  • Step 1.1 — Create the spec file

Create src/app/shared/services/auth.service.spec.ts with the full contents below. Every test will fail (or error) because the current service has the wrong interfaces and API calls.

import { TestBed } from '@angular/core/testing';
import {
  HttpClientTestingModule,
  HttpTestingController
} from '@angular/common/http/testing';
import {
  AuthService,
  LoginResultType,
  UserInfo
} from './auth.service';
import { ApiConfigService } from '../../core/services/api-config.service';

const MOCK_USER: UserInfo = {
  id: 'user-123',
  email: 'test@example.com',
  roles: ['Admin'],
  languagePreference: 'en'
};

const MOCK_API_RESPONSE = {
  accessToken: 'mock-access-token',
  expiresIn: 900,
  user: MOCK_USER
};

describe('AuthService', () => {
  let service: AuthService;
  let httpMock: HttpTestingController;
  let apiConfig: ApiConfigService;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [AuthService, ApiConfigService]
    });
    service = TestBed.inject(AuthService);
    httpMock = TestBed.inject(HttpTestingController);
    apiConfig = TestBed.inject(ApiConfigService);
  });

  afterEach(() => {
    httpMock.verify();
  });

  // ── login() ────────────────────────────────────────────────────────────────

  describe('login()', () => {
    it('should POST to /api/auth/login with email and password', () => {
      service.login({ email: 'test@example.com', password: 'secret' }).subscribe();
      const req = httpMock.expectOne(`${apiConfig.authUrl}/login`);
      expect(req.request.method).toBe('POST');
      expect(req.request.body).toEqual({ email: 'test@example.com', password: 'secret' });
      expect(req.request.withCredentials).toBeTrue();
      req.flush(MOCK_API_RESPONSE);
    });

    it('should return LoginResultType.Success and store token + user on 200', () => {
      let result: any;
      service.login({ email: 'test@example.com', password: 'secret' }).subscribe(r => result = r);
      httpMock.expectOne(`${apiConfig.authUrl}/login`).flush(MOCK_API_RESPONSE);

      expect(result.result).toBe(LoginResultType.Success);
      expect(result.responseData).toEqual(MOCK_USER);
      expect(service.getToken()).toBe('mock-access-token');
      expect(service.getCurrentUser()).toEqual(MOCK_USER);
      expect(service.isAuthenticated()).toBeTrue();
    });

    it('should return LoginResultType.InvalidCredentials on 401', () => {
      let result: any;
      service.login({ email: 'bad@example.com', password: 'wrong' }).subscribe(r => result = r);
      httpMock.expectOne(`${apiConfig.authUrl}/login`).flush(
        { message: 'Invalid credentials' },
        { status: 401, statusText: 'Unauthorized' }
      );

      expect(result.result).toBe(LoginResultType.InvalidCredentials);
      expect(service.getToken()).toBeNull();
      expect(service.isAuthenticated()).toBeFalse();
    });

    it('should return LoginResultType.Error on non-401 HTTP error', () => {
      let result: any;
      service.login({ email: 'test@example.com', password: 'secret' }).subscribe(r => result = r);
      httpMock.expectOne(`${apiConfig.authUrl}/login`).flush(
        { message: 'Server error' },
        { status: 500, statusText: 'Internal Server Error' }
      );

      expect(result.result).toBe(LoginResultType.Error);
    });
  });

  // ── refresh() ──────────────────────────────────────────────────────────────

  describe('refresh()', () => {
    it('should POST to /api/auth/refresh with withCredentials', () => {
      service.refresh().subscribe();
      const req = httpMock.expectOne(`${apiConfig.authUrl}/refresh`);
      expect(req.request.method).toBe('POST');
      expect(req.request.withCredentials).toBeTrue();
      req.flush(MOCK_API_RESPONSE);
    });

    it('should return true and update token + user on 200', () => {
      let result: boolean | undefined;
      service.refresh().subscribe(r => result = r);
      httpMock.expectOne(`${apiConfig.authUrl}/refresh`).flush(MOCK_API_RESPONSE);

      expect(result).toBeTrue();
      expect(service.getToken()).toBe('mock-access-token');
      expect(service.getCurrentUser()).toEqual(MOCK_USER);
    });

    it('should return false and leave state unchanged on 401', () => {
      let result: boolean | undefined;
      service.refresh().subscribe(r => result = r);
      httpMock.expectOne(`${apiConfig.authUrl}/refresh`).flush(
        { message: 'Refresh token expired' },
        { status: 401, statusText: 'Unauthorized' }
      );

      expect(result).toBeFalse();
      expect(service.getToken()).toBeNull();
      expect(service.isAuthenticated()).toBeFalse();
    });
  });

  // ── logout() ───────────────────────────────────────────────────────────────

  describe('logout()', () => {
    it('should clear token and user from memory immediately', () => {
      // Seed state
      service['accessToken$'].next('some-token');
      service['currentUser$'].next(MOCK_USER);

      service.logout();
      httpMock.expectOne(`${apiConfig.authUrl}/logout`).flush(null, { status: 204, statusText: 'No Content' });

      expect(service.getToken()).toBeNull();
      expect(service.getCurrentUser()).toBeNull();
      expect(service.isAuthenticated()).toBeFalse();
    });

    it('should POST to /api/auth/logout with withCredentials', () => {
      service.logout();
      const req = httpMock.expectOne(`${apiConfig.authUrl}/logout`);
      expect(req.request.method).toBe('POST');
      expect(req.request.withCredentials).toBeTrue();
      req.flush(null, { status: 204, statusText: 'No Content' });
    });

    it('should not throw if the logout API call fails', () => {
      expect(() => {
        service.logout();
        httpMock.expectOne(`${apiConfig.authUrl}/logout`).flush(
          { message: 'Server error' },
          { status: 500, statusText: 'Internal Server Error' }
        );
      }).not.toThrow();
    });
  });

  // ── initializeFromRefreshToken() ───────────────────────────────────────────

  describe('initializeFromRefreshToken()', () => {
    it('should resolve even when refresh returns 401 (does not block bootstrap)', async () => {
      const promise = service.initializeFromRefreshToken();
      httpMock.expectOne(`${apiConfig.authUrl}/refresh`).flush(
        { message: 'No cookie' },
        { status: 401, statusText: 'Unauthorized' }
      );
      await expectAsync(promise).toBeResolved();
    });

    it('should resolve and authenticate user when refresh succeeds', async () => {
      const promise = service.initializeFromRefreshToken();
      httpMock.expectOne(`${apiConfig.authUrl}/refresh`).flush(MOCK_API_RESPONSE);
      await expectAsync(promise).toBeResolved();
      expect(service.isAuthenticated()).toBeTrue();
    });
  });

  // ── setCurrentUser() / getCurrentUser() ────────────────────────────────────

  describe('setCurrentUser()', () => {
    it('should update currentUser$ and mark authenticated', () => {
      service.setCurrentUser(MOCK_USER);
      expect(service.getCurrentUser()).toEqual(MOCK_USER);
      expect(service.isAuthenticated()).toBeTrue();
    });
  });

  // ── redirect URL helpers ────────────────────────────────────────────────────

  describe('redirect URL helpers', () => {
    it('should default redirect to /dashboard', () => {
      expect(service.getRedirectUrl()).toBe('/dashboard');
    });

    it('should store and return a custom redirect URL', () => {
      service.setRedirectUrl('/members');
      expect(service.getRedirectUrl()).toBe('/members');
    });
  });
});
  • Step 1.2 — Run tests to confirm they fail
cd E:\VSProject\ROLAC\APP
ng test --include=src/app/shared/services/auth.service.spec.ts --watch=false

Expected output: Multiple FAILED specs — type errors and/or wrong API URLs. If the test runner starts and reports failures (not compilation errors), proceed. If there are import errors for UserInfo or ApiLoginResponse, that is expected and confirms the tests are ahead of the implementation.

  • Step 1.3 — Commit the failing tests
git add src/app/shared/services/auth.service.spec.ts
git commit -m "test: add failing specs for AuthService login API integration"

Task 2: Rewrite auth.service.ts

Files:

  • Modify: src/app/shared/services/auth.service.ts

  • Step 2.1 — Replace the entire file

Replace src/app/shared/services/auth.service.ts with:

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. */
    private accessToken$ = new BehaviorSubject<string | null>(null);
    private 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;
        }
    }
}
  • Step 2.2 — Run the AuthService tests and confirm they all pass
cd E:\VSProject\ROLAC\APP
ng test --include=src/app/shared/services/auth.service.spec.ts --watch=false

Expected: All specs PASS. Fix any failures before moving on.

  • Step 2.3 — Commit
git add src/app/shared/services/auth.service.ts
git commit -m "feat: rewrite AuthService to use ROLAC auth API with in-memory token storage"

Task 3: Write failing tests for AuthInterceptor

Files:

  • Create: src/app/core/interceptors/auth.interceptor.spec.ts

  • Step 3.1 — Create the spec file

Create src/app/core/interceptors/auth.interceptor.spec.ts:

import { TestBed } from '@angular/core/testing';
import {
  HttpClient,
  provideHttpClient,
  withInterceptors
} from '@angular/common/http';
import {
  HttpTestingController,
  provideHttpClientTesting
} from '@angular/common/http/testing';
import { Router } from '@angular/router';
import { of } from 'rxjs';
import { authInterceptor } from './auth.interceptor';
import { AuthService } from '../../shared/services/auth.service';
import { ApiConfigService } from '../services/api-config.service';

describe('authInterceptor', () => {
  let http: HttpClient;
  let httpMock: HttpTestingController;
  let authServiceSpy: jasmine.SpyObj<AuthService>;
  let routerSpy: jasmine.SpyObj<Router>;
  let apiConfig: ApiConfigService;

  beforeEach(() => {
    authServiceSpy = jasmine.createSpyObj('AuthService', [
      'getToken',
      'refresh',
      'logout'
    ]);
    routerSpy = jasmine.createSpyObj('Router', ['navigate']);

    TestBed.configureTestingModule({
      providers: [
        provideHttpClient(withInterceptors([authInterceptor])),
        provideHttpClientTesting(),
        { provide: AuthService, useValue: authServiceSpy },
        { provide: Router, useValue: routerSpy },
        ApiConfigService
      ]
    });

    http = TestBed.inject(HttpClient);
    httpMock = TestBed.inject(HttpTestingController);
    apiConfig = TestBed.inject(ApiConfigService);
  });

  afterEach(() => httpMock.verify());

  // ── Token attachment ────────────────────────────────────────────────────────

  describe('token attachment', () => {
    it('should add Authorization header when token exists', () => {
      authServiceSpy.getToken.and.returnValue('test-token');
      http.get(`${apiConfig.getBaseUrl()}/members`).subscribe();
      const req = httpMock.expectOne(`${apiConfig.getBaseUrl()}/members`);
      expect(req.request.headers.get('Authorization')).toBe('Bearer test-token');
      req.flush({});
    });

    it('should NOT add Authorization header when no token', () => {
      authServiceSpy.getToken.and.returnValue(null);
      http.get(`${apiConfig.getBaseUrl()}/members`).subscribe();
      const req = httpMock.expectOne(`${apiConfig.getBaseUrl()}/members`);
      expect(req.request.headers.has('Authorization')).toBeFalse();
      req.flush({});
    });

    it('should NOT add Authorization header to /auth/login', () => {
      authServiceSpy.getToken.and.returnValue('test-token');
      http.post(`${apiConfig.authUrl}/login`, {}).subscribe();
      const req = httpMock.expectOne(`${apiConfig.authUrl}/login`);
      expect(req.request.headers.has('Authorization')).toBeFalse();
      req.flush({});
    });

    it('should NOT add Authorization header to /auth/refresh', () => {
      authServiceSpy.getToken.and.returnValue('test-token');
      http.post(`${apiConfig.authUrl}/refresh`, {}).subscribe();
      const req = httpMock.expectOne(`${apiConfig.authUrl}/refresh`);
      expect(req.request.headers.has('Authorization')).toBeFalse();
      req.flush({});
    });

    it('should NOT add Authorization header to /auth/logout', () => {
      authServiceSpy.getToken.and.returnValue('test-token');
      http.post(`${apiConfig.authUrl}/logout`, {}).subscribe();
      const req = httpMock.expectOne(`${apiConfig.authUrl}/logout`);
      expect(req.request.headers.has('Authorization')).toBeFalse();
      req.flush({});
    });

    it('should NOT add Authorization header to external URLs', () => {
      authServiceSpy.getToken.and.returnValue('test-token');
      http.get('https://other-domain.com/api/data').subscribe();
      const req = httpMock.expectOne('https://other-domain.com/api/data');
      expect(req.request.headers.has('Authorization')).toBeFalse();
      req.flush({});
    });
  });

  // ── 401 auto-refresh ────────────────────────────────────────────────────────

  describe('401 auto-refresh', () => {
    it('should refresh and retry original request on first 401', () => {
      // First call returns old token (initial request); second returns new token (retry)
      authServiceSpy.getToken.and.returnValues('old-token', 'new-token');
      authServiceSpy.refresh.and.returnValue(of(true));

      let responseData: any;
      http.get(`${apiConfig.getBaseUrl()}/members`).subscribe(r => responseData = r);

      // First attempt → 401
      const first = httpMock.expectOne(`${apiConfig.getBaseUrl()}/members`);
      first.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });

      // Retry after refresh
      const retry = httpMock.expectOne(`${apiConfig.getBaseUrl()}/members`);
      expect(retry.request.headers.get('Authorization')).toBe('Bearer new-token');
      expect(retry.request.headers.get('X-Retry')).toBe('true');
      retry.flush({ data: 'ok' });

      expect(responseData).toEqual({ data: 'ok' });
      expect(authServiceSpy.refresh).toHaveBeenCalledTimes(1);
      expect(authServiceSpy.logout).not.toHaveBeenCalled();
    });

    it('should logout and navigate to /login when refresh fails on 401', () => {
      authServiceSpy.getToken.and.returnValue('old-token');
      authServiceSpy.refresh.and.returnValue(of(false));

      http.get(`${apiConfig.getBaseUrl()}/members`).subscribe({
        error: () => {} // swallow expected error
      });

      httpMock.expectOne(`${apiConfig.getBaseUrl()}/members`).flush(
        { message: 'Unauthorized' },
        { status: 401, statusText: 'Unauthorized' }
      );

      expect(authServiceSpy.logout).toHaveBeenCalledTimes(1);
      expect(routerSpy.navigate).toHaveBeenCalledWith(['/login']);
    });

    it('should NOT retry again if the retry request also gets 401 (prevents loop)', () => {
      authServiceSpy.getToken.and.returnValue('token');
      authServiceSpy.refresh.and.returnValue(of(true));

      http.get(`${apiConfig.getBaseUrl()}/members`).subscribe({
        error: () => {}
      });

      // First request → 401
      httpMock.expectOne(`${apiConfig.getBaseUrl()}/members`).flush(
        { message: 'Unauthorized' },
        { status: 401, statusText: 'Unauthorized' }
      );

      // Retry also gets 401 — should not spawn another request
      httpMock.expectOne(`${apiConfig.getBaseUrl()}/members`).flush(
        { message: 'Still unauthorized' },
        { status: 401, statusText: 'Unauthorized' }
      );

      // No third request
      httpMock.expectNone(`${apiConfig.getBaseUrl()}/members`);
      expect(authServiceSpy.logout).toHaveBeenCalledTimes(1);
      expect(routerSpy.navigate).toHaveBeenCalledWith(['/login']);
    });
  });
});
  • Step 3.2 — Run tests to confirm they fail
cd E:\VSProject\ROLAC\APP
ng test --include=src/app/core/interceptors/auth.interceptor.spec.ts --watch=false

Expected: Multiple failures — the current interceptor uses old paths (/Auth/login, /Token/Create) and has no auto-refresh logic. Proceed once failures are confirmed.

  • Step 3.3 — Commit the failing tests
git add src/app/core/interceptors/auth.interceptor.spec.ts
git commit -m "test: add failing specs for authInterceptor auto-refresh and path matching"

Task 4: Update auth.interceptor.ts

Files:

  • Modify: src/app/core/interceptors/auth.interceptor.ts

  • Step 4.1 — Replace the entire file

Replace src/app/core/interceptors/auth.interceptor.ts with:

import { HttpInterceptorFn, HttpErrorResponse, HttpRequest } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, switchMap, throwError } from 'rxjs';
import { AuthService } from '../../shared/services/auth.service';
import { Router } from '@angular/router';
import { ApiConfigService } from '../services/api-config.service';

/**
 * Functional HTTP interceptor that:
 * 1. Attaches `Authorization: Bearer <token>` to every request destined for
 *    the ROLAC API (except public auth endpoints).
 * 2. On a 401 response, silently calls POST /api/auth/refresh and retries
 *    the original request once with the new token.
 * 3. If the refresh also fails, logs the user out and redirects to /login.
 */
export const authInterceptor: HttpInterceptorFn = (req, next) => {
    const authService = inject(AuthService);
    const apiConfig   = inject(ApiConfigService);
    const router      = inject(Router);

    // Attach token to qualifying requests
    const request = attachToken(req, authService, apiConfig);

    return next(request).pipe(
        catchError((error: HttpErrorResponse) => {
            // Only intercept 401s — and only for the first attempt (no X-Retry header)
            if (error.status === 401 && !req.headers.has('X-Retry')) {
                return authService.refresh().pipe(
                    switchMap(success => {
                        if (success) {
                            // Retry with the fresh token; mark as retry to prevent loops
                            const retryReq = req.clone({
                                setHeaders: {
                                    Authorization: `Bearer ${authService.getToken()}`,
                                    'X-Retry': 'true'
                                }
                            });
                            return next(retryReq);
                        }
                        // Refresh failed — session is gone
                        authService.logout();
                        router.navigate(['/login']);
                        return throwError(() => error);
                    })
                );
            }

            // Second 401 (retry was already attempted) or non-401 error
            if (error.status === 401) {
                authService.logout();
                router.navigate(['/login']);
            }
            return throwError(() => error);
        })
    );
};

/**
 * Returns a cloned request with the Bearer token header if:
 * - The request URL targets the ROLAC API base URL, AND
 * - The endpoint is not a public auth endpoint, AND
 * - A token is currently held in memory.
 */
function attachToken(
    req: HttpRequest<unknown>,
    authService: AuthService,
    apiConfig: ApiConfigService
): HttpRequest<unknown> {
    const token = authService.getToken();
    if (!token || !shouldAddToken(apiConfig, req)) return req;
    return req.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
}

/** Public auth paths that must never carry an access token. */
const PUBLIC_AUTH_PATHS = ['/auth/login', '/auth/refresh', '/auth/logout'];

function shouldAddToken(apiConfig: ApiConfigService, req: HttpRequest<unknown>): boolean {
    if (!req.url.startsWith(apiConfig.getBaseUrl())) return false;
    return !PUBLIC_AUTH_PATHS.some(path => req.url.includes(path));
}
  • Step 4.2 — Run the interceptor tests and confirm they all pass
cd E:\VSProject\ROLAC\APP
ng test --include=src/app/core/interceptors/auth.interceptor.spec.ts --watch=false

Expected: All specs PASS. Fix any failures before moving on.

  • Step 4.3 — Run all tests to check for regressions
cd E:\VSProject\ROLAC\APP
ng test --watch=false

Expected: All specs PASS across both spec files.

  • Step 4.4 — Commit
git add src/app/core/interceptors/auth.interceptor.ts
git commit -m "feat: update authInterceptor with correct auth paths and auto-refresh on 401"

Task 5: Add APP_INITIALIZER to app.config.ts

Files:

  • Modify: src/app/app.config.ts

  • Step 5.1 — Update app.config.ts

Replace src/app/app.config.ts with:

import { ApplicationConfig, APP_INITIALIZER } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideHttpClient, withInterceptors } from '@angular/common/http';

import { routes } from './app.routes';
import { authInterceptor } from './core/interceptors/auth.interceptor';
import { AuthService } from './shared/services/auth.service';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideAnimations(),
    provideHttpClient(withInterceptors([authInterceptor])),
    {
      provide: APP_INITIALIZER,
      useFactory: (authService: AuthService) => () => authService.initializeFromRefreshToken(),
      deps: [AuthService],
      multi: true
    }
  ]
};
  • Step 5.2 — Build to check for compile errors
cd E:\VSProject\ROLAC\APP
ng build --configuration=development

Expected: Build succeeds with no errors (warnings about bundle size are OK).

  • Step 5.3 — Run all tests one final time
cd E:\VSProject\ROLAC\APP
ng test --watch=false

Expected: All specs PASS.

  • Step 5.4 — Commit
git add src/app/app.config.ts
git commit -m "feat: add APP_INITIALIZER to restore session from refresh token cookie on page load"

Task 6: Smoke-test against the running API

This task is manual and requires both the Angular dev server and the ROLAC API to be running.

  • Step 6.1 — Start the API and Angular dev server
# Terminal 1 — API (adjust path if needed)
cd E:\VSProject\ROLAC\API
dotnet run --project ROLAC.API

# Terminal 2 — Angular
cd E:\VSProject\ROLAC\APP
ng serve
  • Step 6.2 — Test happy-path login
  1. Open http://localhost:4200/login
  2. Click the login button to show the form
  3. Enter valid credentials and submit
  4. Should redirect to /dashboard
  5. Open DevTools → Application → Cookies: confirm rolac_rt HttpOnly cookie is present
  6. Open DevTools → Application → Local Storage: confirm no currentUser key (we removed localStorage)
  • Step 6.3 — Test invalid credentials
  1. Enter wrong email/password
  2. Should show "Invalid email or password" error inline — no redirect
  • Step 6.4 — Test session restore on page reload
  1. While logged in, hard-reload the page (Ctrl+Shift+R)
  2. Should stay on /dashboard (not redirect to /login)
  3. Token is back in memory (check Network tab — subsequent API calls carry Authorization: Bearer ...)
  • Step 6.5 — Commit smoke-test confirmation note
git commit --allow-empty -m "chore: smoke-tested login API integration against live API"

Summary of changes

File What changed
auth.service.ts Full rewrite: new interfaces (UserInfo, ApiLoginResponse), POSTs JSON to /api/auth/login, refresh(), logout() API calls, in-memory BehaviorSubject storage
auth.service.spec.ts New: full test coverage for login/refresh/logout/init
auth.interceptor.ts Fixed public paths, added switchMap auto-refresh on 401 with X-Retry sentinel
auth.interceptor.spec.ts New: coverage for token attachment, refresh flow, loop prevention
app.config.ts Added APP_INITIALIZER for session restore on page load