# Module: Azaion.Services.RefreshTokenService ## Purpose Issues, rotates, and validates opaque refresh tokens for interactive sessions. Implements OAuth 2.1 §6.1 reuse-detection: presenting an already-rotated refresh token kills the entire session family. > Added in cycle 2 (2026-05-14) by AZ-531 (Epic AZ-529, Auth Mechanism Modernization). Foundation for AZ-535 (logout/revocation) and AZ-534 (MFA — pins `mfa_authenticated` to the session so refresh rotation inherits the original AMR strength). ## Public Interface ### IRefreshTokenService | Method | Signature | Description | |--------|-----------|-------------| | `IssueForNewLogin` | `Task<(string OpaqueToken, Session Session)> IssueForNewLogin(Guid userId, bool mfaAuthenticated = false, CancellationToken ct = default)` | Mint a fresh refresh token at login; starts a new session family. The opaque token is returned to the caller; only its SHA-256 hash is persisted. `mfaAuthenticated` is pinned to the session row so rotation preserves AMR strength. | | `Rotate` | `Task<(string OpaqueToken, Session Session)> Rotate(string opaqueToken, CancellationToken ct = default)` | Rotate the supplied refresh token. On success returns a new opaque token + the new session row. Throws `BusinessException(InvalidRefreshToken)` on bad/expired/revoked input; on reuse-detection (already-rotated token presented again) the entire session family is revoked first. | ## Internal Logic - **Token format**: 32 random bytes (256 bits) base64url-encoded → 43-char string (no padding). Persisted as the SHA-256 hex digest in `sessions.refresh_hash`. The opaque value is never logged. - **Family semantics**: each `IssueForNewLogin` creates a new family (`family_id == id`). Each `Rotate` inserts a new row in the same family with `parent_session_id` chained to the previous row, then marks the previous row `revoked_reason='rotated'`. - **Reuse detection**: if a presented token is found with `revoked_reason='rotated'`, every active row in the same family is set to `revoked_reason='reuse_detected'` (per OAuth 2.1 §6.1) — even the row that succeeded last cycle stops working. - **Sliding expiry**: each rotation moves `expires_at` to `now + RefreshSlidingHours` (default 8 h). - **Absolute cap**: a family older than `RefreshAbsoluteHours` (default 12 h) since `family_started_at` is rejected even if every individual rotation stayed within the sliding window. - **Concurrency**: rotation runs in a `Serializable` transaction so two concurrent refreshes of the same token can't both succeed. ## Dependencies - `IDbFactory` — admin connection for inserts/updates - `IOptions` — sliding/absolute window TTLs (defined alongside `JwtConfig` in `Azaion.Common/Configs/JwtConfig.cs`) - `Session` entity, `SessionRevokedReasons` constants - `BusinessException` / `ExceptionEnum.InvalidRefreshToken` - `System.Security.Cryptography.RandomNumberGenerator` + `SHA256` ## Consumers - `Program.cs` `/login` → calls `IssueForNewLogin` after `UserService.ValidateUser` succeeds - `Program.cs` `/login/mfa` → calls `IssueForNewLogin` after MFA second factor - `Program.cs` `/token/refresh` → calls `Rotate` ## Data Models Operates on the `Session` entity via `AzaionDb.Sessions` table. ## Configuration `SessionConfig` (bound from `appsettings.json` section `SessionConfig`): - `RefreshSlidingHours` (default 8) - `RefreshAbsoluteHours` (default 12) ## External Integrations PostgreSQL via `IDbFactory`. ## Security - Refresh tokens are opaque random strings, never JWTs — verifiers cannot decode or alter them. - The plaintext token leaves the server only at issue/rotation; the DB stores only the SHA-256 hash. - Reuse-detection is the primary defence against stolen-refresh-token attacks: the legitimate user's next refresh will be rejected and they'll be forced to re-authenticate, but the attacker's token also dies. - Rotation is transactional (`Serializable`) so concurrent refresh races cannot leak two valid descendants. ## Tests - `e2e/Azaion.E2E/Tests/RefreshTokenTests.cs` — covers AC-1 (login dual tokens), AC-2 (rotation invalidates old), AC-3 (reuse kills family), AC-4 (sliding + absolute expiry), AC-5 (opaque, not JWT).