From b8472c1b8003bf83d1edf82bdc5e282f83e0621b Mon Sep 17 00:00:00 2001 From: SashaMIT Date: Thu, 6 Aug 2026 04:03:41 +0700 Subject: [PATCH] feat: add opt-in OAUTH_MERGE_REFUSE_UNVERIFIED_EMAIL to refuse merging on provider-reported unverified emails When OAUTH_MERGE_ACCOUNTS_BY_EMAIL is enabled, the callback binds a new (provider, sub) identity to an existing account looked up by the email claim alone. If the identity provider explicitly reports that email as unverified (OIDC email_verified=false, or verified=false on the GitHub /user/emails entry the code already fetches), an IdP that lets users assert arbitrary addresses would hand over the matching existing account. The new flag (default off - zero behavior change) refuses the merge with a 400 in exactly that case. Providers that send no verification signal at all are unaffected either way, so deployments relying on merge for IdPs without the claim concept keep working. Signed-off-by: SashaMIT Co-authored-by: Cursor --- backend/open_webui/config.py | 7 ++++++ backend/open_webui/utils/oauth.py | 40 +++++++++++++++++++++++++++++-- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 365544e80c..f741158105 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -2464,6 +2464,12 @@ OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE = os.getenv('OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE OAUTH_MERGE_ACCOUNTS_BY_EMAIL = os.getenv('OAUTH_MERGE_ACCOUNTS_BY_EMAIL', 'False').lower() == 'true' +# When merging OAuth accounts by email is enabled, refuse the merge if the +# identity provider explicitly reports the email address as unverified +# (email_verified=false claim, or GitHub's /user/emails verified flag). +# Providers that send no verification signal are unaffected. Off by default. +OAUTH_MERGE_REFUSE_UNVERIFIED_EMAIL = os.getenv('OAUTH_MERGE_REFUSE_UNVERIFIED_EMAIL', 'False').lower() == 'true' + OAUTH_PROVIDERS = {} GOOGLE_CLIENT_ID = os.getenv('GOOGLE_CLIENT_ID', '') @@ -3125,6 +3131,7 @@ DEFAULT_CONFIG = { 'oauth.auto_redirect': OAUTH_AUTO_REDIRECT, 'oauth.refresh_token.include_scope': OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE, 'oauth.merge_accounts_by_email': OAUTH_MERGE_ACCOUNTS_BY_EMAIL, + 'oauth.merge_refuse_unverified_email': OAUTH_MERGE_REFUSE_UNVERIFIED_EMAIL, 'oauth.google.client_id': GOOGLE_CLIENT_ID, 'oauth.google.client_secret': GOOGLE_CLIENT_SECRET, 'oauth.google.scope': GOOGLE_OAUTH_SCOPE, diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 265067390a..859e87d517 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -53,6 +53,7 @@ from open_webui.config import ( OAUTH_GROUPS_CLAIM, OAUTH_GROUPS_SEPARATOR, OAUTH_MERGE_ACCOUNTS_BY_EMAIL, + OAUTH_MERGE_REFUSE_UNVERIFIED_EMAIL, OAUTH_PICTURE_CLAIM, OAUTH_PROVIDERS, OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE, @@ -131,6 +132,11 @@ OAUTH_RUNTIME_CONFIG = { 'OAUTH_MERGE_ACCOUNTS_BY_EMAIL': ( 'oauth.merge_accounts_by_email', OAUTH_MERGE_ACCOUNTS_BY_EMAIL, + OAUTH_MERGE_REFUSE_UNVERIFIED_EMAIL, + ), + 'OAUTH_MERGE_REFUSE_UNVERIFIED_EMAIL': ( + 'oauth.merge_refuse_unverified_email', + OAUTH_MERGE_REFUSE_UNVERIFIED_EMAIL, ), 'ENABLE_OAUTH_ROLE_MANAGEMENT': ( 'oauth.enable_role_mapping', @@ -1848,6 +1854,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 from the GitHub + # /user/emails entry below or from the email_verified claim. + 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 +1874,16 @@ 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, ) + primary_email = ( + primary_entry.get('email') if primary_entry else None + ) if primary_email: email = primary_email + 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 +1899,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 when they track + # verification; the GitHub fallback above already set it. + 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 +1922,19 @@ class OAuthManager: if not user: # If the user does not exist, check if merging is enabled if auth_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL: + # Opt-in hardening: never merge on an email the provider + # explicitly reports as unverified. An IdP that lets users + # assert arbitrary addresses would otherwise hand over the + # matching existing account (account takeover). Providers + # that send no verification signal are unaffected. + if ( + auth_config.OAUTH_MERGE_REFUSE_UNVERIFIED_EMAIL + and 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: