diff --git a/cmd/sts-handlers.go b/cmd/sts-handlers.go index 1633af95c..90a47e29f 100644 --- a/cmd/sts-handlers.go +++ b/cmd/sts-handlers.go @@ -39,15 +39,16 @@ import ( const ( // STS API version. - stsAPIVersion = "2011-06-15" - stsVersion = "Version" - stsAction = "Action" - stsPolicy = "Policy" - stsToken = "Token" - stsWebIdentityToken = "WebIdentityToken" - stsDurationSeconds = "DurationSeconds" - stsLDAPUsername = "LDAPUsername" - stsLDAPPassword = "LDAPPassword" + stsAPIVersion = "2011-06-15" + stsVersion = "Version" + stsAction = "Action" + stsPolicy = "Policy" + stsToken = "Token" + stsWebIdentityToken = "WebIdentityToken" + stsWebIdentityAccessToken = "WebIdentityAccessToken" // only valid if UserInfo is enabled. + stsDurationSeconds = "DurationSeconds" + stsLDAPUsername = "LDAPUsername" + stsLDAPPassword = "LDAPPassword" // STS API action constants clientGrants = "AssumeRoleWithClientGrants" @@ -336,7 +337,9 @@ func (sts *stsAPIHandlers) AssumeRoleWithSSO(w http.ResponseWriter, r *http.Requ token = r.Form.Get(stsWebIdentityToken) } - m, err := v.Validate(token, r.Form.Get(stsDurationSeconds)) + accessToken := r.Form.Get(stsWebIdentityAccessToken) + + m, err := v.Validate(token, accessToken, r.Form.Get(stsDurationSeconds)) if err != nil { switch err { case openid.ErrTokenExpired: diff --git a/docs/sts/web-identity.md b/docs/sts/web-identity.md index 32c106424..2d4079548 100644 --- a/docs/sts/web-identity.md +++ b/docs/sts/web-identity.md @@ -16,6 +16,14 @@ The OAuth 2.0 id_token that is provided by the web identity provider. Applicatio | *Length Constraints* | *Minimum length of 4. Maximum length of 2048.* | | *Required* | *Yes* | +### WebIdentityAccessToken (MinIO Extension) +There are situations when identity provider does not provide user claims in `id_token` instead it needs to be retrieved from UserInfo endpoint, this extension is only useful in this scenario. This is rare so use it accordingly depending on your Identity provider implementation. `access_token` is available as part of the OIDC authentication flow similar to `id_token`. + +| Params | Value | +| :-- | :-- | +| *Type* | *String* | +| *Required* | *No* | + ### Version Indicates STS API version information, the only supported value is '2011-06-15'. This value is borrowed from AWS STS API documentation for compatibility reasons. diff --git a/internal/config/identity/openid/help.go b/internal/config/identity/openid/help.go index a36b22d98..134816d18 100644 --- a/internal/config/identity/openid/help.go +++ b/internal/config/identity/openid/help.go @@ -44,15 +44,21 @@ var ( Optional: true, Type: "string", }, + config.HelpKV{ + Key: ClaimUserinfo, + Description: `Enable fetching claims from UserInfo Endpoint for authenticated user`, + Optional: true, + Type: "on|off", + }, config.HelpKV{ Key: ClaimPrefix, - Description: `JWT claim namespace prefix e.g. "customer1/"`, + Description: `[DEPRECATED use 'claim_name'] JWT claim namespace prefix e.g. "customer1/"`, Optional: true, Type: "string", }, config.HelpKV{ Key: RedirectURI, - Description: `Configure custom redirect_uri for OpenID login flow callback`, + Description: `[DEPRECATED use env 'MINIO_BROWSER_REDIRECT_URL'] Configure custom redirect_uri for OpenID login flow callback`, Optional: true, Type: "string", }, diff --git a/internal/config/identity/openid/jwt.go b/internal/config/identity/openid/jwt.go index 5a137d460..abbfaba95 100644 --- a/internal/config/identity/openid/jwt.go +++ b/internal/config/identity/openid/jwt.go @@ -47,13 +47,14 @@ type Config struct { JWKS struct { URL *xnet.URL `json:"url"` } `json:"jwks"` - URL *xnet.URL `json:"url,omitempty"` - ClaimPrefix string `json:"claimPrefix,omitempty"` - ClaimName string `json:"claimName,omitempty"` - RedirectURI string `json:"redirectURI,omitempty"` - DiscoveryDoc DiscoveryDoc - ClientID string - ClientSecret string + URL *xnet.URL `json:"url,omitempty"` + ClaimPrefix string `json:"claimPrefix,omitempty"` + ClaimName string `json:"claimName,omitempty"` + ClaimUserinfo bool `json:"claimUserInfo,omitempty"` + RedirectURI string `json:"redirectURI,omitempty"` + DiscoveryDoc DiscoveryDoc + ClientID string + ClientSecret string provider provider.Provider publicKeys map[string]crypto.PublicKey @@ -61,6 +62,63 @@ type Config struct { closeRespFn func(io.ReadCloser) } +// UserInfo returns claims for authenticated user from userInfo endpoint. +// +// Some OIDC implementations such as GitLab do not support +// claims as part of the normal oauth2 flow, instead rely +// on service providers making calls to IDP to fetch additional +// claims available from the UserInfo endpoint +func (r *Config) UserInfo(accessToken string) (map[string]interface{}, error) { + if r.JWKS.URL == nil || r.JWKS.URL.String() == "" { + return nil, errors.New("openid not configured") + } + transport := http.DefaultTransport + if r.transport != nil { + transport = r.transport + } + client := &http.Client{ + Transport: transport, + } + + req, err := http.NewRequest(http.MethodPost, r.DiscoveryDoc.UserInfoEndpoint, nil) + if err != nil { + return nil, err + } + + if accessToken != "" { + req.Header.Set("Authorization", "Bearer "+accessToken) + } + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + + defer r.closeRespFn(resp.Body) + if resp.StatusCode != http.StatusOK { + // uncomment this for debugging when needed. + // reqBytes, _ := httputil.DumpRequest(req, false) + // fmt.Println(string(reqBytes)) + // respBytes, _ := httputil.DumpResponse(resp, true) + // fmt.Println(string(respBytes)) + return nil, errors.New(resp.Status) + } + + dec := json.NewDecoder(resp.Body) + var claims = map[string]interface{}{} + + if err = dec.Decode(&claims); err != nil { + // uncomment this for debugging when needed. + // reqBytes, _ := httputil.DumpRequest(req, false) + // fmt.Println(string(reqBytes)) + // respBytes, _ := httputil.DumpResponse(resp, true) + // fmt.Println(string(respBytes)) + return nil, err + } + + return claims, nil +} + // LookupUser lookup userid for the provider func (r Config) LookupUser(userid string) (provider.User, error) { if r.provider != nil { @@ -122,9 +180,6 @@ func (r *Config) InitializeKeycloakProvider(adminURL, realm string) error { // PopulatePublicKey - populates a new publickey from the JWKS URL. func (r *Config) PopulatePublicKey() error { - r.Lock() - defer r.Unlock() - if r.JWKS.URL == nil || r.JWKS.URL.String() == "" { return nil } @@ -135,6 +190,10 @@ func (r *Config) PopulatePublicKey() error { client := &http.Client{ Transport: transport, } + + r.Lock() + defer r.Unlock() + resp, err := client.Get(r.JWKS.URL.String()) if err != nil { return err @@ -234,7 +293,7 @@ func updateClaimsExpiry(dsecs string, claims map[string]interface{}) error { } // Validate - validates the id_token. -func (r *Config) Validate(token, dsecs string) (map[string]interface{}, error) { +func (r *Config) Validate(token, accessToken, dsecs string) (map[string]interface{}, error) { jp := new(jwtgo.Parser) jp.ValidMethods = []string{ "RS256", "RS384", "RS512", "ES256", "ES384", "ES512", @@ -273,6 +332,23 @@ func (r *Config) Validate(token, dsecs string) (map[string]interface{}, error) { return nil, err } + // If claim user info is enabled, get claims from userInfo + // and overwrite them with the claims from JWT. + if r.ClaimUserinfo { + if accessToken == "" { + return nil, errors.New("access_token is mandatory if user_info claim is enabled") + } + uclaims, err := r.UserInfo(accessToken) + if err != nil { + return nil, err + } + for k, v := range uclaims { + if _, ok := claims[k]; !ok { // only add to claims not update it. + claims[k] = v + } + } + } + return claims, nil } @@ -283,29 +359,32 @@ func (Config) ID() ID { // OpenID keys and envs. const ( - JwksURL = "jwks_url" - ConfigURL = "config_url" - ClaimName = "claim_name" - ClaimPrefix = "claim_prefix" - ClientID = "client_id" - ClientSecret = "client_secret" - Vendor = "vendor" - Scopes = "scopes" - RedirectURI = "redirect_uri" + JwksURL = "jwks_url" + ConfigURL = "config_url" + ClaimName = "claim_name" + ClaimUserinfo = "claim_userinfo" + ClaimPrefix = "claim_prefix" + ClientID = "client_id" + ClientSecret = "client_secret" + + Vendor = "vendor" + Scopes = "scopes" + RedirectURI = "redirect_uri" // Vendor specific ENV only enabled if the Vendor matches == "vendor" KeyCloakRealm = "keycloak_realm" KeyCloakAdminURL = "keycloak_admin_url" - EnvIdentityOpenIDVendor = "MINIO_IDENTITY_OPENID_VENDOR" - EnvIdentityOpenIDClientID = "MINIO_IDENTITY_OPENID_CLIENT_ID" - EnvIdentityOpenIDClientSecret = "MINIO_IDENTITY_OPENID_CLIENT_SECRET" - EnvIdentityOpenIDJWKSURL = "MINIO_IDENTITY_OPENID_JWKS_URL" - EnvIdentityOpenIDURL = "MINIO_IDENTITY_OPENID_CONFIG_URL" - EnvIdentityOpenIDClaimName = "MINIO_IDENTITY_OPENID_CLAIM_NAME" - EnvIdentityOpenIDClaimPrefix = "MINIO_IDENTITY_OPENID_CLAIM_PREFIX" - EnvIdentityOpenIDRedirectURI = "MINIO_IDENTITY_OPENID_REDIRECT_URI" - EnvIdentityOpenIDScopes = "MINIO_IDENTITY_OPENID_SCOPES" + EnvIdentityOpenIDVendor = "MINIO_IDENTITY_OPENID_VENDOR" + EnvIdentityOpenIDClientID = "MINIO_IDENTITY_OPENID_CLIENT_ID" + EnvIdentityOpenIDClientSecret = "MINIO_IDENTITY_OPENID_CLIENT_SECRET" + EnvIdentityOpenIDJWKSURL = "MINIO_IDENTITY_OPENID_JWKS_URL" + EnvIdentityOpenIDURL = "MINIO_IDENTITY_OPENID_CONFIG_URL" + EnvIdentityOpenIDClaimName = "MINIO_IDENTITY_OPENID_CLAIM_NAME" + EnvIdentityOpenIDClaimUserInfo = "MINIO_IDENTITY_OPENID_CLAIM_USERINFO" + EnvIdentityOpenIDClaimPrefix = "MINIO_IDENTITY_OPENID_CLAIM_PREFIX" + EnvIdentityOpenIDRedirectURI = "MINIO_IDENTITY_OPENID_REDIRECT_URI" + EnvIdentityOpenIDScopes = "MINIO_IDENTITY_OPENID_SCOPES" // Vendor specific ENVs only enabled if the Vendor matches == "vendor" EnvIdentityOpenIDKeyCloakRealm = "MINIO_IDENTITY_OPENID_KEYCLOAK_REALM" @@ -374,6 +453,10 @@ var ( Key: ClaimName, Value: iampolicy.PolicyName, }, + config.KV{ + Key: ClaimUserinfo, + Value: "", + }, config.KV{ Key: ClaimPrefix, Value: "", @@ -410,15 +493,16 @@ func LookupConfig(kvs config.KVS, transport *http.Transport, closeRespFn func(io } c = Config{ - RWMutex: &sync.RWMutex{}, - ClaimName: env.Get(EnvIdentityOpenIDClaimName, kvs.Get(ClaimName)), - ClaimPrefix: env.Get(EnvIdentityOpenIDClaimPrefix, kvs.Get(ClaimPrefix)), - RedirectURI: env.Get(EnvIdentityOpenIDRedirectURI, kvs.Get(RedirectURI)), - publicKeys: make(map[string]crypto.PublicKey), - ClientID: env.Get(EnvIdentityOpenIDClientID, kvs.Get(ClientID)), - ClientSecret: env.Get(EnvIdentityOpenIDClientSecret, kvs.Get(ClientSecret)), - transport: transport, - closeRespFn: closeRespFn, + RWMutex: &sync.RWMutex{}, + ClaimName: env.Get(EnvIdentityOpenIDClaimName, kvs.Get(ClaimName)), + ClaimUserinfo: env.Get(EnvIdentityOpenIDClaimUserInfo, kvs.Get(ClaimUserinfo)) == config.EnableOn, + ClaimPrefix: env.Get(EnvIdentityOpenIDClaimPrefix, kvs.Get(ClaimPrefix)), + RedirectURI: env.Get(EnvIdentityOpenIDRedirectURI, kvs.Get(RedirectURI)), + publicKeys: make(map[string]crypto.PublicKey), + ClientID: env.Get(EnvIdentityOpenIDClientID, kvs.Get(ClientID)), + ClientSecret: env.Get(EnvIdentityOpenIDClientSecret, kvs.Get(ClientSecret)), + transport: transport, + closeRespFn: closeRespFn, } configURL := env.Get(EnvIdentityOpenIDURL, kvs.Get(ConfigURL)) @@ -433,6 +517,10 @@ func LookupConfig(kvs config.KVS, transport *http.Transport, closeRespFn func(io } } + if c.ClaimUserinfo && configURL == "" { + return c, errors.New("please specify config_url to enable fetching claims from UserInfo endpoint") + } + if scopeList := env.Get(EnvIdentityOpenIDScopes, kvs.Get(Scopes)); scopeList != "" { var scopes []string for _, scope := range strings.Split(scopeList, ",") { diff --git a/internal/config/identity/openid/jwt_test.go b/internal/config/identity/openid/jwt_test.go index c0697fa51..abc297aee 100644 --- a/internal/config/identity/openid/jwt_test.go +++ b/internal/config/identity/openid/jwt_test.go @@ -100,7 +100,7 @@ func TestJWTAzureFail(t *testing.T) { t.Fatalf("Unexpected id %s for the validator", cfg.ID()) } - if _, err := cfg.Validate(jwtToken, ""); err == nil { + if _, err := cfg.Validate(jwtToken, "", ""); err == nil { // Azure should fail due to non OIDC compliant JWT // generated by Azure AD t.Fatal(err) @@ -154,7 +154,7 @@ func TestJWT(t *testing.T) { t.Fatal(err) } - if _, err := cfg.Validate(u.Query().Get("Token"), ""); err == nil { + if _, err := cfg.Validate(u.Query().Get("Token"), "", ""); err == nil { t.Fatal(err) } } diff --git a/internal/config/identity/openid/validators.go b/internal/config/identity/openid/validators.go index 4725b49bf..dae1ff621 100644 --- a/internal/config/identity/openid/validators.go +++ b/internal/config/identity/openid/validators.go @@ -31,7 +31,7 @@ type ID string type Validator interface { // Validate is a custom validator function for this provider, // each validation is authenticationType or provider specific. - Validate(token string, duration string) (map[string]interface{}, error) + Validate(idToken, accessToken, duration string) (map[string]interface{}, error) // ID returns provider name of this provider. ID() ID diff --git a/internal/config/identity/openid/validators_test.go b/internal/config/identity/openid/validators_test.go index eb8c7b5ff..0be8165da 100644 --- a/internal/config/identity/openid/validators_test.go +++ b/internal/config/identity/openid/validators_test.go @@ -27,7 +27,7 @@ import ( type errorValidator struct{} -func (e errorValidator) Validate(token, dsecs string) (map[string]interface{}, error) { +func (e errorValidator) Validate(idToken, accessToken, dsecs string) (map[string]interface{}, error) { return nil, ErrTokenExpired } @@ -79,7 +79,7 @@ func TestValidators(t *testing.T) { t.Fatal(err) } - if _, err = v.Validate("", ""); err != ErrTokenExpired { + if _, err = v.Validate("", "", ""); err != ErrTokenExpired { t.Fatalf("Expected error %s, got %s", ErrTokenExpired, err) }