From 785f95679a2a5df12fa78130a24b15a608fe48ca Mon Sep 17 00:00:00 2001 From: Sasha Mitchell Date: Sat, 8 Aug 2026 05:43:28 +0700 Subject: [PATCH] fix(oauth): require provider-verified email before merging accounts by email The merge-by-email path bound a new (provider, sub) identity to an existing account looked up by email claim alone, on both the OAuth callback (utils/oauth.py) and the token-exchange endpoint (routers/auths.py). An identity provider that lets users assert arbitrary unverified email addresses could hand any matching account, including admin accounts, to an attacker. Track provider verification during email extraction (OIDC email_verified claim in bool or string form, GitHub /user/emails per-address verified flag) and refuse the merge when the provider explicitly reports the email as unverified: the callback redirects with the standard invalid-credentials error, and token exchange returns 403. Providers that send no verification signal keep current behavior. Rebased and extended after #28191 was closed: the same merge-by-email path exists in the token-exchange endpoint; this commit covers both. --- backend/open_webui/routers/auths.py | 14 +++++++++++++ backend/open_webui/utils/oauth.py | 31 +++++++++++++++++++++++++---- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 240c761931..3327dcfb62 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -1669,6 +1669,20 @@ async def token_exchange( user = await Users.get_user_by_oauth_sub(provider, sub, db=db) if not user and await Config.get('oauth.merge_accounts_by_email'): + # Never merge on an email the provider reports as unverified: an IdP + # that lets users claim arbitrary addresses would otherwise hand over + # the matching existing account (account takeover). Same gate as the + # OAuth callback path. Providers that send no verification signal + # keep current behavior. + email_verified = user_data.get('email_verified') + if isinstance(email_verified, str): + email_verified = email_verified.lower() == 'true' + if email_verified is False: + log.warning(f'Token exchange denied: refusing to merge account on unverified email') + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) # Try to find by email if merge is enabled user = await Users.get_user_by_email(email, db=db) if user: diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 265067390a..224ff62e77 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -1848,6 +1848,10 @@ class OAuthManager: # Email extraction email_claim = auth_config.OAUTH_EMAIL_CLAIM email = user_data.get(email_claim, '') + # Whether the provider vouches for the email address. None means + # the provider gave no verification signal; set by the claim + # check below or by the GitHub /user/emails fallback. + email_verified = None # We currently mandate that email addresses are provided if not email: # If the provider is GitHub,and public email is not provided, we can use the access token to fetch the user's email @@ -1864,12 +1868,15 @@ class OAuthManager: if resp.ok: emails = await resp.json() # use the primary email as the user's email - primary_email = next( - (e['email'] for e in emails if e.get('primary')), + primary_entry = next( + (e for e in emails if e.get('primary')), None, ) - if primary_email: - email = primary_email + if primary_entry: + email = primary_entry['email'] + # GitHub reports per-address verification; keep it for + # the account-merge gate below + email_verified = bool(primary_entry.get('verified')) else: log.warning('No primary email found in GitHub response') raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) @@ -1885,6 +1892,15 @@ class OAuthManager: log.warning(f'OAuth callback failed, email is missing: {user_data}') raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) + # OIDC providers send an `email_verified` claim; the GitHub + # fallback above sets email_verified from the /user/emails entry. + if email_verified is None: + verified_claim = user_data.get('email_verified') + if isinstance(verified_claim, bool): + email_verified = verified_claim + elif isinstance(verified_claim, str): + email_verified = verified_claim.lower() == 'true' + email = email.lower() # If allowed domains are configured, check if the email domain is in the list if ( @@ -1899,6 +1915,13 @@ class OAuthManager: if not user: # If the user does not exist, check if merging is enabled if auth_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL: + # Never merge on an email the provider reports as + # unverified: an IdP that lets users claim arbitrary + # addresses would otherwise hand over the matching + # existing account (account takeover) + if email_verified is False: + log.warning(f'OAuth callback failed, refusing to merge account on unverified email: {email}') + raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) # Check if the user exists by email user = await Users.get_user_by_email(email, db=db) if user: