12 KiB
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: Bearerwith 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
JwtTokeninstances (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)RefreshTokenRefreshTokenExpiresAt
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.Login → IdentityService.LoginAsync
- Resolve user by username (
IUserStore.GetByNameAsync). - Validate password (
User.ValidatePassword) with configured pepper. - Optional 2FA fields on
LoginRequestare not validated by Certs—ignored if sent. - Generate access JWT via
JwtGenerator.TryGenerateToken(secret, issuer, audience, expiration from config). - Generate opaque refresh token and build a domain
JwtTokenwith access + refresh expiry (RefreshExpirationfrom config). - Upsert token on user, SetLastLogin,
IIdentityPersistenceService.WriteAsync. - 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.RefreshToken → IdentityService.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).
- Resolve user by refresh token (
IUserStore.GetByRefreshTokenAsync). - Remove revoked JWT rows (
RemoveRevokedJwtTokens). - Find the token where
RefreshTokenmatches. - Unauthorized if not found → e.g. “Invalid refresh token.”
- If the access token is still valid (
UtcNow <= token.ExpiresAt): update last login,WriteAsync, return the sameLoginResponse(no new JWT). This API does not implement a server-sideforcerefresh path. - If access expired but refresh is still valid (
UtcNow <= RefreshTokenExpiresAt): issue a new access JWT + new refresh token, upsert token, save, return newLoginResponse. - 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.Logout → IdentityService.Logout
- Resolve user by access JWT from the validated Bearer token (
GetByAccessTokenAsync/ token string from JWT context). - If found:
LogoutFromAllDevices→RevokeAllJwtTokens(); else →RemoveJwtToken(accessToken)for the current session. WriteAsyncif the user was updated.- 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,setIdentityFromLocalStoragereads and hydrates state and enriches from JWT claims (userId,username,roles,isGlobalAdmin,aclswhen 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,
identitySlicestores the response in state and localStorage;LoginScreenredirects 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
identityis missing orrefreshTokenExpiresAtis in the past, redirects to/login(withstate.fromfor 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 newAuthorization; on failure dispatchclearIdentity()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/refreshPromiseso 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 withrefreshToken(and optionalforcein body for shared code paths; server ignoresforceon 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 |