# Login API Integration — Design Spec *Date: 2026-05-26 | Project: ROLAC Angular App* ## Overview Connect the Angular login page to the ROLAC C# API authentication endpoints. The login page component (`login-page.ts`) is already complete; the work is updating the service layer underneath it to talk to the correct API with a secure, memory-safe token strategy. --- ## Scope **In scope:** - Update `AuthService` to call new ROLAC API auth endpoints - Update Angular interfaces to match API DTO shapes exactly - Replace `localStorage` token storage with in-memory `BehaviorSubject` - Update `AuthInterceptor` to fix path matching and add silent auto-refresh on 401 - Add `APP_INITIALIZER` session restore via the refresh token cookie **Out of scope:** - MFA API integration (no MFA endpoint exists in current ROLAC API — code path kept dormant) - Secret-link token login flow (existing `verifySecretLinkToken()` kept as-is) - UI/template changes to `login-page.component.html` --- ## API Contracts ### `POST /api/auth/login` **Request body:** ```json { "email": "user@example.com", "password": "secret" } ``` **Success response (200):** ```json { "accessToken": "", "expiresIn": 900, "user": { "id": "...", "email": "...", "roles": ["Admin"], "languagePreference": "en" } } ``` **Failure response (401):** `{ "message": "..." }` Sets `rolac_rt` HttpOnly cookie (30-day refresh token). ### `POST /api/auth/refresh` No body. Uses `rolac_rt` HttpOnly cookie. **Success (200):** Same shape as login response. Rotates the refresh token cookie. **Failure (401):** Cookie expired or revoked. ### `POST /api/auth/logout` No body. Uses `rolac_rt` HttpOnly cookie. **Response:** 204 No Content. Clears the cookie. --- ## Architecture ### File changes | File | Change type | |------|-------------| | `src/app/shared/services/auth.service.ts` | Full rewrite | | `src/app/core/interceptors/auth.interceptor.ts` | Update | | `src/app/app.config.ts` | Add APP_INITIALIZER | ### Updated interfaces in `auth.service.ts` **Removed:** `User`, `UserDto`, `AccessDto`, `TokenCreateResponse`, `LoginResponse` (old). **Added/updated:** ```typescript // Matches API UserInfo DTO exactly export interface UserInfo { id: string; email: string; roles: string[]; languagePreference: string; } // Matches API LoginResponse DTO exactly export interface ApiLoginResponse { accessToken: string; expiresIn: number; user: UserInfo; } // LoginCredentials — kept; mfaCode kept for future MFA support export interface LoginCredentials { email: string; password: string; mfaCode?: string; } // LoginResultType — kept; MfaRequired kept dormant export enum LoginResultType { Success = 'Success', MfaRequired = 'MfaRequired', InvalidCredentials = 'InvalidCredentials', Error = 'Error' } // LoginResult — kept; responseData type changes from User to UserInfo export interface LoginResult { result: LoginResultType; responseData?: UserInfo; message?: string; } // TokenVerificationResult — kept as-is (used by secret-link flow) ``` ### `AuthService` — methods ``` login(credentials: LoginCredentials): Observable POST /api/auth/login with JSON body On 200 → store accessToken + user in BehaviorSubjects → return LoginResultType.Success On 401 → return LoginResultType.InvalidCredentials On other error → return LoginResultType.Error refresh(): Observable POST /api/auth/refresh (no body; browser sends rolac_rt cookie automatically) On 200 → update accessToken$ BehaviorSubject → return true On 401/error → return false logout(): void POST /api/auth/logout (fire-and-forget) Clear accessToken$ and currentUser$ BehaviorSubjects initializeFromRefreshToken(): Promise Calls refresh(); resolves regardless of outcome (used as APP_INITIALIZER) getToken(): string | null Snapshot of accessToken$.value isAuthenticated(): boolean currentUser$.value !== null getCurrentUser(): UserInfo | null currentUser$.value setCurrentUser(user: UserInfo): void Update currentUser$ (used by MFA dialog success callback) // Kept unchanged: getRedirectUrl(): string setRedirectUrl(url: string): void verifySecretLinkToken(token: string): Observable isTokenExpired(token: string): boolean ``` **Token storage:** In-memory `BehaviorSubject` only. No `localStorage`. The refresh token lives exclusively in the HttpOnly `rolac_rt` cookie managed by the browser. ### `AuthInterceptor` — updates **Fix 1 — Skip list for `shouldAddToken()`:** Replace old paths (`/Auth/login`, `/Token/Create`) with: ```typescript const publicPaths = ['/auth/login', '/auth/refresh', '/auth/logout']; ``` **Fix 2 — Silent auto-refresh on 401:** ``` On 401 response (and not a retry): 1. Call authService.refresh() 2. If refresh returns true → re-attach new token header → retry original request 3. If refresh returns false → authService.logout() → navigate to /login → propagate error On 401 response (already a retry): → authService.logout() → navigate to /login → propagate error ``` Use an `X-Retry: true` header sentinel to prevent infinite retry loops. ### `app.config.ts` — APP_INITIALIZER ```typescript { provide: APP_INITIALIZER, useFactory: (authService: AuthService) => () => authService.initializeFromRefreshToken(), deps: [AuthService], multi: true } ``` Ensures that on every page load, the app attempts to restore the session from the HttpOnly refresh token cookie before rendering. If the cookie is absent or expired, `initializeFromRefreshToken()` resolves silently and the user sees the login page. --- ## Data Flow ### Standard login ``` User submits form → LoginPage.onSubmit() → authService.login({ email, password }) → POST /api/auth/login → 200: store accessToken + user in memory → LoginResultType.Success → LoginPage redirects to /dashboard → 401: LoginResultType.InvalidCredentials → show error message → network error: LoginResultType.Error → show error message ``` ### Page reload (session restore) ``` App bootstrap → APP_INITIALIZER calls authService.initializeFromRefreshToken() → POST /api/auth/refresh (browser sends rolac_rt cookie) → 200: store new accessToken in memory → user is authenticated → 401: user$ remains null → router guard redirects to /login ``` ### Authenticated API call (any protected endpoint) ``` Component makes HTTP call → AuthInterceptor: getToken() → attach Authorization: Bearer → 200: response returned normally → 401: authService.refresh() → retry with new token → 401 again: authService.logout() → /login ``` --- ## Error Handling | Scenario | Handling | |----------|----------| | Wrong password | API returns 401 → `InvalidCredentials` → "Invalid email or password" shown in form | | Network offline | `catchError` → `LoginResultType.Error` → "An error occurred" message | | Token expired mid-session | Interceptor silently refreshes + retries; user never sees a 401 | | Refresh cookie expired (30 days) | `initializeFromRefreshToken()` resolves false → router guard → `/login` | | Second 401 after refresh attempt | `authService.logout()` + redirect to `/login` | --- ## Security Notes - Access token: **memory only** (not accessible to XSS via localStorage) - Refresh token: **HttpOnly cookie** managed by browser (not accessible to JavaScript) - Cookie flags: `HttpOnly`, `Secure` (in non-dev), `SameSite=Strict`, `Path=/api/auth` - `shouldAddToken()` skips auth endpoints to avoid attaching tokens to login/refresh/logout calls --- ## Testing Considerations - `AuthService.login()` — mock HTTP, verify BehaviorSubjects update, verify `LoginResult` shape - `AuthService.refresh()` — mock HTTP, verify token updates; mock 401, verify returns false - `AuthService.logout()` — verify memory cleared; verify HTTP called - `authInterceptor` — test 401-then-refresh path; test infinite-loop prevention with X-Retry sentinel - `initializeFromRefreshToken()` — verify it resolves even on 401 (does not block bootstrap)