Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/database/schema/sso.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/database/schema/sso_postgres.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 19 additions & 7 deletions internal/handlers/sso_saml.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"log/slog"
"net/http"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -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",
}
}

Expand Down Expand Up @@ -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
Expand Down
37 changes: 29 additions & 8 deletions internal/sso/oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
}
22 changes: 12 additions & 10 deletions internal/sso/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Loading