|
|
|
/*
|
|
|
|
* MinIO Cloud Storage, (C) 2018-2019 MinIO, Inc.
|
|
|
|
*
|
|
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
* you may not use this file except in compliance with the License.
|
|
|
|
* You may obtain a copy of the License at
|
|
|
|
*
|
|
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
*
|
|
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
* See the License for the specific language governing permissions and
|
|
|
|
* limitations under the License.
|
|
|
|
*/
|
|
|
|
|
|
|
|
package openid
|
|
|
|
|
|
|
|
import (
|
|
|
|
"crypto"
|
|
|
|
"encoding/json"
|
|
|
|
"errors"
|
|
|
|
"fmt"
|
|
|
|
"io"
|
|
|
|
"net/http"
|
|
|
|
"strconv"
|
|
|
|
"strings"
|
|
|
|
"sync"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
jwtgo "github.com/dgrijalva/jwt-go"
|
|
|
|
"github.com/minio/minio/cmd/config"
|
|
|
|
"github.com/minio/minio/pkg/auth"
|
|
|
|
"github.com/minio/minio/pkg/env"
|
|
|
|
iampolicy "github.com/minio/minio/pkg/iam/policy"
|
|
|
|
xnet "github.com/minio/minio/pkg/net"
|
|
|
|
)
|
|
|
|
|
|
|
|
// Config - OpenID Config
|
|
|
|
// RSA authentication target arguments
|
|
|
|
type Config struct {
|
|
|
|
JWKS struct {
|
|
|
|
URL *xnet.URL `json:"url"`
|
|
|
|
} `json:"jwks"`
|
|
|
|
URL *xnet.URL `json:"url,omitempty"`
|
|
|
|
ClaimPrefix string `json:"claimPrefix,omitempty"`
|
|
|
|
ClaimName string `json:"claimName,omitempty"`
|
|
|
|
DiscoveryDoc DiscoveryDoc
|
|
|
|
ClientID string
|
|
|
|
publicKeys map[string]crypto.PublicKey
|
|
|
|
transport *http.Transport
|
|
|
|
closeRespFn func(io.ReadCloser)
|
|
|
|
mutex *sync.Mutex
|
|
|
|
}
|
|
|
|
|
|
|
|
// PopulatePublicKey - populates a new publickey from the JWKS URL.
|
|
|
|
func (r *Config) PopulatePublicKey() error {
|
|
|
|
r.mutex.Lock()
|
|
|
|
defer r.mutex.Unlock()
|
|
|
|
|
|
|
|
if r.JWKS.URL == nil || r.JWKS.URL.String() == "" {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
transport := http.DefaultTransport
|
|
|
|
if r.transport != nil {
|
|
|
|
transport = r.transport
|
|
|
|
}
|
|
|
|
client := &http.Client{
|
|
|
|
Transport: transport,
|
|
|
|
}
|
|
|
|
resp, err := client.Get(r.JWKS.URL.String())
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
defer r.closeRespFn(resp.Body)
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
if ar.JWKS.URL == nil || ar.JWKS.URL.String() == "" {
|
|
|
|
*r = ar
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
*r = ar
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// JWT - rs client grants provider details.
|
|
|
|
type JWT struct {
|
|
|
|
Config
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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 7 days.
|
|
|
|
if expirySecs < 900 || expirySecs > 604800 {
|
|
|
|
return 0, auth.ErrInvalidDuration
|
|
|
|
}
|
|
|
|
|
|
|
|
defaultExpiryDuration = time.Duration(expirySecs) * time.Second
|
|
|
|
}
|
|
|
|
return defaultExpiryDuration, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func updateClaimsExpiry(dsecs string, claims map[string]interface{}) error {
|
|
|
|
expStr := claims["exp"]
|
|
|
|
if expStr == "" {
|
|
|
|
return ErrTokenExpired
|
|
|
|
}
|
|
|
|
|
|
|
|
// No custom duration requested, the claims can be used as is.
|
|
|
|
if dsecs == "" {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
expAt, err := auth.ExpToInt64(expStr)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
defaultExpiryDuration, err := GetDefaultExpiration(dsecs)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
// Verify if JWT expiry is lesser than default expiry duration,
|
|
|
|
// if that is the case then set the default expiration to be
|
|
|
|
// from the JWT expiry claim.
|
|
|
|
if time.Unix(expAt, 0).UTC().Sub(time.Now().UTC()) < defaultExpiryDuration {
|
|
|
|
defaultExpiryDuration = time.Unix(expAt, 0).UTC().Sub(time.Now().UTC())
|
|
|
|
} // else honor the specified expiry duration.
|
|
|
|
|
|
|
|
expiry := time.Now().UTC().Add(defaultExpiryDuration).Unix()
|
|
|
|
claims["exp"] = strconv.FormatInt(expiry, 10) // update with new expiry.
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// Validate - validates the access token.
|
|
|
|
func (p *JWT) Validate(token, dsecs string) (map[string]interface{}, error) {
|
|
|
|
jp := new(jwtgo.Parser)
|
|
|
|
jp.ValidMethods = []string{
|
|
|
|
"RS256", "RS384", "RS512", "ES256", "ES384", "ES512",
|
|
|
|
"RS3256", "RS3384", "RS3512", "ES3256", "ES3384", "ES3512",
|
|
|
|
}
|
|
|
|
|
|
|
|
keyFuncCallback := func(jwtToken *jwtgo.Token) (interface{}, error) {
|
|
|
|
kid, ok := jwtToken.Header["kid"].(string)
|
|
|
|
if !ok {
|
|
|
|
return nil, fmt.Errorf("Invalid kid value %v", jwtToken.Header["kid"])
|
|
|
|
}
|
|
|
|
return p.publicKeys[kid], nil
|
|
|
|
}
|
|
|
|
|
|
|
|
var claims jwtgo.MapClaims
|
|
|
|
jwtToken, err := jp.ParseWithClaims(token, &claims, keyFuncCallback)
|
|
|
|
if err != nil {
|
|
|
|
// Re-populate the public key in-case the JWKS
|
|
|
|
// pubkeys are refreshed
|
|
|
|
if err = p.PopulatePublicKey(); err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
jwtToken, err = jwtgo.ParseWithClaims(token, &claims, keyFuncCallback)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if !jwtToken.Valid {
|
|
|
|
return nil, ErrTokenExpired
|
|
|
|
}
|
|
|
|
|
|
|
|
if err = updateClaimsExpiry(dsecs, claims); err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
return claims, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// ID returns the provider name and authentication type.
|
|
|
|
func (p *JWT) ID() ID {
|
|
|
|
return "jwt"
|
|
|
|
}
|
|
|
|
|
|
|
|
// OpenID keys and envs.
|
|
|
|
const (
|
|
|
|
JwksURL = "jwks_url"
|
|
|
|
ConfigURL = "config_url"
|
|
|
|
ClaimName = "claim_name"
|
|
|
|
ClaimPrefix = "claim_prefix"
|
|
|
|
ClientID = "client_id"
|
|
|
|
Scopes = "scopes"
|
|
|
|
|
|
|
|
EnvIdentityOpenIDClientID = "MINIO_IDENTITY_OPENID_CLIENT_ID"
|
|
|
|
EnvIdentityOpenIDJWKSURL = "MINIO_IDENTITY_OPENID_JWKS_URL"
|
|
|
|
EnvIdentityOpenIDURL = "MINIO_IDENTITY_OPENID_CONFIG_URL"
|
|
|
|
EnvIdentityOpenIDClaimName = "MINIO_IDENTITY_OPENID_CLAIM_NAME"
|
|
|
|
EnvIdentityOpenIDClaimPrefix = "MINIO_IDENTITY_OPENID_CLAIM_PREFIX"
|
|
|
|
EnvIdentityOpenIDScopes = "MINIO_IDENTITY_OPENID_SCOPES"
|
|
|
|
)
|
|
|
|
|
|
|
|
// DiscoveryDoc - parses the output from openid-configuration
|
|
|
|
// for example https://accounts.google.com/.well-known/openid-configuration
|
|
|
|
type DiscoveryDoc struct {
|
|
|
|
Issuer string `json:"issuer,omitempty"`
|
|
|
|
AuthEndpoint string `json:"authorization_endpoint,omitempty"`
|
|
|
|
TokenEndpoint string `json:"token_endpoint,omitempty"`
|
|
|
|
UserInfoEndpoint string `json:"userinfo_endpoint,omitempty"`
|
|
|
|
RevocationEndpoint string `json:"revocation_endpoint,omitempty"`
|
|
|
|
JwksURI string `json:"jwks_uri,omitempty"`
|
|
|
|
ResponseTypesSupported []string `json:"response_types_supported,omitempty"`
|
|
|
|
SubjectTypesSupported []string `json:"subject_types_supported,omitempty"`
|
|
|
|
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported,omitempty"`
|
|
|
|
ScopesSupported []string `json:"scopes_supported,omitempty"`
|
|
|
|
TokenEndpointAuthMethods []string `json:"token_endpoint_auth_methods_supported,omitempty"`
|
|
|
|
ClaimsSupported []string `json:"claims_supported,omitempty"`
|
|
|
|
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"`
|
|
|
|
}
|
|
|
|
|
|
|
|
func parseDiscoveryDoc(u *xnet.URL, transport *http.Transport, closeRespFn func(io.ReadCloser)) (DiscoveryDoc, error) {
|
|
|
|
d := DiscoveryDoc{}
|
|
|
|
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
|
|
|
|
if err != nil {
|
|
|
|
return d, err
|
|
|
|
}
|
|
|
|
clnt := http.Client{
|
|
|
|
Transport: transport,
|
|
|
|
}
|
|
|
|
resp, err := clnt.Do(req)
|
|
|
|
if err != nil {
|
|
|
|
clnt.CloseIdleConnections()
|
|
|
|
return d, err
|
|
|
|
}
|
|
|
|
defer closeRespFn(resp.Body)
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
return d, err
|
|
|
|
}
|
|
|
|
dec := json.NewDecoder(resp.Body)
|
|
|
|
if err = dec.Decode(&d); err != nil {
|
|
|
|
return d, err
|
|
|
|
}
|
|
|
|
return d, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// DefaultKVS - default config for OpenID config
|
|
|
|
var (
|
|
|
|
DefaultKVS = config.KVS{
|
|
|
|
config.KV{
|
|
|
|
Key: ConfigURL,
|
|
|
|
Value: "",
|
|
|
|
},
|
|
|
|
config.KV{
|
|
|
|
Key: ClientID,
|
|
|
|
Value: "",
|
|
|
|
},
|
|
|
|
config.KV{
|
|
|
|
Key: ClaimName,
|
|
|
|
Value: iampolicy.PolicyName,
|
|
|
|
},
|
|
|
|
config.KV{
|
|
|
|
Key: ClaimPrefix,
|
|
|
|
Value: "",
|
|
|
|
},
|
|
|
|
config.KV{
|
|
|
|
Key: Scopes,
|
|
|
|
Value: "",
|
|
|
|
},
|
|
|
|
config.KV{
|
|
|
|
Key: JwksURL,
|
|
|
|
Value: "",
|
|
|
|
},
|
|
|
|
}
|
|
|
|
)
|
|
|
|
|
|
|
|
// Enabled returns if jwks is enabled.
|
|
|
|
func Enabled(kvs config.KVS) bool {
|
|
|
|
return kvs.Get(JwksURL) != ""
|
|
|
|
}
|
|
|
|
|
|
|
|
// LookupConfig lookup jwks from config, override with any ENVs.
|
|
|
|
func LookupConfig(kvs config.KVS, transport *http.Transport, closeRespFn func(io.ReadCloser)) (c Config, err error) {
|
|
|
|
if err = config.CheckValidKeys(config.IdentityOpenIDSubSys, kvs, DefaultKVS); err != nil {
|
|
|
|
return c, err
|
|
|
|
}
|
|
|
|
|
|
|
|
jwksURL := env.Get(EnvIamJwksURL, "") // Legacy
|
|
|
|
if jwksURL == "" {
|
|
|
|
jwksURL = env.Get(EnvIdentityOpenIDJWKSURL, kvs.Get(JwksURL))
|
|
|
|
}
|
|
|
|
|
|
|
|
c = Config{
|
|
|
|
ClaimName: env.Get(EnvIdentityOpenIDClaimName, kvs.Get(ClaimName)),
|
|
|
|
ClaimPrefix: env.Get(EnvIdentityOpenIDClaimPrefix, kvs.Get(ClaimPrefix)),
|
|
|
|
publicKeys: make(map[string]crypto.PublicKey),
|
|
|
|
ClientID: env.Get(EnvIdentityOpenIDClientID, kvs.Get(ClientID)),
|
|
|
|
transport: transport,
|
|
|
|
closeRespFn: closeRespFn,
|
|
|
|
mutex: &sync.Mutex{}, // allocate for copying
|
|
|
|
}
|
|
|
|
|
|
|
|
configURL := env.Get(EnvIdentityOpenIDURL, kvs.Get(ConfigURL))
|
|
|
|
if configURL != "" {
|
|
|
|
c.URL, err = xnet.ParseHTTPURL(configURL)
|
|
|
|
if err != nil {
|
|
|
|
return c, err
|
|
|
|
}
|
|
|
|
c.DiscoveryDoc, err = parseDiscoveryDoc(c.URL, transport, closeRespFn)
|
|
|
|
if err != nil {
|
|
|
|
return c, err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
|
if c.ClaimName == "" {
|
|
|
|
c.ClaimName = iampolicy.PolicyName
|
|
|
|
}
|
|
|
|
|
|
|
|
if jwksURL == "" {
|
|
|
|
// Fallback to discovery document jwksURL
|
|
|
|
jwksURL = c.DiscoveryDoc.JwksURI
|
|
|
|
}
|
|
|
|
|
|
|
|
if jwksURL == "" {
|
|
|
|
return c, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
c.JWKS.URL, err = xnet.ParseHTTPURL(jwksURL)
|
|
|
|
if err != nil {
|
|
|
|
return c, err
|
|
|
|
}
|
|
|
|
|
|
|
|
if err = c.PopulatePublicKey(); err != nil {
|
|
|
|
return c, err
|
|
|
|
}
|
|
|
|
|
|
|
|
return c, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// NewJWT - initialize new jwt authenticator.
|
|
|
|
func NewJWT(c Config) *JWT {
|
|
|
|
return &JWT{c}
|
|
|
|
}
|