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:
@@ -7,10 +7,9 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.endpoints.auth import store_refresh_token
|
||||
from app.api.endpoints.auth import _mint_session_tokens
|
||||
from app.core.admin_database import get_admin_db
|
||||
from app.core.config import settings
|
||||
from app.core.security import create_access_token, create_refresh_token
|
||||
from app.models.account import Account
|
||||
from app.models.account_invite import AccountInvite
|
||||
from app.models.oauth_identity import OAuthIdentity
|
||||
@@ -187,17 +186,14 @@ async def google_callback(
|
||||
account_invite_code=payload.account_invite_code,
|
||||
invited_email=payload.invited_email,
|
||||
)
|
||||
refresh_token_str = create_refresh_token({"sub": str(user.id)})
|
||||
# Persist the refresh-token JTI so the first /auth/refresh call doesn't
|
||||
# reject this token as "revoked" (the rotation logic requires a row to
|
||||
# mark as used). _sign_in_or_register already committed; this needs a
|
||||
# second commit.
|
||||
await store_refresh_token(db, refresh_token_str, user.id)
|
||||
token = await _mint_session_tokens(user, db)
|
||||
await db.commit()
|
||||
return OAuthCallbackResponse(
|
||||
access_token=create_access_token({"sub": str(user.id)}),
|
||||
refresh_token=refresh_token_str,
|
||||
access_token=token.access_token,
|
||||
refresh_token=token.refresh_token,
|
||||
is_new_user=is_new,
|
||||
idle_expires_at=token.idle_expires_at,
|
||||
absolute_expires_at=token.absolute_expires_at,
|
||||
)
|
||||
|
||||
|
||||
@@ -217,15 +213,12 @@ async def microsoft_callback(
|
||||
account_invite_code=payload.account_invite_code,
|
||||
invited_email=payload.invited_email,
|
||||
)
|
||||
refresh_token_str = create_refresh_token({"sub": str(user.id)})
|
||||
# Persist the refresh-token JTI so the first /auth/refresh call doesn't
|
||||
# reject this token as "revoked" (the rotation logic requires a row to
|
||||
# mark as used). _sign_in_or_register already committed; this needs a
|
||||
# second commit.
|
||||
await store_refresh_token(db, refresh_token_str, user.id)
|
||||
token = await _mint_session_tokens(user, db)
|
||||
await db.commit()
|
||||
return OAuthCallbackResponse(
|
||||
access_token=create_access_token({"sub": str(user.id)}),
|
||||
refresh_token=refresh_token_str,
|
||||
access_token=token.access_token,
|
||||
refresh_token=token.refresh_token,
|
||||
is_new_user=is_new,
|
||||
idle_expires_at=token.idle_expires_at,
|
||||
absolute_expires_at=token.absolute_expires_at,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user