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

213 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.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` |