maksit-certs-ui/assets/docs/LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md
2026-05-03 10:35:34 +02:00

12 KiB
Raw Blame History

Login and Refresh Token Architecture

This document describes how authentication (login), token refresh, and logout work across the MaksIT.CertsUI WebAPI and WebUI.

Audience: Backend (C# / ASP.NET) and Frontend (TypeScript / React) developers.

MaksIT.CertsUI persists users and refresh tokens in PostgreSQL. Shared MaksIT.Models types may include optional 2FA fields; behavior follows this WebAPI and WebUI implementation.


1. Overview

  • Access token: Short-lived JWT used in the Authorization: Bearer <token> header for API calls.
  • Refresh token: Opaque string stored with the user in PostgreSQL; used to obtain a new access token (and optionally a new refresh token) when the access token expires.
  • Login returns both tokens; the client stores them and uses the access token until it expires, then calls refresh with the refresh token.
  • Logout revokes the current session (or all sessions) on the server and clears tokens on the client. On Certs, logout requires Authorization: Bearer with the current access JWT; the server matches that token when removing the session (see §3.4).

2. Token model

2.1 Backend: JwtToken (domain)

Location: src/MaksIT.CertsUI/Domain/JwtToken.cs

Property Type Description
Id Guid Token identifier.
Token string The JWT access token string.
TokenType string Typically "Bearer".
IssuedAt DateTime When the token was issued (UTC).
ExpiresAt DateTime When the access token expires.
RefreshToken string Opaque refresh token.
RefreshTokenExpiresAt DateTime When the refresh token expires.
IsRevoked bool If true, token is treated as unusable (revoked entries are removed when resolving refresh).
  • A User holds a list of JwtToken instances (multiple devices/sessions).
  • New tokens are upserted via User.UpsertJwtToken.

2.2 API response: LoginResponse

Location: src/MaksIT.Models/LetsEncryptServer/Identity/Login/LoginResponse.cs

Returned by login and refresh:

  • TokenType (e.g. "Bearer")
  • Token (access JWT)
  • ExpiresAt (access token expiry)
  • RefreshToken
  • RefreshTokenExpiresAt

There is no Username field on this model; the WebUI derives display name from JWT claims when hydrating identity (identitySlice).


3. Backend flow

3.1 Layers

Layer Component Responsibility
API IdentityController Login, refresh, logout, and authenticated PATCH user.
Service IdentityService Validates credentials, issues JWTs via JwtGenerator, maps domain JwtToken to LoginResponse, persists users via IUserStore.
Domain User Password validation, JWT token list (upsert/remove/revoke).
Persistence IUserStore Users and JWT rows persist in PostgreSQL.

3.2 Login

Endpoint: POST /api/identity/login
Controller: IdentityController.LoginIdentityService.LoginAsync

  1. Resolve user by username (IUserStore.GetByNameAsync).
  2. Validate password (User.ValidatePassword) with configured pepper.
  3. Optional 2FA fields on LoginRequest are not validated by Certs—ignored if sent.
  4. Generate access JWT via JwtGenerator.TryGenerateToken (secret, issuer, audience, expiration from config).
  5. Generate opaque refresh token and build a domain JwtToken with access + refresh expiry (RefreshExpiration from config).
  6. Upsert token on user, SetLastLogin, IIdentityPersistenceService.WriteAsync.
  7. Return LoginResponse (no username field; client uses JWT claims).

Request body (LoginRequest): username, password, optional unused twoFactorCode / twoFactorRecoveryCode (shared model shape only).

3.3 Refresh

Endpoint: POST /api/identity/refresh
Controller: IdentityController.RefreshTokenIdentityService.RefreshTokenAsync

Request body (RefreshTokenRequest): refreshToken only (src/MaksIT.Models/LetsEncryptServer/Identity/Login/RefreshTokenRequest.cs). The WebUI may send a force flag for parity with shared thunk code; the Certs API model does not include it (extra properties are typically ignored by the serializer).

  1. Resolve user by refresh token (IUserStore.GetByRefreshTokenAsync).
  2. Remove revoked JWT rows (RemoveRevokedJwtTokens).
  3. Find the token where RefreshToken matches.
  4. Unauthorized if not found → e.g. “Invalid refresh token.”
  5. If the access token is still valid (UtcNow <= token.ExpiresAt): update last login, WriteAsync, return the same LoginResponse (no new JWT). This API does not implement a server-side force refresh path.
  6. If access expired but refresh is still valid (UtcNow <= RefreshTokenExpiresAt): issue a new access JWT + new refresh token, upsert token, save, return new LoginResponse.
  7. If refresh is expired: remove that token record, return 401 “Refresh token has expired.”

3.4 Logout

Endpoint: POST /api/identity/logout (requires JwtAuthorizationFilter — send Authorization: Bearer)
Controller: IdentityController.LogoutIdentityService.Logout

  1. Resolve user by access JWT from the validated Bearer token (GetByAccessTokenAsync / token string from JWT context).
  2. If found: LogoutFromAllDevicesRevokeAllJwtTokens(); else → RemoveJwtToken(accessToken) for the current session.
  3. WriteAsync if the user was updated.
  4. Return success (implementation may still return OK if the token was unknown—clients should clear local state regardless).

Request body (LogoutRequest): token (access JWT, for shared model parity), logoutFromAllDevices — the server uses the Bearer access token for lookup.


4. Frontend flow

4.1 Identity state and storage

Redux: identitySlice (src/MaksIT.WebUI/src/redux/slices/identitySlice.ts)

  • State: identity: Identity | null, hydrated: boolean, status, showUserOffcanvas.
  • Persistence: Login/refresh responses are written to localStorage via writeIdentity; on load, setIdentityFromLocalStorage reads and hydrates state and enriches from JWT claims (userId, username, roles, isGlobalAdmin, acls when those claims exist).

Identity type extends LoginResponse with client-side fields: userId, username, roles, isGlobalAdmin, acls.

4.2 Login UI

Component: LoginScreen (src/MaksIT.WebUI/src/components/LoginScreen.tsx)

  • Form: username and password; 2FA inputs are commented out until the backend supports them.
  • On submit: dispatch(login(loginRequest)).
  • On successful login, identitySlice stores the response in state and localStorage; LoginScreen redirects when identity is present and refresh token is not expired.

4.3 Route protection

Component: Authorization (src/MaksIT.WebUI/src/components/Authorization.tsx)

  • Wraps protected routes.
  • On mount, if not hydrated, dispatches setIdentityFromLocalStorage().
  • When hydrated: if identity is missing or refreshTokenExpiresAt is in the past, redirects to /login (with state.from for return URL).
  • Renders children only when hydrated and refresh token is not expired.

Refresh token expiry is what forces re-login; the access token may expire while refresh is still valid (handled by axios).

4.4 Axios: token attachment and refresh

File: src/MaksIT.WebUI/src/axiosConfig.ts

  • Excluded URLs (no Bearer token, no refresh loop): login and refresh routes (GetApiRoute(ApiRoutes.identityLogin).route, GetApiRoute(ApiRoutes.identityRefresh).route).
  • Request interceptor: If access token is expired but refresh is still valid by client clock, await a single shared refreshJwt(); on success attach new Authorization; on failure dispatch clearIdentity() and reject (do not send protected calls with an expired access token).
  • Response interceptor: On 401, optionally retry once after refresh when refresh is still valid; on refresh failure, clearIdentity().
  • Serialization: isRefreshing / refreshPromise so concurrent requests share one refresh.

4.5 Async thunks and clearIdentity

  • login: POST login; on success writes identity and enriches from JWT.
  • refreshJwt(force?): POST refresh with refreshToken (and optional force in body for shared code paths; server ignores force on Certs). On failure, identity cleared.
  • logout: POST logout with access token in body, then clear state/localStorage.
  • clearIdentity(): Clears Redux and localStorage without calling logout API (used when refresh fails).

5. API summary

Method Endpoint Bearer required Purpose
POST /api/identity/login No Login; returns access + refresh token.
POST /api/identity/refresh No Exchange refresh token for same or new tokens.
POST /api/identity/logout Yes Revoke session(s); Bearer identifies the access token to remove.

Other identity routes (e.g. PATCH /api/identity/user/{id}) use JwtAuthorizationFilter and require a valid JWT.

Base route: api/identity (IdentityController, AppMap).


6. Sequence overview

Login: User submits credentials → POST /api/identity/login → user row updated with new JwtToken in PostgreSQL → WebUI stores identity → redirect into app.

Authenticated request (access token valid): Interceptor adds Authorization: Bearer → API validates JWT.

Access expired, refresh valid: Interceptor awaits refreshJwt() → POST /api/identity/refresh → updated identity → original request retried with new token.

401 on protected request: Response interceptor attempts refresh; if refresh returns 401, clearIdentity() and redirect to /login.

Logout: POST /api/identity/logout with Authorization: Bearer and body { token, logoutFromAllDevices } → server removes token(s) from the user row → client clears storage.

Use Certs resources in examples (e.g. accounts, certificate flows): no protected API should run after refresh has failed without clearing identity.


7. Security notes

  • Passwords use salt + server-side pepper; not stored in plain text.
  • Refresh tokens are stored per user in PostgreSQL; expiry and invalidation are enforced in IdentityService.RefreshTokenAsync.
  • 2FA is not implemented on the Certs WebAPI; do not enable the WebUI 2FA fields until backend support exists.
  • Login/refresh do not require Bearer; logout and other protected identity routes use JwtAuthorizationFilter.
  • Frontend keeps one identity in localStorage; refresh is serialized to avoid duplicate refresh storms.

8. Key files reference

Area File
Domain User src/MaksIT.CertsUI/Domain/User.cs
Domain JwtToken src/MaksIT.CertsUI/Domain/JwtToken.cs
API service src/MaksIT.CertsUI/Services/IdentityService.cs
API controller src/MaksIT.CertsUI/Controllers/IdentityController.cs
API models src/MaksIT.Models/LetsEncryptServer/Identity/Login/, .../Logout/
Frontend state src/MaksIT.WebUI/src/redux/slices/identitySlice.ts
Frontend HTTP src/MaksIT.WebUI/src/axiosConfig.ts
Frontend routes src/MaksIT.WebUI/src/components/Authorization.tsx
Frontend login UI src/MaksIT.WebUI/src/components/LoginScreen.tsx
Frontend API map src/MaksIT.WebUI/src/AppMap.tsx