From 1f4f278b206e6a2106f95694421d85db19b92fd1 Mon Sep 17 00:00:00 2001 From: libingtong Date: Sat, 8 Aug 2026 11:06:45 +0800 Subject: [PATCH] fix(identity): normalize blank email/phone to NULL before persisting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit identities.email and identities.phone are both `unique=True` and nullable. Postgres allows many NULLs in a unique index but only a single empty string, so writing "" makes the *second* identity that lacks the field fail with: duplicate key value violates unique constraint "ix_identities_email" ExternalUserInfo defaults email and mobile to "" (not None), and BaseAuthProvider._create_new_user passes them straight to find_or_create_identity, which forwards them to identity_dao.create_identity. Any SSO provider whose upstream directory does not expose an email address therefore works for exactly one user and then breaks for everyone after. Observed in production with an enterprise directory that returns neither email nor mobile for some members: the first such user signs in fine, the second gets a 500 from the OAuth callback. Normalizing at the entry point of find_or_create_identity covers both the lookup and the insert path. It also prevents "" from being used as a lookup key, which would otherwise merge every contact-less user into one identity — the function's own comment notes that only email and phone are authoritative ownership claims, and an empty string is not a claim. --- backend/app/services/registration_service.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/app/services/registration_service.py b/backend/app/services/registration_service.py index 521cea059..0bf8def12 100644 --- a/backend/app/services/registration_service.py +++ b/backend/app/services/registration_service.py @@ -109,6 +109,15 @@ async def find_or_create_identity( """ identity: Identity | None = None + # identities.email and .phone are both unique and nullable. Postgres allows + # many NULLs in a unique index but only a single empty string, so persisting + # "" makes the *second* contact-less identity fail with + # UniqueViolationError on ix_identities_email / ix_identities_phone. + # SSO providers pass ExternalUserInfo defaults ("") when the upstream + # directory exposes no address, so normalize before both lookup and insert. + email = (email or "").strip() or None + phone = (phone or "").strip() or None + # Match by email (primary ownership claim) if email: identity = await identity_dao.get_by_email(email)