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.
This commit is contained in:
Sasha Mitchell 2026-08-08 05:43:28 +07:00
parent 8faaf2cd1e
commit 785f95679a
2 changed files with 41 additions and 4 deletions

View file

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

View file

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