docs: login API integration implementation plan

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Chen
2026-05-26 20:29:33 -07:00
parent 98965274b8
commit aa0c5403a1
@@ -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<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`:
```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<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:
```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 <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:
```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 |