diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 9f917dab36..0c28ff4a04 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -2230,7 +2230,6 @@ class OAuthManager: sessions via Redis, and deletes their OAuth sessions. Returns a JSONResponse per the OIDC Back-Channel Logout 1.0 spec. """ - import jwt as pyjwt from fastapi.responses import JSONResponse # 1. Extract logout_token from form body @@ -2248,7 +2247,7 @@ class OAuthManager: # 2. Peek at unverified issuer to match against configured providers try: - unverified_claims = pyjwt.decode(logout_token, options={'verify_signature': False}) + unverified_claims = jwt.decode(logout_token, options={'verify_signature': False}) token_issuer = unverified_claims.get('iss') except Exception as e: log.warning(f'Back-channel logout: cannot decode logout_token: {e}') @@ -2265,35 +2264,27 @@ class OAuthManager: # 3. Find the configured provider whose issuer matches the token matched_provider = None - matched_client_id = None + matched_client = None matched_jwks_uri = None - matched_issuer = None for provider_name in OAUTH_PROVIDERS: - server_metadata_url = self.get_server_metadata_url(provider_name) - if not server_metadata_url: + client = self.get_client(provider_name) + if not client: continue try: - async with aiohttp.ClientSession(trust_env=True) as session: - async with session.get(server_metadata_url, ssl=AIOHTTP_CLIENT_SESSION_SSL) as r: - if r.status != 200: - continue - oidc_config = await r.json() - - provider_issuer = oidc_config.get('issuer') - if provider_issuer and provider_issuer == token_issuer: - client = self.get_client(provider_name) - matched_provider = provider_name - matched_client_id = client.client_id if client else None - matched_jwks_uri = oidc_config.get('jwks_uri') - matched_issuer = provider_issuer - break + oidc_config = await client.load_server_metadata() except Exception as e: log.debug('Back-channel logout: error checking provider %s: %s', provider_name, e) continue - if not matched_provider or not matched_client_id or not matched_jwks_uri: + if oidc_config.get('issuer') == token_issuer: + matched_provider = provider_name + matched_client = client + matched_jwks_uri = oidc_config.get('jwks_uri') + break + + if not matched_provider or not matched_client or not matched_client.client_id or not matched_jwks_uri: log.warning(f'Back-channel logout: no configured provider matches issuer {token_issuer}') return JSONResponse( status_code=400, @@ -2305,20 +2296,37 @@ class OAuthManager: # 4. Validate the logout_token signature and claims try: - jwks_client = pyjwt.PyJWKClient(matched_jwks_uri) - signing_key = jwks_client.get_signing_key_from_jwt(logout_token) + token_kid = jwt.get_unverified_header(logout_token).get('kid') + if not token_kid: + raise jwt.InvalidTokenError('logout_token missing kid header') - claims = pyjwt.decode( + try: + jwk_set = jwt.PyJWKSet.from_dict(await matched_client.fetch_jwk_set()) + except jwt.PyJWTError as e: + raise jwt.InvalidTokenError(str(e)) + + signing_key = next( + ( + key + for key in jwk_set.keys + if key.key_id == token_kid and key.public_key_use in ['sig', None] + ), + None, + ) + if not signing_key: + raise jwt.InvalidTokenError('no signing key matches the token kid') + + claims = jwt.decode( logout_token, signing_key.key, algorithms=['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512'], - audience=matched_client_id, - issuer=matched_issuer, + audience=matched_client.client_id, + issuer=token_issuer, options={ 'require': ['iss', 'aud', 'iat', 'events'], }, ) - except pyjwt.InvalidTokenError as e: + except jwt.InvalidTokenError as e: log.warning(f'Back-channel logout: invalid logout_token: {e}') return JSONResponse( status_code=400,