# Refresh-Token Flow with Rotation + Reuse Detection **Task**: AZ-531_refresh_token_flow **Name**: Refresh-token flow with rotation + reuse detection **Description**: Replace single 4h JWT with short-lived (15m) access + opaque refresh token. Rotate refresh on every use; kill the session family on reuse-detection per OAuth 2.1 §6.1. Persists session state in a new `sessions` table — the foundation logout/revocation will build on. **Complexity**: 5 points **Dependencies**: None **Component**: Admin API + Services + DataAccess **Tracker**: AZ-531 **Epic**: AZ-529 ## Problem `/login` today returns a single 4-hour HS256 JWT (`AuthService.CreateToken`). There is no refresh, no logout, and no way to shorten the access lifetime without forcing users to re-enter credentials every few minutes. Stolen tokens are valid for the full 4 h with no remediation. ## Outcome - `POST /login` returns `{ access_token, access_exp, refresh_token, refresh_exp }`. Access TTL = 15 min. Refresh TTL = 8 h sliding, 12 h absolute. - `POST /token/refresh` accepts an opaque refresh token, **rotates** it (issues new access + new refresh, invalidates old refresh), and returns the same shape. - Refresh-reuse detection: if an already-rotated refresh token is presented again, the entire session family is killed (per OAuth 2.1 §6.1). - Refresh tokens are opaque random 32-byte base64url strings stored hashed in `sessions` table — never JWTs. - Existing single-token `/login` callers (UI) get an additive shape; older clients that ignore the new fields keep working until they're updated. ## Scope ### Included - New `sessions` table (id, user_id, refresh_hash, family_id, issued_at, last_used_at, expires_at, revoked_at, revoked_reason, parent_session_id). - `IRefreshTokenService` + impl in `Azaion.Services/`. - `/token/refresh` minimal-API handler in `Azaion.AdminApi/Program.cs`. - Update `AuthService.CreateToken` to take refresh-context and stamp `jti` + `sid` claims on access tokens (needed by AZ-535 logout ticket). - Update `LoginRequest`/`LoginResponse` DTO shape in `Azaion.Common/Requests/`. - Migration script for the `sessions` table. ### Excluded - Asymmetric signing — see AZ-532. - Logout endpoint — see AZ-535. This ticket only persists session state. - 2FA enforcement on `/login` — see AZ-534. - UI changes to consume the new shape — cross-workspace ticket filed once admin lands. ## Acceptance Criteria **AC-1: /login returns dual tokens** Given valid credentials When `POST /login` is called Then response body has non-empty `access_token` (JWT, exp ≈ now+15m ±60s) AND `refresh_token` (opaque ≥43 chars), and a session row exists. **AC-2: /token/refresh rotates the refresh token** Given a valid refresh token When `POST /token/refresh` is called with it Then response returns a new access + new refresh; the old refresh becomes invalid; session row's `refresh_hash` is updated; `parent_session_id` chains to the previous row. **AC-3: Reuse-detection kills family** Given refresh token R1 was rotated to R2 When R1 is presented again Then `POST /token/refresh` returns 401, every session in R1's family is marked `revoked_reason='reuse_detected'`, and R2 also stops working. **AC-4: Sliding + absolute expiry** Given a refresh token issued 7 h 50 min ago When used Then rotation succeeds, sliding window extended; if same family is older than 12 h absolute since first issue, refresh fails 401. **AC-5: Refresh tokens are opaque, not JWT** Given any refresh token from `/login` or `/token/refresh` When decoded Then it is not a JWT (no dot-separated base64url segments parse as a header/payload). Stored as SHA-256 hash, raw value never logged. ## Blackbox Tests | AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References | |--------|------------------------|-------------|-------------------|----------------| | AC-1 | Seed user | POST /login | 200 with both tokens, exp ≈ now+15m | — | | AC-2 | Refresh R1 from AC-1 | POST /token/refresh with R1 | New access + new refresh; R1 invalid | — | | AC-3 | R1 rotated to R2 | POST /token/refresh with R1 again | 401; R2 also dead | — | | AC-4 | Refresh issued 11h59m ago | POST /token/refresh | Rotation succeeds; same family at 12h+ → 401 | — | | AC-5 | Refresh token from any path | Decode/parse | Not a JWT; DB stores SHA-256 | — | ## Risks / Notes - `sessions` table needs an index on `(refresh_hash)` for O(1) lookup. - Rotation must be transactional (insert new + invalidate old in one tx) to prevent race where two parallel refreshes both succeed. - Coordinate with AZ-535 (logout) for shared session-table schema. - Coordinate with AZ-534 (2FA) for which `amr` value gets stamped into the access token's claims.