docs: login API integration implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 |
|
||||
Reference in New Issue
Block a user