mirror of
https://github.com/minio/minio.git
synced 2025-11-08 21:24:55 -05:00
add userinfo support for OpenID (#12469)
Some identity providers like GitLab do not provide information about group membership as part of the identity token claims. They only expose it via OIDC compatible '/oauth/userinfo' endpoint, as described in the OpenID Connect 1.0 sepcification. But this of course requires application to make sure to add additional accessToken, since idToken cannot be re-used to perform the same 'userinfo' call. This is why this is specialized requirement. Gitlab seems to be the only OpenID vendor that requires this support for the time being. fixes #12367
This commit is contained in:
@@ -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, ",") {
|
||||
|
||||
Reference in New Issue
Block a user