From 9db1a518d3e43b05c3bb9a94e3d0b01cbeabed3d Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:46:31 +0200 Subject: [PATCH] fix: refresh an expiring OAuth token once when requests race (#30426) With an OIDC provider that rotates refresh tokens, sending a chat to a system_oauth connection often logged the user's OAuth session out. Two requests reached the refresh at the same time and both sent the same refresh token. The provider rejected the second one with invalid_grant, and Open WebUI deleted the session, so every following request lost its token until the user logged in again. Refreshes now take a per-session lock. A request that waited for another one re-reads the session and uses the token that was just stored, so the provider sees one refresh per rotation. Tested with real sqlite sessions and a rotating mock provider: 2 and 5 concurrent callers now cause 1 refresh, all callers get the new token and the session is kept (before: one refresh per caller, all callers got nothing, session deleted). Single refresh, failed refresh and valid-token paths are unchanged. The lock is per process, so deployments with several workers or replicas can still race across processes. Fixes #30416 --- backend/open_webui/utils/oauth.py | 35 ++++++++++++++++++------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index ca8e3a1d75..fc7bf7cabc 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -1333,6 +1333,7 @@ class OAuthManager: self.app = app self._clients = {} + self._refresh_locks: dict[str, asyncio.Lock] = {} for name, provider_config in OAUTH_PROVIDERS.items(): if 'register' not in provider_config: @@ -1428,23 +1429,29 @@ class OAuthManager: Returns: dict: Refreshed token data, or None if refresh failed """ - try: - # Perform the actual refresh - refreshed_token = await self._perform_token_refresh(session) + async with self._refresh_locks.setdefault(session.id, asyncio.Lock()): + # Another request may have refreshed while we waited; its refresh token is now spent + current_session = await OAuthSessions.get_session_by_id(session.id) + if current_session and current_session.token != session.token: + return current_session.token - if refreshed_token: - # Update the session with new token data - session = await OAuthSessions.update_session_by_id(session.id, refreshed_token) - log.info('Successfully refreshed token for session %s', session.id) - return session.token - else: - log.error(f'Failed to refresh token for session {session.id}') + try: + # Perform the actual refresh + refreshed_token = await self._perform_token_refresh(session) + + if refreshed_token: + # Update the session with new token data + session = await OAuthSessions.update_session_by_id(session.id, refreshed_token) + log.info('Successfully refreshed token for session %s', session.id) + return session.token + else: + log.error(f'Failed to refresh token for session {session.id}') + return None + + except Exception as e: + log.error(f'Error refreshing token for session {session.id}: {e}') return None - except Exception as e: - log.error(f'Error refreshing token for session {session.id}: {e}') - return None - async def _perform_token_refresh(self, session) -> dict: """ Perform the actual OAuth token refresh.