cli/azd/cmd/auth_login.go (477 lines of code) (raw):

// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. package cmd import ( "context" "errors" "fmt" "io" "log" "os" "strconv" "strings" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/MakeNowJust/heredoc/v2" "github.com/azure/azure-dev/cli/azd/cmd/actions" "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/internal/runcontext" "github.com/azure/azure-dev/cli/azd/pkg/account" "github.com/azure/azure-dev/cli/azd/pkg/auth" "github.com/azure/azure-dev/cli/azd/pkg/contracts" "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/oneauth" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/output/ux" "github.com/azure/azure-dev/cli/azd/pkg/tools/github" "github.com/spf13/cobra" "github.com/spf13/pflag" ) // The parent of the login command. const loginCmdParentAnnotation = "loginCmdParent" // azurePipelinesClientIDEnvVarName is the name of the environment variable that contains the client ID for the principal // to use when authenticating with Azure Pipelines via OIDC. It is set by both the AzureCLI@2 and AzurePowerShell@5 tasks // when using a service connection or can be set manually when not using these tasks. const azurePipelinesClientIDEnvVarName = "AZURESUBSCRIPTION_CLIENT_ID" // azurePipelinesTenantIDEnvVarName is the name of the environment variable that contains the tenant ID for the principal // to use when authenticating with Azure Pipelines via OIDC. It is set by both the AzureCLI@2 and AzurePowerShell@5 tasks // when using a service connection or can be set manually when not using these tasks. const azurePipelinesTenantIDEnvVarName = "AZURESUBSCRIPTION_TENANT_ID" // AzurePipelinesServiceConnectionNameEnvVarName is the name of the environment variable that contains the name of the // service connection to use when authenticating with Azure Pipelines via OIDC. It is set by both the AzureCLI@2 and // AzurePowerShell@5 tasks when using a service connection or can be set manually when not using these tasks. const azurePipelinesServiceConnectionIDEnvVarName = "AZURESUBSCRIPTION_SERVICE_CONNECTION_ID" // azurePipelinesProvider is the name of the federated token provider to use when authenticating with Azure Pipelines via // OIDC. const azurePipelinesProvider string = "azure-pipelines" type authLoginFlags struct { loginFlags } func newAuthLoginFlags(cmd *cobra.Command, global *internal.GlobalCommandOptions) *authLoginFlags { flags := &authLoginFlags{} flags.Bind(cmd.Flags(), global) return flags } type loginFlags struct { onlyCheckStatus bool browser bool managedIdentity bool useDeviceCode boolPtr tenantID string clientID string clientSecret stringPtr clientCertificate string federatedTokenProvider string scopes []string redirectPort int global *internal.GlobalCommandOptions } // stringPtr implements a pflag.Value and allows us to distinguish between a flag value being explicitly set to the empty // string vs not being present. type stringPtr struct { ptr *string } func (p *stringPtr) Set(s string) error { p.ptr = &s return nil } func (p *stringPtr) String() string { if p.ptr != nil { return *p.ptr } return "" } func (p *stringPtr) Type() string { return "string" } // boolPtr implements a pflag.Value and allows us to distinguish between a flag value being explicitly set to // bool vs not being present. type boolPtr struct { ptr *string } func (p *boolPtr) Set(s string) error { p.ptr = &s return nil } func (p *boolPtr) String() string { if p.ptr != nil { return *p.ptr } return "false" } func (p *boolPtr) Type() string { return "" } const ( cClientSecretFlagName = "client-secret" cClientCertificateFlagName = "client-certificate" cFederatedCredentialProviderFlagName = "federated-credential-provider" ) func (lf *loginFlags) Bind(local *pflag.FlagSet, global *internal.GlobalCommandOptions) { local.BoolVar(&lf.onlyCheckStatus, "check-status", false, "Checks the log-in status instead of logging in.") f := local.VarPF( &lf.useDeviceCode, "use-device-code", "", "When true, log in by using a device code instead of a browser.", ) // ensure the flag behaves as a common boolean flag which is set to true when used without any other arg f.NoOptDefVal = "true" local.BoolVar( &lf.managedIdentity, "managed-identity", false, "Use a managed identity to authenticate.", ) local.StringVar(&lf.clientID, "client-id", "", "The client id for the service principal to authenticate with.") local.Var( &lf.clientSecret, cClientSecretFlagName, "The client secret for the service principal to authenticate with. "+ "Set to the empty string to read the value from the console.") local.StringVar( &lf.clientCertificate, cClientCertificateFlagName, "", "The path to the client certificate for the service principal to authenticate with.") local.StringVar( &lf.federatedTokenProvider, cFederatedCredentialProviderFlagName, "", "The provider to use to acquire a federated token to authenticate with.") local.StringVar( &lf.tenantID, "tenant-id", "", "The tenant id or domain name to authenticate with.") local.StringArrayVar( &lf.scopes, "scope", nil, "The scope to acquire during login") _ = local.MarkHidden("scope") local.IntVar( &lf.redirectPort, "redirect-port", 0, "Choose the port to be used as part of the redirect URI during interactive login.") if oneauth.Supported { local.BoolVar(&lf.browser, "browser", false, "Authenticate in a web browser instead of an integrated dialog.") } lf.global = global } func newLoginFlags(cmd *cobra.Command, global *internal.GlobalCommandOptions) *loginFlags { flags := &loginFlags{} flags.Bind(cmd.Flags(), global) return flags } func newLoginCmd(parent string) *cobra.Command { return &cobra.Command{ Use: "login", Short: "Log in to Azure.", Long: heredoc.Doc(` Log in to Azure. When run without any arguments, log in interactively using a browser. To log in using a device code, pass --use-device-code. To log in as a service principal, pass --client-id and --tenant-id as well as one of: --client-secret, --client-certificate, or --federated-credential-provider. To log in using a managed identity, pass --managed-identity, which will use the system assigned managed identity. To use a user assigned managed identity, pass --client-id in addition to --managed-identity with the client id of the user assigned managed identity you wish to use. `), Annotations: map[string]string{ loginCmdParentAnnotation: parent, }, } } type loginAction struct { formatter output.Formatter writer io.Writer console input.Console authManager *auth.Manager accountSubManager *account.SubscriptionsManager flags *loginFlags annotations CmdAnnotations commandRunner exec.CommandRunner } // it is important to update both newAuthLoginAction and newLoginAction at the same time // newAuthLoginAction is the action that is bound to `azd auth login`, // and newLoginAction is the action that is bound to `azd login` func newAuthLoginAction( formatter output.Formatter, writer io.Writer, authManager *auth.Manager, accountSubManager *account.SubscriptionsManager, flags *authLoginFlags, console input.Console, annotations CmdAnnotations, commandRunner exec.CommandRunner, ) actions.Action { return &loginAction{ formatter: formatter, writer: writer, console: console, authManager: authManager, accountSubManager: accountSubManager, flags: &flags.loginFlags, annotations: annotations, commandRunner: commandRunner, } } // it is important to update both newAuthLoginAction and newLoginAction at the same time // newAuthLoginAction is the action that is bound to `azd auth login`, // and newLoginAction is the action that is bound to `azd login` func newLoginAction( formatter output.Formatter, writer io.Writer, authManager *auth.Manager, accountSubManager *account.SubscriptionsManager, flags *loginFlags, console input.Console, annotations CmdAnnotations, commandRunner exec.CommandRunner, ) actions.Action { return &loginAction{ formatter: formatter, writer: writer, console: console, authManager: authManager, accountSubManager: accountSubManager, flags: flags, annotations: annotations, commandRunner: commandRunner, } } func (la *loginAction) Run(ctx context.Context) (*actions.ActionResult, error) { if len(la.flags.scopes) == 0 { la.flags.scopes = la.authManager.LoginScopes() } if la.annotations[loginCmdParentAnnotation] == "" { fmt.Fprintln( la.console.Handles().Stderr, output.WithWarningFormat( "WARNING: `azd login` is deprecated and will be removed in a future release.")) fmt.Fprintln( la.console.Handles().Stderr, "Next time use `azd auth login`.") } if la.flags.onlyCheckStatus { // In check status mode, we always print the final status to stdout. // We print any non-setup related errors to stderr. // We always return a zero exit code. token, err := la.verifyLoggedIn(ctx) var loginExpiryError *auth.ReLoginRequiredError if err != nil && !errors.Is(err, auth.ErrNoCurrentUser) && !errors.As(err, &loginExpiryError) { fmt.Fprintln(la.console.Handles().Stderr, err.Error()) } res := contracts.LoginResult{} if err != nil { res.Status = contracts.LoginStatusUnauthenticated } else { res.Status = contracts.LoginStatusSuccess res.ExpiresOn = &token.ExpiresOn } if la.formatter.Kind() != output.NoneFormat { return nil, la.formatter.Format(res, la.writer, nil) } else { var msg string switch res.Status { case contracts.LoginStatusSuccess: msg = "Logged in to Azure" case contracts.LoginStatusUnauthenticated: msg = "Not logged in, run `azd auth login` to login to Azure" default: panic("Unhandled login status") } // get user account information - login --check-status details, err := la.authManager.LogInDetails(ctx) // error getting user account or not logged in if err != nil { log.Printf("error: getting signed in account: %v", err) fmt.Fprintln(la.console.Handles().Stdout, msg) return nil, nil } // only print the message if the user is logged in la.console.MessageUxItem(ctx, &ux.LoggedIn{ LoggedInAs: details.Account, LoginType: ux.LoginType(details.LoginType), }) return nil, nil } } if err := la.login(ctx); err != nil { return nil, err } if _, err := la.verifyLoggedIn(ctx); err != nil { return nil, err } forceRefresh := false if v, err := strconv.ParseBool(os.Getenv("AZD_DEBUG_LOGIN_FORCE_SUBSCRIPTION_REFRESH")); err == nil && v { forceRefresh = true } if la.flags.clientID == "" || forceRefresh { // Update the subscriptions cache for regular users (i.e. non-service-principals). // The caching is done here to increase responsiveness of listing subscriptions in the application. // It also allows an implicit command for the user to refresh cached subscriptions. if err := la.accountSubManager.RefreshSubscriptions(ctx); err != nil { // If this fails, the subscriptions will still be loaded on-demand. // erroring out when the user interacts with subscriptions is much more user-friendly. log.Printf("failed retrieving subscriptions: %v", err) } } details, err := la.authManager.LogInDetails(ctx) // error getting user account, successful log in if err != nil { log.Printf("error: getting signed in account: %v", err) la.console.Message(ctx, "Logged in to Azure") return nil, nil } la.console.MessageUxItem(ctx, &ux.LoggedIn{ LoggedInAs: details.Account, LoginType: ux.LoginType(details.LoginType), }) return nil, nil } // Verifies that the user has credentials stored, // and that the credentials stored is accepted by the identity server (can be exchanged for access token). func (la *loginAction) verifyLoggedIn(ctx context.Context) (*azcore.AccessToken, error) { credOptions := auth.CredentialForCurrentUserOptions{ TenantID: la.flags.tenantID, } cred, err := la.authManager.CredentialForCurrentUser(ctx, &credOptions) if err != nil { return nil, err } // Ensure credential is valid, and can be exchanged for an access token token, err := cred.GetToken(ctx, policy.TokenRequestOptions{ Scopes: la.flags.scopes, }) if err != nil { return nil, err } return &token, nil } func countTrue(elms ...bool) int { i := 0 for _, elm := range elms { if elm { i++ } } return i } // runningOnCodespacesBrowser use `code --status` which returns: // // > The --status argument is not yet supported in browsers. // // to detect when vscode is within a WebBrowser environment. func runningOnCodespacesBrowser(ctx context.Context, commandRunner exec.CommandRunner) bool { runArgs := exec.NewRunArgs("code", "--status") result, err := commandRunner.Run(ctx, runArgs) if err != nil { // An error here means VSCode is not installed or found, or something else. // At any case, we know VSCode is not within a webBrowser log.Printf("error running code --status: %s", err.Error()) return false } return strings.Contains(result.Stdout, "The --status argument is not yet supported in browsers") } func (la *loginAction) login(ctx context.Context) error { if la.flags.federatedTokenProvider == azurePipelinesProvider { if la.flags.clientID == "" { log.Printf("setting client id from environment variable %s", azurePipelinesClientIDEnvVarName) la.flags.clientID = os.Getenv(azurePipelinesClientIDEnvVarName) } if la.flags.tenantID == "" { log.Printf("setting tenant id from environment variable %s", azurePipelinesClientIDEnvVarName) la.flags.tenantID = os.Getenv(azurePipelinesTenantIDEnvVarName) } } if la.flags.managedIdentity { if _, err := la.authManager.LoginWithManagedIdentity( ctx, la.flags.clientID, ); err != nil { return fmt.Errorf("logging in: %w", err) } return nil } if !la.flags.managedIdentity && la.flags.clientID != "" { if la.flags.tenantID == "" { return errors.New("must set both `client-id` and `tenant-id` for service principal login") } if countTrue( la.flags.clientSecret.ptr != nil, la.flags.clientCertificate != "", la.flags.federatedTokenProvider != "", ) != 1 { return fmt.Errorf( "must set exactly one of %s for service principal", strings.Join([]string{ cClientSecretFlagName, cClientCertificateFlagName, cFederatedCredentialProviderFlagName, }, ", ")) } switch { case la.flags.clientSecret.ptr != nil: if *la.flags.clientSecret.ptr == "" { v, err := la.console.Prompt(ctx, input.ConsoleOptions{ Message: "Enter your client secret", }) if err != nil { return fmt.Errorf("prompting for client secret: %w", err) } la.flags.clientSecret.ptr = &v } if _, err := la.authManager.LoginWithServicePrincipalSecret( ctx, la.flags.tenantID, la.flags.clientID, *la.flags.clientSecret.ptr, ); err != nil { return fmt.Errorf("logging in: %w", err) } case la.flags.clientCertificate != "": certFile, err := os.Open(la.flags.clientCertificate) if err != nil { return fmt.Errorf("reading certificate: %w", err) } defer certFile.Close() cert, err := io.ReadAll(certFile) if err != nil { return fmt.Errorf("reading certificate: %w", err) } if _, err := la.authManager.LoginWithServicePrincipalCertificate( ctx, la.flags.tenantID, la.flags.clientID, cert, ); err != nil { return fmt.Errorf("logging in: %w", err) } case la.flags.federatedTokenProvider == "github": if _, err := la.authManager.LoginWithGitHubFederatedTokenProvider( ctx, la.flags.tenantID, la.flags.clientID, ); err != nil { return fmt.Errorf("logging in: %w", err) } case la.flags.federatedTokenProvider == azurePipelinesProvider: serviceConnectionID := os.Getenv(azurePipelinesServiceConnectionIDEnvVarName) if serviceConnectionID == "" { return fmt.Errorf("must set %s for azure-pipelines federated token provider", azurePipelinesServiceConnectionIDEnvVarName) } if _, err := la.authManager.LoginWithAzurePipelinesFederatedTokenProvider( ctx, la.flags.tenantID, la.flags.clientID, serviceConnectionID, ); err != nil { return fmt.Errorf("logging in: %w", err) } } return nil } if la.authManager.UseExternalAuth() { // Request a token and assume the external auth system will prompt the user to log in. // // TODO(ellismg): We may want instead to call some explicit `/login` endpoint on the external auth system instead // of abusing the token request in this manner. This would allow the other end to provide a more tailored experience. _, err := la.verifyLoggedIn(ctx) return err } useDevCode, err := parseUseDeviceCode(ctx, la.flags.useDeviceCode, la.commandRunner) if err != nil { return err } if useDevCode { _, err = la.authManager.LoginWithDeviceCode(ctx, la.flags.tenantID, la.flags.scopes, func(url string) error { if !la.flags.global.NoPrompt { la.console.Message(ctx, "Then press enter and continue to log in from your browser...") la.console.WaitForEnter() openWithDefaultBrowser(ctx, la.console, url) return nil } // For no-prompt, Just provide instructions without trying to open the browser // If manual browsing is enabled, we don't want to open the browser automatically la.console.Message(ctx, fmt.Sprintf("Then, go to: %s", url)) return nil }) return err } if oneauth.Supported && !la.flags.browser { err = la.authManager.LoginWithOneAuth(ctx, la.flags.tenantID, la.flags.scopes) } else { _, err = la.authManager.LoginInteractive(ctx, la.flags.scopes, &auth.LoginInteractiveOptions{ TenantID: la.flags.tenantID, RedirectPort: la.flags.redirectPort, WithOpenUrl: func(url string) error { openWithDefaultBrowser(ctx, la.console, url) return nil }, }) } if err != nil { err = fmt.Errorf("logging in: %w", err) } return err } func parseUseDeviceCode(ctx context.Context, flag boolPtr, commandRunner exec.CommandRunner) (bool, error) { var useDevCode bool useDevCodeFlag := flag.ptr != nil if useDevCodeFlag { userInput, err := strconv.ParseBool(*flag.ptr) if err != nil { return false, fmt.Errorf("unexpected boolean input for '--use-device-code': %w", err) } // honor the value from the user input. No override. return userInput, err } // Detect cases where the browser isn't available for interactive auth, and we instead want to set `useDeviceCode` // to be true by default if github.RunningOnCodespaces() { // For VSCode online (in web Browser), like GitHub Codespaces or VSCode online attached to any server, // interactive browser login will 404 when attempting to redirect to localhost // (since azd launches a localhost server running remotely and the login response is accepted locally). // Hence, we override login to device-code. See https://github.com/Azure/azure-dev/issues/1006 useDevCode = runningOnCodespacesBrowser(ctx, commandRunner) } if runcontext.IsRunningInCloudShell() { // Following az CLI behavior in Cloud Shell, use device code authentication when the user is trying to // authenticate. The normal interactive authentication flow will not work in Cloud Shell because the browser // cannot be opened or (if it could) cannot be redirected back to a port on the Cloud Shell instance. return true, nil } return useDevCode, nil }