From d1f342e3d0bc99b3dfa2077d2ab33519ca7620cc Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Tue, 26 May 2026 20:21:43 -0700 Subject: [PATCH] docs: login API integration design spec Co-Authored-By: Claude Sonnet 4.6 --- ...2026-05-26-login-api-integration-design.md | 252 ++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 APP/docs/superpowers/specs/2026-05-26-login-api-integration-design.md diff --git a/APP/docs/superpowers/specs/2026-05-26-login-api-integration-design.md b/APP/docs/superpowers/specs/2026-05-26-login-api-integration-design.md new file mode 100644 index 0000000..b5f3aa7 --- /dev/null +++ b/APP/docs/superpowers/specs/2026-05-26-login-api-integration-design.md @@ -0,0 +1,252 @@ +# 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)