411 lines
13 KiB
Go
411 lines
13 KiB
Go
/*
|
|
Copyright 2021 The Dapr Authors
|
|
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 azure
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
|
"github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud"
|
|
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
|
|
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
|
|
"golang.org/x/crypto/pkcs12"
|
|
|
|
"github.com/dapr/components-contrib/metadata"
|
|
)
|
|
|
|
// EnvironmentSettings hold settings to authenticate with Azure.
|
|
type EnvironmentSettings struct {
|
|
Metadata map[string]string
|
|
Cloud *cloud.Configuration
|
|
}
|
|
|
|
// timeoutWrapper prevents a potentially very long timeout when managed identity or CLI credential aren't available
|
|
type timeoutWrapper struct {
|
|
cred azcore.TokenCredential
|
|
// timeout applies to all auth attempts until one doesn't time out
|
|
timeout time.Duration
|
|
authmethod string
|
|
}
|
|
|
|
// GetToken wraps Token acquisition attempts with a short timeout because managed identity or CLI credential
|
|
// may not be available and connecting to IMDS can take several minutes to time out.
|
|
func (w *timeoutWrapper) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) {
|
|
var tk azcore.AccessToken
|
|
var err error
|
|
// no need to synchronize around this value because it's written only within ChainedTokenCredential's critical section
|
|
if w.timeout > 0 {
|
|
c, cancel := context.WithTimeout(ctx, w.timeout)
|
|
defer cancel()
|
|
tk, err = w.cred.GetToken(c, opts)
|
|
if ce := c.Err(); errors.Is(ce, context.DeadlineExceeded) {
|
|
err = azidentity.NewCredentialUnavailableError(w.authmethod)
|
|
} else {
|
|
// some managed identity implementation is available, so don't apply the timeout to future calls
|
|
w.timeout = 0
|
|
}
|
|
} else {
|
|
tk, err = w.cred.GetToken(ctx, opts)
|
|
}
|
|
return tk, err
|
|
}
|
|
|
|
// NewEnvironmentSettings returns a new EnvironmentSettings configured for a given Azure resource.
|
|
func NewEnvironmentSettings(md map[string]string) (EnvironmentSettings, error) {
|
|
es := EnvironmentSettings{
|
|
Metadata: md,
|
|
}
|
|
azureCloud, err := es.GetAzureEnvironment()
|
|
if err != nil {
|
|
return es, err
|
|
}
|
|
es.Cloud = azureCloud
|
|
return es, nil
|
|
}
|
|
|
|
// GetAzureEnvironment returns the Azure environment for a given name.
|
|
func (s EnvironmentSettings) GetAzureEnvironment() (*cloud.Configuration, error) {
|
|
envName, _ := s.GetEnvironment("AzureEnvironment")
|
|
switch strings.ToLower(envName) {
|
|
case "", "azurepubliccloud", "azurepublic": // Default value if envName is empty
|
|
return &cloud.AzurePublic, nil
|
|
case "azurechinacloud", "azurechina":
|
|
return &cloud.AzureChina, nil
|
|
case "azureusgovernmentcloud", "azureusgovernment":
|
|
return &cloud.AzureGovernment, nil
|
|
default:
|
|
return nil, fmt.Errorf("invalid Azure cloud: %v", envName)
|
|
}
|
|
}
|
|
|
|
// GetTokenCredential returns an azcore.TokenCredential retrieved from, in order:
|
|
// 1. Client credentials
|
|
// 2. Client certificate
|
|
// 3. Workload identity
|
|
// 4. MSI with a timeout of 1 second
|
|
// 5. Azure CLI
|
|
// 6. Retry MSI without timeout
|
|
//
|
|
// This order and timeout (with the exception of the additional step 5) matches the DefaultAzureCredential.
|
|
func (s EnvironmentSettings) GetTokenCredential() (azcore.TokenCredential, error) {
|
|
// Create a chain
|
|
var creds []azcore.TokenCredential
|
|
errs := make([]error, 0, 3)
|
|
|
|
// 1. Client credentials
|
|
if c, e := s.GetClientCredentials(); e == nil {
|
|
cred, err := c.GetTokenCredential()
|
|
if err == nil {
|
|
creds = append(creds, cred)
|
|
} else {
|
|
errs = append(errs, err)
|
|
}
|
|
}
|
|
|
|
// 2. Client certificate
|
|
if c, e := s.GetClientCert(); e == nil {
|
|
cred, err := c.GetTokenCredential()
|
|
if err == nil {
|
|
creds = append(creds, cred)
|
|
} else {
|
|
errs = append(errs, err)
|
|
}
|
|
}
|
|
|
|
// 3. Workload identity
|
|
// workload identity requires values for AZURE_AUTHORITY_HOST, AZURE_CLIENT_ID, AZURE_FEDERATED_TOKEN_FILE, AZURE_TENANT_ID
|
|
// The workload identity mutating admissions webhook in Kubernetes injects these values into the pod.
|
|
// These environment variables are read using the default WorkloadIdentityCredentialOptions
|
|
|
|
workloadCred, err := azidentity.NewWorkloadIdentityCredential(nil)
|
|
if err == nil {
|
|
creds = append(creds, workloadCred)
|
|
} else {
|
|
errs = append(errs, err)
|
|
}
|
|
|
|
// 4. MSI with timeout of 1 second (same as DefaultAzureCredential)
|
|
var msiCred *azcore.TokenCredential
|
|
{
|
|
c := s.GetMSI()
|
|
msiCred, err := c.GetTokenCredential()
|
|
if err == nil {
|
|
creds = append(creds, &timeoutWrapper{cred: msiCred, authmethod: "managed identity", timeout: 1 * time.Second})
|
|
} else {
|
|
errs = append(errs, err)
|
|
}
|
|
}
|
|
|
|
// 5. AzureCLICredential
|
|
{
|
|
cred, credErr := azidentity.NewAzureCLICredential(nil)
|
|
if credErr == nil {
|
|
creds = append(creds, &timeoutWrapper{cred: cred, authmethod: "Azure CLI", timeout: 5 * time.Second})
|
|
} else {
|
|
errs = append(errs, credErr)
|
|
}
|
|
}
|
|
|
|
// 6. Retry MSI without timeout
|
|
if msiCred != nil {
|
|
creds = append(creds, *msiCred)
|
|
}
|
|
|
|
if len(creds) == 0 {
|
|
return nil, fmt.Errorf("no suitable token provider for Azure AD; errors: %w", errors.Join(errs...))
|
|
}
|
|
// The ChainedTokenCredential executes the auth methods (with their given timeouts) in order. No execution occurs before this method.
|
|
// As such there is no option to run the auth methods in parallel.
|
|
// It would be ideal if the Azure SDK for Go team could support a parallel execution option.
|
|
return azidentity.NewChainedTokenCredential(creds, nil)
|
|
}
|
|
|
|
// GetClientCredentials creates a config object from the available client credentials.
|
|
// An error is returned if no certificate credentials are available.
|
|
func (s EnvironmentSettings) GetClientCredentials() (config CredentialsConfig, err error) {
|
|
azureCloud, err := s.GetAzureEnvironment()
|
|
if err != nil {
|
|
return config, err
|
|
}
|
|
|
|
config.ClientID, _ = s.GetEnvironment("ClientID")
|
|
config.ClientSecret, _ = s.GetEnvironment("ClientSecret")
|
|
config.TenantID, _ = s.GetEnvironment("TenantID")
|
|
|
|
if config.ClientID == "" || config.ClientSecret == "" || config.TenantID == "" {
|
|
return config, errors.New("parameters clientId, clientSecret, and tenantId must all be present")
|
|
}
|
|
|
|
config.AzureCloud = azureCloud
|
|
|
|
return config, nil
|
|
}
|
|
|
|
// GetClientCert creates a config object from the available certificate credentials.
|
|
// An error is returned if no certificate credentials are available.
|
|
func (s EnvironmentSettings) GetClientCert() (config CertConfig, err error) {
|
|
azureCloud, err := s.GetAzureEnvironment()
|
|
if err != nil {
|
|
return config, err
|
|
}
|
|
|
|
config.CertificatePath, _ = s.GetEnvironment("CertificateFile")
|
|
config.CertificatePassword, _ = s.GetEnvironment("CertificatePassword")
|
|
certBytes, _ := s.GetEnvironment("Certificate")
|
|
config.ClientID, _ = s.GetEnvironment("ClientID")
|
|
config.TenantID, _ = s.GetEnvironment("TenantID")
|
|
|
|
if config.CertificatePath == "" && certBytes == "" {
|
|
return config, errors.New("missing client certificate")
|
|
}
|
|
|
|
config.CertificateData = []byte(certBytes)
|
|
config.AzureCloud = azureCloud
|
|
|
|
return config, nil
|
|
}
|
|
|
|
// GetMSI creates a MSI config object from the available client ID.
|
|
func (s EnvironmentSettings) GetMSI() (config MSIConfig) {
|
|
// This is optional and it's ok if value is empty
|
|
config.ClientID, _ = s.GetEnvironment("ClientID")
|
|
|
|
return config
|
|
}
|
|
|
|
// CredentialsConfig provides the options to get a bearer authorizer from client credentials.
|
|
type CredentialsConfig struct {
|
|
ClientID string
|
|
ClientSecret string
|
|
TenantID string
|
|
AzureCloud *cloud.Configuration
|
|
}
|
|
|
|
// GetTokenCredential returns the azcore.TokenCredential object from the credentials.
|
|
func (c CredentialsConfig) GetTokenCredential() (token azcore.TokenCredential, err error) {
|
|
var opts *azidentity.ClientSecretCredentialOptions
|
|
if c.AzureCloud != nil {
|
|
opts = &azidentity.ClientSecretCredentialOptions{
|
|
ClientOptions: azcore.ClientOptions{
|
|
Cloud: *c.AzureCloud,
|
|
},
|
|
}
|
|
}
|
|
return azidentity.NewClientSecretCredential(c.TenantID, c.ClientID, c.ClientSecret, opts)
|
|
}
|
|
|
|
// CertConfig provides the options to get a bearer authorizer from a client certificate.
|
|
type CertConfig struct {
|
|
ClientID string
|
|
CertificatePath string
|
|
CertificatePassword string
|
|
TenantID string
|
|
CertificateData []byte
|
|
AzureCloud *cloud.Configuration
|
|
}
|
|
|
|
// GetTokenCredential returns the azcore.TokenCredential object from client certificate.
|
|
func (c CertConfig) GetTokenCredential() (token azcore.TokenCredential, err error) {
|
|
// Certificate data - may be empty here
|
|
data := c.CertificateData
|
|
|
|
// If we have a certificate path, load it
|
|
if c.CertificatePath != "" {
|
|
var errB error
|
|
data, errB = os.ReadFile(c.CertificatePath)
|
|
if errB != nil {
|
|
return nil, fmt.Errorf("failed to read the certificate file (%s): %v", c.CertificatePath, errB)
|
|
}
|
|
}
|
|
if len(data) == 0 {
|
|
return nil, errors.New("certificate is not given")
|
|
}
|
|
|
|
// Decode the certificate
|
|
cert, key, err := c.decodeCertificate(data, c.CertificatePassword)
|
|
if err != nil || cert == nil {
|
|
return nil, fmt.Errorf("failed to decode pkcs12 certificate while creating spt: %v", err)
|
|
}
|
|
|
|
// Create the azcore.TokenCredential object
|
|
certs := []*x509.Certificate{cert}
|
|
var opts *azidentity.ClientCertificateCredentialOptions
|
|
if c.AzureCloud != nil {
|
|
opts = &azidentity.ClientCertificateCredentialOptions{
|
|
ClientOptions: azcore.ClientOptions{
|
|
Cloud: *c.AzureCloud,
|
|
},
|
|
}
|
|
}
|
|
return azidentity.NewClientCertificateCredential(c.TenantID, c.ClientID, certs, key, opts)
|
|
}
|
|
|
|
// Decode a certificate, either as a PKCS#12 (PFX) bundle, or as a single file with both certificate and key encoded in PEM blocks.
|
|
// The password is only used for PFX (and could be empty).
|
|
func (c CertConfig) decodeCertificate(data []byte, password string) (certificate *x509.Certificate, privateKey *rsa.PrivateKey, err error) {
|
|
// First, try to decode the certificate as PKCS#12
|
|
certificate, privateKey, err = c.decodePkcs12(data, password)
|
|
if err == nil && certificate != nil {
|
|
return certificate, privateKey, nil
|
|
}
|
|
|
|
// If it failed, try decoding as PEM
|
|
certificate, privateKey, err = c.decodePEM(data)
|
|
if err == nil && certificate != nil {
|
|
return certificate, privateKey, nil
|
|
}
|
|
|
|
return nil, nil, errors.New("certificate is not valid")
|
|
}
|
|
|
|
func (c CertConfig) decodePkcs12(pkcs []byte, password string) (*x509.Certificate, *rsa.PrivateKey, error) {
|
|
privateKey, certificate, err := pkcs12.Decode(pkcs, password)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
rsaPrivateKey, isRsaKey := privateKey.(*rsa.PrivateKey)
|
|
if !isRsaKey {
|
|
return nil, nil, errors.New("PKCS#12 certificate must contain an RSA private key")
|
|
}
|
|
|
|
return certificate, rsaPrivateKey, nil
|
|
}
|
|
|
|
func (c CertConfig) decodePEM(data []byte) (certificate *x509.Certificate, privateKey *rsa.PrivateKey, err error) {
|
|
// We should have 2 PEM blocks: a certificate and a key
|
|
var (
|
|
block *pem.Block
|
|
parsedKey any
|
|
ok bool
|
|
)
|
|
for i := 0; i < 2; i++ {
|
|
block, data = pem.Decode(data)
|
|
if block == nil {
|
|
break
|
|
}
|
|
|
|
switch block.Type {
|
|
case "CERTIFICATE":
|
|
// If we already have a certificate decoded, return an error
|
|
if certificate != nil {
|
|
return nil, nil, errors.New("invalid certificate")
|
|
}
|
|
certificate, err = x509.ParseCertificate(block.Bytes)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
case "PRIVATE KEY": // PKCS#8
|
|
// If we already have a key decoded, return an error
|
|
if privateKey != nil {
|
|
return nil, nil, errors.New("invalid certificate")
|
|
}
|
|
parsedKey, err = x509.ParsePKCS8PrivateKey(block.Bytes)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
privateKey, ok = parsedKey.(*rsa.PrivateKey)
|
|
if !ok || privateKey == nil {
|
|
return nil, nil, errors.New("certificate must contain an RSA private key")
|
|
}
|
|
case "RSA PRIVATE KEY": // PKCS#1
|
|
// If we already have a key decoded, return an error
|
|
if privateKey != nil {
|
|
return nil, nil, errors.New("invalid certificate")
|
|
}
|
|
parsedKey, err = x509.ParsePKCS1PrivateKey(block.Bytes)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
privateKey, ok = parsedKey.(*rsa.PrivateKey)
|
|
if !ok || privateKey == nil {
|
|
return nil, nil, errors.New("certificate must contain an RSA private key")
|
|
}
|
|
}
|
|
}
|
|
|
|
// We should have both a private key and a certificate
|
|
if privateKey == nil || certificate == nil {
|
|
return nil, nil, errors.New("invalid certificate")
|
|
}
|
|
return certificate, privateKey, nil
|
|
}
|
|
|
|
// MSIConfig provides the options to get a bearer authorizer through MSI.
|
|
type MSIConfig struct {
|
|
ClientID string
|
|
}
|
|
|
|
// GetTokenCredential returns the azcore.TokenCredential object from MSI.
|
|
func (c MSIConfig) GetTokenCredential() (token azcore.TokenCredential, err error) {
|
|
opts := &azidentity.ManagedIdentityCredentialOptions{}
|
|
if c.ClientID != "" {
|
|
opts.ID = azidentity.ClientID(c.ClientID)
|
|
}
|
|
return azidentity.NewManagedIdentityCredential(opts)
|
|
}
|
|
|
|
// GetAzureEnvironment returns the Azure environment for a given name, supporting aliases too.
|
|
func (s EnvironmentSettings) GetEnvironment(key string) (val string, ok bool) {
|
|
return metadata.GetMetadataProperty(s.Metadata, MetadataKeys[key]...)
|
|
}
|