docs: login API integration design spec

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Chen
2026-05-26 20:21:43 -07:00
parent 2aa095c158
commit d1f342e3d0
@@ -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": "<JWT>",
"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<LoginResult>
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<boolean>
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<void>
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<TokenVerificationResult>
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 <token>
→ 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)