diff --git a/pkg/auth/oidc.go b/pkg/auth/oidc.go index 8b935253..b73b41c8 100644 --- a/pkg/auth/oidc.go +++ b/pkg/auth/oidc.go @@ -21,6 +21,12 @@ type OIDCIDTokenClaims struct { UpdatedAt int64 `json:"updated_at,omitempty"` Email string `json:"email,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 { diff --git a/pkg/auth/oidc_consumer.go b/pkg/auth/oidc_consumer.go index fde768e9..145b0e05 100644 --- a/pkg/auth/oidc_consumer.go +++ b/pkg/auth/oidc_consumer.go @@ -43,6 +43,11 @@ type OIDCUserInfo struct { Picture string `json:"picture,omitempty"` Email string `json:"email,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. diff --git a/pkg/auth/oidc_consumer_test.go b/pkg/auth/oidc_consumer_test.go index 804e0f04..ab5c1499 100644 --- a/pkg/auth/oidc_consumer_test.go +++ b/pkg/auth/oidc_consumer_test.go @@ -89,6 +89,23 @@ func TestVerifyOIDCIDToken(t *testing.T) { _, err := VerifyOIDCIDToken("not-a-jwt", "https://idp.example.com", "cloudreve", "nonce-1", jwks) 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) { diff --git a/service/user/sso.go b/service/user/sso.go index 6784bd2b..5eebe730 100644 --- a/service/user/sso.go +++ b/service/user/sso.go @@ -170,9 +170,18 @@ func (service *SSOCallbackService) SSOCallback(c *gin.Context) { 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 + if name == "" { + name = strings.TrimSpace(claims.GivenName + " " + claims.FamilyName) + } preferred := claims.PreferredUsername + if preferred == "" { + preferred = claims.UniqueName + } // Some providers omit email from the ID token; fall back to userinfo. if email == "" && tokens.AccessToken != "" && discovery.UserinfoEndpoint != "" { @@ -180,12 +189,18 @@ func (service *SSOCallbackService) SSOCallback(c *gin.Context) { if err != nil { dep.Logger().Warning("SSO userinfo request failed: %s", err) } else { - email = strings.ToLower(strings.TrimSpace(info.Email)) + email = firstEmailClaim(info.Email, info.UPN, info.UniqueName) if name == "" { name = info.Name + if name == "" { + name = strings.TrimSpace(info.GivenName + " " + info.FamilyName) + } } if preferred == "" { 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 } + +// 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 "" +} diff --git a/service/user/sso_test.go b/service/user/sso_test.go index 2b0fbcdf..4091b2f2 100644 --- a/service/user/sso_test.go +++ b/service/user/sso_test.go @@ -135,3 +135,11 @@ func TestSanitizeSSORedirect(t *testing.T) { require.Equal(t, "/home", sanitizeSSORedirect("/home")) 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()) +}