@@ -140,6 +140,41 @@ class OAuthClientInformationFull(OAuthClientMetadata):
140140auth_manager_config .OAUTH_AUDIENCE = OAUTH_AUDIENCE
141141
142142
143+ # Conservative default when the provider omits both expires_in and expires_at.
144+ # Matches the value recommended by Authlib's compliance_fix documentation.
145+ DEFAULT_TOKEN_EXPIRY_SECONDS = 3600
146+
147+
148+ def _normalize_token_expiry (token : dict ) -> dict :
149+ """Ensure a token dict always has a numeric ``expires_at``.
150+
151+ Resolution order:
152+ 1. If *expires_at* is already present and non-None, trust it.
153+ 2. Else if *expires_in* is present and non-None, compute *expires_at*.
154+ 3. Otherwise fall back to ``DEFAULT_TOKEN_EXPIRY_SECONDS`` and log a
155+ warning so operators can identify providers that omit expiration.
156+
157+ Also stamps *issued_at* for auditing.
158+ """
159+ token ['issued_at' ] = datetime .now ().timestamp ()
160+
161+ if token .get ('expires_at' ) is not None :
162+ token ['expires_at' ] = int (token ['expires_at' ])
163+ return token
164+
165+ if token .get ('expires_in' ) is not None :
166+ token ['expires_at' ] = int (datetime .now ().timestamp () + token ['expires_in' ])
167+ return token
168+
169+ # Neither field present — conservative fallback
170+ log .warning (
171+ "OAuth token response missing both 'expires_in' and 'expires_at'; "
172+ f"defaulting to { DEFAULT_TOKEN_EXPIRY_SECONDS } s from now"
173+ )
174+ token ['expires_at' ] = int (datetime .now ().timestamp () + DEFAULT_TOKEN_EXPIRY_SECONDS )
175+ return token
176+
177+
143178FERNET = None
144179
145180if len (OAUTH_CLIENT_INFO_ENCRYPTION_KEY ) != 44 :
@@ -712,7 +747,7 @@ async def get_oauth_token(self, user_id: str, client_id: str, force_refresh: boo
712747 log .warning (f'No OAuth session found for user { user_id } , client_id { client_id } ' )
713748 return None
714749
715- if force_refresh or datetime .now () + timedelta (minutes = 5 ) >= datetime .fromtimestamp (session .expires_at ):
750+ if force_refresh or session . expires_at is None or datetime .now () + timedelta (minutes = 5 ) >= datetime .fromtimestamp (session .expires_at ):
716751 log .debug (f'Token refresh needed for user { user_id } , client_id { session .provider } ' )
717752 refreshed_token = await self ._refresh_token (session )
718753 if refreshed_token :
@@ -823,14 +858,7 @@ async def _perform_token_refresh(self, session) -> dict:
823858 if 'refresh_token' not in new_token_data :
824859 new_token_data ['refresh_token' ] = token_data ['refresh_token' ]
825860
826- # Add timestamp for tracking
827- new_token_data ['issued_at' ] = datetime .now ().timestamp ()
828-
829- # Calculate expires_at if we have expires_in
830- if 'expires_in' in new_token_data and 'expires_at' not in new_token_data :
831- new_token_data ['expires_at' ] = int (
832- datetime .now ().timestamp () + new_token_data ['expires_in' ]
833- )
861+ _normalize_token_expiry (new_token_data )
834862
835863 log .debug (f'Token refresh successful for client_id { client_id } ' )
836864 return new_token_data
@@ -883,12 +911,7 @@ async def handle_callback(self, request, client_id: str, user_id: str, response)
883911
884912 if token :
885913 try :
886- # Add timestamp for tracking
887- token ['issued_at' ] = datetime .now ().timestamp ()
888-
889- # Calculate expires_at if we have expires_in
890- if 'expires_in' in token and 'expires_at' not in token :
891- token ['expires_at' ] = datetime .now ().timestamp () + token ['expires_in' ]
914+ _normalize_token_expiry (token )
892915
893916 # Clean up any existing sessions for this user/client_id first
894917 sessions = await OAuthSessions .get_sessions_by_user_id (user_id )
@@ -975,7 +998,7 @@ async def get_oauth_token(self, user_id: str, session_id: str, force_refresh: bo
975998 log .warning (f'No OAuth session found for user { user_id } , session { session_id } ' )
976999 return None
9771000
978- if force_refresh or datetime .now () + timedelta (minutes = 5 ) >= datetime .fromtimestamp (session .expires_at ):
1001+ if force_refresh or session . expires_at is None or datetime .now () + timedelta (minutes = 5 ) >= datetime .fromtimestamp (session .expires_at ):
9791002 log .debug (f'Token refresh needed for user { user_id } , provider { session .provider } ' )
9801003 refreshed_token = await self ._refresh_token (session )
9811004 if refreshed_token :
@@ -1089,14 +1112,7 @@ async def _perform_token_refresh(self, session) -> dict:
10891112 if 'refresh_token' not in new_token_data :
10901113 new_token_data ['refresh_token' ] = token_data ['refresh_token' ]
10911114
1092- # Add timestamp for tracking
1093- new_token_data ['issued_at' ] = datetime .now ().timestamp ()
1094-
1095- # Calculate expires_at if we have expires_in
1096- if 'expires_in' in new_token_data and 'expires_at' not in new_token_data :
1097- new_token_data ['expires_at' ] = int (
1098- datetime .now ().timestamp () + new_token_data ['expires_in' ]
1099- )
1115+ _normalize_token_expiry (new_token_data )
11001116
11011117 log .debug (f'Token refresh successful for provider { provider } ' )
11021118 return new_token_data
@@ -1694,12 +1710,7 @@ async def handle_callback(self, request, provider, response, db=None):
16941710 )
16951711
16961712 try :
1697- # Add timestamp for tracking
1698- token ['issued_at' ] = datetime .now ().timestamp ()
1699-
1700- # Calculate expires_at if we have expires_in
1701- if 'expires_in' in token and 'expires_at' not in token :
1702- token ['expires_at' ] = datetime .now ().timestamp () + token ['expires_in' ]
1713+ _normalize_token_expiry (token )
17031714
17041715 # Enforce max concurrent sessions per user/provider to prevent
17051716 # unbounded growth while allowing multi-device usage
0 commit comments