mirror of
https://github.com/minio/minio.git
synced 2025-11-08 21:24:55 -05:00
Add support for multiple OpenID providers with role policies (#14223)
- When using multiple providers, claim-based providers are not allowed. All providers must use role policies. - Update markdown config to allow `details` HTML element
This commit is contained in:
committed by
GitHub
parent
424b44c247
commit
0e502899a8
@@ -35,22 +35,56 @@ import (
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// Config - OpenID Config
|
||||
// RSA authentication target arguments
|
||||
type Config struct {
|
||||
var errSingleProvider = config.Errorf("Only one OpenID provider can be configured if not using role policy mapping")
|
||||
|
||||
type publicKeys struct {
|
||||
*sync.RWMutex
|
||||
|
||||
Enabled bool `json:"enabled"`
|
||||
JWKS struct {
|
||||
// map of kid to public key
|
||||
pkMap map[string]crypto.PublicKey
|
||||
}
|
||||
|
||||
func (pk *publicKeys) parseAndAdd(b io.Reader) error {
|
||||
var jwk JWKS
|
||||
err := json.NewDecoder(b).Decode(&jwk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pk.Lock()
|
||||
defer pk.Unlock()
|
||||
|
||||
for _, key := range jwk.Keys {
|
||||
pk.pkMap[key.Kid], err = key.DecodePublicKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pk *publicKeys) get(kid string) crypto.PublicKey {
|
||||
pk.RLock()
|
||||
defer pk.RUnlock()
|
||||
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"`
|
||||
@@ -64,11 +98,32 @@ type Config struct {
|
||||
ClientSecret string
|
||||
RolePolicy string
|
||||
|
||||
roleArn arn.ARN
|
||||
provider provider.Provider
|
||||
publicKeys map[string]crypto.PublicKey
|
||||
transport *http.Transport
|
||||
closeRespFn func(io.ReadCloser)
|
||||
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.
|
||||
@@ -77,19 +132,15 @@ type Config struct {
|
||||
// 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() == "" {
|
||||
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")
|
||||
}
|
||||
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)
|
||||
req, err := http.NewRequest(http.MethodPost, p.DiscoveryDoc.UserInfoEndpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -103,7 +154,7 @@ func (r *Config) UserInfo(accessToken string) (map[string]interface{}, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer r.closeRespFn(resp.Body)
|
||||
defer xhttp.DrainBody(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// uncomment this for debugging when needed.
|
||||
// reqBytes, _ := httputil.DumpRequest(req, false)
|
||||
@@ -128,18 +179,50 @@ func (r *Config) UserInfo(accessToken string) (map[string]interface{}, error) {
|
||||
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(userid string) (provider.User, error) {
|
||||
if r.provider != nil {
|
||||
user, err := r.provider.LookupUser(userid)
|
||||
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 = r.provider.LoginWithClientID(r.ClientID, r.ClientSecret); err != nil {
|
||||
if err = pCfg.provider.LoginWithClientID(pCfg.ClientID, pCfg.ClientSecret); err != nil {
|
||||
return user, err
|
||||
}
|
||||
user, err = r.provider.LookupUser(userid)
|
||||
user, err = pCfg.provider.LookupUser(userid)
|
||||
}
|
||||
return user, err
|
||||
}
|
||||
@@ -152,64 +235,41 @@ const (
|
||||
keyCloakVendor = "keycloak"
|
||||
)
|
||||
|
||||
// InitializeProvider initializes if any additional vendor specific
|
||||
// information was provided, initialization will return an error
|
||||
// initial login fails.
|
||||
func (r *Config) InitializeProvider(kvs config.KVS) error {
|
||||
vendor := env.Get(EnvIdentityOpenIDVendor, kvs.Get(Vendor))
|
||||
if vendor == "" {
|
||||
return nil
|
||||
}
|
||||
switch vendor {
|
||||
case keyCloakVendor:
|
||||
adminURL := env.Get(EnvIdentityOpenIDKeyCloakAdminURL, kvs.Get(KeyCloakAdminURL))
|
||||
realm := env.Get(EnvIdentityOpenIDKeyCloakRealm, kvs.Get(KeyCloakRealm))
|
||||
return r.InitializeKeycloakProvider(adminURL, realm)
|
||||
default:
|
||||
return fmt.Errorf("Unsupport vendor %s", keyCloakVendor)
|
||||
}
|
||||
}
|
||||
|
||||
// ProviderEnabled returns true if any vendor specific provider is enabled.
|
||||
func (r Config) ProviderEnabled() bool {
|
||||
return r.Enabled && r.provider != nil
|
||||
if !r.Enabled {
|
||||
return false
|
||||
}
|
||||
for _, v := range r.arnProviderCfgsMap {
|
||||
if v.provider != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetRoleInfo - returns role ARN and policy if present, otherwise returns false
|
||||
// boolean.
|
||||
func (r Config) GetRoleInfo() (arn.ARN, string, bool) {
|
||||
return r.roleArn, r.RolePolicy, r.RolePolicy != ""
|
||||
}
|
||||
|
||||
// InitializeKeycloakProvider - initializes keycloak provider
|
||||
func (r *Config) InitializeKeycloakProvider(adminURL, realm string) error {
|
||||
var err error
|
||||
r.provider, err = provider.KeyCloak(
|
||||
provider.WithAdminURL(adminURL),
|
||||
provider.WithOpenIDConfig(provider.DiscoveryDoc(r.DiscoveryDoc)),
|
||||
provider.WithTransport(r.transport),
|
||||
provider.WithRealm(realm),
|
||||
)
|
||||
return err
|
||||
// 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() error {
|
||||
if r.JWKS.URL == nil || r.JWKS.URL.String() == "" {
|
||||
func (r *Config) PopulatePublicKey(arn arn.ARN) error {
|
||||
pCfg := r.arnProviderCfgsMap[arn]
|
||||
if pCfg.JWKS.URL == nil || pCfg.JWKS.URL.String() == "" {
|
||||
return nil
|
||||
}
|
||||
transport := http.DefaultTransport
|
||||
if r.transport != nil {
|
||||
transport = r.transport
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
Transport: r.transport,
|
||||
}
|
||||
|
||||
r.Lock()
|
||||
defer r.Unlock()
|
||||
|
||||
resp, err := client.Get(r.JWKS.URL.String())
|
||||
resp, err := client.Get(pCfg.JWKS.URL.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -218,19 +278,7 @@ func (r *Config) PopulatePublicKey() error {
|
||||
return errors.New(resp.Status)
|
||||
}
|
||||
|
||||
var jwk JWKS
|
||||
if err = json.NewDecoder(resp.Body).Decode(&jwk); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, key := range jwk.Keys {
|
||||
r.publicKeys[key.Kid], err = key.DecodePublicKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return r.pubKeys.parseAndAdd(resp.Body)
|
||||
}
|
||||
|
||||
// UnmarshalJSON - decodes JSON data.
|
||||
@@ -244,11 +292,6 @@ func (r *Config) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
ar := Config(sr)
|
||||
if ar.JWKS.URL == nil || ar.JWKS.URL.String() == "" {
|
||||
*r = ar
|
||||
return nil
|
||||
}
|
||||
|
||||
*r = ar
|
||||
return nil
|
||||
}
|
||||
@@ -274,6 +317,11 @@ func GetDefaultExpiration(dsecs string) (time.Duration, error) {
|
||||
return defaultExpiryDuration, nil
|
||||
}
|
||||
|
||||
// ErrTokenExpired - error token expired
|
||||
var (
|
||||
ErrTokenExpired = errors.New("token expired")
|
||||
)
|
||||
|
||||
func updateClaimsExpiry(dsecs string, claims map[string]interface{}) error {
|
||||
expStr := claims["exp"]
|
||||
if expStr == "" {
|
||||
@@ -306,8 +354,13 @@ func updateClaimsExpiry(dsecs string, claims map[string]interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
audClaim = "aud"
|
||||
azpClaim = "azp"
|
||||
)
|
||||
|
||||
// Validate - validates the id_token.
|
||||
func (r *Config) Validate(token, accessToken, dsecs string) (map[string]interface{}, error) {
|
||||
func (r *Config) Validate(arn arn.ARN, token, accessToken, dsecs string) (map[string]interface{}, error) {
|
||||
jp := new(jwtgo.Parser)
|
||||
jp.ValidMethods = []string{
|
||||
"RS256", "RS384", "RS512", "ES256", "ES384", "ES512",
|
||||
@@ -319,9 +372,12 @@ func (r *Config) Validate(token, accessToken, dsecs string) (map[string]interfac
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("Invalid kid value %v", jwtToken.Header["kid"])
|
||||
}
|
||||
r.RLock()
|
||||
defer r.RUnlock()
|
||||
return r.publicKeys[kid], nil
|
||||
return r.pubKeys.get(kid), nil
|
||||
}
|
||||
|
||||
pCfg, ok := r.arnProviderCfgsMap[arn]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("Role %s does not exist", arn)
|
||||
}
|
||||
|
||||
var claims jwtgo.MapClaims
|
||||
@@ -329,7 +385,7 @@ func (r *Config) Validate(token, accessToken, dsecs string) (map[string]interfac
|
||||
if err != nil {
|
||||
// Re-populate the public key in-case the JWKS
|
||||
// pubkeys are refreshed
|
||||
if err = r.PopulatePublicKey(); err != nil {
|
||||
if err = r.PopulatePublicKey(arn); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jwtToken, err = jwtgo.ParseWithClaims(token, &claims, keyFuncCallback)
|
||||
@@ -346,15 +402,56 @@ func (r *Config) Validate(token, accessToken, dsecs string) (map[string]interfac
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = r.updateUserinfoClaims(arn, accessToken, claims); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate that matching clientID appears in the aud or azp claims.
|
||||
|
||||
// REQUIRED. Audience(s) that this ID Token is intended for.
|
||||
// It MUST contain the OAuth 2.0 client_id of the Relying Party
|
||||
// as an audience value. It MAY also contain identifiers for
|
||||
// other audiences. In the general case, the aud value is an
|
||||
// array of case sensitive strings. In the common special case
|
||||
// when there is one audience, the aud value MAY be a single
|
||||
// case sensitive
|
||||
audValues, ok := iampolicy.GetValuesFromClaims(claims, audClaim)
|
||||
if !ok {
|
||||
return nil, errors.New("STS JWT Token has `aud` claim invalid, `aud` must match configured OpenID Client ID")
|
||||
}
|
||||
if !audValues.Contains(pCfg.ClientID) {
|
||||
// if audience claims is missing, look for "azp" claims.
|
||||
// OPTIONAL. Authorized party - the party to which the ID
|
||||
// Token was issued. If present, it MUST contain the OAuth
|
||||
// 2.0 Client ID of this party. This Claim is only needed
|
||||
// when the ID Token has a single audience value and that
|
||||
// audience is different than the authorized party. It MAY
|
||||
// be included even when the authorized party is the same
|
||||
// as the sole audience. The azp value is a case sensitive
|
||||
// string containing a StringOrURI value
|
||||
azpValues, ok := iampolicy.GetValuesFromClaims(claims, azpClaim)
|
||||
if !ok {
|
||||
return nil, errors.New("STS JWT Token has `azp` claim invalid, `azp` must match configured OpenID Client ID")
|
||||
}
|
||||
if !azpValues.Contains(pCfg.ClientID) {
|
||||
return nil, errors.New("STS JWT Token has `azp` claim invalid, `azp` must match configured OpenID Client ID")
|
||||
}
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (r *Config) updateUserinfoClaims(arn arn.ARN, accessToken string, claims map[string]interface{}) error {
|
||||
pCfg, ok := r.arnProviderCfgsMap[arn]
|
||||
// If claim user info is enabled, get claims from userInfo
|
||||
// and overwrite them with the claims from JWT.
|
||||
if r.ClaimUserinfo {
|
||||
if ok && pCfg.ClaimUserinfo {
|
||||
if accessToken == "" {
|
||||
return nil, errors.New("access_token is mandatory if user_info claim is enabled")
|
||||
return errors.New("access_token is mandatory if user_info claim is enabled")
|
||||
}
|
||||
uclaims, err := r.UserInfo(accessToken)
|
||||
uclaims, err := pCfg.UserInfo(accessToken, r.transport)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
for k, v := range uclaims {
|
||||
if _, ok := claims[k]; !ok { // only add to claims not update it.
|
||||
@@ -362,13 +459,7 @@ func (r *Config) Validate(token, accessToken, dsecs string) (map[string]interfac
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// ID returns the provider name and authentication type.
|
||||
func (Config) ID() ID {
|
||||
return "jwt"
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSettings - fetches OIDC settings for site-replication related validation.
|
||||
@@ -379,29 +470,35 @@ func (r *Config) GetSettings() madmin.OpenIDSettings {
|
||||
return res
|
||||
}
|
||||
|
||||
hashedSecret := ""
|
||||
{
|
||||
h := sha256.New()
|
||||
h.Write([]byte(r.ClientSecret))
|
||||
bs := h.Sum(nil)
|
||||
hashedSecret = base64.RawURLEncoding.EncodeToString(bs)
|
||||
}
|
||||
if r.RolePolicy != "" {
|
||||
res.Roles = make(map[string]madmin.OpenIDProviderSettings)
|
||||
res.Roles[r.roleArn.String()] = madmin.OpenIDProviderSettings{
|
||||
ClaimUserinfoEnabled: r.ClaimUserinfo,
|
||||
RolePolicy: r.RolePolicy,
|
||||
ClientID: r.ClientID,
|
||||
HashedClientSecret: hashedSecret,
|
||||
for arn, provCfg := range r.arnProviderCfgsMap {
|
||||
hashedSecret := ""
|
||||
{
|
||||
h := sha256.New()
|
||||
h.Write([]byte(provCfg.ClientSecret))
|
||||
bs := h.Sum(nil)
|
||||
hashedSecret = base64.RawURLEncoding.EncodeToString(bs)
|
||||
}
|
||||
} else {
|
||||
res.ClaimProvider = madmin.OpenIDProviderSettings{
|
||||
ClaimName: r.ClaimName,
|
||||
ClaimUserinfoEnabled: r.ClaimUserinfo,
|
||||
ClientID: r.ClientID,
|
||||
HashedClientSecret: hashedSecret,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -415,6 +512,7 @@ const (
|
||||
ClientID = "client_id"
|
||||
ClientSecret = "client_secret"
|
||||
RolePolicy = "role_policy"
|
||||
DisplayName = "display_name"
|
||||
|
||||
Vendor = "vendor"
|
||||
Scopes = "scopes"
|
||||
@@ -425,6 +523,7 @@ const (
|
||||
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"
|
||||
@@ -436,6 +535,7 @@ const (
|
||||
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"
|
||||
@@ -460,7 +560,7 @@ type DiscoveryDoc struct {
|
||||
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"`
|
||||
}
|
||||
|
||||
func parseDiscoveryDoc(u *xnet.URL, transport *http.Transport, closeRespFn func(io.ReadCloser)) (DiscoveryDoc, error) {
|
||||
func parseDiscoveryDoc(u *xnet.URL, transport http.RoundTripper, closeRespFn func(io.ReadCloser)) (DiscoveryDoc, error) {
|
||||
d := DiscoveryDoc{}
|
||||
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
@@ -487,6 +587,14 @@ func parseDiscoveryDoc(u *xnet.URL, transport *http.Transport, closeRespFn func(
|
||||
// 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: "",
|
||||
@@ -535,121 +643,250 @@ func Enabled(kvs config.KVS) bool {
|
||||
return kvs.Get(ConfigURL) != ""
|
||||
}
|
||||
|
||||
// LookupConfig lookup jwks from config, override with any ENVs.
|
||||
func LookupConfig(kvs config.KVS, transport *http.Transport, closeRespFn func(io.ReadCloser), serverRegion string) (c Config, err error) {
|
||||
// remove this since we have removed this already.
|
||||
kvs.Delete(JwksURL)
|
||||
|
||||
if err = config.CheckValidKeys(config.IdentityOpenIDSubSys, kvs, DefaultKVS); err != nil {
|
||||
return c, err
|
||||
}
|
||||
|
||||
c = Config{
|
||||
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)),
|
||||
RedirectURIDynamic: env.Get(EnvIdentityOpenIDRedirectURIDynamic, kvs.Get(RedirectURIDynamic)) == config.EnableOn,
|
||||
publicKeys: make(map[string]crypto.PublicKey),
|
||||
ClientID: env.Get(EnvIdentityOpenIDClientID, kvs.Get(ClientID)),
|
||||
ClientSecret: env.Get(EnvIdentityOpenIDClientSecret, kvs.Get(ClientSecret)),
|
||||
RolePolicy: env.Get(EnvIdentityOpenIDRolePolicy, kvs.Get(RolePolicy)),
|
||||
transport: transport,
|
||||
closeRespFn: closeRespFn,
|
||||
}
|
||||
|
||||
configURL := env.Get(EnvIdentityOpenIDURL, kvs.Get(ConfigURL))
|
||||
var configURLDomain string
|
||||
if configURL != "" {
|
||||
c.URL, err = xnet.ParseHTTPURL(configURL)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
configURLDomain, _, _ = net.SplitHostPort(c.URL.Host)
|
||||
c.DiscoveryDoc, err = parseDiscoveryDoc(c.URL, transport, closeRespFn)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
}
|
||||
|
||||
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, ",") {
|
||||
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.
|
||||
c.DiscoveryDoc.ScopesSupported = scopes
|
||||
}
|
||||
|
||||
// Check if claim name is the non-default value and role policy is set.
|
||||
if c.ClaimName != iampolicy.PolicyName && c.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 and Claim Name cannot both be set.")
|
||||
}
|
||||
|
||||
if c.RolePolicy != "" {
|
||||
// RolePolicy is valided 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, _, _ = net.SplitHostPort(c.JWKS.URL.Host)
|
||||
if domain == "" {
|
||||
return c, config.Errorf("unable to generate a domain from the OpenID config.")
|
||||
}
|
||||
}
|
||||
|
||||
if c.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(c.ClientID))
|
||||
bs := h.Sum(nil)
|
||||
resourceID = base64.RawURLEncoding.EncodeToString(bs)
|
||||
}
|
||||
c.roleArn, err = arn.NewIAMRoleARN(resourceID, serverRegion)
|
||||
if err != nil {
|
||||
return c, config.Errorf("unable to generate ARN from the OpenID config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
jwksURL := c.DiscoveryDoc.JwksURI
|
||||
if jwksURL == "" {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
c.JWKS.URL, err = xnet.ParseHTTPURL(jwksURL)
|
||||
// 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 {
|
||||
return c, err
|
||||
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,
|
||||
}
|
||||
|
||||
if err = c.PopulatePublicKey(); err != nil {
|
||||
return c, err
|
||||
// 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
|
||||
}
|
||||
|
||||
if err = c.InitializeProvider(kvs); err != nil {
|
||||
return c, err
|
||||
// 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)
|
||||
|
||||
var configURLDomain string
|
||||
p.URL, err = xnet.ParseHTTPURL(configURL)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
configURLDomain, _, _ = net.SplitHostPort(p.URL.Host)
|
||||
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)
|
||||
}
|
||||
|
||||
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, _, _ = net.SplitHostPort(p.JWKS.URL.Host)
|
||||
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")
|
||||
}
|
||||
|
||||
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 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
|
||||
|
||||
Reference in New Issue
Block a user