@@ -280,6 +280,43 @@ async def should_allow_email_for_org(
280280 return normalized_domain in active_domains
281281
282282
283+ def _extract_candidate_emails (parser : SAMLParser ) -> list [str ]:
284+ """Extract candidate emails from known SAML attributes in priority order."""
285+ candidates = [
286+ parser .get_attribute_value ("email" ),
287+ # Okta
288+ parser .get_attribute_value (
289+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
290+ ),
291+ # Microsoft Entra ID (prefer explicit email over UPN/name)
292+ parser .get_attribute_value (
293+ "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"
294+ ),
295+ parser .get_attribute_value (
296+ "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"
297+ ),
298+ ]
299+ seen : set [str ] = set ()
300+ deduped : list [str ] = []
301+ for value in candidates :
302+ email = value .strip ()
303+ if not email or email in seen :
304+ continue
305+ seen .add (email )
306+ deduped .append (email )
307+ return deduped
308+
309+
310+ async def _select_allowlisted_email (
311+ session : AsyncSession , organization_id : OrganizationID , candidates : list [str ]
312+ ) -> str | None :
313+ """Return the first candidate that satisfies org-domain allowlist."""
314+ for candidate in candidates :
315+ if await should_allow_email_for_org (session , organization_id , candidate ):
316+ return candidate
317+ return None
318+
319+
283320async def create_saml_client (
284321 saml_idp_metadata_url : str ,
285322) -> Saml2Client :
@@ -607,23 +644,8 @@ async def sso_acs(
607644 logger .info ("SAML response validated successfully" )
608645
609646 parser = SAMLParser (str (authn_response ))
610-
611- email = (
612- parser .get_attribute_value ("email" )
613- # Okta
614- or parser .get_attribute_value (
615- "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
616- )
617- # Microsoft Entra ID
618- or parser .get_attribute_value (
619- "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"
620- )
621- or parser .get_attribute_value (
622- "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"
623- )
624- )
625-
626- if not email :
647+ candidate_emails = _extract_candidate_emails (parser )
648+ if not candidate_emails :
627649 attributes = parser .attributes or {}
628650 logger .error (
629651 f"Expected attribute 'email' in the SAML response, but got { len (attributes )} attributes"
@@ -633,7 +655,10 @@ async def sso_acs(
633655 detail = "Authentication failed" ,
634656 )
635657
636- if not await should_allow_email_for_org (db_session , organization_id , email ):
658+ email = await _select_allowlisted_email (
659+ db_session , organization_id , candidate_emails
660+ )
661+ if email is None :
637662 logger .warning ("SAML login denied by org domain allowlist" )
638663 raise HTTPException (
639664 status_code = status .HTTP_403_FORBIDDEN ,
0 commit comments