Skip to content

fix(identity): link an email-optional identity to a phone-only user - #2699

Open
hamodywe wants to merge 1 commit into
supabase:masterfrom
hamodywe:fix/link-email-optional-identity-to-phone-only-user
Open

fix(identity): link an email-optional identity to a phone-only user#2699
hamodywe wants to merge 1 commit into
supabase:masterfrom
hamodywe:fix/link-email-optional-identity-to-phone-only-user

Conversation

@hamodywe

Copy link
Copy Markdown

What

Linking an email-optional identity (for example an OIDC provider whose claim
carries no email) to a confirmed phone-only user currently fails with
422 email_not_confirmed, even though there is no email to confirm.

Fixes #2640.

Why it happens

In linkIdentityToUser (internal/api/identity.go), when the target user has
no primary email the code does:

if targetUser.GetEmail() == "" {
    targetUser.UpdateUserEmailFromIdentities(tx, autoconfirm) // may leave email empty
    if !userData.Metadata.EmailVerified {
        a.sendConfirmation(...)                 // <- sends a confirmation for a non-existent email
        return ..., ErrorCodeEmailNotConfirmed  // <- 422 to the caller
    }
    ...
}

UpdateUserEmailFromIdentities picks the highest-ranked identity's email, and an
email-optional identity contributes none — so the primary email is still empty
after the call. Because the OIDC claim also has email_verified = false, the
!EmailVerified branch runs and a confirmation email is "sent" for an address
that does not exist, and the link is rejected with email_not_confirmed.

#634 added support for providers without an email, but this linking path still
assumes an unverified email is always present.

The fix

Only run the email-confirmation logic when an email is actually present after
UpdateUserEmailFromIdentities:

if targetUser.GetEmail() != "" {
    if !userData.Metadata.EmailVerified {
        a.sendConfirmation(...)
        return ..., ErrorCodeEmailNotConfirmed
    }
    targetUser.Confirm(tx)
}

The anonymous→permanent transition stays outside that guard, so an anonymous
user that links a no-email identity is still de-anonymized. Behaviour is
unchanged whenever the linked identity does carry an email:

linked identity before after
verified email user email set + confirmed unchanged
unverified email email_not_confirmed (confirmation sent) unchanged
no email (email-optional) email_not_confirmed (spurious) linked, email stays empty

Tests

TestLinkIdentityToUserEmailOptional in internal/api/identity_test.go, two sub-cases:

  1. no email in the identity links without confirmation — the regression:
    a confirmed phone-only user links an email-less OIDC identity; asserts success,
    the phone is preserved, the email stays empty, and the identity is created.
  2. unverified email in the identity still requires confirmation — a control
    case proving the fix does not over-broaden: an identity that does carry an
    unverified email still returns email_not_confirmed.

Verification

  • go vet ./internal/api/ clean, go test -c ./internal/api/ (compiles the full
    suite incl. these tests) clean, gofmt clean.
  • Local run gap, disclosed honestly: internal/api tests are Postgres-backed
    (-p 1 -race) and this dev machine has no Docker/Postgres/cgo, so I could not
    execute the suite locally. The tests are written to the suite's existing
    patterns and are intended to run on CI's Postgres matrix. Expected result there:
    sub-case 1 passes with the fix and fails (422 email_not_confirmed) if
    identity.go is reverted — i.e. genuine regression coverage — and sub-case 2
    passes unchanged.
  • The control sub-case deliberately uses an email-less (anonymous) user rather
    than a phone-only one, because a phone-only user's empty-email phone identity
    makes UpdateUserEmailFromIdentities keep the empty email (its "an identity
    already uses this email" early-return matches "" == ""), so no email is
    promoted and there would be nothing to confirm — which is exactly the mechanism
    behind the bug this PR fixes.

Linking an email-optional identity (for example an OIDC provider whose
claim carries no email) to a confirmed phone-only user returned a
spurious 422 email_not_confirmed. UpdateUserEmailFromIdentities leaves
the primary email empty in that case, but the linking path still called
sendConfirmation and rejected the link with EmailNotConfirmed.

Guard the email-confirmation block on the resulting email being
non-empty, keeping the anonymous->permanent transition outside it so a
no-email link still de-anonymizes. Behaviour is unchanged when the
linked identity does carry an email (verified confirms, unverified still
requires confirmation).

Adds TestLinkIdentityToUserEmailOptional covering the no-email
regression and an unverified-email control case.

Fixes supabase#2640.
@hamodywe
hamodywe requested a review from a team as a code owner August 14, 2026 21:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

linkIdentity rejects phone-only users when an email-optional OIDC identity has no email

1 participant