# 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 ` 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.Login` → `IdentityService.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.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). 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.Logout` → `IdentityService.Logout` 1. **Resolve user** by **access JWT** from the validated Bearer token (`GetByAccessTokenAsync` / token string from JWT context). 2. If found: **`LogoutFromAllDevices`** → `RevokeAllJwtTokens()`; 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` |