From c055203f29881a6e80b1681123d51dbd7b9079af Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:56:18 +0200 Subject: [PATCH] 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 --- backend/open_webui/utils/oauth.py | 32 ++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 91d76d819d..293df9ae4b 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -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