From db173da216cac1ead4660cdf045f6f4d02940f77 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 09:31:05 +0000 Subject: [PATCH] fix: renew non-refreshable OAuth tokens instead of deleting the session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01JKLX4jqe4E868Zr43ZmzVC --- backend/open_webui/utils/oauth.py | 42 ++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 2846979bdd..4d44524bdb 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -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