fix: renew non-refreshable OAuth tokens instead of deleting the session

Addresses review feedback on #26802: even after stamping a long-lived
expiry, _perform_token_refresh still returned None for tokens without a
refresh_token, so get_oauth_token would delete the session once that
expiry elapsed — reproducing the original bug a year later instead of
hourly.

Flag tokens that carry neither expiry metadata nor a refresh_token with
NON_REFRESHABLE_TOKEN_MARKER in _normalize_token_expiry. When such a
token reaches the refresh path, _renew_non_refreshable_token re-stamps
its local expiry in place and returns it, so the session keeps renewing
itself (Slack xoxp user tokens never expire and never rotate). Tokens
with a real provider-declared expiry but no refresh token are not
flagged and remain untouched: they still expire and require re-auth.

Also fixes the Ruff format failure on the branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JKLX4jqe4E868Zr43ZmzVC
This commit is contained in:
Claude 2026-07-09 09:31:05 +00:00
parent d4101ab0b2
commit db173da216
No known key found for this signature in database

View file

@ -192,6 +192,11 @@ DEFAULT_TOKEN_EXPIRY_SECONDS = 3600
# (e.g. Slack xoxp user tokens): long-lived until revoked, cannot be refreshed.
NON_REFRESHABLE_TOKEN_EXPIRY_SECONDS = 365 * 24 * 3600
# Marker key stamped on tokens that carry no expiry metadata and no
# refresh_token. It flags the token as long-lived so the refresh path can
# renew the local expiry in place instead of deleting the session (#26141).
NON_REFRESHABLE_TOKEN_MARKER = 'non_refreshable'
# Apereo CAS includes client_id in ID token JWS headers; Authlib 1.7/joserfc
# rejects unknown headers unless we register the provider extension.
@ -211,8 +216,9 @@ def _normalize_token_expiry(token: dict) -> dict:
``DEFAULT_TOKEN_EXPIRY_SECONDS`` and log a warning so operators can
identify providers that omit expiration.
4. Otherwise the token is non-expiring and non-refreshable; use
``NON_REFRESHABLE_TOKEN_EXPIRY_SECONDS`` so the refresh path (which
would inevitably fail and delete the session) is not triggered.
``NON_REFRESHABLE_TOKEN_EXPIRY_SECONDS`` and flag it with
``NON_REFRESHABLE_TOKEN_MARKER`` so the refresh path renews the local
expiry in place instead of deleting the session.
Also stamps *issued_at* for auditing.
"""
@ -237,13 +243,35 @@ def _normalize_token_expiry(token: dict) -> dict:
token['expires_at'] = int(datetime.now().timestamp() + DEFAULT_TOKEN_EXPIRY_SECONDS)
else:
log.info(
"OAuth token response missing 'expires_in', 'expires_at' and 'refresh_token'; "
'treating token as long-lived'
"OAuth token response missing 'expires_in', 'expires_at' and 'refresh_token'; treating token as long-lived"
)
token['expires_at'] = int(datetime.now().timestamp() + NON_REFRESHABLE_TOKEN_EXPIRY_SECONDS)
token[NON_REFRESHABLE_TOKEN_MARKER] = True
return token
def _renew_non_refreshable_token(token_data: dict, session_id: str):
"""Renew the local expiry of a long-lived, non-refreshable token.
Providers such as Slack issue user tokens that never expire and never
carry a ``refresh_token`` (see ``_normalize_token_expiry`` case 4). When
such a token approaches its fabricated expiry it reaches the refresh path,
which can only fail — deleting the session and forcing a needless
re-authentication (#26141). Instead of failing, re-stamp the local expiry
in place so the session keeps renewing itself.
Returns the (mutated) token dict when it was flagged long-lived, else
``None`` so the caller falls through to its normal no-refresh-token
handling (tokens with a real provider-declared expiry are left to expire).
"""
if not token_data.get(NON_REFRESHABLE_TOKEN_MARKER):
return None
token_data['expires_at'] = int(datetime.now().timestamp() + NON_REFRESHABLE_TOKEN_EXPIRY_SECONDS)
log.debug(f'Renewing long-lived non-refreshable token for session {session_id}')
return token_data
FERNET = None
if len(OAUTH_CLIENT_INFO_ENCRYPTION_KEY) != 44:
@ -1070,6 +1098,9 @@ class OAuthClientManager:
token_data = session.token
if not token_data.get('refresh_token'):
renewed = _renew_non_refreshable_token(token_data, session.id)
if renewed is not None:
return renewed
log.warning(f'No refresh token available for session {session.id}')
return None
@ -1349,6 +1380,9 @@ class OAuthManager:
auth_config = await get_oauth_runtime_config()
if not token_data.get('refresh_token'):
renewed = _renew_non_refreshable_token(token_data, session.id)
if renewed is not None:
return renewed
log.warning(f'No refresh token available for session {session.id}')
return None