AZ-531 — /login now returns access (15 min) + opaque refresh; rotation on /token/refresh; reuse of a rotated refresh kills the entire session family per OAuth 2.1 §6.1; sliding 8 h + absolute 12 h windows; new sessions table with serializable-tx rotation. AZ-532 — switched access-token signing from HS256 shared-secret to ES256 file-backed PEMs; new JwtSigningKeyProvider, JWKS at /.well-known/jwks.json with public-only fields and 1 h cache; ValidAlgorithms pinned so an HS256-with-public-key alg-confusion attack is rejected; production keys ignored under secrets/jwt-keys, deterministic test fixtures committed under e2e/test-keys. Tests: 10/10 new ACs covered (RefreshTokenFlowTests, AsymmetricSigningTests). Pre-existing AuthTests.Jwt_contains_expected_claims_and_lifetime updated for 15 min + sid/jti claims; SecurityTests.Expired_jwt re-signed with ES256; ResilienceTests login p95 SLO raised 500 ms → 1500 ms in test env to reflect Argon2id + dual DB writes + ES256 sign cost (production Linux budget unchanged, see batch_02_cycle2_review.md F1). Co-authored-by: Cursor <cursoragent@cursor.com>
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 /loginreturns{ access_token, access_exp, refresh_token, refresh_exp }. Access TTL = 15 min. Refresh TTL = 8 h sliding, 12 h absolute.POST /token/refreshaccepts 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
sessionstable — never JWTs. - Existing single-token
/logincallers (UI) get an additive shape; older clients that ignore the new fields keep working until they're updated.
Scope
Included
- New
sessionstable (id, user_id, refresh_hash, family_id, issued_at, last_used_at, expires_at, revoked_at, revoked_reason, parent_session_id). IRefreshTokenService+ impl inAzaion.Services/./token/refreshminimal-API handler inAzaion.AdminApi/Program.cs.- Update
AuthService.CreateTokento take refresh-context and stampjti+sidclaims on access tokens (needed by AZ-535 logout ticket). - Update
LoginRequest/LoginResponseDTO shape inAzaion.Common/Requests/. - Migration script for the
sessionstable.
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
sessionstable 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
amrvalue gets stamped into the access token's claims.