fix: refresh OAuth session before the id_token expires (#27520)
`_normalize_token_expiry()` derived the session expiry from the access token alone, and that value is what `oauth_session.expires_at` stores and what both `get_oauth_token()` implementations check to decide whether to refresh five minutes ahead. Providers that issue a shorter-lived id_token than access token (Microsoft Entra ID: roughly 60 minutes against 75) therefore left a window where the session still looked valid while the id_token had already expired, so pipes and tools reading `__oauth_token__["id_token"]` forwarded a dead JWT and downstream services rejected it with 401. The stored expiry is now capped at the id_token's `exp` claim whenever that JWT expires first, which moves the refresh ahead of the earliest expiring token in the set. This is applied in the single function every session write already passes through, so it covers both the SSO manager and the MCP client manager on their callback and refresh paths alike. Sessions without an id_token, with an opaque one, or with no `exp` claim are unaffected. Fixes #27066
This commit is contained in:
@@ -15,6 +15,7 @@ from types import SimpleNamespace
|
||||
from typing import Literal, Optional
|
||||
|
||||
import aiohttp
|
||||
import jwt
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
from authlib.oauth2.rfc6749.errors import OAuth2Error
|
||||
from authlib.oidc.core import UserInfo
|
||||
@@ -213,19 +214,28 @@ def _normalize_token_expiry(token: dict) -> dict:
|
||||
token['issued_at'] = datetime.now().timestamp()
|
||||
|
||||
if token.get('expires_at') is not None:
|
||||
token['expires_at'] = int(token['expires_at'])
|
||||
return token
|
||||
expires_at = int(token['expires_at'])
|
||||
elif token.get('expires_in') is not None:
|
||||
expires_at = int(datetime.now().timestamp() + token['expires_in'])
|
||||
else:
|
||||
# Neither field present — conservative fallback
|
||||
log.warning(
|
||||
"OAuth token response missing both 'expires_in' and 'expires_at'; "
|
||||
f'defaulting to {DEFAULT_TOKEN_EXPIRY_SECONDS}s from now'
|
||||
)
|
||||
expires_at = int(datetime.now().timestamp() + DEFAULT_TOKEN_EXPIRY_SECONDS)
|
||||
|
||||
if token.get('expires_in') is not None:
|
||||
token['expires_at'] = int(datetime.now().timestamp() + token['expires_in'])
|
||||
return token
|
||||
id_token = token.get('id_token')
|
||||
if id_token:
|
||||
# Cap at the id_token expiry so pipes and tools never receive an expired JWT
|
||||
try:
|
||||
exp = jwt.decode(id_token, options={'verify_signature': False}).get('exp')
|
||||
if exp is not None:
|
||||
expires_at = min(expires_at, int(exp))
|
||||
except Exception as e:
|
||||
log.debug(f'Could not read exp from id_token: {e}')
|
||||
|
||||
# Neither field present — conservative fallback
|
||||
log.warning(
|
||||
"OAuth token response missing both 'expires_in' and 'expires_at'; "
|
||||
f'defaulting to {DEFAULT_TOKEN_EXPIRY_SECONDS}s from now'
|
||||
)
|
||||
token['expires_at'] = int(datetime.now().timestamp() + DEFAULT_TOKEN_EXPIRY_SECONDS)
|
||||
token['expires_at'] = expires_at
|
||||
return token
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user