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:
Aditya Manthramurthy
2022-04-28 18:27:09 -07:00
committed by GitHub
parent 424b44c247
commit 0e502899a8
22 changed files with 954 additions and 649 deletions

View File

@@ -26,6 +26,12 @@ var (
}
Help = config.HelpKVS{
config.HelpKV{
Key: DisplayName,
Description: "Friendly display name for this Provider/App" + defaultHelpPostfix(DisplayName),
Optional: true,
Type: "string",
},
config.HelpKV{
Key: ConfigURL,
Description: `openid discovery document e.g. "https://accounts.google.com/.well-known/openid-configuration"` + defaultHelpPostfix(ConfigURL),
@@ -40,19 +46,6 @@ var (
Key: ClientSecret,
Description: `secret for the unique public identifier for apps` + defaultHelpPostfix(ClientSecret),
Type: "string",
Optional: true,
},
config.HelpKV{
Key: ClaimName,
Description: `JWT canned policy claim name` + defaultHelpPostfix(ClaimName),
Optional: true,
Type: "string",
},
config.HelpKV{
Key: ClaimUserinfo,
Description: `Enable fetching claims from UserInfo Endpoint for authenticated user` + defaultHelpPostfix(ClaimUserinfo),
Optional: true,
Type: "on|off",
},
config.HelpKV{
Key: RolePolicy,
@@ -60,6 +53,12 @@ var (
Optional: true,
Type: "string",
},
config.HelpKV{
Key: ClaimName,
Description: `JWT canned policy claim name` + defaultHelpPostfix(ClaimName),
Optional: true,
Type: "string",
},
config.HelpKV{
Key: Scopes,
Description: `Comma separated list of OpenID scopes for server, defaults to advertised scopes from discovery document e.g. "email,admin"` + defaultHelpPostfix(Scopes),
@@ -72,6 +71,12 @@ var (
Optional: true,
Type: "string",
},
config.HelpKV{
Key: ClaimUserinfo,
Description: `Enable fetching claims from UserInfo Endpoint for authenticated user` + defaultHelpPostfix(ClaimUserinfo),
Optional: true,
Type: "on|off",
},
config.HelpKV{
Key: KeyCloakRealm,
Description: `Specify Keycloak 'realm' name, only honored if vendor was set to 'keycloak' as value, if no realm is specified 'master' is default` + defaultHelpPostfix(KeyCloakRealm),

View File

@@ -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

View File

@@ -18,15 +18,18 @@
package openid
import (
"bytes"
"crypto"
"encoding/base64"
"encoding/json"
"net/http"
"net/url"
"sync"
"testing"
"time"
jwtg "github.com/golang-jwt/jwt/v4"
"github.com/minio/minio/internal/arn"
"github.com/minio/minio/internal/config"
jwtm "github.com/minio/minio/internal/jwt"
xnet "github.com/minio/pkg/net"
@@ -71,20 +74,16 @@ func TestUpdateClaimsExpiry(t *testing.T) {
func TestJWTAzureFail(t *testing.T) {
const jsonkey = `{"keys":[{"kty":"RSA","use":"sig","kid":"SsZsBNhZcF3Q9S4trpQBTByNRRI","x5t":"SsZsBNhZcF3Q9S4trpQBTByNRRI","n":"uHPewhg4WC3eLVPkEFlj7RDtaKYWXCI5G-LPVzsMKOuIu7qQQbeytIA6P6HT9_iIRt8zNQvuw4P9vbNjgUCpI6vfZGsjk3XuCVoB_bAIhvuBcQh9ePH2yEwS5reR-NrG1PsqzobnZZuigKCoDmuOb_UDx1DiVyNCbMBlEG7UzTQwLf5NP6HaRHx027URJeZvPAWY7zjHlSOuKoS_d1yUveaBFIgZqPWLCg44ck4gvik45HsNVWT9zYfT74dvUSSrMSR-SHFT7Hy1XjbVXpHJHNNAXpPoGoWXTuc0BxMsB4cqjfJqoftFGOG4x32vEzakArLPxAKwGvkvu0jToAyvSQ","e":"AQAB","x5c":"MIIDBTCCAe2gAwIBAgIQWHw7h/Ysh6hPcXpnrJ0N8DANBgkqhkiG9w0BAQsFADAtMSswKQYDVQQDEyJhY2NvdW50cy5hY2Nlc3Njb250cm9sLndpbmRvd3MubmV0MB4XDTIwMDQyNzAwMDAwMFoXDTI1MDQyNzAwMDAwMFowLTErMCkGA1UEAxMiYWNjb3VudHMuYWNjZXNzY29udHJvbC53aW5kb3dzLm5ldDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALhz3sIYOFgt3i1T5BBZY+0Q7WimFlwiORviz1c7DCjriLu6kEG3srSAOj+h0/f4iEbfMzUL7sOD/b2zY4FAqSOr32RrI5N17glaAf2wCIb7gXEIfXjx9shMEua3kfjaxtT7Ks6G52WbooCgqA5rjm/1A8dQ4lcjQmzAZRBu1M00MC3+TT+h2kR8dNu1ESXmbzwFmO84x5UjriqEv3dclL3mgRSIGaj1iwoOOHJOIL4pOOR7DVVk/c2H0++Hb1EkqzEkfkhxU+x8tV421V6RyRzTQF6T6BqFl07nNAcTLAeHKo3yaqH7RRjhuMd9rxM2pAKyz8QCsBr5L7tI06AMr0kCAwEAAaMhMB8wHQYDVR0OBBYEFOI7M+DDFMlP7Ac3aomPnWo1QL1SMA0GCSqGSIb3DQEBCwUAA4IBAQBv+8rBiDY8sZDBoUDYwFQM74QjqCmgNQfv5B0Vjwg20HinERjQeH24uAWzyhWN9++FmeY4zcRXDY5UNmB0nJz7UGlprA9s7voQ0Lkyiud0DO072RPBg38LmmrqoBsLb3MB9MZ2CGBaHftUHfpdTvrgmXSP0IJn7mCUq27g+hFk7n/MLbN1k8JswEODIgdMRvGqN+mnrPKkviWmcVAZccsWfcmS1pKwXqICTKzd6WmVdz+cL7ZSd9I2X0pY4oRwauoE2bS95vrXljCYgLArI3XB2QcnglDDBRYu3Z3aIJb26PTIyhkVKT7xaXhXl4OgrbmQon9/O61G2dzpjzzBPqNP","issuer":"https://login.microsoftonline.com/906aefe9-76a7-4f65-b82d-5ec20775d5aa/v2.0"},{"kty":"RSA","use":"sig","kid":"huN95IvPfehq34GzBDZ1GXGirnM","x5t":"huN95IvPfehq34GzBDZ1GXGirnM","n":"6lldKm5Rc_vMKa1RM_TtUv3tmtj52wLRrJqu13yGM3_h0dwru2ZP53y65wDfz6_tLCjoYuRCuVsjoW37-0zXUORJvZ0L90CAX-58lW7NcE4bAzA1pXv7oR9kQw0X8dp0atU4HnHeaTU8LZxcjJO79_H9cxgwa-clKfGxllcos8TsuurM8xi2dx5VqwzqNMB2s62l3MTN7AzctHUiQCiX2iJArGjAhs-mxS1wmyMIyOSipdodhjQWRAcseW-aFVyRTFVi8okl2cT1HJjPXdx0b1WqYSOzeRdrrLUcA0oR2Tzp7xzOYJZSGNnNLQqa9f6h6h52XbX0iAgxKgEDlRpbJw","e":"AQAB","x5c":["MIIDBTCCAe2gAwIBAgIQPCxFbySVSLZOggeWRzBWOjANBgkqhkiG9w0BAQsFADAtMSswKQYDVQQDEyJhY2NvdW50cy5hY2Nlc3Njb250cm9sLndpbmRvd3MubmV0MB4XDTIwMDYwNzAwMDAwMFoXDTI1MDYwNzAwMDAwMFowLTErMCkGA1UEAxMiYWNjb3VudHMuYWNjZXNzY29udHJvbC53aW5kb3dzLm5ldDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOpZXSpuUXP7zCmtUTP07VL97ZrY+dsC0ayartd8hjN/4dHcK7tmT+d8uucA38+v7Swo6GLkQrlbI6Ft+/tM11DkSb2dC/dAgF/ufJVuzXBOGwMwNaV7+6EfZEMNF/HadGrVOB5x3mk1PC2cXIyTu/fx/XMYMGvnJSnxsZZXKLPE7LrqzPMYtnceVasM6jTAdrOtpdzEzewM3LR1IkAol9oiQKxowIbPpsUtcJsjCMjkoqXaHYY0FkQHLHlvmhVckUxVYvKJJdnE9RyYz13cdG9VqmEjs3kXa6y1HANKEdk86e8czmCWUhjZzS0KmvX+oeoedl219IgIMSoBA5UaWycCAwEAAaMhMB8wHQYDVR0OBBYEFFXP0ODFhjf3RS6oRijM5Tb+yB8CMA0GCSqGSIb3DQEBCwUAA4IBAQB9GtVikLTbJWIu5x9YCUTTKzNhi44XXogP/v8VylRSUHI5YTMdnWwvDIt/Y1sjNonmSy9PrioEjcIiI1U8nicveafMwIq5VLn+gEY2lg6KDJAzgAvA88CXqwfHHvtmYBovN7goolp8TY/kddMTf6TpNzN3lCTM2MK4Ye5xLLVGdp4bqWCOJ/qjwDxpTRSydYIkLUDwqNjv+sYfOElJpYAB4rTL/aw3ChJ1iaA4MtXEt6OjbUtbOa21lShfLzvNRbYK3+ukbrhmRl9lemJEeUls51vPuIe+jg+Ssp43aw7PQjxt4/MpfNMS2BfZ5F8GVSVG7qNb352cLLeJg5rc398Z"],"issuer":"https://login.microsoftonline.com/906aefe9-76a7-4f65-b82d-5ec20775d5aa/v2.0"},{"kty":"RSA","use":"sig","kid":"M6pX7RHoraLsprfJeRCjSxuURhc","x5t":"M6pX7RHoraLsprfJeRCjSxuURhc","n":"xHScZMPo8FifoDcrgncWQ7mGJtiKhrsho0-uFPXg-OdnRKYudTD7-Bq1MDjcqWRf3IfDVjFJixQS61M7wm9wALDj--lLuJJ9jDUAWTA3xWvQLbiBM-gqU0sj4mc2lWm6nPfqlyYeWtQcSC0sYkLlayNgX4noKDaXivhVOp7bwGXq77MRzeL4-9qrRYKjuzHfZL7kNBCsqO185P0NI2Jtmw-EsqYsrCaHsfNRGRrTvUHUq3hWa859kK_5uNd7TeY2ZEwKVD8ezCmSfR59ZzyxTtuPpkCSHS9OtUvS3mqTYit73qcvprjl3R8hpjXLb8oftfpWr3hFRdpxrwuoQEO4QQ","e":"AQAB","x5c":["MIIC8TCCAdmgAwIBAgIQfEWlTVc1uINEc9RBi6qHMjANBgkqhkiG9w0BAQsFADAjMSEwHwYDVQQDExhsb2dpbi5taWNyb3NvZnRvbmxpbmUudXMwHhcNMTgxMDE0MDAwMDAwWhcNMjAxMDE0MDAwMDAwWjAjMSEwHwYDVQQDExhsb2dpbi5taWNyb3NvZnRvbmxpbmUudXMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDEdJxkw+jwWJ+gNyuCdxZDuYYm2IqGuyGjT64U9eD452dEpi51MPv4GrUwONypZF/ch8NWMUmLFBLrUzvCb3AAsOP76Uu4kn2MNQBZMDfFa9AtuIEz6CpTSyPiZzaVabqc9+qXJh5a1BxILSxiQuVrI2BfiegoNpeK+FU6ntvAZervsxHN4vj72qtFgqO7Md9kvuQ0EKyo7Xzk/Q0jYm2bD4SypiysJoex81EZGtO9QdSreFZrzn2Qr/m413tN5jZkTApUPx7MKZJ9Hn1nPLFO24+mQJIdL061S9LeapNiK3vepy+muOXdHyGmNctvyh+1+laveEVF2nGvC6hAQ7hBAgMBAAGjITAfMB0GA1UdDgQWBBQ5TKadw06O0cvXrQbXW0Nb3M3h/DANBgkqhkiG9w0BAQsFAAOCAQEAI48JaFtwOFcYS/3pfS5+7cINrafXAKTL+/+he4q+RMx4TCu/L1dl9zS5W1BeJNO2GUznfI+b5KndrxdlB6qJIDf6TRHh6EqfA18oJP5NOiKhU4pgkF2UMUw4kjxaZ5fQrSoD9omjfHAFNjradnHA7GOAoF4iotvXDWDBWx9K4XNZHWvD11Td66zTg5IaEQDIZ+f8WS6nn/98nAVMDtR9zW7Te5h9kGJGfe6WiHVaGRPpBvqC4iypGHjbRwANwofZvmp5wP08hY1CsnKY5tfP+E2k/iAQgKKa6QoxXToYvP7rsSkglak8N5g/+FJGnq4wP6cOzgZpjdPMwaVt5432GA=="],"issuer":"https://login.microsoftonline.com/906aefe9-76a7-4f65-b82d-5ec20775d5aa/v2.0"}]}`
var jk JWKS
if err := json.Unmarshal([]byte(jsonkey), &jk); err != nil {
t.Fatal("Unmarshal: ", err)
} else if len(jk.Keys) != 3 {
t.Fatalf("Expected 3 keys, got %d", len(jk.Keys))
pubKeys := publicKeys{
RWMutex: &sync.RWMutex{},
pkMap: map[string]crypto.PublicKey{},
}
keys := make(map[string]crypto.PublicKey, len(jk.Keys))
for ii, jks := range jk.Keys {
var err error
keys[jks.Kid], err = jks.DecodePublicKey()
if err != nil {
t.Fatalf("Failed to decode key %d: %v", ii, err)
}
err := pubKeys.parseAndAdd(bytes.NewBuffer([]byte(jsonkey)))
if err != nil {
t.Fatal("Error loading pubkeys:", err)
}
if len(pubKeys.pkMap) != 3 {
t.Fatalf("Expected 3 keys, got %d", len(pubKeys.pkMap))
}
jwtToken := `eyJ0eXAiOiJKV1QiLCJub25jZSI6Il9KUlNlS0tjNmxIVVRJdk1tMmZNWktBTEtZOUpwenNPalc5cl96OEk2VFkiLCJhbGciOiJSUzI1NiIsIng1dCI6Imh1Tjk1SXZQZmVocTM0R3pCRFoxR1hHaXJuTSIsImtpZCI6Imh1Tjk1SXZQZmVocTM0R3pCRFoxR1hHaXJuTSJ9.eyJhdWQiOiIwMDAwMDAwMy0wMDAwLTAwMDAtYzAwMC0wMDAwMDAwMDAwMDAiLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC85MDZhZWZlOS03NmE3LTRmNjUtYjgyZC01ZWMyMDc3NWQ1YWEvIiwiaWF0IjoxNTk0NjU3NTIwLCJuYmYiOjE1OTQ2NTc1MjAsImV4cCI6MTU5NDY2MTQyMCwiYWNjdCI6MCwiYWNyIjoiMSIsImFpbyI6IkUyQmdZTmliK3QydHh5SklRT1dEeXFsRDNVWUxwWGxVeXhmMGxFZmxMQ2t0VTU3TnpBVUEiLCJhbXIiOlsicHdkIl0sImFwcF9kaXNwbGF5bmFtZSI6ImR4YXp1cmUiLCJhcHBpZCI6ImY0ZDM0M2IyLTRmNDYtNGUyYy04M2RlLTVkN2QyN2Q2OTUyNSIsImFwcGlkYWNyIjoiMSIsImZhbWlseV9uYW1lIjoiS2FzYSIsImdpdmVuX25hbWUiOiJCYWxha3Jpc2huYSIsImluX2NvcnAiOiJ0cnVlIiwiaXBhZGRyIjoiMTk4LjE3OC4xMi42OCIsIm5hbWUiOiJLYXNhLCBCYWxha3Jpc2huYSIsIm9pZCI6IjZjNDJhMTYwLTIyZGMtNDJmNy05MDRlLTQwODZkNzg0MzQ0OCIsIm9ucHJlbV9zaWQiOiJTLTEtNS0yMS0yMDUyMTExMzAyLTQ0ODUzOTcyMy0xODAxNjc0NTMxLTQ2NDkzMDciLCJwbGF0ZiI6IjE0IiwicHVpZCI6IjEwMDNCRkZEOTZGRTM3MzkiLCJzY3AiOiJEaXJlY3RvcnkuUmVhZC5BbGwgb3BlbmlkIHByb2ZpbGUgVXNlci5SZWFkIGVtYWlsIiwic2lnbmluX3N0YXRlIjpbImlua25vd25udHdrIl0sInN1YiI6IkNkTEQ3X2tnbnRsdHQta2FqaUJOYWkyNkxvUUxsMF9xd3d6MXhCcDRzcHciLCJ0ZW5hbnRfcmVnaW9uX3Njb3BlIjoiTkEiLCJ0aWQiOiI5MDZhZWZlOS03NmE3LTRmNjUtYjgyZC01ZWMyMDc3NWQ1YWEiLCJ1bmlxdWVfbmFtZSI6ImJrYXNhNzI0QGNhYmxlLmNvbWNhc3QuY29tIiwidXBuIjoiYmthc2E3MjRAY2FibGUuY29tY2FzdC5jb20iLCJ1dGkiOiJ0UThJVEpjb0lVdUhaZXpBb2twZ0FBIiwidmVyIjoiMS4wIiwieG1zX3N0Ijp7InN1YiI6InJCQlZGX1NlOUZpcG16VUg5VVNWNXl1aVRwazFkb2s4ODNxb3R6UVN0bU0ifSwieG1zX3RjZHQiOjEzNzUxMjYzMzR9.TNzUp6b2ZJA6rBJzwpyC58UmH5CkEZFoB1d4sFnDGR_o3sdgtsRdR6ogeCZudaIPBCDCQz5_yMo59_hWUt0Q2iQI2sy1SUtdOAUtu4dcY-0LhqS0tIprc5mwBJytxJ9BVttmZ8r0_lqBSqn9dl8LajWpSCcVNBSFxT7V6N0zi8ONtWXbizkZOb52Tt2uVO4ak7bzi9gstEGiDTLxhDDJLpo3sZVy7LTI2gSMVsOoyeKBHk4GL5Fs0Ezz0yHad0MrJ8tULiqXocIC3vlA5u6-klOyfx04v-Lzs1L4F4XkAysJgGIAj7E9TBSw0XhMM5WKF25AzKGznLLt11r3cCIxCg`
@@ -94,17 +93,20 @@ func TestJWTAzureFail(t *testing.T) {
t.Fatal(err)
}
provider := providerCfg{}
provider.JWKS.URL = u1
cfg := Config{
RWMutex: &sync.RWMutex{},
Enabled: true,
}
cfg.JWKS.URL = u1
cfg.publicKeys = keys
if cfg.ID() != "jwt" {
t.Fatalf("Unexpected id %s for the validator", cfg.ID())
pubKeys: pubKeys,
arnProviderCfgsMap: map[arn.ARN]*providerCfg{
DummyRoleARN: &provider,
},
ProviderCfgs: map[string]*providerCfg{
"1": &provider,
},
}
if _, err := cfg.Validate(jwtToken, "", ""); err == nil {
if _, err := cfg.Validate(DummyRoleARN, jwtToken, "", ""); err == nil {
// Azure should fail due to non OIDC compliant JWT
// generated by Azure AD
t.Fatal(err)
@@ -122,20 +124,16 @@ func TestJWT(t *testing.T) {
]
}`
var jk JWKS
if err := json.Unmarshal([]byte(jsonkey), &jk); err != nil {
t.Fatal("Unmarshal: ", err)
} else if len(jk.Keys) != 1 {
t.Fatalf("Expected 1 keys, got %d", len(jk.Keys))
pubKeys := publicKeys{
RWMutex: &sync.RWMutex{},
pkMap: map[string]crypto.PublicKey{},
}
keys := make(map[string]crypto.PublicKey, len(jk.Keys))
for ii, jks := range jk.Keys {
var err error
keys[jks.Kid], err = jks.DecodePublicKey()
if err != nil {
t.Fatalf("Failed to decode key %d: %v", ii, err)
}
err := pubKeys.parseAndAdd(bytes.NewBuffer([]byte(jsonkey)))
if err != nil {
t.Fatal("Error loading pubkeys:", err)
}
if len(pubKeys.pkMap) != 1 {
t.Fatalf("Expected 1 keys, got %d", len(pubKeys.pkMap))
}
u1, err := xnet.ParseHTTPURL("http://localhost:8443")
@@ -143,14 +141,17 @@ func TestJWT(t *testing.T) {
t.Fatal(err)
}
provider := providerCfg{}
provider.JWKS.URL = u1
cfg := Config{
RWMutex: &sync.RWMutex{},
Enabled: true,
}
cfg.JWKS.URL = u1
cfg.publicKeys = keys
if cfg.ID() != "jwt" {
t.Fatalf("Unexpected id %s for the validator", cfg.ID())
pubKeys: pubKeys,
arnProviderCfgsMap: map[arn.ARN]*providerCfg{
DummyRoleARN: &provider,
},
ProviderCfgs: map[string]*providerCfg{
"1": &provider,
},
}
u, err := url.Parse("http://localhost:8443/?Token=invalid")
@@ -158,7 +159,7 @@ func TestJWT(t *testing.T) {
t.Fatal(err)
}
if _, err := cfg.Validate(u.Query().Get("Token"), "", ""); err == nil {
if _, err := cfg.Validate(DummyRoleARN, u.Query().Get("Token"), "", ""); err == nil {
t.Fatal(err)
}
}
@@ -233,7 +234,7 @@ func TestExpCorrect(t *testing.T) {
}
func TestKeycloakProviderInitialization(t *testing.T) {
testConfig := Config{
testConfig := providerCfg{
DiscoveryDoc: DiscoveryDoc{
TokenEndpoint: "http://keycloak.test/token/endpoint",
},
@@ -242,12 +243,15 @@ func TestKeycloakProviderInitialization(t *testing.T) {
testKvs.Set(Vendor, "keycloak")
testKvs.Set(KeyCloakRealm, "TestRealm")
testKvs.Set(KeyCloakAdminURL, "http://keycloak.test/auth/admin")
cfgGet := func(env, param string) string {
return testKvs.Get(param)
}
if testConfig.provider != nil {
t.Errorf("Empty config cannot have any provider!")
}
if err := testConfig.InitializeProvider(testKvs); err != nil {
if err := testConfig.initializeProvider(cfgGet, http.DefaultTransport); err != nil {
t.Error(err)
}

View File

@@ -124,7 +124,7 @@ func (k *KeycloakProvider) LookupUser(userid string) (User, error) {
type Option func(*KeycloakProvider)
// WithTransport provide custom transport
func WithTransport(transport *http.Transport) Option {
func WithTransport(transport http.RoundTripper) Option {
return func(p *KeycloakProvider) {
p.client = http.Client{
Transport: transport,

View File

@@ -1,92 +0,0 @@
// Copyright (c) 2015-2021 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 <http://www.gnu.org/licenses/>.
package openid
import (
"errors"
"fmt"
"sync"
)
// ID - holds identification name authentication validator target.
type ID string
// Validator interface describes basic implementation
// requirements of various authentication providers.
type Validator interface {
// Validate is a custom validator function for this provider,
// each validation is authenticationType or provider specific.
Validate(idToken, accessToken, duration string) (map[string]interface{}, error)
// ID returns provider name of this provider.
ID() ID
}
// ErrTokenExpired - error token expired
var (
ErrTokenExpired = errors.New("token expired")
)
// Validators - holds list of providers indexed by provider id.
type Validators struct {
sync.RWMutex
providers map[ID]Validator
}
// Add - adds unique provider to provider list.
func (list *Validators) Add(provider Validator) error {
list.Lock()
defer list.Unlock()
if _, ok := list.providers[provider.ID()]; ok {
return fmt.Errorf("provider %v already exists", provider.ID())
}
list.providers[provider.ID()] = provider
return nil
}
// List - returns available provider IDs.
func (list *Validators) List() []ID {
list.RLock()
defer list.RUnlock()
keys := []ID{}
for k := range list.providers {
keys = append(keys, k)
}
return keys
}
// Get - returns the provider for the given providerID, if not found
// returns an error.
func (list *Validators) Get(id ID) (p Validator, err error) {
list.RLock()
defer list.RUnlock()
var ok bool
if p, ok = list.providers[id]; !ok {
return nil, fmt.Errorf("provider %v doesn't exist", id)
}
return p, nil
}
// NewValidators - creates Validators.
func NewValidators() *Validators {
return &Validators{providers: make(map[ID]Validator)}
}

View File

@@ -1,105 +0,0 @@
// Copyright (c) 2015-2021 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 <http://www.gnu.org/licenses/>.
package openid
import (
"net/http"
"net/http/httptest"
"testing"
xnet "github.com/minio/pkg/net"
)
type errorValidator struct{}
func (e errorValidator) Validate(idToken, accessToken, dsecs string) (map[string]interface{}, error) {
return nil, ErrTokenExpired
}
func (e errorValidator) ID() ID {
return "err"
}
func TestValidators(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "application/json")
w.Write([]byte(`{
"keys" : [ {
"kty" : "RSA",
"kid" : "1438289820780",
"use" : "sig",
"alg" : "RS256",
"n" : "idWPro_QiAFOdMsJD163lcDIPogOwXogRo3Pct2MMyeE2GAGqV20Sc8QUbuLDfPl-7Hi9IfFOz--JY6QL5l92eV-GJXkTmidUEooZxIZSp3ghRxLCqlyHeF5LuuM5LPRFDeF4YWFQT_D2eNo_w95g6qYSeOwOwGIfaHa2RMPcQAiM6LX4ot-Z7Po9z0_3ztFa02m3xejEFr2rLRqhFl3FZJaNnwTUk6an6XYsunxMk3Ya3lRaKJReeXeFtfTpShgtPiAl7lIfLJH9h26h2OAlww531DpxHSm1gKXn6bjB0NTC55vJKft4wXoc_0xKZhnWmjQE8d9xE8e1Z3Ll1LYbw",
"e" : "AQAB"
}, {
"kty" : "RSA",
"kid" : "1438289856256",
"use" : "sig",
"alg" : "RS256",
"n" : "zo5cKcbFECeiH8eGx2D-DsFSpjSKbTVlXD6uL5JAy9rYIv7eYEP6vrKeX-x1z70yEdvgk9xbf9alc8siDfAz3rLCknqlqL7XGVAQL0ZP63UceDmD60LHOzMrx4eR6p49B3rxFfjvX2SWSV3-1H6XNyLk_ALbG6bGCFGuWBQzPJB4LMKCrOFq-6jtRKOKWBXYgkYkaYs5dG-3e2ULbq-y2RdgxYh464y_-MuxDQfvUgP787XKfcXP_XjJZvyuOEANjVyJYZSOyhHUlSGJapQ8ztHdF-swsnf7YkePJ2eR9fynWV2ZoMaXOdidgZtGTa4R1Z4BgH2C0hKJiqRy9fB7Gw",
"e" : "AQAB"
} ]
}
`))
w.(http.Flusher).Flush()
}))
defer ts.Close()
vrs := NewValidators()
if err := vrs.Add(&errorValidator{}); err != nil {
t.Fatal(err)
}
if err := vrs.Add(&errorValidator{}); err == nil {
t.Fatal("Unexpected should return error for double inserts")
}
if _, err := vrs.Get("unknown"); err == nil {
t.Fatal("Unexpected should return error for unknown validators")
}
v, err := vrs.Get("err")
if err != nil {
t.Fatal(err)
}
if _, err = v.Validate("", "", ""); err != ErrTokenExpired {
t.Fatalf("Expected error %s, got %s", ErrTokenExpired, err)
}
vids := vrs.List()
if len(vids) == 0 || len(vids) > 1 {
t.Fatalf("Unexpected number of vids %v", vids)
}
u, err := xnet.ParseHTTPURL(ts.URL)
if err != nil {
t.Fatal(err)
}
cfg := Config{}
cfg.JWKS.URL = u
if err = vrs.Add(&cfg); err != nil {
t.Fatal(err)
}
if _, err = vrs.Get("jwt"); err != nil {
t.Fatal(err)
}
}