diff --git a/internal/config/identity/openid/jwt.go b/internal/config/identity/openid/jwt.go
index 6448f7e4d..145fbc07c 100644
--- a/internal/config/identity/openid/jwt.go
+++ b/internal/config/identity/openid/jwt.go
@@ -1,4 +1,4 @@
-// Copyright (c) 2015-2021 MinIO, Inc.
+// Copyright (c) 2015-2022 MinIO, Inc.
//
// This file is part of MinIO Object Storage stack
//
@@ -19,34 +19,21 @@ package openid
import (
"crypto"
- "crypto/sha1"
- "crypto/sha256"
- "encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
- "strconv"
- "strings"
"sync"
"time"
jwtgo "github.com/golang-jwt/jwt/v4"
- "github.com/minio/madmin-go"
- "github.com/minio/minio-go/v7/pkg/set"
"github.com/minio/minio/internal/arn"
"github.com/minio/minio/internal/auth"
- "github.com/minio/minio/internal/config"
- "github.com/minio/minio/internal/config/identity/openid/provider"
- xhttp "github.com/minio/minio/internal/http"
- "github.com/minio/pkg/env"
iampolicy "github.com/minio/pkg/iam/policy"
xnet "github.com/minio/pkg/net"
)
-var errSingleProvider = config.Errorf("Only one OpenID provider can be configured if not using role policy mapping")
-
type publicKeys struct {
*sync.RWMutex
@@ -79,185 +66,6 @@ func (pk *publicKeys) get(kid string) crypto.PublicKey {
return pk.pkMap[kid]
}
-type providerCfg struct {
- // Used for user interface like console
- DisplayName string `json:"displayName,omitempty"`
-
- 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"`
- ClaimUserinfo bool `json:"claimUserInfo,omitempty"`
- RedirectURI string `json:"redirectURI,omitempty"`
- RedirectURIDynamic bool `json:"redirectURIDynamic"`
- DiscoveryDoc DiscoveryDoc
- ClientID string
- ClientSecret string
- RolePolicy string
-
- roleArn arn.ARN
- provider provider.Provider
-}
-
-// initializeProvider initializes if any additional vendor specific information
-// was provided, initialization will return an error initial login fails.
-func (p *providerCfg) initializeProvider(cfgGet func(string, string) string, transport http.RoundTripper) error {
- vendor := cfgGet(EnvIdentityOpenIDVendor, Vendor)
- if vendor == "" {
- return nil
- }
- var err error
- switch vendor {
- case keyCloakVendor:
- adminURL := cfgGet(EnvIdentityOpenIDKeyCloakAdminURL, KeyCloakAdminURL)
- realm := cfgGet(EnvIdentityOpenIDKeyCloakRealm, KeyCloakRealm)
- p.provider, err = provider.KeyCloak(
- provider.WithAdminURL(adminURL),
- provider.WithOpenIDConfig(provider.DiscoveryDoc(p.DiscoveryDoc)),
- provider.WithTransport(transport),
- provider.WithRealm(realm),
- )
- return err
- default:
- return fmt.Errorf("Unsupport vendor %s", keyCloakVendor)
- }
-}
-
-// 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 (p *providerCfg) UserInfo(accessToken string, transport http.RoundTripper) (map[string]interface{}, error) {
- if p.JWKS.URL == nil || p.JWKS.URL.String() == "" {
- return nil, errors.New("openid not configured")
- }
- client := &http.Client{
- Transport: transport,
- }
-
- req, err := http.NewRequest(http.MethodPost, p.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 xhttp.DrainBody(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)
- 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
-}
-
-// Config - OpenID Config
-// RSA authentication target arguments
-type Config struct {
- Enabled bool `json:"enabled"`
-
- // map of roleARN to providerCfg's
- arnProviderCfgsMap map[arn.ARN]*providerCfg
-
- // map of config names to providerCfg's
- ProviderCfgs map[string]*providerCfg
-
- pubKeys publicKeys
- roleArnPolicyMap map[arn.ARN]string
-
- transport http.RoundTripper
- closeRespFn func(io.ReadCloser)
-}
-
-// GetIAMPolicyClaimName - returns the policy claim name for the (at most one)
-// provider configured without a role policy.
-func (r *Config) GetIAMPolicyClaimName() string {
- pCfg, ok := r.arnProviderCfgsMap[DummyRoleARN]
- if !ok {
- return ""
- }
- return pCfg.ClaimPrefix + pCfg.ClaimName
-}
-
-// LookupUser lookup userid for the provider
-func (r Config) LookupUser(roleArn, userid string) (provider.User, error) {
- // Can safely ignore error here as empty or invalid ARNs will not be
- // mapped.
- arnVal, _ := arn.Parse(roleArn)
- pCfg, ok := r.arnProviderCfgsMap[arnVal]
- if ok {
- user, err := pCfg.provider.LookupUser(userid)
- if err != nil && err != provider.ErrAccessTokenExpired {
- return user, err
- }
- if err == provider.ErrAccessTokenExpired {
- if err = pCfg.provider.LoginWithClientID(pCfg.ClientID, pCfg.ClientSecret); err != nil {
- return user, err
- }
- user, err = pCfg.provider.LookupUser(userid)
- }
- return user, err
- }
- // Without any specific logic for a provider, all accounts
- // are always enabled.
- return provider.User{ID: userid, Enabled: true}, nil
-}
-
-const (
- keyCloakVendor = "keycloak"
-)
-
-// ProviderEnabled returns true if any vendor specific provider is enabled.
-func (r Config) ProviderEnabled() bool {
- if !r.Enabled {
- return false
- }
- for _, v := range r.arnProviderCfgsMap {
- if v.provider != nil {
- return true
- }
- }
- return false
-}
-
-// GetRoleInfo - returns ARN to policies map if a role policy based openID
-// provider is configured. Otherwise returns nil.
-func (r Config) GetRoleInfo() map[arn.ARN]string {
- for _, p := range r.arnProviderCfgsMap {
- if p.RolePolicy != "" {
- return r.roleArnPolicyMap
- }
- }
- return nil
-}
-
// PopulatePublicKey - populates a new publickey from the JWKS URL.
func (r *Config) PopulatePublicKey(arn arn.ARN) error {
pCfg := r.arnProviderCfgsMap[arn]
@@ -280,42 +88,6 @@ func (r *Config) PopulatePublicKey(arn arn.ARN) error {
return r.pubKeys.parseAndAdd(resp.Body)
}
-// UnmarshalJSON - decodes JSON data.
-func (r *Config) UnmarshalJSON(data []byte) error {
- // subtype to avoid recursive call to UnmarshalJSON()
- type subConfig Config
- var sr subConfig
-
- if err := json.Unmarshal(data, &sr); err != nil {
- return err
- }
-
- ar := Config(sr)
- *r = ar
- return nil
-}
-
-// GetDefaultExpiration - returns the expiration seconds expected.
-func GetDefaultExpiration(dsecs string) (time.Duration, error) {
- defaultExpiryDuration := time.Duration(60) * time.Minute // Defaults to 1hr.
- if dsecs != "" {
- expirySecs, err := strconv.ParseInt(dsecs, 10, 64)
- if err != nil {
- return 0, auth.ErrInvalidDuration
- }
-
- // The duration, in seconds, of the role session.
- // The value can range from 900 seconds (15 minutes)
- // up to 365 days.
- if expirySecs < 900 || expirySecs > 31536000 {
- return 0, auth.ErrInvalidDuration
- }
-
- defaultExpiryDuration = time.Duration(expirySecs) * time.Second
- }
- return defaultExpiryDuration, nil
-}
-
// ErrTokenExpired - error token expired
var (
ErrTokenExpired = errors.New("token expired")
@@ -461,86 +233,6 @@ func (r *Config) updateUserinfoClaims(arn arn.ARN, accessToken string, claims ma
return nil
}
-// GetSettings - fetches OIDC settings for site-replication related validation.
-// NOTE that region must be populated by caller as this package does not know.
-func (r *Config) GetSettings() madmin.OpenIDSettings {
- res := madmin.OpenIDSettings{}
- if !r.Enabled {
- return res
- }
-
- for arn, provCfg := range r.arnProviderCfgsMap {
- hashedSecret := ""
- {
- h := sha256.New()
- h.Write([]byte(provCfg.ClientSecret))
- bs := h.Sum(nil)
- hashedSecret = base64.RawURLEncoding.EncodeToString(bs)
- }
- if arn != DummyRoleARN {
- if res.Roles != nil {
- res.Roles = make(map[string]madmin.OpenIDProviderSettings)
- }
- res.Roles[arn.String()] = madmin.OpenIDProviderSettings{
- ClaimUserinfoEnabled: provCfg.ClaimUserinfo,
- RolePolicy: provCfg.RolePolicy,
- ClientID: provCfg.ClientID,
- HashedClientSecret: hashedSecret,
- }
- } else {
- res.ClaimProvider = madmin.OpenIDProviderSettings{
- ClaimUserinfoEnabled: provCfg.ClaimUserinfo,
- RolePolicy: provCfg.RolePolicy,
- ClientID: provCfg.ClientID,
- HashedClientSecret: hashedSecret,
- }
- }
-
- }
-
- return res
-}
-
-// OpenID keys and envs.
-const (
- JwksURL = "jwks_url"
- ConfigURL = "config_url"
- ClaimName = "claim_name"
- ClaimUserinfo = "claim_userinfo"
- ClaimPrefix = "claim_prefix"
- ClientID = "client_id"
- ClientSecret = "client_secret"
- RolePolicy = "role_policy"
- DisplayName = "display_name"
-
- Vendor = "vendor"
- Scopes = "scopes"
- RedirectURI = "redirect_uri"
- RedirectURIDynamic = "redirect_uri_dynamic"
-
- // Vendor specific ENV only enabled if the Vendor matches == "vendor"
- KeyCloakRealm = "keycloak_realm"
- KeyCloakAdminURL = "keycloak_admin_url"
-
- EnvIdentityOpenIDEnable = "MINIO_IDENTITY_OPENID_ENABLE"
- EnvIdentityOpenIDVendor = "MINIO_IDENTITY_OPENID_VENDOR"
- EnvIdentityOpenIDClientID = "MINIO_IDENTITY_OPENID_CLIENT_ID"
- EnvIdentityOpenIDClientSecret = "MINIO_IDENTITY_OPENID_CLIENT_SECRET"
- EnvIdentityOpenIDURL = "MINIO_IDENTITY_OPENID_CONFIG_URL"
- EnvIdentityOpenIDClaimName = "MINIO_IDENTITY_OPENID_CLAIM_NAME"
- EnvIdentityOpenIDClaimUserInfo = "MINIO_IDENTITY_OPENID_CLAIM_USERINFO"
- EnvIdentityOpenIDClaimPrefix = "MINIO_IDENTITY_OPENID_CLAIM_PREFIX"
- EnvIdentityOpenIDRolePolicy = "MINIO_IDENTITY_OPENID_ROLE_POLICY"
- EnvIdentityOpenIDRedirectURI = "MINIO_IDENTITY_OPENID_REDIRECT_URI"
- EnvIdentityOpenIDRedirectURIDynamic = "MINIO_IDENTITY_OPENID_REDIRECT_URI_DYNAMIC"
- EnvIdentityOpenIDScopes = "MINIO_IDENTITY_OPENID_SCOPES"
- EnvIdentityOpenIDDisplayName = "MINIO_IDENTITY_OPENID_DISPLAY_NAME"
-
- // Vendor specific ENVs only enabled if the Vendor matches == "vendor"
- EnvIdentityOpenIDKeyCloakRealm = "MINIO_IDENTITY_OPENID_KEYCLOAK_REALM"
- EnvIdentityOpenIDKeyCloakAdminURL = "MINIO_IDENTITY_OPENID_KEYCLOAK_ADMIN_URL"
-)
-
// DiscoveryDoc - parses the output from openid-configuration
// for example https://accounts.google.com/.well-known/openid-configuration
type DiscoveryDoc struct {
@@ -582,312 +274,3 @@ func parseDiscoveryDoc(u *xnet.URL, transport http.RoundTripper, closeRespFn fun
}
return d, nil
}
-
-// DefaultKVS - default config for OpenID config
-var (
- DefaultKVS = config.KVS{
- config.KV{
- Key: config.Enable,
- Value: "",
- },
- config.KV{
- Key: DisplayName,
- Value: "",
- },
- config.KV{
- Key: ConfigURL,
- Value: "",
- },
- config.KV{
- Key: ClientID,
- Value: "",
- },
- config.KV{
- Key: ClientSecret,
- Value: "",
- },
- config.KV{
- Key: ClaimName,
- Value: iampolicy.PolicyName,
- },
- config.KV{
- Key: ClaimUserinfo,
- Value: "",
- },
- config.KV{
- Key: RolePolicy,
- Value: "",
- },
- config.KV{
- Key: ClaimPrefix,
- Value: "",
- },
- config.KV{
- Key: RedirectURI,
- Value: "",
- },
- config.KV{
- Key: RedirectURIDynamic,
- Value: "off",
- },
- config.KV{
- Key: Scopes,
- Value: "",
- },
- }
-)
-
-// Enabled returns if configURL is enabled.
-func Enabled(kvs config.KVS) bool {
- return kvs.Get(ConfigURL) != ""
-}
-
-// DummyRoleARN is used to indicate that the user associated with it was
-// authenticated via policy-claim based OpenID provider.
-var DummyRoleARN = func() arn.ARN {
- v, err := arn.NewIAMRoleARN("dummy-internal", "")
- if err != nil {
- panic("should not happen!")
- }
- return v
-}()
-
-// LookupConfig lookup jwks from config, override with any ENVs.
-func LookupConfig(kvsMap map[string]config.KVS, transport http.RoundTripper, closeRespFn func(io.ReadCloser), serverRegion string) (c Config, err error) {
- openIDClientTransport := http.DefaultTransport
- if transport != nil {
- openIDClientTransport = transport
- }
- c = Config{
- Enabled: false,
- arnProviderCfgsMap: map[arn.ARN]*providerCfg{},
- ProviderCfgs: map[string]*providerCfg{},
- pubKeys: publicKeys{
- RWMutex: &sync.RWMutex{},
- pkMap: map[string]crypto.PublicKey{},
- },
- roleArnPolicyMap: map[arn.ARN]string{},
- transport: openIDClientTransport,
- closeRespFn: closeRespFn,
- }
-
- // Make a copy of the config we received so we can mutate it safely.
- kvsMap2 := make(map[string]config.KVS, len(kvsMap))
- for k, v := range kvsMap {
- kvsMap2[k] = v
- }
-
- // Add in each configuration name found from environment variables, i.e.
- // if we see MINIO_IDENTITY_OPENID_CONFIG_URL_2, we add the key "2" to
- // `kvsMap2` if it does not already exist.
- envs := env.List(EnvIdentityOpenIDURL + config.Default)
- for _, k := range envs {
- cfgName := strings.TrimPrefix(k, EnvIdentityOpenIDURL+config.Default)
- if cfgName == "" {
- return c, config.Errorf("Environment variable must have a non-empty config name: %s", k)
- }
-
- // It is possible that some variables were specified via config
- // commands and some variables are intended to be overridden
- // from the environment, so we ensure that the key is not
- // overwritten in `kvsMap2` as it may have existing config.
- if _, ok := kvsMap2[cfgName]; !ok {
- kvsMap2[cfgName] = DefaultKVS
- }
- }
-
- var (
- hasLegacyPolicyMapping = false
- seenClientIDs = set.NewStringSet()
- )
- for cfgName, kvs := range kvsMap2 {
- // remove this since we have removed support for this already.
- kvs.Delete(JwksURL)
-
- if err = config.CheckValidKeys(config.IdentityOpenIDSubSys, kvs, DefaultKVS); err != nil {
- return c, err
- }
-
- getCfgVal := func(envVar, cfgParam string) string {
- if cfgName != config.Default {
- envVar += config.Default + cfgName
- }
- return env.Get(envVar, kvs.Get(cfgParam))
- }
-
- // In the past, when only one openID provider was allowed, there
- // was no `enable` parameter - the configuration is turned off
- // by clearing the values. With multiple providers, we support
- // individually enabling/disabling provider configurations. If
- // the enable parameter's value is non-empty, we use that
- // setting, otherwise we treat it as enabled if some important
- // parameters are non-empty.
- var (
- cfgEnableVal = getCfgVal(EnvIdentityOpenIDEnable, config.Enable)
- isExplicitlyEnabled = false
- )
- if cfgEnableVal != "" {
- isExplicitlyEnabled = true
- }
-
- var enabled bool
- if isExplicitlyEnabled {
- enabled, err = config.ParseBool(cfgEnableVal)
- if err != nil {
- return c, err
- }
- // No need to continue loading if the config is not enabled.
- if !enabled {
- continue
- }
- }
-
- p := providerCfg{
- DisplayName: getCfgVal(EnvIdentityOpenIDDisplayName, DisplayName),
- ClaimName: getCfgVal(EnvIdentityOpenIDClaimName, ClaimName),
- ClaimUserinfo: getCfgVal(EnvIdentityOpenIDClaimUserInfo, ClaimUserinfo) == config.EnableOn,
- ClaimPrefix: getCfgVal(EnvIdentityOpenIDClaimPrefix, ClaimPrefix),
- RedirectURI: getCfgVal(EnvIdentityOpenIDRedirectURI, RedirectURI),
- RedirectURIDynamic: getCfgVal(EnvIdentityOpenIDRedirectURIDynamic, RedirectURIDynamic) == config.EnableOn,
- ClientID: getCfgVal(EnvIdentityOpenIDClientID, ClientID),
- ClientSecret: getCfgVal(EnvIdentityOpenIDClientSecret, ClientSecret),
- RolePolicy: getCfgVal(EnvIdentityOpenIDRolePolicy, RolePolicy),
- }
-
- configURL := getCfgVal(EnvIdentityOpenIDURL, ConfigURL)
-
- if !isExplicitlyEnabled {
- enabled = true
- if p.ClientID == "" && p.ClientSecret == "" && configURL == "" {
- enabled = false
- }
- }
-
- // No need to continue loading if the config is not enabled.
- if !enabled {
- continue
- }
-
- // Validate that client ID has not been duplicately specified.
- if seenClientIDs.Contains(p.ClientID) {
- return c, config.Errorf("Client ID %s is present with multiple OpenID configurations", p.ClientID)
- }
- seenClientIDs.Add(p.ClientID)
-
- p.URL, err = xnet.ParseHTTPURL(configURL)
- if err != nil {
- return c, err
- }
- configURLDomain := p.URL.Hostname()
- p.DiscoveryDoc, err = parseDiscoveryDoc(p.URL, transport, closeRespFn)
- if err != nil {
- return c, err
- }
-
- if p.ClaimUserinfo && configURL == "" {
- return c, errors.New("please specify config_url to enable fetching claims from UserInfo endpoint")
- }
-
- if scopeList := getCfgVal(EnvIdentityOpenIDScopes, Scopes); scopeList != "" {
- var scopes []string
- for _, scope := range strings.Split(scopeList, ",") {
- scope = strings.TrimSpace(scope)
- if scope == "" {
- return c, config.Errorf("empty scope value is not allowed '%s', please refer to our documentation", scopeList)
- }
- scopes = append(scopes, scope)
- }
- // Replace the discovery document scopes by client customized scopes.
- p.DiscoveryDoc.ScopesSupported = scopes
- }
-
- // Check if claim name is the non-default value and role policy is set.
- if p.ClaimName != iampolicy.PolicyName && p.RolePolicy != "" {
- // In the unlikely event that the user specifies
- // `iampolicy.PolicyName` as the claim name explicitly and sets
- // a role policy, this check is thwarted, but we will be using
- // the role policy anyway.
- return c, config.Errorf("Role Policy (=`%s`) and Claim Name (=`%s`) cannot both be set", p.RolePolicy, p.ClaimName)
- }
-
- jwksURL := p.DiscoveryDoc.JwksURI
- if jwksURL == "" {
- return c, config.Errorf("no JWKS URI found in your provider's discovery doc (config_url=%s)", configURL)
- }
-
- p.JWKS.URL, err = xnet.ParseHTTPURL(jwksURL)
- if err != nil {
- return c, err
- }
-
- if p.RolePolicy != "" {
- // RolePolicy is validated by IAM System during its
- // initialization.
-
- // Generate role ARN as combination of provider domain and
- // prefix of client ID.
- domain := configURLDomain
- if domain == "" {
- // Attempt to parse the JWKs URI.
- domain = p.JWKS.URL.Hostname()
- if domain == "" {
- return c, config.Errorf("unable to generate a domain from the OpenID config")
- }
- }
- if p.ClientID == "" {
- return c, config.Errorf("client ID must not be empty")
- }
-
- // We set the resource ID of the role arn as a hash of client
- // ID, so we can get a short roleARN that stays the same on
- // restart.
- var resourceID string
- {
- h := sha1.New()
- h.Write([]byte(p.ClientID))
- bs := h.Sum(nil)
- resourceID = base64.RawURLEncoding.EncodeToString(bs)
- }
- p.roleArn, err = arn.NewIAMRoleARN(resourceID, serverRegion)
- if err != nil {
- return c, config.Errorf("unable to generate ARN from the OpenID config: %v", err)
- }
-
- c.roleArnPolicyMap[p.roleArn] = p.RolePolicy
- } else if p.ClaimName == "" {
- return c, config.Errorf("A role policy or claim name must be specified")
- }
-
- if err = p.initializeProvider(getCfgVal, c.transport); err != nil {
- return c, err
- }
-
- arnKey := p.roleArn
- if p.RolePolicy == "" {
- arnKey = DummyRoleARN
- hasLegacyPolicyMapping = true
- // Ensure that when a JWT policy claim based provider
- // exists, it is the only one.
- if _, ok := c.arnProviderCfgsMap[DummyRoleARN]; ok {
- return c, errSingleProvider
- }
- }
-
- c.arnProviderCfgsMap[arnKey] = &p
- c.ProviderCfgs[cfgName] = &p
-
- if err = c.PopulatePublicKey(arnKey); err != nil {
- return c, err
- }
- }
-
- // Ensure that when a JWT policy claim based provider
- // exists, it is the only one.
- if hasLegacyPolicyMapping && len(c.ProviderCfgs) > 1 {
- return c, errSingleProvider
- }
-
- c.Enabled = true
-
- return c, nil
-}
diff --git a/internal/config/identity/openid/openid.go b/internal/config/identity/openid/openid.go
new file mode 100644
index 000000000..3771daa8e
--- /dev/null
+++ b/internal/config/identity/openid/openid.go
@@ -0,0 +1,518 @@
+// Copyright (c) 2015-2022 MinIO, Inc.
+//
+// This file is part of MinIO Object Storage stack
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package openid
+
+import (
+ "crypto"
+ "crypto/sha1"
+ "crypto/sha256"
+ "encoding/base64"
+ "errors"
+ "io"
+ "net/http"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/minio/madmin-go"
+ "github.com/minio/minio-go/v7/pkg/set"
+ "github.com/minio/minio/internal/arn"
+ "github.com/minio/minio/internal/auth"
+ "github.com/minio/minio/internal/config"
+ "github.com/minio/minio/internal/config/identity/openid/provider"
+ "github.com/minio/pkg/env"
+ iampolicy "github.com/minio/pkg/iam/policy"
+ xnet "github.com/minio/pkg/net"
+)
+
+// OpenID keys and envs.
+const (
+ JwksURL = "jwks_url"
+ ConfigURL = "config_url"
+ ClaimName = "claim_name"
+ ClaimUserinfo = "claim_userinfo"
+ ClaimPrefix = "claim_prefix"
+ ClientID = "client_id"
+ ClientSecret = "client_secret"
+ RolePolicy = "role_policy"
+ DisplayName = "display_name"
+
+ Vendor = "vendor"
+ Scopes = "scopes"
+ RedirectURI = "redirect_uri"
+ RedirectURIDynamic = "redirect_uri_dynamic"
+
+ // Vendor specific ENV only enabled if the Vendor matches == "vendor"
+ KeyCloakRealm = "keycloak_realm"
+ KeyCloakAdminURL = "keycloak_admin_url"
+
+ EnvIdentityOpenIDEnable = "MINIO_IDENTITY_OPENID_ENABLE"
+ EnvIdentityOpenIDVendor = "MINIO_IDENTITY_OPENID_VENDOR"
+ EnvIdentityOpenIDClientID = "MINIO_IDENTITY_OPENID_CLIENT_ID"
+ EnvIdentityOpenIDClientSecret = "MINIO_IDENTITY_OPENID_CLIENT_SECRET"
+ EnvIdentityOpenIDURL = "MINIO_IDENTITY_OPENID_CONFIG_URL"
+ EnvIdentityOpenIDClaimName = "MINIO_IDENTITY_OPENID_CLAIM_NAME"
+ EnvIdentityOpenIDClaimUserInfo = "MINIO_IDENTITY_OPENID_CLAIM_USERINFO"
+ EnvIdentityOpenIDClaimPrefix = "MINIO_IDENTITY_OPENID_CLAIM_PREFIX"
+ EnvIdentityOpenIDRolePolicy = "MINIO_IDENTITY_OPENID_ROLE_POLICY"
+ EnvIdentityOpenIDRedirectURI = "MINIO_IDENTITY_OPENID_REDIRECT_URI"
+ EnvIdentityOpenIDRedirectURIDynamic = "MINIO_IDENTITY_OPENID_REDIRECT_URI_DYNAMIC"
+ EnvIdentityOpenIDScopes = "MINIO_IDENTITY_OPENID_SCOPES"
+ EnvIdentityOpenIDDisplayName = "MINIO_IDENTITY_OPENID_DISPLAY_NAME"
+
+ // Vendor specific ENVs only enabled if the Vendor matches == "vendor"
+ EnvIdentityOpenIDKeyCloakRealm = "MINIO_IDENTITY_OPENID_KEYCLOAK_REALM"
+ EnvIdentityOpenIDKeyCloakAdminURL = "MINIO_IDENTITY_OPENID_KEYCLOAK_ADMIN_URL"
+)
+
+// DefaultKVS - default config for OpenID config
+var (
+ DefaultKVS = config.KVS{
+ config.KV{
+ Key: config.Enable,
+ Value: "",
+ },
+ config.KV{
+ Key: DisplayName,
+ Value: "",
+ },
+ config.KV{
+ Key: ConfigURL,
+ Value: "",
+ },
+ config.KV{
+ Key: ClientID,
+ Value: "",
+ },
+ config.KV{
+ Key: ClientSecret,
+ Value: "",
+ },
+ config.KV{
+ Key: ClaimName,
+ Value: iampolicy.PolicyName,
+ },
+ config.KV{
+ Key: ClaimUserinfo,
+ Value: "",
+ },
+ config.KV{
+ Key: RolePolicy,
+ Value: "",
+ },
+ config.KV{
+ Key: ClaimPrefix,
+ Value: "",
+ },
+ config.KV{
+ Key: RedirectURI,
+ Value: "",
+ },
+ config.KV{
+ Key: RedirectURIDynamic,
+ Value: "off",
+ },
+ config.KV{
+ Key: Scopes,
+ Value: "",
+ },
+ }
+)
+
+var errSingleProvider = config.Errorf("Only one OpenID provider can be configured if not using role policy mapping")
+
+// DummyRoleARN is used to indicate that the user associated with it was
+// authenticated via policy-claim based OpenID provider.
+var DummyRoleARN = func() arn.ARN {
+ v, err := arn.NewIAMRoleARN("dummy-internal", "")
+ if err != nil {
+ panic("should not happen!")
+ }
+ return v
+}()
+
+// Config - OpenID Config
+type Config struct {
+ Enabled bool
+
+ // map of roleARN to providerCfg's
+ arnProviderCfgsMap map[arn.ARN]*providerCfg
+
+ // map of config names to providerCfg's
+ ProviderCfgs map[string]*providerCfg
+
+ pubKeys publicKeys
+ roleArnPolicyMap map[arn.ARN]string
+
+ transport http.RoundTripper
+ closeRespFn func(io.ReadCloser)
+}
+
+// LookupConfig lookup jwks from config, override with any ENVs.
+func LookupConfig(kvsMap map[string]config.KVS, transport http.RoundTripper, closeRespFn func(io.ReadCloser), serverRegion string) (c Config, err error) {
+ openIDClientTransport := http.DefaultTransport
+ if transport != nil {
+ openIDClientTransport = transport
+ }
+ c = Config{
+ Enabled: false,
+ arnProviderCfgsMap: map[arn.ARN]*providerCfg{},
+ ProviderCfgs: map[string]*providerCfg{},
+ pubKeys: publicKeys{
+ RWMutex: &sync.RWMutex{},
+ pkMap: map[string]crypto.PublicKey{},
+ },
+ roleArnPolicyMap: map[arn.ARN]string{},
+ transport: openIDClientTransport,
+ closeRespFn: closeRespFn,
+ }
+
+ // Make a copy of the config we received so we can mutate it safely.
+ kvsMap2 := make(map[string]config.KVS, len(kvsMap))
+ for k, v := range kvsMap {
+ kvsMap2[k] = v
+ }
+
+ // Add in each configuration name found from environment variables, i.e.
+ // if we see MINIO_IDENTITY_OPENID_CONFIG_URL_2, we add the key "2" to
+ // `kvsMap2` if it does not already exist.
+ envs := env.List(EnvIdentityOpenIDURL + config.Default)
+ for _, k := range envs {
+ cfgName := strings.TrimPrefix(k, EnvIdentityOpenIDURL+config.Default)
+ if cfgName == "" {
+ return c, config.Errorf("Environment variable must have a non-empty config name: %s", k)
+ }
+
+ // It is possible that some variables were specified via config
+ // commands and some variables are intended to be overridden
+ // from the environment, so we ensure that the key is not
+ // overwritten in `kvsMap2` as it may have existing config.
+ if _, ok := kvsMap2[cfgName]; !ok {
+ kvsMap2[cfgName] = DefaultKVS
+ }
+ }
+
+ var (
+ hasLegacyPolicyMapping = false
+ seenClientIDs = set.NewStringSet()
+ )
+ for cfgName, kvs := range kvsMap2 {
+ // remove this since we have removed support for this already.
+ kvs.Delete(JwksURL)
+
+ if err = config.CheckValidKeys(config.IdentityOpenIDSubSys, kvs, DefaultKVS); err != nil {
+ return c, err
+ }
+
+ getCfgVal := func(envVar, cfgParam string) string {
+ if cfgName != config.Default {
+ envVar += config.Default + cfgName
+ }
+ return env.Get(envVar, kvs.Get(cfgParam))
+ }
+
+ // In the past, when only one openID provider was allowed, there
+ // was no `enable` parameter - the configuration is turned off
+ // by clearing the values. With multiple providers, we support
+ // individually enabling/disabling provider configurations. If
+ // the enable parameter's value is non-empty, we use that
+ // setting, otherwise we treat it as enabled if some important
+ // parameters are non-empty.
+ var (
+ cfgEnableVal = getCfgVal(EnvIdentityOpenIDEnable, config.Enable)
+ isExplicitlyEnabled = false
+ )
+ if cfgEnableVal != "" {
+ isExplicitlyEnabled = true
+ }
+
+ var enabled bool
+ if isExplicitlyEnabled {
+ enabled, err = config.ParseBool(cfgEnableVal)
+ if err != nil {
+ return c, err
+ }
+ // No need to continue loading if the config is not enabled.
+ if !enabled {
+ continue
+ }
+ }
+
+ p := newProviderCfgFromConfig(getCfgVal)
+ configURL := getCfgVal(EnvIdentityOpenIDURL, ConfigURL)
+
+ if !isExplicitlyEnabled {
+ enabled = true
+ if p.ClientID == "" && p.ClientSecret == "" && configURL == "" {
+ enabled = false
+ }
+ }
+
+ // No need to continue loading if the config is not enabled.
+ if !enabled {
+ continue
+ }
+
+ // Validate that client ID has not been duplicately specified.
+ if seenClientIDs.Contains(p.ClientID) {
+ return c, config.Errorf("Client ID %s is present with multiple OpenID configurations", p.ClientID)
+ }
+ seenClientIDs.Add(p.ClientID)
+
+ p.URL, err = xnet.ParseHTTPURL(configURL)
+ if err != nil {
+ return c, err
+ }
+ configURLDomain := p.URL.Hostname()
+ p.DiscoveryDoc, err = parseDiscoveryDoc(p.URL, transport, closeRespFn)
+ if err != nil {
+ return c, err
+ }
+
+ if p.ClaimUserinfo && configURL == "" {
+ return c, errors.New("please specify config_url to enable fetching claims from UserInfo endpoint")
+ }
+
+ if scopeList := getCfgVal(EnvIdentityOpenIDScopes, Scopes); scopeList != "" {
+ var scopes []string
+ for _, scope := range strings.Split(scopeList, ",") {
+ scope = strings.TrimSpace(scope)
+ if scope == "" {
+ return c, config.Errorf("empty scope value is not allowed '%s', please refer to our documentation", scopeList)
+ }
+ scopes = append(scopes, scope)
+ }
+ // Replace the discovery document scopes by client customized scopes.
+ p.DiscoveryDoc.ScopesSupported = scopes
+ }
+
+ // Check if claim name is the non-default value and role policy is set.
+ if p.ClaimName != iampolicy.PolicyName && p.RolePolicy != "" {
+ // In the unlikely event that the user specifies
+ // `iampolicy.PolicyName` as the claim name explicitly and sets
+ // a role policy, this check is thwarted, but we will be using
+ // the role policy anyway.
+ return c, config.Errorf("Role Policy (=`%s`) and Claim Name (=`%s`) cannot both be set", p.RolePolicy, p.ClaimName)
+ }
+
+ jwksURL := p.DiscoveryDoc.JwksURI
+ if jwksURL == "" {
+ return c, config.Errorf("no JWKS URI found in your provider's discovery doc (config_url=%s)", configURL)
+ }
+
+ p.JWKS.URL, err = xnet.ParseHTTPURL(jwksURL)
+ if err != nil {
+ return c, err
+ }
+
+ if p.RolePolicy != "" {
+ // RolePolicy is validated by IAM System during its
+ // initialization.
+
+ // Generate role ARN as combination of provider domain and
+ // prefix of client ID.
+ domain := configURLDomain
+ if domain == "" {
+ // Attempt to parse the JWKs URI.
+ domain = p.JWKS.URL.Hostname()
+ if domain == "" {
+ return c, config.Errorf("unable to parse a domain from the OpenID config")
+ }
+ }
+ if p.ClientID == "" {
+ return c, config.Errorf("client ID must not be empty")
+ }
+
+ // We set the resource ID of the role arn as a hash of client
+ // ID, so we can get a short roleARN that stays the same on
+ // restart.
+ var resourceID string
+ {
+ h := sha1.New()
+ h.Write([]byte(p.ClientID))
+ bs := h.Sum(nil)
+ resourceID = base64.RawURLEncoding.EncodeToString(bs)
+ }
+ p.roleArn, err = arn.NewIAMRoleARN(resourceID, serverRegion)
+ if err != nil {
+ return c, config.Errorf("unable to generate ARN from the OpenID config: %v", err)
+ }
+
+ c.roleArnPolicyMap[p.roleArn] = p.RolePolicy
+ } else if p.ClaimName == "" {
+ return c, config.Errorf("A role policy or claim name must be specified")
+ }
+
+ if err = p.initializeProvider(getCfgVal, c.transport); err != nil {
+ return c, err
+ }
+
+ arnKey := p.roleArn
+ if p.RolePolicy == "" {
+ arnKey = DummyRoleARN
+ hasLegacyPolicyMapping = true
+ // Ensure that when a JWT policy claim based provider
+ // exists, it is the only one.
+ if _, ok := c.arnProviderCfgsMap[DummyRoleARN]; ok {
+ return c, errSingleProvider
+ }
+ }
+
+ c.arnProviderCfgsMap[arnKey] = &p
+ c.ProviderCfgs[cfgName] = &p
+
+ if err = c.PopulatePublicKey(arnKey); err != nil {
+ return c, err
+ }
+ }
+
+ // Ensure that when a JWT policy claim based provider
+ // exists, it is the only one.
+ if hasLegacyPolicyMapping && len(c.ProviderCfgs) > 1 {
+ return c, errSingleProvider
+ }
+
+ c.Enabled = true
+
+ return c, nil
+}
+
+// Enabled returns if configURL is enabled.
+func Enabled(kvs config.KVS) bool {
+ return kvs.Get(ConfigURL) != ""
+}
+
+// GetSettings - fetches OIDC settings for site-replication related validation.
+// NOTE that region must be populated by caller as this package does not know.
+func (r *Config) GetSettings() madmin.OpenIDSettings {
+ res := madmin.OpenIDSettings{}
+ if !r.Enabled {
+ return res
+ }
+
+ for arn, provCfg := range r.arnProviderCfgsMap {
+ hashedSecret := ""
+ {
+ h := sha256.New()
+ h.Write([]byte(provCfg.ClientSecret))
+ bs := h.Sum(nil)
+ hashedSecret = base64.RawURLEncoding.EncodeToString(bs)
+ }
+ if arn != DummyRoleARN {
+ if res.Roles != nil {
+ res.Roles = make(map[string]madmin.OpenIDProviderSettings)
+ }
+ res.Roles[arn.String()] = madmin.OpenIDProviderSettings{
+ ClaimUserinfoEnabled: provCfg.ClaimUserinfo,
+ RolePolicy: provCfg.RolePolicy,
+ ClientID: provCfg.ClientID,
+ HashedClientSecret: hashedSecret,
+ }
+ } else {
+ res.ClaimProvider = madmin.OpenIDProviderSettings{
+ ClaimUserinfoEnabled: provCfg.ClaimUserinfo,
+ RolePolicy: provCfg.RolePolicy,
+ ClientID: provCfg.ClientID,
+ HashedClientSecret: hashedSecret,
+ }
+ }
+
+ }
+
+ return res
+}
+
+// GetIAMPolicyClaimName - returns the policy claim name for the (at most one)
+// provider configured without a role policy.
+func (r *Config) GetIAMPolicyClaimName() string {
+ pCfg, ok := r.arnProviderCfgsMap[DummyRoleARN]
+ if !ok {
+ return ""
+ }
+ return pCfg.ClaimPrefix + pCfg.ClaimName
+}
+
+// LookupUser lookup userid for the provider
+func (r Config) LookupUser(roleArn, userid string) (provider.User, error) {
+ // Can safely ignore error here as empty or invalid ARNs will not be
+ // mapped.
+ arnVal, _ := arn.Parse(roleArn)
+ pCfg, ok := r.arnProviderCfgsMap[arnVal]
+ if ok {
+ user, err := pCfg.provider.LookupUser(userid)
+ if err != nil && err != provider.ErrAccessTokenExpired {
+ return user, err
+ }
+ if err == provider.ErrAccessTokenExpired {
+ if err = pCfg.provider.LoginWithClientID(pCfg.ClientID, pCfg.ClientSecret); err != nil {
+ return user, err
+ }
+ user, err = pCfg.provider.LookupUser(userid)
+ }
+ return user, err
+ }
+ // Without any specific logic for a provider, all accounts
+ // are always enabled.
+ return provider.User{ID: userid, Enabled: true}, nil
+}
+
+// ProviderEnabled returns true if any vendor specific provider is enabled.
+func (r Config) ProviderEnabled() bool {
+ if !r.Enabled {
+ return false
+ }
+ for _, v := range r.arnProviderCfgsMap {
+ if v.provider != nil {
+ return true
+ }
+ }
+ return false
+}
+
+// GetRoleInfo - returns ARN to policies map if a role policy based openID
+// provider is configured. Otherwise returns nil.
+func (r Config) GetRoleInfo() map[arn.ARN]string {
+ for _, p := range r.arnProviderCfgsMap {
+ if p.RolePolicy != "" {
+ return r.roleArnPolicyMap
+ }
+ }
+ return nil
+}
+
+// GetDefaultExpiration - returns the expiration seconds expected.
+func GetDefaultExpiration(dsecs string) (time.Duration, error) {
+ defaultExpiryDuration := time.Duration(60) * time.Minute // Defaults to 1hr.
+ if dsecs != "" {
+ expirySecs, err := strconv.ParseInt(dsecs, 10, 64)
+ if err != nil {
+ return 0, auth.ErrInvalidDuration
+ }
+
+ // The duration, in seconds, of the role session.
+ // The value can range from 900 seconds (15 minutes)
+ // up to 365 days.
+ if expirySecs < 900 || expirySecs > 31536000 {
+ return 0, auth.ErrInvalidDuration
+ }
+
+ defaultExpiryDuration = time.Duration(expirySecs) * time.Second
+ }
+ return defaultExpiryDuration, nil
+}
diff --git a/internal/config/identity/openid/providercfg.go b/internal/config/identity/openid/providercfg.go
new file mode 100644
index 000000000..4981a520f
--- /dev/null
+++ b/internal/config/identity/openid/providercfg.go
@@ -0,0 +1,148 @@
+// Copyright (c) 2015-2022 MinIO, Inc.
+//
+// This file is part of MinIO Object Storage stack
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package openid
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+
+ "github.com/minio/minio/internal/arn"
+ "github.com/minio/minio/internal/config"
+ "github.com/minio/minio/internal/config/identity/openid/provider"
+ xhttp "github.com/minio/minio/internal/http"
+ xnet "github.com/minio/pkg/net"
+)
+
+type providerCfg struct {
+ // Used for user interface like console
+ DisplayName string
+
+ JWKS struct {
+ URL *xnet.URL
+ }
+ URL *xnet.URL
+ ClaimPrefix string
+ ClaimName string
+ ClaimUserinfo bool
+ RedirectURI string
+ RedirectURIDynamic bool
+ DiscoveryDoc DiscoveryDoc
+ ClientID string
+ ClientSecret string
+ RolePolicy string
+
+ roleArn arn.ARN
+ provider provider.Provider
+}
+
+func newProviderCfgFromConfig(getCfgVal func(env, cfgName string) string) providerCfg {
+ return providerCfg{
+ DisplayName: getCfgVal(EnvIdentityOpenIDDisplayName, DisplayName),
+ ClaimName: getCfgVal(EnvIdentityOpenIDClaimName, ClaimName),
+ ClaimUserinfo: getCfgVal(EnvIdentityOpenIDClaimUserInfo, ClaimUserinfo) == config.EnableOn,
+ ClaimPrefix: getCfgVal(EnvIdentityOpenIDClaimPrefix, ClaimPrefix),
+ RedirectURI: getCfgVal(EnvIdentityOpenIDRedirectURI, RedirectURI),
+ RedirectURIDynamic: getCfgVal(EnvIdentityOpenIDRedirectURIDynamic, RedirectURIDynamic) == config.EnableOn,
+ ClientID: getCfgVal(EnvIdentityOpenIDClientID, ClientID),
+ ClientSecret: getCfgVal(EnvIdentityOpenIDClientSecret, ClientSecret),
+ RolePolicy: getCfgVal(EnvIdentityOpenIDRolePolicy, RolePolicy),
+ }
+}
+
+const (
+ keyCloakVendor = "keycloak"
+)
+
+// initializeProvider initializes if any additional vendor specific information
+// was provided, initialization will return an error initial login fails.
+func (p *providerCfg) initializeProvider(cfgGet func(string, string) string, transport http.RoundTripper) error {
+ vendor := cfgGet(EnvIdentityOpenIDVendor, Vendor)
+ if vendor == "" {
+ return nil
+ }
+ var err error
+ switch vendor {
+ case keyCloakVendor:
+ adminURL := cfgGet(EnvIdentityOpenIDKeyCloakAdminURL, KeyCloakAdminURL)
+ realm := cfgGet(EnvIdentityOpenIDKeyCloakRealm, KeyCloakRealm)
+ p.provider, err = provider.KeyCloak(
+ provider.WithAdminURL(adminURL),
+ provider.WithOpenIDConfig(provider.DiscoveryDoc(p.DiscoveryDoc)),
+ provider.WithTransport(transport),
+ provider.WithRealm(realm),
+ )
+ return err
+ default:
+ return fmt.Errorf("Unsupport vendor %s", keyCloakVendor)
+ }
+}
+
+// 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 (p *providerCfg) UserInfo(accessToken string, transport http.RoundTripper) (map[string]interface{}, error) {
+ if p.JWKS.URL == nil || p.JWKS.URL.String() == "" {
+ return nil, errors.New("openid not configured")
+ }
+ client := &http.Client{
+ Transport: transport,
+ }
+
+ req, err := http.NewRequest(http.MethodPost, p.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 xhttp.DrainBody(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)
+ 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
+}