Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
8.0 KiB
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
AuthServiceto call new ROLAC API auth endpoints - Update Angular interfaces to match API DTO shapes exactly
- Replace
localStoragetoken storage with in-memoryBehaviorSubject - Update
AuthInterceptorto fix path matching and add silent auto-refresh on 401 - Add
APP_INITIALIZERsession 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:
{ "email": "user@example.com", "password": "secret" }
Success response (200):
{
"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:
// 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:
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
{
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, verifyLoginResultshapeAuthService.refresh()— mock HTTP, verify token updates; mock 401, verify returns falseAuthService.logout()— verify memory cleared; verify HTTP calledauthInterceptor— test 401-then-refresh path; test infinite-loop prevention with X-Retry sentinelinitializeFromRefreshToken()— verify it resolves even on 401 (does not block bootstrap)