Third commit in the session-expiration-policy series. Every refresh token
issued from now on carries the policy snapshot in its JWT (in seconds,
for direct Unix math), and every login/OAuth response surfaces both
expiry windows as ISO timestamps. /auth/refresh carries the claims
forward unchanged — including auth_time, which never resets on rotation.
Does NOT yet enforce the absolute cap — that's commit 4, sequenced so
the gate can be reverted independently if pilots hit an edge case.
But the wire is fully populated, and a grandfather path is already in
_refresh_session_tokens for tokens issued before this PR.
Key changes:
- core/security.py: create_refresh_token signature changes to
(user_id, *, auth_time, idle_max_seconds, abs_max_seconds). Adds
resolve_session_policy(account) -> (idle_minutes, absolute_minutes)
applying defaults for NULL overrides.
- schemas/token.py + schemas/oauth.py: Token and OAuthCallbackResponse
gain idle_expires_at + absolute_expires_at (Optional[datetime],
Pydantic emits ISO 8601 UTC strings).
- endpoints/auth.py: new _mint_session_tokens(user, db) and
_refresh_session_tokens(payload, user, db) helpers. /auth/login,
/auth/login/json, and /auth/refresh now route through them. The
refresh endpoint's pre-existing "Refresh token has been revoked"
error normalized to the taxonomy detail "invalid_refresh_token".
- endpoints/oauth.py: both Google and Microsoft callbacks call
_mint_session_tokens; OAuthCallbackResponse carries the expiry
fields through.
- tests: two new cases in test_session_policy.py — login_json embeds
the claims with strict defaults (3d/14d -> 259200/1209600 sec) and
surfaces matching ISO expiry fields; refresh carries auth_time,
idle_max, abs_max forward unchanged across rotation.
35/35 across test_session_policy + test_auth + test_oauth_callbacks +
test_account_invite_lookup + test_account_management.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>