Files
admin/_docs/02_tasks/todo/AZ-531_refresh_token_flow.md
T
Oleksandr Bezdieniezhnykh 3a925b9b0f
ci/woodpecker/push/01-test Pipeline failed
ci/woodpecker/push/02-build-push unknown status
refactor: remove obsolete resource download and installer endpoints
- Deleted the `POST /resources/get/{dataFolder?}` and `GET /resources/get-installer` endpoints as part of the architectural shift towards simplified resource management.
- Removed associated methods and configurations, including `ResourcesService.GetEncryptedResource`, `ResourcesService.GetInstaller`, and related properties in `ResourcesConfig`.
- Cleaned up environment variables and configuration files to reflect the removal of installer-related settings.
- Eliminated the `GetResourceRequest` DTO and its validator, along with the `WrongResourceName` error code.
- Updated documentation to clarify the changes in resource handling and the retirement of per-user file encryption.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 04:17:55 +03:00

4.6 KiB

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.