feat(sso): map AD FS profile claims from validated ID tokens (#3572)

AD FS userinfo returns only sub; profile attributes live in the ID
token as upn/unique_name/given_name/family_name. Extract those claims
alongside the standard set on both token and userinfo payloads, and
resolve email across email/upn/unique_name candidates — accepting
upn/unique_name only when email-shaped since DOMAIN\user forms cannot
serve as addresses. Display name falls back to given_name+family_name,
nick to preferred_username/unique_name before the email local part.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
pull/3582/head
Tomas Dvorak 2 weeks ago
parent 1188662067
commit 2b812d8447

@ -21,6 +21,12 @@ type OIDCIDTokenClaims struct {
UpdatedAt int64 `json:"updated_at,omitempty"` UpdatedAt int64 `json:"updated_at,omitempty"`
Email string `json:"email,omitempty"` Email string `json:"email,omitempty"`
EmailVerified bool `json:"email_verified,omitempty"` EmailVerified bool `json:"email_verified,omitempty"`
// AD FS and similar providers emit these instead of the standard
// profile claims; their userinfo endpoints often return only sub.
UPN string `json:"upn,omitempty"`
UniqueName string `json:"unique_name,omitempty"`
GivenName string `json:"given_name,omitempty"`
FamilyName string `json:"family_name,omitempty"`
} }
type JWKSet struct { type JWKSet struct {

@ -43,6 +43,11 @@ type OIDCUserInfo struct {
Picture string `json:"picture,omitempty"` Picture string `json:"picture,omitempty"`
Email string `json:"email,omitempty"` Email string `json:"email,omitempty"`
EmailVerified *bool `json:"email_verified,omitempty"` EmailVerified *bool `json:"email_verified,omitempty"`
// AD FS-style alternates, mirrored from the ID-token claims.
UPN string `json:"upn,omitempty"`
UniqueName string `json:"unique_name,omitempty"`
GivenName string `json:"given_name,omitempty"`
FamilyName string `json:"family_name,omitempty"`
} }
// Validate checks the discovery document has the endpoints the code flow requires. // Validate checks the discovery document has the endpoints the code flow requires.

@ -89,6 +89,23 @@ func TestVerifyOIDCIDToken(t *testing.T) {
_, err := VerifyOIDCIDToken("not-a-jwt", "https://idp.example.com", "cloudreve", "nonce-1", jwks) _, err := VerifyOIDCIDToken("not-a-jwt", "https://idp.example.com", "cloudreve", "nonce-1", jwks)
require.Error(t, err) require.Error(t, err)
}) })
t.Run("adfs profile claims", func(t *testing.T) {
claims := validClaims()
claims.Email = ""
claims.UPN = "user@corp.example.com"
claims.UniqueName = `CORP\user`
claims.GivenName = "Jane"
claims.FamilyName = "Doe"
claims.Name = "Jane Doe"
token := signConsumerToken(t, pemKey, claims)
parsed, err := VerifyOIDCIDToken(token, "https://idp.example.com", "cloudreve", "nonce-1", jwks)
require.NoError(t, err)
require.Equal(t, "user@corp.example.com", parsed.UPN)
require.Equal(t, `CORP\user`, parsed.UniqueName)
require.Equal(t, "Jane", parsed.GivenName)
require.Equal(t, "Doe", parsed.FamilyName)
})
} }
func TestOIDCDiscoveryValidate(t *testing.T) { func TestOIDCDiscoveryValidate(t *testing.T) {

@ -170,9 +170,18 @@ func (service *SSOCallbackService) SSOCallback(c *gin.Context) {
return return
} }
email := strings.ToLower(strings.TrimSpace(claims.Email)) // Profile claims resolve across standard and AD FS-style alternates;
// AD FS userinfo returns only sub, so the ID token is the primary
// source for name/email/upn (#3572).
email := firstEmailClaim(claims.Email, claims.UPN, claims.UniqueName)
name := claims.Name name := claims.Name
if name == "" {
name = strings.TrimSpace(claims.GivenName + " " + claims.FamilyName)
}
preferred := claims.PreferredUsername preferred := claims.PreferredUsername
if preferred == "" {
preferred = claims.UniqueName
}
// Some providers omit email from the ID token; fall back to userinfo. // Some providers omit email from the ID token; fall back to userinfo.
if email == "" && tokens.AccessToken != "" && discovery.UserinfoEndpoint != "" { if email == "" && tokens.AccessToken != "" && discovery.UserinfoEndpoint != "" {
@ -180,12 +189,18 @@ func (service *SSOCallbackService) SSOCallback(c *gin.Context) {
if err != nil { if err != nil {
dep.Logger().Warning("SSO userinfo request failed: %s", err) dep.Logger().Warning("SSO userinfo request failed: %s", err)
} else { } else {
email = strings.ToLower(strings.TrimSpace(info.Email)) email = firstEmailClaim(info.Email, info.UPN, info.UniqueName)
if name == "" { if name == "" {
name = info.Name name = info.Name
if name == "" {
name = strings.TrimSpace(info.GivenName + " " + info.FamilyName)
}
} }
if preferred == "" { if preferred == "" {
preferred = info.PreferredUsername preferred = info.PreferredUsername
if preferred == "" {
preferred = info.UniqueName
}
} }
} }
} }
@ -582,3 +597,16 @@ func fetchOIDCUserInfo(c *gin.Context, client request.Client, endpoint, accessTo
return &info, nil return &info, nil
} }
// firstEmailClaim returns the first candidate usable as an email address.
// AD FS upn/unique_name are accepted only when email-shaped, since
// DOMAIN\user and non-routable identifiers cannot serve as addresses.
func firstEmailClaim(candidates ...string) string {
for _, c := range candidates {
c = strings.ToLower(strings.TrimSpace(c))
if strings.Contains(c, "@") {
return c
}
}
return ""
}

@ -135,3 +135,11 @@ func TestSanitizeSSORedirect(t *testing.T) {
require.Equal(t, "/home", sanitizeSSORedirect("/home")) require.Equal(t, "/home", sanitizeSSORedirect("/home"))
require.Equal(t, "/share/s/abc", sanitizeSSORedirect("/share/s/abc")) require.Equal(t, "/share/s/abc", sanitizeSSORedirect("/share/s/abc"))
} }
func TestFirstEmailClaim(t *testing.T) {
require.Equal(t, "user@corp.com", firstEmailClaim("User@Corp.com"))
require.Equal(t, "user@corp.com", firstEmailClaim(" User@Corp.COM "))
require.Equal(t, "user@corp.com", firstEmailClaim("", `CORP\user`, "user@corp.com"))
require.Equal(t, "", firstEmailClaim("", `CORP\user`, "not-an-email"))
require.Equal(t, "", firstEmailClaim())
}

Loading…
Cancel
Save