Skip to content

Commit 413da74

Browse files
committed
feat: add email_verified mapping to SSO attribute configuration
1 parent a41f58f commit 413da74

5 files changed

Lines changed: 62 additions & 27 deletions

File tree

internal/database/schema/sso.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS sso_providers (
2020
auto_provision_users BOOLEAN DEFAULT FALSE, -- Create users on first SSO login
2121
require_verified_email BOOLEAN DEFAULT TRUE, -- Require email_verified=true from IdP (security)
2222
-- Claim/attribute mappings (JSON for flexibility)
23-
attribute_mapping TEXT DEFAULT '{"email":"email","name":"name","given_name":"given_name","family_name":"family_name","username":"preferred_username"}',
23+
attribute_mapping TEXT DEFAULT '{"email":"email","name":"name","given_name":"given_name","family_name":"family_name","username":"preferred_username","email_verified":"email_verified"}',
2424
-- SAML-specific fields
2525
saml_idp_metadata_url TEXT, -- IdP metadata URL for auto-configuration
2626
saml_idp_sso_url TEXT, -- IdP Single Sign-On URL

internal/database/schema/sso_postgres.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS sso_providers (
2020
auto_provision_users BOOLEAN DEFAULT FALSE, -- Create users on first SSO login
2121
require_verified_email BOOLEAN DEFAULT TRUE, -- Require email_verified=true from IdP (security)
2222
-- Claim/attribute mappings (JSON for flexibility)
23-
attribute_mapping TEXT DEFAULT '{"email":"email","name":"name","given_name":"given_name","family_name":"family_name","username":"preferred_username"}',
23+
attribute_mapping TEXT DEFAULT '{"email":"email","name":"name","given_name":"given_name","family_name":"family_name","username":"preferred_username","email_verified":"email_verified"}',
2424
-- SAML-specific fields
2525
saml_idp_metadata_url TEXT, -- IdP metadata URL for auto-configuration
2626
saml_idp_sso_url TEXT, -- IdP Single Sign-On URL

internal/handlers/sso_saml.go

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"log/slog"
77
"net/http"
8+
"strconv"
89
"strings"
910
"time"
1011

@@ -305,11 +306,12 @@ func (h *SSOHandler) samlAssertionToClaims(info *sso.SAMLAssertionInfo, provider
305306
attrMap, _ := provider.GetAttributeMap()
306307
if attrMap == nil {
307308
attrMap = &sso.AttributeMap{
308-
Email: "email",
309-
Name: "name",
310-
GivenName: "given_name",
311-
FamilyName: "family_name",
312-
Username: "preferred_username",
309+
Email: "email",
310+
EmailVerified: "email_verified",
311+
Name: "name",
312+
GivenName: "given_name",
313+
FamilyName: "family_name",
314+
Username: "preferred_username",
313315
}
314316
}
315317

@@ -367,9 +369,19 @@ func (h *SSOHandler) samlAssertionToClaims(info *sso.SAMLAssertionInfo, provider
367369
claims.Username = strings.Split(claims.Email, "@")[0]
368370
}
369371

370-
// SAML lacks a standard verified-email claim. Provider trust decides whether
371-
// the asserted email can auto-link an existing account.
372+
// SAML lacks a standard verified-email claim. If the IdP asserts the mapped
373+
// attribute, honour it; otherwise provider trust decides whether the
374+
// asserted email can auto-link an existing account.
372375
claims.EmailVerifiedProvided = true
376+
if attrMap.EmailVerified != "" {
377+
if v := info.GetAttribute(attrMap.EmailVerified); v != "" {
378+
// Any value that isn't a recognisable "true" means not verified.
379+
if verified, err := strconv.ParseBool(strings.TrimSpace(v)); err == nil {
380+
claims.EmailVerified = verified
381+
}
382+
return claims
383+
}
384+
}
373385
claims.EmailVerified = provider.RequireVerifiedEmail
374386

375387
return claims

internal/sso/oidc.go

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"fmt"
77
"net/http"
8+
"strconv"
89
"strings"
910
"time"
1011

@@ -181,15 +182,21 @@ func (s *OIDCService) ExtractClaims(tokens *oidc.Tokens[*oidc.IDTokenClaims], at
181182
claims.Email = idTokenClaims.Email
182183
}
183184

184-
// Check if email_verified was explicitly provided by the IdP
185-
// The zitadel/oidc library returns a special type that defaults to false
186-
// We need to check the raw claims to know if it was actually provided
187-
if allClaims != nil {
188-
if _, exists := allClaims["email_verified"]; exists {
189-
claims.EmailVerifiedProvided = true
190-
}
185+
// Check if email_verified was explicitly provided by the IdP.
186+
// The claim name is configurable via the attribute mapping; it falls back
187+
// to the standard OIDC "email_verified" claim. The zitadel/oidc library
188+
// returns a special type that defaults to false, so we inspect the raw
189+
// claims to know whether the IdP actually sent a value.
190+
emailVerifiedClaim := "email_verified"
191+
if attributeMap != nil && attributeMap.EmailVerified != "" {
192+
emailVerifiedClaim = attributeMap.EmailVerified
193+
}
194+
if emailVerifiedValue, exists := allClaims[emailVerifiedClaim]; exists {
195+
claims.EmailVerifiedProvided = true
196+
claims.EmailVerified = claimToBool(emailVerifiedValue)
197+
} else if emailVerifiedClaim == "email_verified" {
198+
claims.EmailVerified = bool(idTokenClaims.EmailVerified)
191199
}
192-
claims.EmailVerified = bool(idTokenClaims.EmailVerified)
193200

194201
// Extract name fields using attribute mapping or standard claims
195202
if attributeMap != nil {
@@ -274,3 +281,17 @@ func getClaimString(claims map[string]any, key string) (string, bool) {
274281
}
275282
return "", false
276283
}
284+
285+
// claimToBool coerces a raw claim value to a bool. IdPs normally send a JSON
286+
// boolean for email_verified, but some send the string "true"/"false".
287+
func claimToBool(v any) bool {
288+
switch val := v.(type) {
289+
case bool:
290+
return val
291+
case string:
292+
b, err := strconv.ParseBool(strings.TrimSpace(val))
293+
return err == nil && b
294+
default:
295+
return false
296+
}
297+
}

internal/sso/provider.go

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -127,22 +127,24 @@ func scanProviderNoSecret(row interface {
127127

128128
// AttributeMap represents the claim/attribute mapping configuration
129129
type AttributeMap struct {
130-
Email string `json:"email"`
131-
Name string `json:"name"`
132-
GivenName string `json:"given_name"`
133-
FamilyName string `json:"family_name"`
134-
Username string `json:"username"`
130+
Email string `json:"email"`
131+
EmailVerified string `json:"email_verified"`
132+
Name string `json:"name"`
133+
GivenName string `json:"given_name"`
134+
FamilyName string `json:"family_name"`
135+
Username string `json:"username"`
135136
}
136137

137138
// GetAttributeMap parses the attribute mapping JSON
138139
func (p *SSOProvider) GetAttributeMap() (*AttributeMap, error) {
139140
if p.AttributeMapping == "" {
140141
return &AttributeMap{
141-
Email: "email",
142-
Name: "name",
143-
GivenName: "given_name",
144-
FamilyName: "family_name",
145-
Username: "preferred_username",
142+
Email: "email",
143+
EmailVerified: "email_verified",
144+
Name: "name",
145+
GivenName: "given_name",
146+
FamilyName: "family_name",
147+
Username: "preferred_username",
146148
}, nil
147149
}
148150

0 commit comments

Comments
 (0)