feat(auth): embed auth_time/idle_max/abs_max in refresh tokens at every login
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>
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
@@ -16,6 +18,11 @@ class OAuthCallbackResponse(BaseModel):
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
is_new_user: bool
|
||||
# Session-policy expiry windows — mirrors Token in token.py so the
|
||||
# frontend can drive expiry-soon toasts identically for password and
|
||||
# OAuth logins.
|
||||
idle_expires_at: datetime | None = None
|
||||
absolute_expires_at: datetime | None = None
|
||||
|
||||
|
||||
class InviteLookupResponse(BaseModel):
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -7,6 +8,12 @@ class Token(BaseModel):
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
must_change_password: bool = False
|
||||
# Session-policy expiry windows derived from the refresh JWT. Frontend
|
||||
# uses these to drive the "your session ends soon" toast and to know
|
||||
# when /auth/refresh will reject for absolute expiry. See
|
||||
# docs/plans/2026-05-13-session-expiration-policy.md §4.2.
|
||||
idle_expires_at: Optional[datetime] = None
|
||||
absolute_expires_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class TokenPayload(BaseModel):
|
||||
|
||||
Reference in New Issue
Block a user