Files
ROLAC/APP/docs/superpowers/specs/2026-05-26-login-api-integration-design.md
T
Chris Chen d1f342e3d0 docs: login API integration design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 20:21:43 -07:00

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 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:

{ "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 catchErrorLoginResultType.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)