diff --git a/internal/database/schema/sso.sql b/internal/database/schema/sso.sql index 2dd3f78c..ac8d8043 100644 --- a/internal/database/schema/sso.sql +++ b/internal/database/schema/sso.sql @@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS sso_providers ( auto_provision_users BOOLEAN DEFAULT FALSE, -- Create users on first SSO login require_verified_email BOOLEAN DEFAULT TRUE, -- Require email_verified=true from IdP (security) -- Claim/attribute mappings (JSON for flexibility) - attribute_mapping TEXT DEFAULT '{"email":"email","name":"name","given_name":"given_name","family_name":"family_name","username":"preferred_username"}', + attribute_mapping TEXT DEFAULT '{"email":"email","name":"name","given_name":"given_name","family_name":"family_name","username":"preferred_username","email_verified":"email_verified"}', -- SAML-specific fields saml_idp_metadata_url TEXT, -- IdP metadata URL for auto-configuration saml_idp_sso_url TEXT, -- IdP Single Sign-On URL diff --git a/internal/database/schema/sso_postgres.sql b/internal/database/schema/sso_postgres.sql index ec67121b..db2b8583 100644 --- a/internal/database/schema/sso_postgres.sql +++ b/internal/database/schema/sso_postgres.sql @@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS sso_providers ( auto_provision_users BOOLEAN DEFAULT FALSE, -- Create users on first SSO login require_verified_email BOOLEAN DEFAULT TRUE, -- Require email_verified=true from IdP (security) -- Claim/attribute mappings (JSON for flexibility) - attribute_mapping TEXT DEFAULT '{"email":"email","name":"name","given_name":"given_name","family_name":"family_name","username":"preferred_username"}', + attribute_mapping TEXT DEFAULT '{"email":"email","name":"name","given_name":"given_name","family_name":"family_name","username":"preferred_username","email_verified":"email_verified"}', -- SAML-specific fields saml_idp_metadata_url TEXT, -- IdP metadata URL for auto-configuration saml_idp_sso_url TEXT, -- IdP Single Sign-On URL diff --git a/internal/handlers/sso_saml.go b/internal/handlers/sso_saml.go index b80d0b86..e1d0784a 100644 --- a/internal/handlers/sso_saml.go +++ b/internal/handlers/sso_saml.go @@ -5,6 +5,7 @@ import ( "errors" "log/slog" "net/http" + "strconv" "strings" "time" @@ -305,11 +306,12 @@ func (h *SSOHandler) samlAssertionToClaims(info *sso.SAMLAssertionInfo, provider attrMap, _ := provider.GetAttributeMap() if attrMap == nil { attrMap = &sso.AttributeMap{ - Email: "email", - Name: "name", - GivenName: "given_name", - FamilyName: "family_name", - Username: "preferred_username", + Email: "email", + EmailVerified: "email_verified", + Name: "name", + GivenName: "given_name", + FamilyName: "family_name", + Username: "preferred_username", } } @@ -367,9 +369,19 @@ func (h *SSOHandler) samlAssertionToClaims(info *sso.SAMLAssertionInfo, provider claims.Username = strings.Split(claims.Email, "@")[0] } - // SAML lacks a standard verified-email claim. Provider trust decides whether - // the asserted email can auto-link an existing account. + // SAML lacks a standard verified-email claim. If the IdP asserts the mapped + // attribute, honour it; otherwise provider trust decides whether the + // asserted email can auto-link an existing account. claims.EmailVerifiedProvided = true + if attrMap.EmailVerified != "" { + if v := info.GetAttribute(attrMap.EmailVerified); v != "" { + // Any value that isn't a recognisable "true" means not verified. + if verified, err := strconv.ParseBool(strings.TrimSpace(v)); err == nil { + claims.EmailVerified = verified + } + return claims + } + } claims.EmailVerified = provider.RequireVerifiedEmail return claims diff --git a/internal/sso/oidc.go b/internal/sso/oidc.go index 04601f5a..774b6458 100644 --- a/internal/sso/oidc.go +++ b/internal/sso/oidc.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "strconv" "strings" "time" @@ -181,15 +182,21 @@ func (s *OIDCService) ExtractClaims(tokens *oidc.Tokens[*oidc.IDTokenClaims], at claims.Email = idTokenClaims.Email } - // Check if email_verified was explicitly provided by the IdP - // The zitadel/oidc library returns a special type that defaults to false - // We need to check the raw claims to know if it was actually provided - if allClaims != nil { - if _, exists := allClaims["email_verified"]; exists { - claims.EmailVerifiedProvided = true - } + // Check if email_verified was explicitly provided by the IdP. + // The claim name is configurable via the attribute mapping; it falls back + // to the standard OIDC "email_verified" claim. The zitadel/oidc library + // returns a special type that defaults to false, so we inspect the raw + // claims to know whether the IdP actually sent a value. + emailVerifiedClaim := "email_verified" + if attributeMap != nil && attributeMap.EmailVerified != "" { + emailVerifiedClaim = attributeMap.EmailVerified + } + if emailVerifiedValue, exists := allClaims[emailVerifiedClaim]; exists { + claims.EmailVerifiedProvided = true + claims.EmailVerified = claimToBool(emailVerifiedValue) + } else if emailVerifiedClaim == "email_verified" { + claims.EmailVerified = bool(idTokenClaims.EmailVerified) } - claims.EmailVerified = bool(idTokenClaims.EmailVerified) // Extract name fields using attribute mapping or standard claims if attributeMap != nil { @@ -274,3 +281,17 @@ func getClaimString(claims map[string]any, key string) (string, bool) { } return "", false } + +// claimToBool coerces a raw claim value to a bool. IdPs normally send a JSON +// boolean for email_verified, but some send the string "true"/"false". +func claimToBool(v any) bool { + switch val := v.(type) { + case bool: + return val + case string: + b, err := strconv.ParseBool(strings.TrimSpace(val)) + return err == nil && b + default: + return false + } +} diff --git a/internal/sso/provider.go b/internal/sso/provider.go index c76365b4..7f2c71f9 100644 --- a/internal/sso/provider.go +++ b/internal/sso/provider.go @@ -127,22 +127,24 @@ func scanProviderNoSecret(row interface { // AttributeMap represents the claim/attribute mapping configuration type AttributeMap struct { - Email string `json:"email"` - Name string `json:"name"` - GivenName string `json:"given_name"` - FamilyName string `json:"family_name"` - Username string `json:"username"` + Email string `json:"email"` + EmailVerified string `json:"email_verified"` + Name string `json:"name"` + GivenName string `json:"given_name"` + FamilyName string `json:"family_name"` + Username string `json:"username"` } // GetAttributeMap parses the attribute mapping JSON func (p *SSOProvider) GetAttributeMap() (*AttributeMap, error) { if p.AttributeMapping == "" { return &AttributeMap{ - Email: "email", - Name: "name", - GivenName: "given_name", - FamilyName: "family_name", - Username: "preferred_username", + Email: "email", + EmailVerified: "email_verified", + Name: "name", + GivenName: "given_name", + FamilyName: "family_name", + Username: "preferred_username", }, nil }