From aa0c5403a16f9531f17d61fe46ef6a8eef99b6e2 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Tue, 26 May 2026 20:29:33 -0700 Subject: [PATCH] docs: login API integration implementation plan Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-05-26-login-api-integration.md | 974 ++++++++++++++++++ 1 file changed, 974 insertions(+) create mode 100644 APP/docs/superpowers/plans/2026-05-26-login-api-integration.md diff --git a/APP/docs/superpowers/plans/2026-05-26-login-api-integration.md b/APP/docs/superpowers/plans/2026-05-26-login-api-integration.md new file mode 100644 index 0000000..20988ee --- /dev/null +++ b/APP/docs/superpowers/plans/2026-05-26-login-api-integration.md @@ -0,0 +1,974 @@ +# 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 `BehaviorSubject`s (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. + +```typescript +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: + +```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. */ + private accessToken$ = new BehaviorSubject(null); + private 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; + } + } +} +``` + +- [ ] **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`: + +```typescript +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; + let routerSpy: jasmine.SpyObj; + 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: + +```typescript +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 ` 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, + authService: AuthService, + apiConfig: ApiConfigService +): HttpRequest { + 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): 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: + +```typescript +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 |