github.com/openshift/installer@v1.4.17/pkg/asset/installconfig/azure/session.go (about)

     1  package azure
     2  
     3  import (
     4  	"encoding/json"
     5  	"errors"
     6  	"fmt"
     7  	"io/fs"
     8  	"os"
     9  	"path/filepath"
    10  	"strings"
    11  	"sync"
    12  
    13  	"github.com/AlecAivazis/survey/v2"
    14  	"github.com/Azure/azure-sdk-for-go/sdk/azcore"
    15  	"github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud"
    16  	"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
    17  	"github.com/Azure/go-autorest/autorest"
    18  	azureenv "github.com/Azure/go-autorest/autorest/azure"
    19  	"github.com/jongio/azidext/go/azidext"
    20  	azurekiota "github.com/microsoft/kiota-authentication-azure-go"
    21  	"github.com/sirupsen/logrus"
    22  
    23  	"github.com/openshift/installer/pkg/types/azure"
    24  )
    25  
    26  const azureAuthEnv = "AZURE_AUTH_LOCATION"
    27  
    28  var (
    29  	defaultAuthFilePath = filepath.Join(os.Getenv("HOME"), ".azure", "osServicePrincipal.json")
    30  	onceLoggers         = map[string]*sync.Once{}
    31  )
    32  
    33  // AuthenticationType identifies the authentication method used.
    34  type AuthenticationType int
    35  
    36  // The authentication types supported by the installer.
    37  const (
    38  	ClientSecretAuth AuthenticationType = iota
    39  	ClientCertificateAuth
    40  	ManagedIdentityAuth
    41  )
    42  
    43  // Session is an object representing session for subscription
    44  type Session struct {
    45  	Authorizer   autorest.Authorizer
    46  	Credentials  Credentials
    47  	Environment  azureenv.Environment
    48  	AuthProvider *azurekiota.AzureIdentityAuthenticationProvider
    49  	TokenCreds   azcore.TokenCredential
    50  	CloudConfig  cloud.Configuration
    51  	AuthType     AuthenticationType
    52  }
    53  
    54  // Credentials is the data type for credentials as understood by the azure sdk
    55  type Credentials struct {
    56  	SubscriptionID            string `json:"subscriptionId,omitempty"`
    57  	ClientID                  string `json:"clientId,omitempty"`
    58  	ClientSecret              string `json:"clientSecret,omitempty"`
    59  	TenantID                  string `json:"tenantId,omitempty"`
    60  	ClientCertificatePath     string `json:"clientCertificate,omitempty"`
    61  	ClientCertificatePassword string `json:"clientCertificatePassword,omitempty"`
    62  }
    63  
    64  // GetSession returns an azure session by using credentials found in ~/.azure/osServicePrincipal.json
    65  // and, if no creds are found, asks for them and stores them on disk in a config file
    66  func GetSession(cloudName azure.CloudEnvironment, armEndpoint string) (*Session, error) {
    67  	return GetSessionWithCredentials(cloudName, armEndpoint, nil)
    68  }
    69  
    70  // GetSessionWithCredentials returns an Azure session by using prepopulated credentials.
    71  // If there are no prepopulated credentials it falls back to reading credentials from file system
    72  // or from user input.
    73  func GetSessionWithCredentials(cloudName azure.CloudEnvironment, armEndpoint string, credentials *Credentials) (*Session, error) {
    74  	var cloudEnv azureenv.Environment
    75  	var err error
    76  	switch cloudName {
    77  	case azure.StackCloud:
    78  		cloudEnv, err = azureenv.EnvironmentFromURL(armEndpoint)
    79  	default:
    80  		cloudEnv, err = azureenv.EnvironmentFromName(string(cloudName))
    81  	}
    82  	if err != nil {
    83  		return nil, fmt.Errorf("failed to get Azure environment for the %q cloud: %w", cloudName, err)
    84  	}
    85  
    86  	cloudConfig, err := GetCloudConfiguration(cloudName, armEndpoint)
    87  	if err != nil {
    88  		return nil, fmt.Errorf("failed to get cloud configuration for the %q cloud: %w", cloudName, err)
    89  	}
    90  
    91  	if credentials == nil {
    92  		credentials, err = credentialsFromFileOrUser()
    93  		if err != nil {
    94  			return nil, err
    95  		}
    96  	}
    97  	var cred azcore.TokenCredential
    98  	var authType AuthenticationType
    99  	switch {
   100  	case credentials.ClientCertificatePath != "":
   101  		logrus.Warnf("Using client certs to authenticate. Please be warned cluster does not support certs and only the installer does.")
   102  		cred, err = newTokenCredentialFromCertificates(credentials, *cloudConfig)
   103  		authType = ClientCertificateAuth
   104  	case credentials.ClientSecret != "":
   105  		cred, err = newTokenCredentialFromCredentials(credentials, *cloudConfig)
   106  		authType = ClientSecretAuth
   107  	default:
   108  		cred, err = newTokenCredentialFromMSI(credentials, *cloudConfig)
   109  		authType = ManagedIdentityAuth
   110  	}
   111  	if err != nil {
   112  		return nil, err
   113  	}
   114  	session, err := newSessionFromCredentials(cloudEnv, credentials, cred)
   115  	if err != nil {
   116  		return nil, err
   117  	}
   118  	session.CloudConfig = *cloudConfig
   119  	session.AuthType = authType
   120  	return session, nil
   121  }
   122  
   123  // GetCloudConfiguration gets a cloud configuration from the cloud name and endpoint.
   124  func GetCloudConfiguration(cloudName azure.CloudEnvironment, armEndpoint string) (*cloud.Configuration, error) {
   125  	var cloudEnv azureenv.Environment
   126  	var err error
   127  	switch cloudName {
   128  	case azure.StackCloud:
   129  		cloudEnv, err = azureenv.EnvironmentFromURL(armEndpoint)
   130  	default:
   131  		cloudEnv, err = azureenv.EnvironmentFromName(string(cloudName))
   132  	}
   133  	if err != nil {
   134  		return nil, err
   135  	}
   136  
   137  	var cloudConfig cloud.Configuration
   138  	switch cloudName {
   139  	case azure.StackCloud:
   140  		cloudConfig = cloud.Configuration{
   141  			ActiveDirectoryAuthorityHost: cloudEnv.ActiveDirectoryEndpoint,
   142  			Services: map[cloud.ServiceName]cloud.ServiceConfiguration{
   143  				cloud.ResourceManager: {
   144  					Audience: cloudEnv.TokenAudience,
   145  					Endpoint: cloudEnv.ResourceManagerEndpoint,
   146  				},
   147  			},
   148  		}
   149  	case azure.USGovernmentCloud:
   150  		cloudConfig = cloud.AzureGovernment
   151  	case azure.ChinaCloud:
   152  		cloudConfig = cloud.AzureChina
   153  	default:
   154  		cloudConfig = cloud.AzurePublic
   155  	}
   156  
   157  	return &cloudConfig, nil
   158  }
   159  
   160  // credentialsFromFileOrUser returns credentials found
   161  // in ~/.azure/osServicePrincipal.json and, if no creds are found,
   162  // asks for them and stores them on disk in a config file
   163  func credentialsFromFileOrUser() (*Credentials, error) {
   164  	authFilePath := defaultAuthFilePath
   165  	if f := os.Getenv(azureAuthEnv); len(f) > 0 {
   166  		authFilePath = f
   167  	}
   168  
   169  	var authFile Credentials
   170  
   171  	contents, err := os.ReadFile(authFilePath)
   172  	if err != nil {
   173  		// If the file with creds was not found, ask user for auth info
   174  		if errors.Is(err, fs.ErrNotExist) {
   175  			logrus.Infof("Asking user to provide authentication info")
   176  			credentials, cerr := askForCredentials()
   177  			if cerr != nil {
   178  				return nil, fmt.Errorf("failed to retrieve credentials from user: %w", cerr)
   179  			}
   180  			logrus.Infof("Saving user credentials to %q", authFilePath)
   181  			if cerr = saveCredentials(*credentials, authFilePath); cerr != nil {
   182  				return nil, fmt.Errorf("failed to save credentials: %w", cerr)
   183  			}
   184  			authFile = *credentials
   185  		} else {
   186  			// File was found but we failed to read it, just error out and let the user handle it
   187  			return nil, err
   188  		}
   189  	} else {
   190  		err = json.Unmarshal(contents, &authFile)
   191  		if err != nil {
   192  			return nil, err
   193  		}
   194  	}
   195  
   196  	if err := checkCredentials(authFile); err != nil {
   197  		return nil, err
   198  	}
   199  
   200  	if _, has := onceLoggers[authFilePath]; !has {
   201  		onceLoggers[authFilePath] = new(sync.Once)
   202  	}
   203  	onceLoggers[authFilePath].Do(func() {
   204  		logrus.Infof("Credentials loaded from file %q", authFilePath)
   205  	})
   206  
   207  	return &authFile, nil
   208  }
   209  
   210  func checkCredentials(creds Credentials) error {
   211  	if creds.SubscriptionID == "" {
   212  		return errors.New("could not retrieve subscriptionId from auth file")
   213  	}
   214  	if creds.TenantID == "" {
   215  		return errors.New("could not retrieve tenantId from auth file")
   216  	}
   217  	if (creds.ClientSecret != "" || creds.ClientCertificatePath != "") && creds.ClientID == "" {
   218  		return errors.New("could not retrieve clientId from auth file")
   219  	}
   220  	// If neither client secret nor client certificate are present, we default to Managed Identity
   221  	return nil
   222  }
   223  
   224  func askForCredentials() (*Credentials, error) {
   225  	var subscriptionID, tenantID, clientID, clientSecret string
   226  
   227  	err := survey.Ask([]*survey.Question{
   228  		{
   229  			Prompt: &survey.Input{
   230  				Message: "azure subscription id",
   231  				Help:    "The azure subscription id to use for installation",
   232  			},
   233  		},
   234  	}, &subscriptionID)
   235  	if err != nil {
   236  		return nil, err
   237  	}
   238  
   239  	err = survey.Ask([]*survey.Question{
   240  		{
   241  			Prompt: &survey.Input{
   242  				Message: "azure tenant id",
   243  				Help:    "The azure tenant id to use for installation",
   244  			},
   245  		},
   246  	}, &tenantID)
   247  	if err != nil {
   248  		return nil, err
   249  	}
   250  
   251  	err = survey.Ask([]*survey.Question{
   252  		{
   253  			Prompt: &survey.Input{
   254  				Message: "azure service principal client id",
   255  				Help:    "The azure client id to use for installation (this is not your username)",
   256  			},
   257  		},
   258  	}, &clientID)
   259  	if err != nil {
   260  		return nil, err
   261  	}
   262  
   263  	err = survey.Ask([]*survey.Question{
   264  		{
   265  			Prompt: &survey.Password{
   266  				Message: "azure service principal client secret",
   267  				Help:    "The azure secret access key corresponding to your client secret (this is not your password).",
   268  			},
   269  		},
   270  	}, &clientSecret)
   271  	if err != nil {
   272  		return nil, err
   273  	}
   274  
   275  	return &Credentials{
   276  		SubscriptionID: subscriptionID,
   277  		ClientID:       clientID,
   278  		ClientSecret:   clientSecret,
   279  		TenantID:       tenantID,
   280  	}, nil
   281  }
   282  
   283  func saveCredentials(credentials Credentials, filePath string) error {
   284  	jsonCreds, err := json.Marshal(credentials)
   285  	if err != nil {
   286  		return err
   287  	}
   288  
   289  	err = os.MkdirAll(filepath.Dir(filePath), 0700)
   290  	if err != nil {
   291  		return err
   292  	}
   293  
   294  	return os.WriteFile(filePath, jsonCreds, 0o600)
   295  }
   296  
   297  func newTokenCredentialFromCredentials(credentials *Credentials, cloudConfig cloud.Configuration) (azcore.TokenCredential, error) {
   298  	options := azidentity.ClientSecretCredentialOptions{
   299  		ClientOptions: azcore.ClientOptions{
   300  			Cloud: cloudConfig,
   301  		},
   302  	}
   303  
   304  	cred, err := azidentity.NewClientSecretCredential(credentials.TenantID, credentials.ClientID, credentials.ClientSecret, &options)
   305  	if err != nil {
   306  		return nil, fmt.Errorf("failed to get client credentials from secret: %w", err)
   307  	}
   308  	return cred, nil
   309  }
   310  
   311  func newTokenCredentialFromCertificates(credentials *Credentials, cloudConfig cloud.Configuration) (azcore.TokenCredential, error) {
   312  	options := azidentity.ClientCertificateCredentialOptions{
   313  		ClientOptions: azcore.ClientOptions{
   314  			Cloud: cloudConfig,
   315  		},
   316  	}
   317  
   318  	data, err := os.ReadFile(credentials.ClientCertificatePath)
   319  	if err != nil {
   320  		return nil, fmt.Errorf("failed to read client certificate file: %w", err)
   321  	}
   322  
   323  	// NewClientCertificateCredential requires at least one *x509.Certificate,
   324  	// and a crypto.PrivateKey. ParseCertificates returns these given
   325  	// certificate data in PEM or PKCS12 format. It handles common scenarios
   326  	// but has limitations, for example it doesn't load PEM encrypted private
   327  	// keys.
   328  	certs, key, err := azidentity.ParseCertificates(data, []byte(credentials.ClientCertificatePassword))
   329  	if err != nil {
   330  		return nil, fmt.Errorf("failed to parse client certificate: %w", err)
   331  	}
   332  
   333  	cred, err := azidentity.NewClientCertificateCredential(credentials.TenantID, credentials.ClientID, certs, key, &options)
   334  	if err != nil {
   335  		return nil, fmt.Errorf("failed to get client credentials from certificate: %w", err)
   336  	}
   337  	return cred, nil
   338  }
   339  
   340  func newTokenCredentialFromMSI(credentials *Credentials, cloudConfig cloud.Configuration) (azcore.TokenCredential, error) {
   341  	options := azidentity.ManagedIdentityCredentialOptions{
   342  		ClientOptions: azcore.ClientOptions{
   343  			Cloud: cloudConfig,
   344  		},
   345  	}
   346  	// User-assigned identity
   347  	if credentials.ClientID != "" {
   348  		options.ID = azidentity.ClientID(credentials.ClientID)
   349  	}
   350  
   351  	cred, err := azidentity.NewManagedIdentityCredential(&options)
   352  	if err != nil {
   353  		return nil, fmt.Errorf("failed to get client credentials from MSI: %w", err)
   354  	}
   355  	return cred, nil
   356  }
   357  
   358  func newSessionFromCredentials(cloudEnv azureenv.Environment, credentials *Credentials, cred azcore.TokenCredential) (*Session, error) {
   359  	var scope []string
   360  	// This can be empty for StackCloud
   361  	if cloudEnv.MicrosoftGraphEndpoint != "" {
   362  		// GovClouds need a properly set scope in the authenticator, otherwise we
   363  		// get an 'Invalid audience' error when doing MSGraph API calls
   364  		// https://learn.microsoft.com/en-us/graph/sdks/national-clouds?tabs=go
   365  		scope = []string{endpointToScope(cloudEnv.MicrosoftGraphEndpoint)}
   366  	}
   367  	authProvider, err := azurekiota.NewAzureIdentityAuthenticationProviderWithScopes(cred, scope)
   368  	if err != nil {
   369  		return nil, fmt.Errorf("failed to get Azidentity authentication provider: %w", err)
   370  	}
   371  
   372  	// Use an adapter so azidentity in the Azure SDK can be used as
   373  	// Authorizer when calling the Azure Management Packages, which we
   374  	// currently use. Once the Azure SDK clients (found in /sdk) move to
   375  	// stable, we can update our clients and they will be able to use the
   376  	// creds directly without the authorizer. The schedule is here:
   377  	// https://azure.github.io/azure-sdk/releases/latest/index.html#go
   378  	authorizer := azidext.NewTokenCredentialAdapter(cred, []string{endpointToScope(cloudEnv.TokenAudience)})
   379  
   380  	return &Session{
   381  		Authorizer:   authorizer,
   382  		Credentials:  *credentials,
   383  		Environment:  cloudEnv,
   384  		AuthProvider: authProvider,
   385  		TokenCreds:   cred,
   386  	}, nil
   387  }
   388  
   389  func endpointToScope(endpoint string) string {
   390  	if !strings.HasSuffix(endpoint, "/.default") {
   391  		endpoint += "/.default"
   392  	}
   393  	return endpoint
   394  }