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