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
This commit is contained in:
Classic298 2026-09-23 15:46:31 +02:00 • committed by GitHub
parent d66e5dc403
commit 9db1a518d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -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.