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:
Classic298
2026-07-26 18:56:18 -04:00
committed by GitHub
parent 99da2324e3
commit c055203f29
+21 -11
View File
@@ -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