OP#792 initial commit after fork
This commit is contained in:
50
internal/config/identity/openid/ecdsa-sha3_contrib.go
Normal file
50
internal/config/identity/openid/ecdsa-sha3_contrib.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// MinIO Object Storage (c) 2021 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"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
|
||||
// Needed for SHA3 to work - See: https://golang.org/src/crypto/crypto.go?s=1034:1288
|
||||
_ "golang.org/x/crypto/sha3"
|
||||
)
|
||||
|
||||
// Specific instances for EC256 and company
|
||||
var (
|
||||
SigningMethodES3256 *jwt.SigningMethodECDSA
|
||||
SigningMethodES3384 *jwt.SigningMethodECDSA
|
||||
SigningMethodES3512 *jwt.SigningMethodECDSA
|
||||
)
|
||||
|
||||
func init() {
|
||||
// ES256
|
||||
SigningMethodES3256 = &jwt.SigningMethodECDSA{Name: "ES3256", Hash: crypto.SHA3_256, KeySize: 32, CurveBits: 256}
|
||||
jwt.RegisterSigningMethod(SigningMethodES3256.Alg(), func() jwt.SigningMethod {
|
||||
return SigningMethodES3256
|
||||
})
|
||||
|
||||
// ES384
|
||||
SigningMethodES3384 = &jwt.SigningMethodECDSA{Name: "ES3384", Hash: crypto.SHA3_384, KeySize: 48, CurveBits: 384}
|
||||
jwt.RegisterSigningMethod(SigningMethodES3384.Alg(), func() jwt.SigningMethod {
|
||||
return SigningMethodES3384
|
||||
})
|
||||
|
||||
// ES512
|
||||
SigningMethodES3512 = &jwt.SigningMethodECDSA{Name: "ES3512", Hash: crypto.SHA3_512, KeySize: 66, CurveBits: 521}
|
||||
jwt.RegisterSigningMethod(SigningMethodES3512.Alg(), func() jwt.SigningMethod {
|
||||
return SigningMethodES3512
|
||||
})
|
||||
}
|
||||
119
internal/config/identity/openid/help.go
Normal file
119
internal/config/identity/openid/help.go
Normal file
@@ -0,0 +1,119 @@
|
||||
// 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 "github.com/minio/minio/internal/config"
|
||||
|
||||
// Help template for OpenID identity feature.
|
||||
var (
|
||||
defaultHelpPostfix = func(key string) string {
|
||||
return config.DefaultHelpPostfix(DefaultKVS, key)
|
||||
}
|
||||
|
||||
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),
|
||||
Type: "url",
|
||||
},
|
||||
config.HelpKV{
|
||||
Key: ClientID,
|
||||
Description: `unique public identifier for apps e.g. "292085223830.apps.googleusercontent.com"` + defaultHelpPostfix(ClientID),
|
||||
Type: "string",
|
||||
},
|
||||
config.HelpKV{
|
||||
Key: ClientSecret,
|
||||
Description: `secret for the unique public identifier for apps` + defaultHelpPostfix(ClientSecret),
|
||||
Sensitive: true,
|
||||
Type: "string",
|
||||
Secret: true,
|
||||
},
|
||||
config.HelpKV{
|
||||
Key: RolePolicy,
|
||||
Description: `Set the IAM access policies applicable to this client application and IDP e.g. "app-bucket-write,app-bucket-list"` + defaultHelpPostfix(RolePolicy),
|
||||
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),
|
||||
Optional: true,
|
||||
Type: "csv",
|
||||
},
|
||||
config.HelpKV{
|
||||
Key: Vendor,
|
||||
Description: `Specify vendor type for vendor specific behavior to checking validity of temporary credentials and service accounts on MinIO` + defaultHelpPostfix(Vendor),
|
||||
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),
|
||||
Optional: true,
|
||||
Type: "string",
|
||||
},
|
||||
config.HelpKV{
|
||||
Key: KeyCloakAdminURL,
|
||||
Description: `Specify Keycloak 'admin' REST API endpoint e.g. http://localhost:8080/auth/admin/` + defaultHelpPostfix(KeyCloakAdminURL),
|
||||
Optional: true,
|
||||
Type: "string",
|
||||
},
|
||||
config.HelpKV{
|
||||
Key: RedirectURIDynamic,
|
||||
Description: `Enable 'Host' header based dynamic redirect URI` + defaultHelpPostfix(RedirectURIDynamic),
|
||||
Optional: true,
|
||||
Type: "on|off",
|
||||
},
|
||||
config.HelpKV{
|
||||
Key: ClaimPrefix,
|
||||
Description: `[DEPRECATED use 'claim_name'] JWT claim namespace prefix e.g. "customer1/"` + defaultHelpPostfix(ClaimPrefix),
|
||||
Optional: true,
|
||||
Type: "string",
|
||||
},
|
||||
config.HelpKV{
|
||||
Key: RedirectURI,
|
||||
Description: `[DEPRECATED use env 'MINIO_BROWSER_REDIRECT_URL'] Configure custom redirect_uri for OpenID login flow callback` + defaultHelpPostfix(RedirectURI),
|
||||
Optional: true,
|
||||
Type: "string",
|
||||
},
|
||||
config.HelpKV{
|
||||
Key: config.Comment,
|
||||
Description: config.DefaultComment,
|
||||
Optional: true,
|
||||
Type: "sentence",
|
||||
},
|
||||
}
|
||||
)
|
||||
130
internal/config/identity/openid/jwks.go
Normal file
130
internal/config/identity/openid/jwks.go
Normal file
@@ -0,0 +1,130 @@
|
||||
// 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 (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
// JWKS - https://tools.ietf.org/html/rfc7517
|
||||
type JWKS struct {
|
||||
Keys []*JWKS `json:"keys,omitempty"`
|
||||
|
||||
Kty string `json:"kty"`
|
||||
Use string `json:"use,omitempty"`
|
||||
Kid string `json:"kid,omitempty"`
|
||||
Alg string `json:"alg,omitempty"`
|
||||
|
||||
Crv string `json:"crv,omitempty"`
|
||||
X string `json:"x,omitempty"`
|
||||
Y string `json:"y,omitempty"`
|
||||
D string `json:"d,omitempty"`
|
||||
N string `json:"n,omitempty"`
|
||||
E string `json:"e,omitempty"`
|
||||
K string `json:"k,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
errMalformedJWKRSAKey = errors.New("malformed JWK RSA key")
|
||||
errMalformedJWKECKey = errors.New("malformed JWK EC key")
|
||||
)
|
||||
|
||||
// DecodePublicKey - decodes JSON Web Key (JWK) as public key
|
||||
func (key *JWKS) DecodePublicKey() (crypto.PublicKey, error) {
|
||||
switch key.Kty {
|
||||
case "RSA":
|
||||
if key.N == "" || key.E == "" {
|
||||
return nil, errMalformedJWKRSAKey
|
||||
}
|
||||
|
||||
// decode exponent
|
||||
ebuf, err := base64.RawURLEncoding.DecodeString(key.E)
|
||||
if err != nil {
|
||||
return nil, errMalformedJWKRSAKey
|
||||
}
|
||||
|
||||
nbuf, err := base64.RawURLEncoding.DecodeString(key.N)
|
||||
if err != nil {
|
||||
return nil, errMalformedJWKRSAKey
|
||||
}
|
||||
|
||||
var n, e big.Int
|
||||
n.SetBytes(nbuf)
|
||||
e.SetBytes(ebuf)
|
||||
|
||||
return &rsa.PublicKey{
|
||||
E: int(e.Int64()),
|
||||
N: &n,
|
||||
}, nil
|
||||
case "EC":
|
||||
if key.Crv == "" || key.X == "" || key.Y == "" {
|
||||
return nil, errMalformedJWKECKey
|
||||
}
|
||||
|
||||
var curve elliptic.Curve
|
||||
switch key.Crv {
|
||||
case "P-224":
|
||||
curve = elliptic.P224()
|
||||
case "P-256":
|
||||
curve = elliptic.P256()
|
||||
case "P-384":
|
||||
curve = elliptic.P384()
|
||||
case "P-521":
|
||||
curve = elliptic.P521()
|
||||
default:
|
||||
return nil, fmt.Errorf("Unknown curve type: %s", key.Crv)
|
||||
}
|
||||
|
||||
xbuf, err := base64.RawURLEncoding.DecodeString(key.X)
|
||||
if err != nil {
|
||||
return nil, errMalformedJWKECKey
|
||||
}
|
||||
|
||||
ybuf, err := base64.RawURLEncoding.DecodeString(key.Y)
|
||||
if err != nil {
|
||||
return nil, errMalformedJWKECKey
|
||||
}
|
||||
|
||||
var x, y big.Int
|
||||
x.SetBytes(xbuf)
|
||||
y.SetBytes(ybuf)
|
||||
|
||||
return &ecdsa.PublicKey{
|
||||
Curve: curve,
|
||||
X: &x,
|
||||
Y: &y,
|
||||
}, nil
|
||||
default:
|
||||
if key.Alg == "EdDSA" && key.Crv == "Ed25519" && key.X != "" {
|
||||
pb, err := base64.RawURLEncoding.DecodeString(key.X)
|
||||
if err != nil {
|
||||
return nil, errMalformedJWKECKey
|
||||
}
|
||||
return ed25519.PublicKey(pb), nil
|
||||
}
|
||||
return nil, fmt.Errorf("Unknown JWK key type %s", key.Kty)
|
||||
}
|
||||
}
|
||||
134
internal/config/identity/openid/jwks_test.go
Normal file
134
internal/config/identity/openid/jwks_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
// 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 (
|
||||
"bytes"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAzurePublicKey(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))
|
||||
}
|
||||
|
||||
var kids []string
|
||||
for ii, jks := range jk.Keys {
|
||||
_, err := jks.DecodePublicKey()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decode key %d: %v", ii, err)
|
||||
}
|
||||
kids = append(kids, jks.Kid)
|
||||
}
|
||||
if len(kids) != 3 {
|
||||
t.Fatalf("Failed to find the expected number of kids: 3, got %d", len(kids))
|
||||
}
|
||||
}
|
||||
|
||||
// A.1 - Example public keys
|
||||
func TestPublicKey(t *testing.T) {
|
||||
const jsonkey = `{"keys":
|
||||
[
|
||||
{"kty":"EC",
|
||||
"crv":"P-256",
|
||||
"x":"MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4",
|
||||
"y":"4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM",
|
||||
"use":"enc",
|
||||
"kid":"1"},
|
||||
|
||||
{"kty":"RSA",
|
||||
"n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw",
|
||||
"e":"AQAB",
|
||||
"alg":"RS256",
|
||||
"kid":"2011-04-29"}
|
||||
]
|
||||
}`
|
||||
|
||||
var jk JWKS
|
||||
if err := json.Unmarshal([]byte(jsonkey), &jk); err != nil {
|
||||
t.Fatal("Unmarshal: ", err)
|
||||
} else if len(jk.Keys) != 2 {
|
||||
t.Fatalf("Expected 2 keys, got %d", len(jk.Keys))
|
||||
}
|
||||
|
||||
keys := make([]crypto.PublicKey, len(jk.Keys))
|
||||
for ii, jks := range jk.Keys {
|
||||
var err error
|
||||
keys[ii], err = jks.DecodePublicKey()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decode key %d: %v", ii, err)
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:gocritic
|
||||
if key0, ok := keys[0].(*ecdsa.PublicKey); !ok {
|
||||
t.Fatalf("Expected ECDSA key[0], got %T", keys[0])
|
||||
} else if key1, ok := keys[1].(*rsa.PublicKey); !ok {
|
||||
t.Fatalf("Expected RSA key[1], got %T", keys[1])
|
||||
} else if key0.Curve != elliptic.P256() {
|
||||
t.Fatal("Key[0] is not using P-256 curve")
|
||||
} else if !bytes.Equal(key0.X.Bytes(), []byte{
|
||||
0x30, 0xa0, 0x42, 0x4c, 0xd2,
|
||||
0x1c, 0x29, 0x44, 0x83, 0x8a, 0x2d, 0x75, 0xc9, 0x2b, 0x37, 0xe7, 0x6e, 0xa2,
|
||||
0xd, 0x9f, 0x0, 0x89, 0x3a, 0x3b, 0x4e, 0xee, 0x8a, 0x3c, 0xa, 0xaf, 0xec, 0x3e,
|
||||
}) {
|
||||
t.Fatalf("Bad key[0].X, got %v", key0.X.Bytes())
|
||||
} else if !bytes.Equal(key0.Y.Bytes(), []byte{
|
||||
0xe0, 0x4b, 0x65, 0xe9, 0x24,
|
||||
0x56, 0xd9, 0x88, 0x8b, 0x52, 0xb3, 0x79, 0xbd, 0xfb, 0xd5, 0x1e, 0xe8,
|
||||
0x69, 0xef, 0x1f, 0xf, 0xc6, 0x5b, 0x66, 0x59, 0x69, 0x5b, 0x6c, 0xce,
|
||||
0x8, 0x17, 0x23,
|
||||
}) {
|
||||
t.Fatalf("Bad key[0].Y, got %v", key0.Y.Bytes())
|
||||
} else if key1.E != 0x10001 {
|
||||
t.Fatalf("Bad key[1].E: %d", key1.E)
|
||||
} else if !bytes.Equal(key1.N.Bytes(), []byte{
|
||||
0xd2, 0xfc, 0x7b, 0x6a, 0xa, 0x1e,
|
||||
0x6c, 0x67, 0x10, 0x4a, 0xeb, 0x8f, 0x88, 0xb2, 0x57, 0x66, 0x9b, 0x4d, 0xf6,
|
||||
0x79, 0xdd, 0xad, 0x9, 0x9b, 0x5c, 0x4a, 0x6c, 0xd9, 0xa8, 0x80, 0x15, 0xb5,
|
||||
0xa1, 0x33, 0xbf, 0xb, 0x85, 0x6c, 0x78, 0x71, 0xb6, 0xdf, 0x0, 0xb, 0x55,
|
||||
0x4f, 0xce, 0xb3, 0xc2, 0xed, 0x51, 0x2b, 0xb6, 0x8f, 0x14, 0x5c, 0x6e, 0x84,
|
||||
0x34, 0x75, 0x2f, 0xab, 0x52, 0xa1, 0xcf, 0xc1, 0x24, 0x40, 0x8f, 0x79, 0xb5,
|
||||
0x8a, 0x45, 0x78, 0xc1, 0x64, 0x28, 0x85, 0x57, 0x89, 0xf7, 0xa2, 0x49, 0xe3,
|
||||
0x84, 0xcb, 0x2d, 0x9f, 0xae, 0x2d, 0x67, 0xfd, 0x96, 0xfb, 0x92, 0x6c, 0x19,
|
||||
0x8e, 0x7, 0x73, 0x99, 0xfd, 0xc8, 0x15, 0xc0, 0xaf, 0x9, 0x7d, 0xde, 0x5a,
|
||||
0xad, 0xef, 0xf4, 0x4d, 0xe7, 0xe, 0x82, 0x7f, 0x48, 0x78, 0x43, 0x24, 0x39,
|
||||
0xbf, 0xee, 0xb9, 0x60, 0x68, 0xd0, 0x47, 0x4f, 0xc5, 0xd, 0x6d, 0x90, 0xbf,
|
||||
0x3a, 0x98, 0xdf, 0xaf, 0x10, 0x40, 0xc8, 0x9c, 0x2, 0xd6, 0x92, 0xab, 0x3b,
|
||||
0x3c, 0x28, 0x96, 0x60, 0x9d, 0x86, 0xfd, 0x73, 0xb7, 0x74, 0xce, 0x7, 0x40,
|
||||
0x64, 0x7c, 0xee, 0xea, 0xa3, 0x10, 0xbd, 0x12, 0xf9, 0x85, 0xa8, 0xeb, 0x9f,
|
||||
0x59, 0xfd, 0xd4, 0x26, 0xce, 0xa5, 0xb2, 0x12, 0xf, 0x4f, 0x2a, 0x34, 0xbc,
|
||||
0xab, 0x76, 0x4b, 0x7e, 0x6c, 0x54, 0xd6, 0x84, 0x2, 0x38, 0xbc, 0xc4, 0x5, 0x87,
|
||||
0xa5, 0x9e, 0x66, 0xed, 0x1f, 0x33, 0x89, 0x45, 0x77, 0x63, 0x5c, 0x47, 0xa,
|
||||
0xf7, 0x5c, 0xf9, 0x2c, 0x20, 0xd1, 0xda, 0x43, 0xe1, 0xbf, 0xc4, 0x19, 0xe2,
|
||||
0x22, 0xa6, 0xf0, 0xd0, 0xbb, 0x35, 0x8c, 0x5e, 0x38, 0xf9, 0xcb, 0x5, 0xa, 0xea,
|
||||
0xfe, 0x90, 0x48, 0x14, 0xf1, 0xac, 0x1a, 0xa4, 0x9c, 0xca, 0x9e, 0xa0, 0xca, 0x83,
|
||||
}) {
|
||||
t.Fatalf("Bad key[1].N, got %v", key1.N.Bytes())
|
||||
}
|
||||
}
|
||||
286
internal/config/identity/openid/jwt.go
Normal file
286
internal/config/identity/openid/jwt.go
Normal file
@@ -0,0 +1,286 @@
|
||||
// Copyright (c) 2015-2022 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package openid
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
jwtgo "github.com/golang-jwt/jwt/v4"
|
||||
"github.com/minio/minio/internal/arn"
|
||||
"github.com/minio/minio/internal/auth"
|
||||
xnet "github.com/minio/pkg/v3/net"
|
||||
"github.com/minio/pkg/v3/policy"
|
||||
)
|
||||
|
||||
type publicKeys struct {
|
||||
*sync.RWMutex
|
||||
|
||||
// map of kid to public key
|
||||
pkMap map[string]any
|
||||
}
|
||||
|
||||
func (pk *publicKeys) parseAndAdd(b io.Reader) error {
|
||||
var jwk JWKS
|
||||
err := json.NewDecoder(b).Decode(&jwk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, key := range jwk.Keys {
|
||||
pkey, err := key.DecodePublicKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pk.add(key.Kid, pkey)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pk *publicKeys) add(keyID string, key any) {
|
||||
pk.Lock()
|
||||
defer pk.Unlock()
|
||||
|
||||
pk.pkMap[keyID] = key
|
||||
}
|
||||
|
||||
func (pk *publicKeys) get(kid string) any {
|
||||
pk.RLock()
|
||||
defer pk.RUnlock()
|
||||
return pk.pkMap[kid]
|
||||
}
|
||||
|
||||
// PopulatePublicKey - populates a new publickey from the JWKS URL.
|
||||
func (r *Config) PopulatePublicKey(arn arn.ARN) error {
|
||||
pCfg := r.arnProviderCfgsMap[arn]
|
||||
if pCfg.JWKS.URL == nil || pCfg.JWKS.URL.String() == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add client secret for the client ID for HMAC based signature.
|
||||
r.pubKeys.add(pCfg.ClientID, []byte(pCfg.ClientSecret))
|
||||
|
||||
client := &http.Client{
|
||||
Transport: r.transport,
|
||||
}
|
||||
|
||||
resp, err := client.Get(pCfg.JWKS.URL.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.closeRespFn(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return errors.New(resp.Status)
|
||||
}
|
||||
|
||||
return r.pubKeys.parseAndAdd(resp.Body)
|
||||
}
|
||||
|
||||
// ErrTokenExpired - error token expired
|
||||
var (
|
||||
ErrTokenExpired = errors.New("token expired")
|
||||
)
|
||||
|
||||
func updateClaimsExpiry(dsecs string, claims map[string]any) error {
|
||||
expStr := claims["exp"]
|
||||
if expStr == "" {
|
||||
return ErrTokenExpired
|
||||
}
|
||||
|
||||
// No custom duration requested, the claims can be used as is.
|
||||
if dsecs == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := auth.ExpToInt64(expStr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defaultExpiryDuration, err := GetDefaultExpiration(dsecs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
claims["exp"] = time.Now().UTC().Add(defaultExpiryDuration).Unix() // update with new expiry.
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
audClaim = "aud"
|
||||
azpClaim = "azp"
|
||||
)
|
||||
|
||||
// Validate - validates the id_token.
|
||||
func (r *Config) Validate(ctx context.Context, arn arn.ARN, token, accessToken, dsecs string, claims map[string]any) error {
|
||||
jp := new(jwtgo.Parser)
|
||||
jp.ValidMethods = []string{
|
||||
"RS256", "RS384", "RS512",
|
||||
"ES256", "ES384", "ES512",
|
||||
"HS256", "HS384", "HS512",
|
||||
"RS3256", "RS3384", "RS3512",
|
||||
"ES3256", "ES3384", "ES3512",
|
||||
}
|
||||
|
||||
keyFuncCallback := func(jwtToken *jwtgo.Token) (any, error) {
|
||||
kid, ok := jwtToken.Header["kid"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("Invalid kid value %v", jwtToken.Header["kid"])
|
||||
}
|
||||
pubkey := r.pubKeys.get(kid)
|
||||
if pubkey == nil {
|
||||
return nil, fmt.Errorf("No public key found for kid %s", kid)
|
||||
}
|
||||
return pubkey, nil
|
||||
}
|
||||
|
||||
pCfg, ok := r.arnProviderCfgsMap[arn]
|
||||
if !ok {
|
||||
return fmt.Errorf("Role %s does not exist", arn)
|
||||
}
|
||||
|
||||
mclaims := jwtgo.MapClaims(claims)
|
||||
jwtToken, err := jp.ParseWithClaims(token, &mclaims, keyFuncCallback)
|
||||
if err != nil {
|
||||
// Re-populate the public key in-case the JWKS
|
||||
// pubkeys are refreshed
|
||||
if err = r.PopulatePublicKey(arn); err != nil {
|
||||
return err
|
||||
}
|
||||
jwtToken, err = jwtgo.ParseWithClaims(token, &mclaims, keyFuncCallback)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !jwtToken.Valid {
|
||||
return ErrTokenExpired
|
||||
}
|
||||
|
||||
if err = updateClaimsExpiry(dsecs, mclaims); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = r.updateUserinfoClaims(ctx, arn, accessToken, mclaims); err != nil {
|
||||
return 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 := policy.GetValuesFromClaims(mclaims, audClaim)
|
||||
if !ok {
|
||||
return 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 := policy.GetValuesFromClaims(mclaims, azpClaim)
|
||||
if !ok {
|
||||
return errors.New("STS JWT Token has `azp` claim invalid, `azp` must match configured OpenID Client ID")
|
||||
}
|
||||
if !azpValues.Contains(pCfg.ClientID) {
|
||||
return errors.New("STS JWT Token has `azp` claim invalid, `azp` must match configured OpenID Client ID")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Config) updateUserinfoClaims(ctx context.Context, arn arn.ARN, accessToken string, claims map[string]any) 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 ok && pCfg.ClaimUserinfo {
|
||||
if accessToken == "" {
|
||||
return errors.New("access_token is mandatory if user_info claim is enabled")
|
||||
}
|
||||
uclaims, err := pCfg.UserInfo(ctx, accessToken, r.transport)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for k, v := range uclaims {
|
||||
if _, ok := claims[k]; !ok { // only add to claims not update it.
|
||||
claims[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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"`
|
||||
EndSessionEndpoint string `json:"end_session_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.RoundTripper, 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 {
|
||||
return d, err
|
||||
}
|
||||
defer closeRespFn(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return d, fmt.Errorf("unexpected error returned by %s : status(%s)", u, resp.Status)
|
||||
}
|
||||
dec := json.NewDecoder(resp.Body)
|
||||
if err = dec.Decode(&d); err != nil {
|
||||
return d, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
300
internal/config/identity/openid/jwt_test.go
Normal file
300
internal/config/identity/openid/jwt_test.go
Normal file
@@ -0,0 +1,300 @@
|
||||
// Copyright (c) 2015-2022 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package openid
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
jwtgo "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/v3/net"
|
||||
)
|
||||
|
||||
func TestUpdateClaimsExpiry(t *testing.T) {
|
||||
testCases := []struct {
|
||||
exp any
|
||||
dsecs string
|
||||
expectedFailure bool
|
||||
}{
|
||||
{"", "", true},
|
||||
{"-1", "0", true},
|
||||
{"-1", "900", true},
|
||||
{"1574812326", "900", false},
|
||||
{1574812326, "900", false},
|
||||
{int64(1574812326), "900", false},
|
||||
{int(1574812326), "900", false},
|
||||
{uint(1574812326), "900", false},
|
||||
{uint64(1574812326), "900", false},
|
||||
{json.Number("1574812326"), "900", false},
|
||||
{1574812326.000, "900", false},
|
||||
{time.Duration(3) * time.Minute, "900", false},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run("", func(t *testing.T) {
|
||||
claims := map[string]any{}
|
||||
claims["exp"] = testCase.exp
|
||||
err := updateClaimsExpiry(testCase.dsecs, claims)
|
||||
if err != nil && !testCase.expectedFailure {
|
||||
t.Errorf("Expected success, got failure %s", err)
|
||||
}
|
||||
if err == nil && testCase.expectedFailure {
|
||||
t.Error("Expected failure, got success")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func initJWKSServer() *httptest.Server {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
const jsonkey = `{"keys":
|
||||
[
|
||||
{"kty":"RSA",
|
||||
"n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw",
|
||||
"e":"AQAB",
|
||||
"alg":"RS256",
|
||||
"kid":"2011-04-29"}
|
||||
]
|
||||
}`
|
||||
w.Write([]byte(jsonkey))
|
||||
}))
|
||||
return server
|
||||
}
|
||||
|
||||
func TestJWTHMACType(t *testing.T) {
|
||||
server := initJWKSServer()
|
||||
defer server.Close()
|
||||
|
||||
jwt := &jwtgo.Token{
|
||||
Method: jwtgo.SigningMethodHS256,
|
||||
Claims: jwtgo.StandardClaims{
|
||||
ExpiresAt: 253428928061,
|
||||
Audience: "76b95ae5-33ef-4283-97b7-d2a85dc2d8f4",
|
||||
},
|
||||
Header: map[string]any{
|
||||
"typ": "JWT",
|
||||
"alg": jwtgo.SigningMethodHS256.Alg(),
|
||||
"kid": "76b95ae5-33ef-4283-97b7-d2a85dc2d8f4",
|
||||
},
|
||||
}
|
||||
|
||||
token, err := jwt.SignedString([]byte("WNGvKVyyNmXq0TraSvjaDN9CtpFgx35IXtGEffMCPR0"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Println(token)
|
||||
|
||||
u1, err := xnet.ParseHTTPURL(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pubKeys := publicKeys{
|
||||
RWMutex: &sync.RWMutex{},
|
||||
pkMap: map[string]any{},
|
||||
}
|
||||
pubKeys.add("76b95ae5-33ef-4283-97b7-d2a85dc2d8f4", []byte("WNGvKVyyNmXq0TraSvjaDN9CtpFgx35IXtGEffMCPR0"))
|
||||
|
||||
if len(pubKeys.pkMap) != 1 {
|
||||
t.Fatalf("Expected 1 keys, got %d", len(pubKeys.pkMap))
|
||||
}
|
||||
|
||||
provider := providerCfg{
|
||||
ClientID: "76b95ae5-33ef-4283-97b7-d2a85dc2d8f4",
|
||||
ClientSecret: "WNGvKVyyNmXq0TraSvjaDN9CtpFgx35IXtGEffMCPR0",
|
||||
}
|
||||
provider.JWKS.URL = u1
|
||||
cfg := Config{
|
||||
Enabled: true,
|
||||
pubKeys: pubKeys,
|
||||
arnProviderCfgsMap: map[arn.ARN]*providerCfg{
|
||||
DummyRoleARN: &provider,
|
||||
},
|
||||
ProviderCfgs: map[string]*providerCfg{
|
||||
"1": &provider,
|
||||
},
|
||||
closeRespFn: func(rc io.ReadCloser) {
|
||||
rc.Close()
|
||||
},
|
||||
}
|
||||
|
||||
var claims jwtgo.MapClaims
|
||||
if err = cfg.Validate(t.Context(), DummyRoleARN, token, "", "", claims); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWT(t *testing.T) {
|
||||
const jsonkey = `{"keys":
|
||||
[
|
||||
{"kty":"RSA",
|
||||
"n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw",
|
||||
"e":"AQAB",
|
||||
"alg":"RS256",
|
||||
"kid":"2011-04-29"}
|
||||
]
|
||||
}`
|
||||
|
||||
pubKeys := publicKeys{
|
||||
RWMutex: &sync.RWMutex{},
|
||||
pkMap: map[string]any{},
|
||||
}
|
||||
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://127.0.0.1:8443")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
provider := providerCfg{}
|
||||
provider.JWKS.URL = u1
|
||||
cfg := Config{
|
||||
Enabled: true,
|
||||
pubKeys: pubKeys,
|
||||
arnProviderCfgsMap: map[arn.ARN]*providerCfg{
|
||||
DummyRoleARN: &provider,
|
||||
},
|
||||
ProviderCfgs: map[string]*providerCfg{
|
||||
"1": &provider,
|
||||
},
|
||||
}
|
||||
|
||||
u, err := url.Parse("http://127.0.0.1:8443/?Token=invalid")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var claims jwtgo.MapClaims
|
||||
if err = cfg.Validate(t.Context(), DummyRoleARN, u.Query().Get("Token"), "", "", claims); err == nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultExpiryDuration(t *testing.T) {
|
||||
testCases := []struct {
|
||||
reqURL string
|
||||
duration time.Duration
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
reqURL: "http://127.0.0.1:8443/?Token=xxxxx",
|
||||
duration: time.Duration(60) * time.Minute,
|
||||
},
|
||||
{
|
||||
reqURL: "http://127.0.0.1:8443/?DurationSeconds=9s",
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
reqURL: "http://127.0.0.1:8443/?DurationSeconds=31536001",
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
reqURL: "http://127.0.0.1:8443/?DurationSeconds=800",
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
reqURL: "http://127.0.0.1:8443/?DurationSeconds=901",
|
||||
duration: time.Duration(901) * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
u, err := url.Parse(testCase.reqURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d, err := GetDefaultExpiration(u.Query().Get("DurationSeconds"))
|
||||
gotErr := (err != nil)
|
||||
if testCase.expectErr != gotErr {
|
||||
t.Errorf("Test %d: Expected %v, got %v with error %s", i+1, testCase.expectErr, gotErr, err)
|
||||
}
|
||||
if d != testCase.duration {
|
||||
t.Errorf("Test %d: Expected duration %d, got %d", i+1, testCase.duration, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpCorrect(t *testing.T) {
|
||||
signKey, _ := base64.StdEncoding.DecodeString("NTNv7j0TuYARvmNMmWXo6fKvM4o6nv/aUi9ryX38ZH+L1bkrnD1ObOQ8JAUmHCBq7Iy7otZcyAagBLHVKvvYaIpmMuxmARQ97jUVG16Jkpkp1wXOPsrF9zwew6TpczyHkHgX5EuLg2MeBuiT/qJACs1J0apruOOJCg/gOtkjB4c=")
|
||||
|
||||
claimsMap := jwtm.NewMapClaims()
|
||||
claimsMap.SetExpiry(time.Now().Add(time.Minute))
|
||||
claimsMap.SetAccessKey("test-access")
|
||||
if err := updateClaimsExpiry("3600", claimsMap.MapClaims); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
// Build simple token with updated expiration claim
|
||||
token := jwtgo.NewWithClaims(jwtgo.SigningMethodHS256, claimsMap)
|
||||
tokenString, err := token.SignedString(signKey)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
// Parse token to be sure it is valid
|
||||
err = jwtm.ParseWithClaims(tokenString, claimsMap, func(*jwtm.MapClaims) ([]byte, error) {
|
||||
return signKey, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeycloakProviderInitialization(t *testing.T) {
|
||||
testConfig := providerCfg{
|
||||
DiscoveryDoc: DiscoveryDoc{
|
||||
TokenEndpoint: "http://keycloak.test/token/endpoint",
|
||||
},
|
||||
}
|
||||
testKvs := config.KVS{}
|
||||
testKvs.Set(Vendor, "keycloak")
|
||||
testKvs.Set(KeyCloakRealm, "TestRealm")
|
||||
testKvs.Set(KeyCloakAdminURL, "http://keycloak.test/auth/admin")
|
||||
cfgGet := func(param string) string {
|
||||
return testKvs.Get(param)
|
||||
}
|
||||
|
||||
if testConfig.provider != nil {
|
||||
t.Errorf("Empty config cannot have any provider!")
|
||||
}
|
||||
|
||||
if err := testConfig.initializeProvider(cfgGet, http.DefaultTransport); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if testConfig.provider == nil {
|
||||
t.Errorf("keycloak provider must be initialized!")
|
||||
}
|
||||
}
|
||||
652
internal/config/identity/openid/openid.go
Normal file
652
internal/config/identity/openid/openid.go
Normal file
@@ -0,0 +1,652 @@
|
||||
// Copyright (c) 2015-2022 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package openid
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio-go/v7/pkg/set"
|
||||
"github.com/minio/minio/internal/arn"
|
||||
"github.com/minio/minio/internal/auth"
|
||||
"github.com/minio/minio/internal/config"
|
||||
"github.com/minio/minio/internal/config/identity/openid/provider"
|
||||
"github.com/minio/minio/internal/hash/sha256"
|
||||
"github.com/minio/pkg/v3/env"
|
||||
xnet "github.com/minio/pkg/v3/net"
|
||||
"github.com/minio/pkg/v3/policy"
|
||||
)
|
||||
|
||||
// OpenID keys and envs.
|
||||
const (
|
||||
ClientID = "client_id"
|
||||
ClientSecret = "client_secret"
|
||||
ConfigURL = "config_url"
|
||||
ClaimName = "claim_name"
|
||||
ClaimUserinfo = "claim_userinfo"
|
||||
RolePolicy = "role_policy"
|
||||
DisplayName = "display_name"
|
||||
UserReadableClaim = "user_readable_claim"
|
||||
UserIDClaim = "user_id_claim"
|
||||
|
||||
Scopes = "scopes"
|
||||
RedirectURI = "redirect_uri"
|
||||
RedirectURIDynamic = "redirect_uri_dynamic"
|
||||
Vendor = "vendor"
|
||||
|
||||
// Vendor specific ENV only enabled if the Vendor matches == "vendor"
|
||||
KeyCloakRealm = "keycloak_realm"
|
||||
KeyCloakAdminURL = "keycloak_admin_url"
|
||||
|
||||
// Removed params
|
||||
JwksURL = "jwks_url"
|
||||
ClaimPrefix = "claim_prefix"
|
||||
)
|
||||
|
||||
// DefaultKVS - default config for OpenID config
|
||||
var (
|
||||
DefaultKVS = config.KVS{
|
||||
config.KV{
|
||||
Key: config.Enable,
|
||||
Value: "",
|
||||
},
|
||||
config.KV{
|
||||
Key: DisplayName,
|
||||
Value: "",
|
||||
},
|
||||
config.KV{
|
||||
Key: ConfigURL,
|
||||
Value: "",
|
||||
},
|
||||
config.KV{
|
||||
Key: ClientID,
|
||||
Value: "",
|
||||
},
|
||||
config.KV{
|
||||
Key: ClientSecret,
|
||||
Value: "",
|
||||
},
|
||||
config.KV{
|
||||
Key: ClaimName,
|
||||
Value: policy.PolicyName,
|
||||
},
|
||||
config.KV{
|
||||
Key: ClaimUserinfo,
|
||||
Value: "",
|
||||
},
|
||||
config.KV{
|
||||
Key: RolePolicy,
|
||||
Value: "",
|
||||
},
|
||||
config.KV{
|
||||
Key: ClaimPrefix,
|
||||
Value: "",
|
||||
HiddenIfEmpty: true,
|
||||
},
|
||||
config.KV{
|
||||
Key: RedirectURI,
|
||||
Value: "",
|
||||
HiddenIfEmpty: true,
|
||||
},
|
||||
config.KV{
|
||||
Key: RedirectURIDynamic,
|
||||
Value: "off",
|
||||
},
|
||||
config.KV{
|
||||
Key: Scopes,
|
||||
Value: "",
|
||||
},
|
||||
config.KV{
|
||||
Key: Vendor,
|
||||
Value: "",
|
||||
},
|
||||
config.KV{
|
||||
Key: KeyCloakRealm,
|
||||
Value: "",
|
||||
},
|
||||
config.KV{
|
||||
Key: KeyCloakAdminURL,
|
||||
Value: "",
|
||||
},
|
||||
config.KV{
|
||||
Key: UserReadableClaim,
|
||||
Value: "",
|
||||
},
|
||||
config.KV{
|
||||
Key: UserIDClaim,
|
||||
Value: "",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
var errSingleProvider = config.Errorf("Only one OpenID provider can be configured if not using role policy mapping")
|
||||
|
||||
// DummyRoleARN is used to indicate that the user associated with it was
|
||||
// authenticated via policy-claim based OpenID provider.
|
||||
var DummyRoleARN = func() arn.ARN {
|
||||
v, err := arn.NewIAMRoleARN("dummy-internal", "")
|
||||
if err != nil {
|
||||
panic("should not happen!")
|
||||
}
|
||||
return v
|
||||
}()
|
||||
|
||||
// Config - OpenID Config
|
||||
type Config struct {
|
||||
Enabled bool
|
||||
|
||||
// map of roleARN to providerCfg's
|
||||
arnProviderCfgsMap map[arn.ARN]*providerCfg
|
||||
|
||||
// map of config names to providerCfg's
|
||||
ProviderCfgs map[string]*providerCfg
|
||||
|
||||
pubKeys publicKeys
|
||||
roleArnPolicyMap map[arn.ARN]string
|
||||
|
||||
transport http.RoundTripper
|
||||
closeRespFn func(io.ReadCloser)
|
||||
}
|
||||
|
||||
// Clone returns a cloned copy of OpenID config.
|
||||
func (r *Config) Clone() Config {
|
||||
if r == nil {
|
||||
return Config{}
|
||||
}
|
||||
cfg := Config{
|
||||
Enabled: r.Enabled,
|
||||
arnProviderCfgsMap: make(map[arn.ARN]*providerCfg, len(r.arnProviderCfgsMap)),
|
||||
ProviderCfgs: make(map[string]*providerCfg, len(r.ProviderCfgs)),
|
||||
pubKeys: r.pubKeys,
|
||||
roleArnPolicyMap: make(map[arn.ARN]string, len(r.roleArnPolicyMap)),
|
||||
transport: r.transport,
|
||||
closeRespFn: r.closeRespFn,
|
||||
}
|
||||
maps.Copy(cfg.arnProviderCfgsMap, r.arnProviderCfgsMap)
|
||||
maps.Copy(cfg.ProviderCfgs, r.ProviderCfgs)
|
||||
maps.Copy(cfg.roleArnPolicyMap, r.roleArnPolicyMap)
|
||||
return cfg
|
||||
}
|
||||
|
||||
// LookupConfig lookup jwks from config, override with any ENVs.
|
||||
func LookupConfig(s config.Config, 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]any{},
|
||||
},
|
||||
roleArnPolicyMap: map[arn.ARN]string{},
|
||||
transport: openIDClientTransport,
|
||||
closeRespFn: closeRespFn,
|
||||
}
|
||||
|
||||
seenClientIDs := set.NewStringSet()
|
||||
|
||||
deprecatedKeys := []string{JwksURL}
|
||||
|
||||
// remove this since we have removed support for this already.
|
||||
for k := range s[config.IdentityOpenIDSubSys] {
|
||||
for _, dk := range deprecatedKeys {
|
||||
kvs := s[config.IdentityOpenIDSubSys][k]
|
||||
kvs.Delete(dk)
|
||||
s[config.IdentityOpenIDSubSys][k] = kvs
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.CheckValidKeys(config.IdentityOpenIDSubSys, deprecatedKeys); err != nil {
|
||||
return c, err
|
||||
}
|
||||
|
||||
openIDTargets, err := s.GetAvailableTargets(config.IdentityOpenIDSubSys)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
|
||||
for _, cfgName := range openIDTargets {
|
||||
getCfgVal := func(cfgParam string) string {
|
||||
// As parameters are already validated, we skip checking
|
||||
// if the config param was found.
|
||||
val, _, _ := s.ResolveConfigParam(config.IdentityOpenIDSubSys, cfgName, cfgParam, false)
|
||||
return val
|
||||
}
|
||||
|
||||
// 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(config.Enable)
|
||||
isExplicitlyEnabled = cfgEnableVal != ""
|
||||
)
|
||||
|
||||
var enabled bool
|
||||
if isExplicitlyEnabled {
|
||||
enabled, err = config.ParseBool(cfgEnableVal)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
// No need to continue loading if the config is not enabled.
|
||||
if !enabled {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
p := newProviderCfgFromConfig(getCfgVal)
|
||||
configURL := getCfgVal(ConfigURL)
|
||||
|
||||
if !isExplicitlyEnabled {
|
||||
enabled = true
|
||||
if p.ClientID == "" && p.ClientSecret == "" && configURL == "" {
|
||||
enabled = false
|
||||
}
|
||||
}
|
||||
|
||||
// No need to continue loading if the config is not enabled.
|
||||
if !enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate that client ID has not been duplicately specified.
|
||||
if seenClientIDs.Contains(p.ClientID) {
|
||||
return c, config.Errorf("Client ID %s is present with multiple OpenID configurations", p.ClientID)
|
||||
}
|
||||
seenClientIDs.Add(p.ClientID)
|
||||
|
||||
p.URL, err = xnet.ParseHTTPURL(configURL)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
configURLDomain := p.URL.Hostname()
|
||||
p.DiscoveryDoc, err = parseDiscoveryDoc(p.URL, transport, closeRespFn)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
|
||||
if p.ClaimUserinfo && configURL == "" {
|
||||
return c, errors.New("please specify config_url to enable fetching claims from UserInfo endpoint")
|
||||
}
|
||||
|
||||
if scopeList := getCfgVal(Scopes); scopeList != "" {
|
||||
var scopes []string
|
||||
for scope := range strings.SplitSeq(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 != policy.PolicyName && p.RolePolicy != "" {
|
||||
// In the unlikely event that the user specifies
|
||||
// `policy.PolicyName` as the claim name explicitly and sets
|
||||
// a role policy, this check is thwarted, but we will be using
|
||||
// the role policy anyway.
|
||||
return c, config.Errorf("Role Policy (=`%s`) and Claim Name (=`%s`) cannot both be set", p.RolePolicy, p.ClaimName)
|
||||
}
|
||||
|
||||
jwksURL := p.DiscoveryDoc.JwksURI
|
||||
if jwksURL == "" {
|
||||
return c, config.Errorf("no JWKS URI found in your provider's discovery doc (config_url=%s)", configURL)
|
||||
}
|
||||
|
||||
p.JWKS.URL, err = xnet.ParseHTTPURL(jwksURL)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
|
||||
if p.RolePolicy != "" {
|
||||
// RolePolicy is validated by IAM System during its
|
||||
// initialization.
|
||||
|
||||
// Generate role ARN as combination of provider domain and
|
||||
// prefix of client ID.
|
||||
domain := configURLDomain
|
||||
if domain == "" {
|
||||
// Attempt to parse the JWKs URI.
|
||||
domain = p.JWKS.URL.Hostname()
|
||||
if domain == "" {
|
||||
return c, config.Errorf("unable to parse a domain from the OpenID config")
|
||||
}
|
||||
}
|
||||
if p.ClientID == "" {
|
||||
return c, config.Errorf("client ID must not be empty")
|
||||
}
|
||||
|
||||
// We set the resource ID of the role arn as a hash of client
|
||||
// ID, so we can get a short roleARN that stays the same on
|
||||
// restart.
|
||||
var resourceID string
|
||||
{
|
||||
h := sha1.New()
|
||||
h.Write([]byte(p.ClientID))
|
||||
bs := h.Sum(nil)
|
||||
resourceID = base64.RawURLEncoding.EncodeToString(bs)
|
||||
}
|
||||
p.roleArn, err = arn.NewIAMRoleARN(resourceID, serverRegion)
|
||||
if err != nil {
|
||||
return c, config.Errorf("unable to generate ARN from the OpenID config: %v", err)
|
||||
}
|
||||
|
||||
c.roleArnPolicyMap[p.roleArn] = p.RolePolicy
|
||||
} else if p.ClaimName == "" {
|
||||
return c, config.Errorf("A role policy or claim name must be specified")
|
||||
}
|
||||
|
||||
if err = p.initializeProvider(getCfgVal, c.transport); err != nil {
|
||||
return c, err
|
||||
}
|
||||
|
||||
arnKey := p.roleArn
|
||||
if p.RolePolicy == "" {
|
||||
arnKey = DummyRoleARN
|
||||
// Ensure that at most one JWT policy claim based provider may be
|
||||
// defined.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
c.Enabled = true
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// ErrProviderConfigNotFound - represents a non-existing provider error.
|
||||
var ErrProviderConfigNotFound = errors.New("provider configuration not found")
|
||||
|
||||
// GetConfigInfo - returns configuration and related info for the given IDP
|
||||
// provider.
|
||||
func (r *Config) GetConfigInfo(s config.Config, cfgName string) ([]madmin.IDPCfgInfo, error) {
|
||||
openIDConfigs, err := s.GetAvailableTargets(config.IdentityOpenIDSubSys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
present := slices.Contains(openIDConfigs, cfgName)
|
||||
|
||||
if !present {
|
||||
return nil, ErrProviderConfigNotFound
|
||||
}
|
||||
|
||||
kvsrcs, err := s.GetResolvedConfigParams(config.IdentityOpenIDSubSys, cfgName, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := make([]madmin.IDPCfgInfo, 0, len(kvsrcs)+1)
|
||||
for _, kvsrc := range kvsrcs {
|
||||
// skip returning default config values.
|
||||
if kvsrc.Src == config.ValueSourceDef {
|
||||
if kvsrc.Key != madmin.EnableKey {
|
||||
continue
|
||||
}
|
||||
// for EnableKey we set an explicit on/off from live configuration
|
||||
// if it is present.
|
||||
if _, ok := r.ProviderCfgs[cfgName]; !ok {
|
||||
// No live config is present
|
||||
continue
|
||||
}
|
||||
if r.Enabled {
|
||||
kvsrc.Value = "on"
|
||||
} else {
|
||||
kvsrc.Value = "off"
|
||||
}
|
||||
}
|
||||
res = append(res, madmin.IDPCfgInfo{
|
||||
Key: kvsrc.Key,
|
||||
Value: kvsrc.Value,
|
||||
IsCfg: true,
|
||||
IsEnv: kvsrc.Src == config.ValueSourceEnv,
|
||||
})
|
||||
}
|
||||
|
||||
if provCfg, exists := r.ProviderCfgs[cfgName]; exists && provCfg.RolePolicy != "" {
|
||||
// Append roleARN
|
||||
res = append(res, madmin.IDPCfgInfo{
|
||||
Key: "roleARN",
|
||||
Value: provCfg.roleArn.String(),
|
||||
IsCfg: false,
|
||||
})
|
||||
}
|
||||
|
||||
// sort the structs by the key
|
||||
sort.Slice(res, func(i, j int) bool {
|
||||
return res[i].Key < res[j].Key
|
||||
})
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// GetConfigList - list openID configurations
|
||||
func (r *Config) GetConfigList(s config.Config) ([]madmin.IDPListItem, error) {
|
||||
openIDConfigs, err := s.GetAvailableTargets(config.IdentityOpenIDSubSys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var res []madmin.IDPListItem
|
||||
for _, cfg := range openIDConfigs {
|
||||
pcfg, ok := r.ProviderCfgs[cfg]
|
||||
if !ok {
|
||||
res = append(res, madmin.IDPListItem{
|
||||
Type: "openid",
|
||||
Name: cfg,
|
||||
Enabled: false,
|
||||
})
|
||||
} else {
|
||||
var roleARN string
|
||||
if pcfg.RolePolicy != "" {
|
||||
roleARN = pcfg.roleArn.String()
|
||||
}
|
||||
res = append(res, madmin.IDPListItem{
|
||||
Type: "openid",
|
||||
Name: cfg,
|
||||
Enabled: r.Enabled,
|
||||
RoleARN: roleARN,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Enabled returns if configURL is enabled.
|
||||
func Enabled(kvs config.KVS) bool {
|
||||
return kvs.Get(ConfigURL) != ""
|
||||
}
|
||||
|
||||
// GetSettings - fetches OIDC settings for site-replication related validation.
|
||||
// NOTE that region must be populated by caller as this package does not know.
|
||||
func (r *Config) GetSettings() madmin.OpenIDSettings {
|
||||
res := madmin.OpenIDSettings{}
|
||||
if !r.Enabled {
|
||||
return res
|
||||
}
|
||||
h := sha256.New()
|
||||
hashedSecret := ""
|
||||
for arn, provCfg := range r.arnProviderCfgsMap {
|
||||
h.Write([]byte(provCfg.ClientSecret))
|
||||
hashedSecret = base64.RawURLEncoding.EncodeToString(h.Sum(nil))
|
||||
h.Reset()
|
||||
if arn != DummyRoleARN {
|
||||
if res.Roles == nil {
|
||||
res.Roles = make(map[string]madmin.OpenIDProviderSettings)
|
||||
}
|
||||
res.Roles[arn.String()] = madmin.OpenIDProviderSettings{
|
||||
ClaimUserinfoEnabled: provCfg.ClaimUserinfo,
|
||||
RolePolicy: provCfg.RolePolicy,
|
||||
ClientID: provCfg.ClientID,
|
||||
HashedClientSecret: hashedSecret,
|
||||
}
|
||||
} else {
|
||||
res.ClaimProvider = madmin.OpenIDProviderSettings{
|
||||
ClaimUserinfoEnabled: provCfg.ClaimUserinfo,
|
||||
RolePolicy: provCfg.RolePolicy,
|
||||
ClientID: provCfg.ClientID,
|
||||
HashedClientSecret: hashedSecret,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// GetIAMPolicyClaimName - returns the policy claim name for the (at most one)
|
||||
// provider configured without a role policy.
|
||||
func (r *Config) GetIAMPolicyClaimName() string {
|
||||
pCfg, ok := r.arnProviderCfgsMap[DummyRoleARN]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return pCfg.ClaimPrefix + pCfg.ClaimName
|
||||
}
|
||||
|
||||
// LookupUser lookup userid for the provider
|
||||
func (r Config) LookupUser(roleArn, userid string) (provider.User, error) {
|
||||
// Can safely ignore error here as empty or invalid ARNs will not be
|
||||
// mapped.
|
||||
arnVal, _ := arn.Parse(roleArn)
|
||||
pCfg, ok := r.arnProviderCfgsMap[arnVal]
|
||||
if ok {
|
||||
user, err := pCfg.provider.LookupUser(userid)
|
||||
if err != nil && err != provider.ErrAccessTokenExpired {
|
||||
return user, err
|
||||
}
|
||||
if err == provider.ErrAccessTokenExpired {
|
||||
if err = pCfg.provider.LoginWithClientID(pCfg.ClientID, pCfg.ClientSecret); err != nil {
|
||||
return user, err
|
||||
}
|
||||
user, err = pCfg.provider.LookupUser(userid)
|
||||
}
|
||||
return user, err
|
||||
}
|
||||
// Without any specific logic for a provider, all accounts
|
||||
// are always enabled.
|
||||
return provider.User{ID: userid, Enabled: true}, nil
|
||||
}
|
||||
|
||||
// ProviderEnabled returns true if any vendor specific provider is enabled.
|
||||
func (r Config) ProviderEnabled() bool {
|
||||
if !r.Enabled {
|
||||
return false
|
||||
}
|
||||
for _, v := range r.arnProviderCfgsMap {
|
||||
if v.provider != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetRoleInfo - returns ARN to policies map if a role policy based openID
|
||||
// provider is configured. Otherwise returns nil.
|
||||
func (r Config) GetRoleInfo() map[arn.ARN]string {
|
||||
for _, p := range r.arnProviderCfgsMap {
|
||||
if p.RolePolicy != "" {
|
||||
return r.roleArnPolicyMap
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDefaultExpiration - returns the expiration seconds expected.
|
||||
func GetDefaultExpiration(dsecs string) (time.Duration, error) {
|
||||
timeout := env.Get(config.EnvMinioStsDuration, "")
|
||||
defaultExpiryDuration, err := time.ParseDuration(timeout)
|
||||
if err != nil {
|
||||
defaultExpiryDuration = time.Hour
|
||||
}
|
||||
if timeout == "" && dsecs != "" {
|
||||
expirySecs, err := strconv.ParseInt(dsecs, 10, 64)
|
||||
if err != nil {
|
||||
return 0, auth.ErrInvalidDuration
|
||||
}
|
||||
|
||||
// The duration, in seconds, of the role session.
|
||||
// The value can range from 900 seconds (15 minutes)
|
||||
// up to 365 days.
|
||||
if expirySecs < config.MinExpiration || expirySecs > config.MaxExpiration {
|
||||
return 0, auth.ErrInvalidDuration
|
||||
}
|
||||
|
||||
defaultExpiryDuration = time.Duration(expirySecs) * time.Second
|
||||
} else if timeout == "" && dsecs == "" {
|
||||
return time.Hour, nil
|
||||
}
|
||||
|
||||
if defaultExpiryDuration.Seconds() < config.MinExpiration || defaultExpiryDuration.Seconds() > config.MaxExpiration {
|
||||
return 0, auth.ErrInvalidDuration
|
||||
}
|
||||
|
||||
return defaultExpiryDuration, nil
|
||||
}
|
||||
|
||||
// GetUserReadableClaim returns the human readable claim name for the given
|
||||
// configuration name.
|
||||
func (r Config) GetUserReadableClaim(cfgName string) string {
|
||||
pCfg, ok := r.ProviderCfgs[cfgName]
|
||||
if ok {
|
||||
return pCfg.UserReadableClaim
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetUserIDClaim returns the user ID claim for the given configuration name, or "sub" if not set.
|
||||
func (r Config) GetUserIDClaim(cfgName string) string {
|
||||
pCfg, ok := r.ProviderCfgs[cfgName]
|
||||
if ok {
|
||||
if pCfg.UserIDClaim != "" {
|
||||
return pCfg.UserIDClaim
|
||||
}
|
||||
return "sub"
|
||||
}
|
||||
return "" // an incorrect config should be handled outside this function
|
||||
}
|
||||
186
internal/config/identity/openid/provider/keycloak.go
Normal file
186
internal/config/identity/openid/provider/keycloak.go
Normal file
@@ -0,0 +1,186 @@
|
||||
// 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 provider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Token - parses the output from IDP id_token.
|
||||
type Token struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
Expiry int `json:"expires_in"`
|
||||
}
|
||||
|
||||
// KeycloakProvider implements Provider interface for KeyCloak Identity Provider.
|
||||
type KeycloakProvider struct {
|
||||
sync.Mutex
|
||||
|
||||
oeConfig DiscoveryDoc
|
||||
client http.Client
|
||||
adminURL string
|
||||
realm string
|
||||
|
||||
// internal value refreshed
|
||||
accessToken Token
|
||||
}
|
||||
|
||||
// LoginWithUser authenticates username/password, not needed for Keycloak
|
||||
func (k *KeycloakProvider) LoginWithUser(username, password string) error {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
|
||||
// LoginWithClientID is implemented by Keycloak service account support
|
||||
func (k *KeycloakProvider) LoginWithClientID(clientID, clientSecret string) error {
|
||||
values := url.Values{}
|
||||
values.Set("client_id", clientID)
|
||||
values.Set("client_secret", clientSecret)
|
||||
values.Set("grant_type", "client_credentials")
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, k.oeConfig.TokenEndpoint, strings.NewReader(values.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := k.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var accessToken Token
|
||||
if err = json.NewDecoder(resp.Body).Decode(&accessToken); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
k.Lock()
|
||||
k.accessToken = accessToken
|
||||
k.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// LookupUser lookup user by their userid.
|
||||
func (k *KeycloakProvider) LookupUser(userid string) (User, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, k.adminURL, nil)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
req.URL.Path = path.Join(req.URL.Path, "realms", k.realm, "users", userid)
|
||||
|
||||
k.Lock()
|
||||
accessToken := k.accessToken
|
||||
k.Unlock()
|
||||
if accessToken.AccessToken == "" {
|
||||
return User{}, ErrAccessTokenExpired
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken.AccessToken)
|
||||
resp, err := k.client.Do(req)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK, http.StatusPartialContent:
|
||||
var u User
|
||||
if err = json.NewDecoder(resp.Body).Decode(&u); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return u, nil
|
||||
case http.StatusNotFound:
|
||||
return User{
|
||||
ID: userid,
|
||||
Enabled: false,
|
||||
}, nil
|
||||
case http.StatusUnauthorized:
|
||||
return User{}, ErrAccessTokenExpired
|
||||
}
|
||||
return User{}, fmt.Errorf("Unable to lookup - keycloak user lookup returned %v", resp.Status)
|
||||
}
|
||||
|
||||
// Option is a function type that accepts a pointer Target
|
||||
type Option func(*KeycloakProvider)
|
||||
|
||||
// WithTransport provide custom transport
|
||||
func WithTransport(transport http.RoundTripper) Option {
|
||||
return func(p *KeycloakProvider) {
|
||||
p.client = http.Client{
|
||||
Transport: transport,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithOpenIDConfig provide OpenID Endpoint configuration discovery document
|
||||
func WithOpenIDConfig(oeConfig DiscoveryDoc) Option {
|
||||
return func(p *KeycloakProvider) {
|
||||
p.oeConfig = oeConfig
|
||||
}
|
||||
}
|
||||
|
||||
// WithAdminURL provide admin URL configuration for Keycloak
|
||||
func WithAdminURL(url string) Option {
|
||||
return func(p *KeycloakProvider) {
|
||||
p.adminURL = url
|
||||
}
|
||||
}
|
||||
|
||||
// WithRealm provide realm configuration for Keycloak
|
||||
func WithRealm(realm string) Option {
|
||||
return func(p *KeycloakProvider) {
|
||||
p.realm = realm
|
||||
}
|
||||
}
|
||||
|
||||
// KeyCloak initializes a new keycloak provider
|
||||
func KeyCloak(opts ...Option) (Provider, error) {
|
||||
p := &KeycloakProvider{}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(p)
|
||||
}
|
||||
|
||||
if p.adminURL == "" {
|
||||
return nil, errors.New("Admin URL cannot be empty")
|
||||
}
|
||||
|
||||
_, err := url.Parse(p.adminURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Unable to parse the adminURL %s: %w", p.adminURL, err)
|
||||
}
|
||||
|
||||
if p.client.Transport == nil {
|
||||
p.client.Transport = http.DefaultTransport
|
||||
}
|
||||
|
||||
if p.oeConfig.TokenEndpoint == "" {
|
||||
return nil, errors.New("missing OpenID token endpoint")
|
||||
}
|
||||
|
||||
if p.realm == "" {
|
||||
p.realm = "master" // default realm
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
62
internal/config/identity/openid/provider/provider.go
Normal file
62
internal/config/identity/openid/provider/provider.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// 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 provider
|
||||
|
||||
import "errors"
|
||||
|
||||
// DiscoveryDoc - parses the output from openid-configuration
|
||||
// for example https://accounts.google.com/.well-known/openid-configuration
|
||||
//
|
||||
//nolint:unused
|
||||
type DiscoveryDoc struct {
|
||||
Issuer string `json:"issuer,omitempty"`
|
||||
AuthEndpoint string `json:"authorization_endpoint,omitempty"`
|
||||
TokenEndpoint string `json:"token_endpoint,omitempty"`
|
||||
EndSessionEndpoint string `json:"end_session_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"`
|
||||
}
|
||||
|
||||
// User represents information about user.
|
||||
type User struct {
|
||||
Name string `json:"username"`
|
||||
ID string `json:"id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// Standard errors.
|
||||
var (
|
||||
ErrNotImplemented = errors.New("function not implemented")
|
||||
ErrAccessTokenExpired = errors.New("access_token expired or unauthorized")
|
||||
)
|
||||
|
||||
// Provider implements identity provider specific admin operations, such as
|
||||
// looking up users, fetching additional attributes etc.
|
||||
type Provider interface {
|
||||
LoginWithUser(username, password string) error
|
||||
LoginWithClientID(clientID, clientSecret string) error
|
||||
LookupUser(userid string) (User, error)
|
||||
}
|
||||
161
internal/config/identity/openid/providercfg.go
Normal file
161
internal/config/identity/openid/providercfg.go
Normal file
@@ -0,0 +1,161 @@
|
||||
// Copyright (c) 2015-2022 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package openid
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/minio/minio/internal/arn"
|
||||
"github.com/minio/minio/internal/config"
|
||||
"github.com/minio/minio/internal/config/identity/openid/provider"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
xnet "github.com/minio/pkg/v3/net"
|
||||
)
|
||||
|
||||
type providerCfg struct {
|
||||
// Used for user interface like console
|
||||
DisplayName string
|
||||
|
||||
JWKS struct {
|
||||
URL *xnet.URL
|
||||
}
|
||||
URL *xnet.URL
|
||||
ClaimPrefix string
|
||||
ClaimName string
|
||||
ClaimUserinfo bool
|
||||
RedirectURI string
|
||||
RedirectURIDynamic bool
|
||||
DiscoveryDoc DiscoveryDoc
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
RolePolicy string
|
||||
UserReadableClaim string
|
||||
UserIDClaim string
|
||||
|
||||
roleArn arn.ARN
|
||||
provider provider.Provider
|
||||
}
|
||||
|
||||
func newProviderCfgFromConfig(getCfgVal func(cfgName string) string) providerCfg {
|
||||
return providerCfg{
|
||||
DisplayName: getCfgVal(DisplayName),
|
||||
ClaimName: getCfgVal(ClaimName),
|
||||
ClaimUserinfo: getCfgVal(ClaimUserinfo) == config.EnableOn,
|
||||
ClaimPrefix: getCfgVal(ClaimPrefix),
|
||||
RedirectURI: getCfgVal(RedirectURI),
|
||||
RedirectURIDynamic: getCfgVal(RedirectURIDynamic) == config.EnableOn,
|
||||
ClientID: getCfgVal(ClientID),
|
||||
ClientSecret: getCfgVal(ClientSecret),
|
||||
RolePolicy: getCfgVal(RolePolicy),
|
||||
UserReadableClaim: getCfgVal(UserReadableClaim),
|
||||
UserIDClaim: getCfgVal(UserIDClaim),
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
keyCloakVendor = "keycloak"
|
||||
)
|
||||
|
||||
// initializeProvider initializes if any additional vendor specific information
|
||||
// was provided, initialization will return an error initial login fails.
|
||||
func (p *providerCfg) initializeProvider(cfgGet func(string) string, transport http.RoundTripper) error {
|
||||
vendor := cfgGet(Vendor)
|
||||
if vendor == "" {
|
||||
return nil
|
||||
}
|
||||
var err error
|
||||
switch vendor {
|
||||
case keyCloakVendor:
|
||||
adminURL := cfgGet(KeyCloakAdminURL)
|
||||
realm := cfgGet(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("Unsupported vendor %s", keyCloakVendor)
|
||||
}
|
||||
}
|
||||
|
||||
// GetRoleArn returns the role ARN.
|
||||
func (p *providerCfg) GetRoleArn() string {
|
||||
if p.RolePolicy == "" {
|
||||
return ""
|
||||
}
|
||||
return p.roleArn.String()
|
||||
}
|
||||
|
||||
// UserInfo returns claims for authenticated user from userInfo endpoint.
|
||||
//
|
||||
// Some OIDC implementations such as GitLab do not support
|
||||
// claims as part of the normal oauth2 flow, instead rely
|
||||
// on service providers making calls to IDP to fetch additional
|
||||
// claims available from the UserInfo endpoint
|
||||
func (p *providerCfg) UserInfo(ctx context.Context, accessToken string, transport http.RoundTripper) (map[string]any, error) {
|
||||
if p.JWKS.URL == nil || p.JWKS.URL.String() == "" {
|
||||
return nil, errors.New("openid not configured")
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.DiscoveryDoc.UserInfoEndpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if accessToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer xhttp.DrainBody(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// uncomment this for debugging when needed.
|
||||
// reqBytes, _ := httputil.DumpRequest(req, false)
|
||||
// fmt.Println(string(reqBytes))
|
||||
// respBytes, _ := httputil.DumpResponse(resp, true)
|
||||
// fmt.Println(string(respBytes))
|
||||
return nil, errors.New(resp.Status)
|
||||
}
|
||||
|
||||
claims := map[string]any{}
|
||||
if err = json.NewDecoder(resp.Body).Decode(&claims); err != nil {
|
||||
// uncomment this for debugging when needed.
|
||||
// reqBytes, _ := httputil.DumpRequest(req, false)
|
||||
// fmt.Println(string(reqBytes))
|
||||
// respBytes, _ := httputil.DumpResponse(resp, true)
|
||||
// fmt.Println(string(respBytes))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
51
internal/config/identity/openid/rsa-sha3_contrib.go
Normal file
51
internal/config/identity/openid/rsa-sha3_contrib.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// MinIO Object Storage (c) 2021 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"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
|
||||
// Needed for SHA3 to work - See: https://golang.org/src/crypto/crypto.go?s=1034:1288
|
||||
_ "golang.org/x/crypto/sha3"
|
||||
)
|
||||
|
||||
// Specific instances for RS256 and company
|
||||
var (
|
||||
SigningMethodRS3256 *jwt.SigningMethodRSA
|
||||
SigningMethodRS3384 *jwt.SigningMethodRSA
|
||||
SigningMethodRS3512 *jwt.SigningMethodRSA
|
||||
)
|
||||
|
||||
func init() {
|
||||
// RS3256
|
||||
SigningMethodRS3256 = &jwt.SigningMethodRSA{Name: "RS3256", Hash: crypto.SHA3_256}
|
||||
jwt.RegisterSigningMethod(SigningMethodRS3256.Alg(), func() jwt.SigningMethod {
|
||||
return SigningMethodRS3256
|
||||
})
|
||||
|
||||
// RS3384
|
||||
SigningMethodRS3384 = &jwt.SigningMethodRSA{Name: "RS3384", Hash: crypto.SHA3_384}
|
||||
jwt.RegisterSigningMethod(SigningMethodRS3384.Alg(), func() jwt.SigningMethod {
|
||||
return SigningMethodRS3384
|
||||
})
|
||||
|
||||
// RS3512
|
||||
SigningMethodRS3512 = &jwt.SigningMethodRSA{Name: "RS3512", Hash: crypto.SHA3_512}
|
||||
jwt.RegisterSigningMethod(SigningMethodRS3512.Alg(), func() jwt.SigningMethod {
|
||||
return SigningMethodRS3512
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user