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 }