Fourth commit in the session-expiration-policy series. The gate that ends "logged in forever" — refresh now rejects tokens whose original login (auth_time) is older than abs_max seconds. Algorithm (plan §4.5): 1. Decode JWT (dep already handles idle expiry). 2. Load user; reject inactive/missing as invalid_refresh_token. 3. Resolve effective auth_time/idle_max/abs_max, grandfathering pre-PR tokens by snapshotting current account policy. 4. Atomically revoke the JTI regardless of outcome — this consumes the token whether or not the absolute check passes, so an absolute-expired token cannot be replayed forever. 5. If the atomic UPDATE matched zero rows -> invalid_refresh_token. 6. If now >= auth_time + abs_max -> commit the revoke explicitly (so it survives the rollback hook in get_admin_db) and 401 session_expired_absolute. 7. Otherwise mint via _mint_with_claims, carrying claims forward. Boundary check uses `>=`, not `>` — a deadline equal to now is expired. _refresh_session_tokens (commit 3) replaced by two narrower helpers: _resolve_refresh_claims (grandfather logic, no mint) and _mint_with_claims (mint with explicit claims, no grandfather). Makes the endpoint's algorithm read top-down without indirection. Tests added in test_session_policy.py: - #8: backdate auth_time by exactly abs_max -> session_expired_absolute at the deadline boundary. - #9: same token tried twice; first returns session_expired_absolute AND consumes the row; second returns invalid_refresh_token. - #12: legacy token without auth_time/idle_max/abs_max gets one successful rotation; new JWT carries fresh policy snapshot from the account (3d/14d defaults under Strict). 25/25 across test_session_policy + test_auth + test_oauth_callbacks. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
32 KiB
32 KiB