Skip to content

Commit be4340e

Browse files
committed
httpd: add OIDC option to read claims from the UserInfo endpoint
With query_userinfo enabled, the UserInfo endpoint is queried after authentication and the user claims are read from both sources. Claims from the verified ID token take precedence on conflicts, empty values do not override, and the UserInfo subject must match the ID token subject. The option is validated at startup against the provider metadata. Reading the claims from the UserInfo endpoint was requested in #2265 and an implementation, enabled by default and with the opposite claim precedence, was proposed in #2266. Fixes #2265 Closes #2266 Suggested-by: TEC <git@tecosaur.net> Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
1 parent 270f3fc commit be4340e

6 files changed

Lines changed: 351 additions & 0 deletions

File tree

internal/config/config.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ var (
133133
ImplicitRoles: false,
134134
Scopes: []string{"openid", "profile", "email"},
135135
CustomFields: []string{},
136+
QueryUserInfo: false,
136137
InsecureSkipSignatureCheck: false,
137138
Debug: false,
138139
},
@@ -1676,6 +1677,12 @@ func getHTTPDOIDCFromEnv(idx int) (httpd.OIDC, bool) {
16761677
isSet = true
16771678
}
16781679

1680+
queryUserInfo, ok := lookupBoolFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__OIDC__QUERY_USERINFO", idx))
1681+
if ok {
1682+
result.QueryUserInfo = queryUserInfo
1683+
isSet = true
1684+
}
1685+
16791686
skipSignatureCheck, ok := lookupBoolFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__OIDC__INSECURE_SKIP_SIGNATURE_CHECK", idx))
16801687
if ok {
16811688
result.InsecureSkipSignatureCheck = skipSignatureCheck

internal/config/config_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1222,6 +1222,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
12221222
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__IMPLICIT_ROLES", "1")
12231223
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__CUSTOM_FIELDS", "field1,field2")
12241224
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__INSECURE_SKIP_SIGNATURE_CHECK", "1")
1225+
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__QUERY_USERINFO", "1")
12251226
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__DEBUG", "1")
12261227
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__ENABLED", "true")
12271228
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__ALLOWED_HOSTS", "*.example.com,*.example.net")
@@ -1294,6 +1295,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
12941295
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__IMPLICIT_ROLES")
12951296
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__CUSTOM_FIELDS")
12961297
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__INSECURE_SKIP_SIGNATURE_CHECK")
1298+
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__QUERY_USERINFO")
12971299
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__DEBUG")
12981300
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__ENABLED")
12991301
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__ALLOWED_HOSTS")
@@ -1353,6 +1355,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
13531355
require.Equal(t, 0, bindings[0].ClientIPHeaderDepth)
13541356
require.Len(t, bindings[0].OIDC.Scopes, 3)
13551357
require.False(t, bindings[0].OIDC.InsecureSkipSignatureCheck)
1358+
require.False(t, bindings[0].OIDC.QueryUserInfo)
13561359
require.False(t, bindings[0].OIDC.Debug)
13571360
require.Empty(t, bindings[0].Security.ReferrerPolicy)
13581361
require.Equal(t, 8000, bindings[1].Port)
@@ -1373,6 +1376,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
13731376
require.Empty(t, bindings[1].OIDC.ClientID)
13741377
require.Len(t, bindings[1].OIDC.Scopes, 3)
13751378
require.False(t, bindings[1].OIDC.InsecureSkipSignatureCheck)
1379+
require.False(t, bindings[1].OIDC.QueryUserInfo)
13761380
require.False(t, bindings[1].OIDC.Debug)
13771381
require.False(t, bindings[1].Security.Enabled)
13781382
require.Equal(t, "Web Admin", bindings[1].Branding.WebAdmin.Name)
@@ -1420,6 +1424,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
14201424
require.Equal(t, "field1", bindings[2].OIDC.CustomFields[0])
14211425
require.Equal(t, "field2", bindings[2].OIDC.CustomFields[1])
14221426
require.True(t, bindings[2].OIDC.InsecureSkipSignatureCheck)
1427+
require.True(t, bindings[2].OIDC.QueryUserInfo)
14231428
require.True(t, bindings[2].OIDC.Debug)
14241429
require.True(t, bindings[2].Security.Enabled)
14251430
require.Len(t, bindings[2].Security.AllowedHosts, 2)

internal/httpd/httpd_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27677,10 +27677,23 @@ func startOIDCMockServer() {
2767727677
http.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
2767827678
fmt.Fprintf(w, "OK\n")
2767927679
})
27680+
http.HandleFunc("/auth/realms/sftpgo/protocol/openid-connect/userinfo", func(w http.ResponseWriter, r *http.Request) {
27681+
w.Header().Set("Content-Type", "application/json")
27682+
if r.Header.Get("Authorization") == "Bearer 500" {
27683+
w.WriteHeader(http.StatusInternalServerError)
27684+
return
27685+
}
27686+
fmt.Fprintf(w, `{"sub":"123","preferred_username":"oidc_user","email":"example@example.com","sftpgo_role":"admin"}`)
27687+
})
2768027688
http.HandleFunc("/auth/realms/sftpgo/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
2768127689
w.Header().Set("Content-Type", "application/json")
2768227690
fmt.Fprintf(w, `{"issuer":"http://127.0.0.1:11111/auth/realms/sftpgo","authorization_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/auth","token_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/token","introspection_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/token/introspect","userinfo_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/userinfo","end_session_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/logout","frontchannel_logout_session_supported":true,"frontchannel_logout_supported":true,"jwks_uri":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/certs","check_session_iframe":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/login-status-iframe.html","grant_types_supported":["authorization_code","implicit","refresh_token","password","client_credentials","urn:ietf:params:oauth:grant-type:device_code","urn:openid:params:grant-type:ciba"],"response_types_supported":["code","none","id_token","token","id_token token","code id_token","code token","code id_token token"],"subject_types_supported":["public","pairwise"],"id_token_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"id_token_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"id_token_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"userinfo_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512","none"],"request_object_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512","none"],"request_object_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"request_object_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"response_modes_supported":["query","fragment","form_post","query.jwt","fragment.jwt","form_post.jwt","jwt"],"registration_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/clients-registrations/openid-connect","token_endpoint_auth_methods_supported":["private_key_jwt","client_secret_basic","client_secret_post","tls_client_auth","client_secret_jwt"],"token_endpoint_auth_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"introspection_endpoint_auth_methods_supported":["private_key_jwt","client_secret_basic","client_secret_post","tls_client_auth","client_secret_jwt"],"introspection_endpoint_auth_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"authorization_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"authorization_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"authorization_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"claims_supported":["aud","sub","iss","auth_time","name","given_name","family_name","preferred_username","email","acr"],"claim_types_supported":["normal"],"claims_parameter_supported":true,"scopes_supported":["openid","phone","email","web-origins","offline_access","microprofile-jwt","profile","address","roles"],"request_parameter_supported":true,"request_uri_parameter_supported":true,"require_request_uri_registration":true,"code_challenge_methods_supported":["plain","S256"],"tls_client_certificate_bound_access_tokens":true,"revocation_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/revoke","revocation_endpoint_auth_methods_supported":["private_key_jwt","client_secret_basic","client_secret_post","tls_client_auth","client_secret_jwt"],"revocation_endpoint_auth_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"backchannel_logout_supported":true,"backchannel_logout_session_supported":true,"device_authorization_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/auth/device","backchannel_token_delivery_modes_supported":["poll","ping"],"backchannel_authentication_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/ext/ciba/auth","backchannel_authentication_request_signing_alg_values_supported":["PS384","ES384","RS384","ES256","RS256","ES512","PS256","PS512","RS512"],"require_pushed_authorization_requests":false,"pushed_authorization_request_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/ext/par/request","mtls_endpoint_aliases":{"token_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/token","revocation_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/revoke","introspection_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/token/introspect","device_authorization_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/auth/device","registration_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/clients-registrations/openid-connect","userinfo_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/userinfo","pushed_authorization_request_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/ext/par/request","backchannel_authentication_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/ext/ciba/auth"}}`)
2768327691
})
27692+
// realm without a userinfo endpoint
27693+
http.HandleFunc("/auth/realms/sftpgo2/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
27694+
w.Header().Set("Content-Type", "application/json")
27695+
fmt.Fprintf(w, `{"issuer":"http://127.0.0.1:11111/auth/realms/sftpgo2","authorization_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo2/protocol/openid-connect/auth","token_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo2/protocol/openid-connect/token","jwks_uri":"http://127.0.0.1:11111/auth/realms/sftpgo2/protocol/openid-connect/certs","response_types_supported":["code"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256"]}`)
27696+
})
2768427697
http.HandleFunc("/404", func(w http.ResponseWriter, _ *http.Request) {
2768527698
w.WriteHeader(http.StatusNotFound)
2768627699
fmt.Fprintf(w, "Not found\n")

internal/httpd/oidc.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,11 @@ type OIDC struct {
9595
Scopes []string `json:"scopes" mapstructure:"scopes"`
9696
// Custom token claims fields to pass to the pre-login hook
9797
CustomFields []string `json:"custom_fields" mapstructure:"custom_fields"`
98+
// QueryUserInfo defines whether to query the UserInfo endpoint after
99+
// authentication and read the user claims from both sources.
100+
// ID token claims take precedence over UserInfo claims with the same name.
101+
// The UserInfo subject must match the ID token subject.
102+
QueryUserInfo bool `json:"query_userinfo" mapstructure:"query_userinfo"`
98103
// InsecureSkipSignatureCheck causes SFTPGo to skip JWT signature validation.
99104
// It's intended for special cases where providers, such as Azure, use the "none"
100105
// algorithm. Skipping the signature validation can cause security issues
@@ -172,6 +177,9 @@ func (o *OIDC) initialize() error {
172177
logger.Debug(logSender, "", "oidc end session endpoint %q", o.providerLogoutURL)
173178
}
174179
}
180+
if o.QueryUserInfo && provider.UserInfoEndpoint() == "" {
181+
return errors.New("oidc: query_userinfo is enabled but the provider has no userinfo endpoint")
182+
}
175183
o.provider = provider
176184
o.verifier = nil
177185
o.oauth2Config = &oauth2.Config{
@@ -284,6 +292,42 @@ func (t *oidcToken) parseClaims(claims map[string]any, usernameField, roleField
284292
return nil
285293
}
286294

295+
// mergeOIDCClaims returns the UserInfo claims overlaid with the verified ID
296+
// token claims, so the signed token wins on conflicts. Claims describing the
297+
// authentication event are read from the ID token only. Null, empty string
298+
// and empty array values are dropped from both sources: OIDC Core 5.3.2
299+
// requires omitting unreturned claims instead of setting them to null or empty.
300+
func mergeOIDCClaims(idTokenClaims, userInfoClaims map[string]any) map[string]any {
301+
merged := make(map[string]any, len(idTokenClaims)+len(userInfoClaims))
302+
for k, v := range userInfoClaims {
303+
switch k {
304+
case "sid", "auth_time", "nonce":
305+
continue
306+
}
307+
if isEmptyOIDCClaim(v) {
308+
continue
309+
}
310+
merged[k] = v
311+
}
312+
for k, v := range idTokenClaims {
313+
if isEmptyOIDCClaim(v) {
314+
continue
315+
}
316+
merged[k] = v
317+
}
318+
return merged
319+
}
320+
321+
func isEmptyOIDCClaim(v any) bool {
322+
if v == nil || v == "" {
323+
return true
324+
}
325+
if s, ok := v.([]any); ok && len(s) == 0 {
326+
return true
327+
}
328+
return false
329+
}
330+
287331
func (t *oidcToken) getRoleFromField(claims map[string]any, roleField string) {
288332
role, ok := getOIDCFieldFromClaims(claims, roleField)
289333
if ok {
@@ -710,6 +754,36 @@ func (s *httpdServer) handleOIDCRedirect(w http.ResponseWriter, r *http.Request)
710754
return
711755
}
712756
s.debugTokenClaims(claims, rawIDToken)
757+
if s.binding.OIDC.QueryUserInfo {
758+
userInfo, err := s.binding.OIDC.provider.UserInfo(ctx, oauth2.StaticTokenSource(oauth2Token))
759+
if err != nil {
760+
logger.Warn(logSender, "", "unable to query the user info endpoint: %v", err)
761+
setFlashMessage(w, r, newFlashMessage("Unable to query the OpenID user info endpoint", util.I18nOIDCErrTokenExchange))
762+
doRedirect()
763+
doLogout(rawIDToken)
764+
return
765+
}
766+
if userInfo.Subject != idToken.Subject {
767+
logger.Debug(logSender, "", "user info subject %q does not match the id token subject %q",
768+
userInfo.Subject, idToken.Subject)
769+
setFlashMessage(w, r, newFlashMessage("User info subject does not match the ID token subject", util.I18nOIDCTokenInvalid))
770+
doRedirect()
771+
doLogout(rawIDToken)
772+
return
773+
}
774+
userInfoClaims := make(map[string]any)
775+
if err := userInfo.Claims(&userInfoClaims); err != nil {
776+
logger.Debug(logSender, "", "unable to get user info claims: %v", err)
777+
setFlashMessage(w, r, newFlashMessage("Unable to get OpenID user info claims", util.I18nOIDCTokenInvalid))
778+
doRedirect()
779+
doLogout(rawIDToken)
780+
return
781+
}
782+
claims = mergeOIDCClaims(claims, userInfoClaims)
783+
if s.binding.OIDC.Debug {
784+
logger.Debug(logSender, "", "claims after user info merge %+v", claims)
785+
}
786+
}
713787
token := oidcToken{
714788
AccessToken: oauth2Token.AccessToken,
715789
TokenType: oauth2Token.TokenType,

0 commit comments

Comments
 (0)