kubeform.dev/terraform-backend-sdk@v0.0.0-20220310143633-45f07fe731c5/command/login.go (about)

     1  package command
     2  
     3  import (
     4  	"context"
     5  	"crypto/sha256"
     6  	"encoding/base64"
     7  	"encoding/json"
     8  	"errors"
     9  	"fmt"
    10  	"io/ioutil"
    11  	"log"
    12  	"math/rand"
    13  	"net"
    14  	"net/http"
    15  	"net/url"
    16  	"path/filepath"
    17  	"strings"
    18  
    19  	tfe "github.com/hashicorp/go-tfe"
    20  	svchost "github.com/hashicorp/terraform-svchost"
    21  	svcauth "github.com/hashicorp/terraform-svchost/auth"
    22  	"github.com/hashicorp/terraform-svchost/disco"
    23  	"kubeform.dev/terraform-backend-sdk/command/cliconfig"
    24  	"kubeform.dev/terraform-backend-sdk/httpclient"
    25  	"kubeform.dev/terraform-backend-sdk/terraform"
    26  	"kubeform.dev/terraform-backend-sdk/tfdiags"
    27  
    28  	uuid "github.com/hashicorp/go-uuid"
    29  	"golang.org/x/oauth2"
    30  )
    31  
    32  // LoginCommand is a Command implementation that runs an interactive login
    33  // flow for a remote service host. It then stashes credentials in a tfrc
    34  // file in the user's home directory.
    35  type LoginCommand struct {
    36  	Meta
    37  }
    38  
    39  // Run implements cli.Command.
    40  func (c *LoginCommand) Run(args []string) int {
    41  	args = c.Meta.process(args)
    42  	cmdFlags := c.Meta.extendedFlagSet("login")
    43  	cmdFlags.Usage = func() { c.Ui.Error(c.Help()) }
    44  	if err := cmdFlags.Parse(args); err != nil {
    45  		return 1
    46  	}
    47  
    48  	args = cmdFlags.Args()
    49  	if len(args) > 1 {
    50  		c.Ui.Error(
    51  			"The login command expects at most one argument: the host to log in to.")
    52  		cmdFlags.Usage()
    53  		return 1
    54  	}
    55  
    56  	var diags tfdiags.Diagnostics
    57  
    58  	if !c.input {
    59  		diags = diags.Append(tfdiags.Sourceless(
    60  			tfdiags.Error,
    61  			"Login is an interactive command",
    62  			"The \"terraform login\" command uses interactive prompts to obtain and record credentials, so it can't be run with input disabled.\n\nTo configure credentials in a non-interactive context, write existing credentials directly to a CLI configuration file.",
    63  		))
    64  		c.showDiagnostics(diags)
    65  		return 1
    66  	}
    67  
    68  	givenHostname := "app.terraform.io"
    69  	if len(args) != 0 {
    70  		givenHostname = args[0]
    71  	}
    72  
    73  	hostname, err := svchost.ForComparison(givenHostname)
    74  	if err != nil {
    75  		diags = diags.Append(tfdiags.Sourceless(
    76  			tfdiags.Error,
    77  			"Invalid hostname",
    78  			fmt.Sprintf("The given hostname %q is not valid: %s.", givenHostname, err.Error()),
    79  		))
    80  		c.showDiagnostics(diags)
    81  		return 1
    82  	}
    83  
    84  	// From now on, since we've validated the given hostname, we should use
    85  	// dispHostname in the UI to ensure we're presenting it in the canonical
    86  	// form, in case that helpers users with debugging when things aren't
    87  	// working as expected. (Perhaps the normalization is part of the cause.)
    88  	dispHostname := hostname.ForDisplay()
    89  
    90  	host, err := c.Services.Discover(hostname)
    91  	if err != nil {
    92  		diags = diags.Append(tfdiags.Sourceless(
    93  			tfdiags.Error,
    94  			"Service discovery failed for "+dispHostname,
    95  
    96  			// Contrary to usual Go idiom, the Discover function returns
    97  			// full sentences with initial capitalization in its error messages,
    98  			// and they are written with the end-user as the audience. We
    99  			// only need to add the trailing period to make them consistent
   100  			// with our usual error reporting standards.
   101  			err.Error()+".",
   102  		))
   103  		c.showDiagnostics(diags)
   104  		return 1
   105  	}
   106  
   107  	creds := c.Services.CredentialsSource().(*cliconfig.CredentialsSource)
   108  	filename, _ := creds.CredentialsFilePath()
   109  	credsCtx := &loginCredentialsContext{
   110  		Location:      creds.HostCredentialsLocation(hostname),
   111  		LocalFilename: filename, // empty in the very unlikely event that we can't select a config directory for this user
   112  		HelperType:    creds.CredentialsHelperType(),
   113  	}
   114  
   115  	clientConfig, err := host.ServiceOAuthClient("login.v1")
   116  	switch err.(type) {
   117  	case nil:
   118  		// Great! No problem, then.
   119  	case *disco.ErrServiceNotProvided:
   120  		// This is also fine! We'll try the manual token creation process.
   121  	case *disco.ErrVersionNotSupported:
   122  		diags = diags.Append(tfdiags.Sourceless(
   123  			tfdiags.Warning,
   124  			"Host does not support Terraform login",
   125  			fmt.Sprintf("The given hostname %q allows creating Terraform authorization tokens, but requires a newer version of Terraform CLI to do so.", dispHostname),
   126  		))
   127  	default:
   128  		diags = diags.Append(tfdiags.Sourceless(
   129  			tfdiags.Warning,
   130  			"Host does not support Terraform login",
   131  			fmt.Sprintf("The given hostname %q cannot support \"terraform login\": %s.", dispHostname, err),
   132  		))
   133  	}
   134  
   135  	// If login service is unavailable, check for a TFE v2 API as fallback
   136  	var tfeservice *url.URL
   137  	if clientConfig == nil {
   138  		tfeservice, err = host.ServiceURL("tfe.v2")
   139  		switch err.(type) {
   140  		case nil:
   141  			// Success!
   142  		case *disco.ErrServiceNotProvided:
   143  			diags = diags.Append(tfdiags.Sourceless(
   144  				tfdiags.Error,
   145  				"Host does not support Terraform tokens API",
   146  				fmt.Sprintf("The given hostname %q does not support creating Terraform authorization tokens.", dispHostname),
   147  			))
   148  		case *disco.ErrVersionNotSupported:
   149  			diags = diags.Append(tfdiags.Sourceless(
   150  				tfdiags.Error,
   151  				"Host does not support Terraform tokens API",
   152  				fmt.Sprintf("The given hostname %q allows creating Terraform authorization tokens, but requires a newer version of Terraform CLI to do so.", dispHostname),
   153  			))
   154  		default:
   155  			diags = diags.Append(tfdiags.Sourceless(
   156  				tfdiags.Error,
   157  				"Host does not support Terraform tokens API",
   158  				fmt.Sprintf("The given hostname %q cannot support \"terraform login\": %s.", dispHostname, err),
   159  			))
   160  		}
   161  	}
   162  
   163  	if credsCtx.Location == cliconfig.CredentialsInOtherFile {
   164  		diags = diags.Append(tfdiags.Sourceless(
   165  			tfdiags.Error,
   166  			fmt.Sprintf("Credentials for %s are manually configured", dispHostname),
   167  			"The \"terraform login\" command cannot log in because credentials for this host are already configured in a CLI configuration file.\n\nTo log in, first revoke the existing credentials and remove that block from the CLI configuration.",
   168  		))
   169  	}
   170  
   171  	if diags.HasErrors() {
   172  		c.showDiagnostics(diags)
   173  		return 1
   174  	}
   175  
   176  	var token svcauth.HostCredentialsToken
   177  	var tokenDiags tfdiags.Diagnostics
   178  
   179  	// Prefer Terraform login if available
   180  	if clientConfig != nil {
   181  		var oauthToken *oauth2.Token
   182  
   183  		switch {
   184  		case clientConfig.SupportedGrantTypes.Has(disco.OAuthAuthzCodeGrant):
   185  			// We prefer an OAuth code grant if the server supports it.
   186  			oauthToken, tokenDiags = c.interactiveGetTokenByCode(hostname, credsCtx, clientConfig)
   187  		case clientConfig.SupportedGrantTypes.Has(disco.OAuthOwnerPasswordGrant) && hostname == svchost.Hostname("app.terraform.io"):
   188  			// The password grant type is allowed only for Terraform Cloud SaaS.
   189  			// Note this case is purely theoretical at this point, as TFC currently uses
   190  			// its own bespoke login protocol (tfe)
   191  			oauthToken, tokenDiags = c.interactiveGetTokenByPassword(hostname, credsCtx, clientConfig)
   192  		default:
   193  			tokenDiags = tokenDiags.Append(tfdiags.Sourceless(
   194  				tfdiags.Error,
   195  				"Host does not support Terraform login",
   196  				fmt.Sprintf("The given hostname %q does not allow any OAuth grant types that are supported by this version of Terraform.", dispHostname),
   197  			))
   198  		}
   199  		if oauthToken != nil {
   200  			token = svcauth.HostCredentialsToken(oauthToken.AccessToken)
   201  		}
   202  	} else if tfeservice != nil {
   203  		token, tokenDiags = c.interactiveGetTokenByUI(hostname, credsCtx, tfeservice)
   204  	}
   205  
   206  	diags = diags.Append(tokenDiags)
   207  	if diags.HasErrors() {
   208  		c.showDiagnostics(diags)
   209  		return 1
   210  	}
   211  
   212  	err = creds.StoreForHost(hostname, token)
   213  	if err != nil {
   214  		diags = diags.Append(tfdiags.Sourceless(
   215  			tfdiags.Error,
   216  			"Failed to save API token",
   217  			fmt.Sprintf("The given host returned an API token, but Terraform failed to save it: %s.", err),
   218  		))
   219  	}
   220  
   221  	c.showDiagnostics(diags)
   222  	if diags.HasErrors() {
   223  		return 1
   224  	}
   225  
   226  	c.Ui.Output("\n---------------------------------------------------------------------------------\n")
   227  	if hostname == "app.terraform.io" { // Terraform Cloud
   228  		var motd struct {
   229  			Message string        `json:"msg"`
   230  			Errors  []interface{} `json:"errors"`
   231  		}
   232  
   233  		// Throughout the entire process of fetching a MOTD from TFC, use a default
   234  		// message if the platform-provided message is unavailable for any reason -
   235  		// be it the service isn't provided, the request failed, or any sort of
   236  		// platform error returned.
   237  
   238  		motdServiceURL, err := host.ServiceURL("motd.v1")
   239  		if err != nil {
   240  			c.logMOTDError(err)
   241  			c.outputDefaultTFCLoginSuccess()
   242  			return 0
   243  		}
   244  
   245  		req, err := http.NewRequest("GET", motdServiceURL.String(), nil)
   246  		if err != nil {
   247  			c.logMOTDError(err)
   248  			c.outputDefaultTFCLoginSuccess()
   249  			return 0
   250  		}
   251  
   252  		req.Header.Set("Authorization", "Bearer "+token.Token())
   253  
   254  		resp, err := httpclient.New().Do(req)
   255  		if err != nil {
   256  			c.logMOTDError(err)
   257  			c.outputDefaultTFCLoginSuccess()
   258  			return 0
   259  		}
   260  
   261  		body, err := ioutil.ReadAll(resp.Body)
   262  		if err != nil {
   263  			c.logMOTDError(err)
   264  			c.outputDefaultTFCLoginSuccess()
   265  			return 0
   266  		}
   267  
   268  		defer resp.Body.Close()
   269  		json.Unmarshal(body, &motd)
   270  
   271  		if motd.Errors == nil && motd.Message != "" {
   272  			c.Ui.Output(
   273  				c.Colorize().Color(motd.Message),
   274  			)
   275  			return 0
   276  		} else {
   277  			c.logMOTDError(fmt.Errorf("platform responded with errors or an empty message"))
   278  			c.outputDefaultTFCLoginSuccess()
   279  			return 0
   280  		}
   281  	}
   282  
   283  	if tfeservice != nil { // Terraform Enterprise
   284  		c.outputDefaultTFELoginSuccess(dispHostname)
   285  	} else {
   286  		c.Ui.Output(
   287  			fmt.Sprintf(
   288  				c.Colorize().Color(strings.TrimSpace(`
   289  [green][bold]Success![reset] [bold]Terraform has obtained and saved an API token.[reset]
   290  
   291  The new API token will be used for any future Terraform command that must make
   292  authenticated requests to %s.
   293  `)),
   294  				dispHostname,
   295  			) + "\n",
   296  		)
   297  	}
   298  
   299  	return 0
   300  }
   301  
   302  func (c *LoginCommand) outputDefaultTFELoginSuccess(dispHostname string) {
   303  	c.Ui.Output(
   304  		fmt.Sprintf(
   305  			c.Colorize().Color(strings.TrimSpace(`
   306  [green][bold]Success![reset] [bold]Logged in to Terraform Enterprise (%s)[reset]
   307  `)),
   308  			dispHostname,
   309  		) + "\n",
   310  	)
   311  }
   312  
   313  func (c *LoginCommand) outputDefaultTFCLoginSuccess() {
   314  	c.Ui.Output(c.Colorize().Color(strings.TrimSpace(`
   315  [green][bold]Success![reset] [bold]Logged in to Terraform Cloud[reset]
   316  ` + "\n")))
   317  }
   318  
   319  func (c *LoginCommand) logMOTDError(err error) {
   320  	log.Printf("[TRACE] login: An error occurred attempting to fetch a message of the day for Terraform Cloud: %s", err)
   321  }
   322  
   323  // Help implements cli.Command.
   324  func (c *LoginCommand) Help() string {
   325  	defaultFile := c.defaultOutputFile()
   326  	if defaultFile == "" {
   327  		// Because this is just for the help message and it's very unlikely
   328  		// that a user wouldn't have a functioning home directory anyway,
   329  		// we'll just use a placeholder here. The real command has some
   330  		// more complex behavior for this case. This result is not correct
   331  		// on all platforms, but given how unlikely we are to hit this case
   332  		// that seems okay.
   333  		defaultFile = "~/.terraform/credentials.tfrc.json"
   334  	}
   335  
   336  	helpText := fmt.Sprintf(`
   337  Usage: terraform [global options] login [hostname]
   338  
   339    Retrieves an authentication token for the given hostname, if it supports
   340    automatic login, and saves it in a credentials file in your home directory.
   341  
   342    If no hostname is provided, the default hostname is app.terraform.io, to
   343    log in to Terraform Cloud.
   344  
   345    If not overridden by credentials helper settings in the CLI configuration,
   346    the credentials will be written to the following local file:
   347        %s
   348  `, defaultFile)
   349  	return strings.TrimSpace(helpText)
   350  }
   351  
   352  // Synopsis implements cli.Command.
   353  func (c *LoginCommand) Synopsis() string {
   354  	return "Obtain and save credentials for a remote host"
   355  }
   356  
   357  func (c *LoginCommand) defaultOutputFile() string {
   358  	if c.CLIConfigDir == "" {
   359  		return "" // no default available
   360  	}
   361  	return filepath.Join(c.CLIConfigDir, "credentials.tfrc.json")
   362  }
   363  
   364  func (c *LoginCommand) interactiveGetTokenByCode(hostname svchost.Hostname, credsCtx *loginCredentialsContext, clientConfig *disco.OAuthClient) (*oauth2.Token, tfdiags.Diagnostics) {
   365  	var diags tfdiags.Diagnostics
   366  
   367  	confirm, confirmDiags := c.interactiveContextConsent(hostname, disco.OAuthAuthzCodeGrant, credsCtx)
   368  	diags = diags.Append(confirmDiags)
   369  	if !confirm {
   370  		diags = diags.Append(errors.New("Login cancelled"))
   371  		return nil, diags
   372  	}
   373  
   374  	// We'll use an entirely pseudo-random UUID for our temporary request
   375  	// state. The OAuth server must echo this back to us in the callback
   376  	// request to make it difficult for some other running process to
   377  	// interfere by sending its own request to our temporary server.
   378  	reqState, err := uuid.GenerateUUID()
   379  	if err != nil {
   380  		// This should be very unlikely, but could potentially occur if e.g.
   381  		// there's not enough pseudo-random entropy available.
   382  		diags = diags.Append(tfdiags.Sourceless(
   383  			tfdiags.Error,
   384  			"Can't generate login request state",
   385  			fmt.Sprintf("Cannot generate random request identifier for login request: %s.", err),
   386  		))
   387  		return nil, diags
   388  	}
   389  
   390  	proofKey, proofKeyChallenge, err := c.proofKey()
   391  	if err != nil {
   392  		diags = diags.Append(tfdiags.Sourceless(
   393  			tfdiags.Error,
   394  			"Can't generate login request state",
   395  			fmt.Sprintf("Cannot generate random prrof key for login request: %s.", err),
   396  		))
   397  		return nil, diags
   398  	}
   399  
   400  	listener, callbackURL, err := c.listenerForCallback(clientConfig.MinPort, clientConfig.MaxPort)
   401  	if err != nil {
   402  		diags = diags.Append(tfdiags.Sourceless(
   403  			tfdiags.Error,
   404  			"Can't start temporary login server",
   405  			fmt.Sprintf(
   406  				"The login process uses OAuth, which requires starting a temporary HTTP server on localhost. However, no TCP port numbers between %d and %d are available to create such a server.",
   407  				clientConfig.MinPort, clientConfig.MaxPort,
   408  			),
   409  		))
   410  		return nil, diags
   411  	}
   412  
   413  	// codeCh will allow our temporary HTTP server to transmit the OAuth code
   414  	// to the main execution path that follows.
   415  	codeCh := make(chan string)
   416  	server := &http.Server{
   417  		Handler: http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
   418  			log.Printf("[TRACE] login: request to callback server")
   419  			err := req.ParseForm()
   420  			if err != nil {
   421  				log.Printf("[ERROR] login: cannot ParseForm on callback request: %s", err)
   422  				resp.WriteHeader(400)
   423  				return
   424  			}
   425  			gotState := req.Form.Get("state")
   426  			if gotState != reqState {
   427  				log.Printf("[ERROR] login: incorrect \"state\" value in callback request")
   428  				resp.WriteHeader(400)
   429  				return
   430  			}
   431  			gotCode := req.Form.Get("code")
   432  			if gotCode == "" {
   433  				log.Printf("[ERROR] login: no \"code\" argument in callback request")
   434  				resp.WriteHeader(400)
   435  				return
   436  			}
   437  
   438  			log.Printf("[TRACE] login: request contains an authorization code")
   439  
   440  			// Send the code to our blocking wait below, so that the token
   441  			// fetching process can continue.
   442  			codeCh <- gotCode
   443  			close(codeCh)
   444  
   445  			log.Printf("[TRACE] login: returning response from callback server")
   446  
   447  			resp.Header().Add("Content-Type", "text/html")
   448  			resp.WriteHeader(200)
   449  			resp.Write([]byte(callbackSuccessMessage))
   450  		}),
   451  	}
   452  	go func() {
   453  		err := server.Serve(listener)
   454  		if err != nil && err != http.ErrServerClosed {
   455  			diags = diags.Append(tfdiags.Sourceless(
   456  				tfdiags.Error,
   457  				"Can't start temporary login server",
   458  				fmt.Sprintf(
   459  					"The login process uses OAuth, which requires starting a temporary HTTP server on localhost. However, no TCP port numbers between %d and %d are available to create such a server.",
   460  					clientConfig.MinPort, clientConfig.MaxPort,
   461  				),
   462  			))
   463  			close(codeCh)
   464  		}
   465  	}()
   466  
   467  	oauthConfig := &oauth2.Config{
   468  		ClientID:    clientConfig.ID,
   469  		Endpoint:    clientConfig.Endpoint(),
   470  		RedirectURL: callbackURL,
   471  		Scopes:      clientConfig.Scopes,
   472  	}
   473  
   474  	authCodeURL := oauthConfig.AuthCodeURL(
   475  		reqState,
   476  		oauth2.SetAuthURLParam("code_challenge", proofKeyChallenge),
   477  		oauth2.SetAuthURLParam("code_challenge_method", "S256"),
   478  	)
   479  
   480  	launchBrowserManually := false
   481  	if c.BrowserLauncher != nil {
   482  		err = c.BrowserLauncher.OpenURL(authCodeURL)
   483  		if err == nil {
   484  			c.Ui.Output(fmt.Sprintf("Terraform must now open a web browser to the login page for %s.\n", hostname.ForDisplay()))
   485  			c.Ui.Output(fmt.Sprintf("If a browser does not open this automatically, open the following URL to proceed:\n    %s\n", authCodeURL))
   486  		} else {
   487  			// Assume we're on a platform where opening a browser isn't possible.
   488  			launchBrowserManually = true
   489  		}
   490  	} else {
   491  		launchBrowserManually = true
   492  	}
   493  
   494  	if launchBrowserManually {
   495  		c.Ui.Output(fmt.Sprintf("Open the following URL to access the login page for %s:\n    %s\n", hostname.ForDisplay(), authCodeURL))
   496  	}
   497  
   498  	c.Ui.Output("Terraform will now wait for the host to signal that login was successful.\n")
   499  
   500  	code, ok := <-codeCh
   501  	if !ok {
   502  		// If we got no code at all then the server wasn't able to start
   503  		// up, so we'll just give up.
   504  		return nil, diags
   505  	}
   506  
   507  	if err := server.Close(); err != nil {
   508  		// The server will close soon enough when our process exits anyway,
   509  		// so we won't fuss about it for right now.
   510  		log.Printf("[WARN] login: callback server can't shut down: %s", err)
   511  	}
   512  
   513  	ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpclient.New())
   514  	token, err := oauthConfig.Exchange(
   515  		ctx, code,
   516  		oauth2.SetAuthURLParam("code_verifier", proofKey),
   517  	)
   518  	if err != nil {
   519  		diags = diags.Append(tfdiags.Sourceless(
   520  			tfdiags.Error,
   521  			"Failed to obtain auth token",
   522  			fmt.Sprintf("The remote server did not assign an auth token: %s.", err),
   523  		))
   524  		return nil, diags
   525  	}
   526  
   527  	return token, diags
   528  }
   529  
   530  func (c *LoginCommand) interactiveGetTokenByPassword(hostname svchost.Hostname, credsCtx *loginCredentialsContext, clientConfig *disco.OAuthClient) (*oauth2.Token, tfdiags.Diagnostics) {
   531  	var diags tfdiags.Diagnostics
   532  
   533  	confirm, confirmDiags := c.interactiveContextConsent(hostname, disco.OAuthOwnerPasswordGrant, credsCtx)
   534  	diags = diags.Append(confirmDiags)
   535  	if !confirm {
   536  		diags = diags.Append(errors.New("Login cancelled"))
   537  		return nil, diags
   538  	}
   539  
   540  	c.Ui.Output("\n---------------------------------------------------------------------------------\n")
   541  	c.Ui.Output("Terraform must temporarily use your password to request an API token.\nThis password will NOT be saved locally.\n")
   542  
   543  	username, err := c.UIInput().Input(context.Background(), &terraform.InputOpts{
   544  		Id:    "username",
   545  		Query: fmt.Sprintf("Username for %s:", hostname.ForDisplay()),
   546  	})
   547  	if err != nil {
   548  		diags = diags.Append(fmt.Errorf("Failed to request username: %s", err))
   549  		return nil, diags
   550  	}
   551  	password, err := c.UIInput().Input(context.Background(), &terraform.InputOpts{
   552  		Id:     "password",
   553  		Query:  fmt.Sprintf("Password for %s:", hostname.ForDisplay()),
   554  		Secret: true,
   555  	})
   556  	if err != nil {
   557  		diags = diags.Append(fmt.Errorf("Failed to request password: %s", err))
   558  		return nil, diags
   559  	}
   560  
   561  	oauthConfig := &oauth2.Config{
   562  		ClientID: clientConfig.ID,
   563  		Endpoint: clientConfig.Endpoint(),
   564  		Scopes:   clientConfig.Scopes,
   565  	}
   566  	token, err := oauthConfig.PasswordCredentialsToken(context.Background(), username, password)
   567  	if err != nil {
   568  		// FIXME: The OAuth2 library generates errors that are not appropriate
   569  		// for a Terraform end-user audience, so once we have more experience
   570  		// with which errors are most common we should try to recognize them
   571  		// here and produce better error messages for them.
   572  		diags = diags.Append(tfdiags.Sourceless(
   573  			tfdiags.Error,
   574  			"Failed to retrieve API token",
   575  			fmt.Sprintf("The remote host did not issue an API token: %s.", err),
   576  		))
   577  	}
   578  
   579  	return token, diags
   580  }
   581  
   582  func (c *LoginCommand) interactiveGetTokenByUI(hostname svchost.Hostname, credsCtx *loginCredentialsContext, service *url.URL) (svcauth.HostCredentialsToken, tfdiags.Diagnostics) {
   583  	var diags tfdiags.Diagnostics
   584  
   585  	confirm, confirmDiags := c.interactiveContextConsent(hostname, disco.OAuthGrantType(""), credsCtx)
   586  	diags = diags.Append(confirmDiags)
   587  	if !confirm {
   588  		diags = diags.Append(errors.New("Login cancelled"))
   589  		return "", diags
   590  	}
   591  
   592  	c.Ui.Output("\n---------------------------------------------------------------------------------\n")
   593  
   594  	tokensURL := url.URL{
   595  		Scheme:   "https",
   596  		Host:     service.Hostname(),
   597  		Path:     "/app/settings/tokens",
   598  		RawQuery: "source=terraform-login",
   599  	}
   600  
   601  	launchBrowserManually := false
   602  	if c.BrowserLauncher != nil {
   603  		err := c.BrowserLauncher.OpenURL(tokensURL.String())
   604  		if err == nil {
   605  			c.Ui.Output(fmt.Sprintf("Terraform must now open a web browser to the tokens page for %s.\n", hostname.ForDisplay()))
   606  			c.Ui.Output(fmt.Sprintf("If a browser does not open this automatically, open the following URL to proceed:\n    %s\n", tokensURL.String()))
   607  		} else {
   608  			log.Printf("[DEBUG] error opening web browser: %s", err)
   609  			// Assume we're on a platform where opening a browser isn't possible.
   610  			launchBrowserManually = true
   611  		}
   612  	} else {
   613  		launchBrowserManually = true
   614  	}
   615  
   616  	if launchBrowserManually {
   617  		c.Ui.Output(fmt.Sprintf("Open the following URL to access the tokens page for %s:\n    %s\n", hostname.ForDisplay(), tokensURL.String()))
   618  	}
   619  
   620  	c.Ui.Output("\n---------------------------------------------------------------------------------\n")
   621  	c.Ui.Output("Generate a token using your browser, and copy-paste it into this prompt.\n")
   622  
   623  	// credsCtx might not be set if we're using a mock credentials source
   624  	// in a test, but it should always be set in normal use.
   625  	if credsCtx != nil {
   626  		switch credsCtx.Location {
   627  		case cliconfig.CredentialsViaHelper:
   628  			c.Ui.Output(fmt.Sprintf("Terraform will store the token in the configured %q credentials helper\nfor use by subsequent commands.\n", credsCtx.HelperType))
   629  		case cliconfig.CredentialsInPrimaryFile, cliconfig.CredentialsNotAvailable:
   630  			c.Ui.Output(fmt.Sprintf("Terraform will store the token in plain text in the following file\nfor use by subsequent commands:\n    %s\n", credsCtx.LocalFilename))
   631  		}
   632  	}
   633  
   634  	token, err := c.UIInput().Input(context.Background(), &terraform.InputOpts{
   635  		Id:     "token",
   636  		Query:  fmt.Sprintf("Token for %s:", hostname.ForDisplay()),
   637  		Secret: true,
   638  	})
   639  	if err != nil {
   640  		diags := diags.Append(fmt.Errorf("Failed to retrieve token: %s", err))
   641  		return "", diags
   642  	}
   643  
   644  	token = strings.TrimSpace(token)
   645  	cfg := &tfe.Config{
   646  		Address:  service.String(),
   647  		BasePath: service.Path,
   648  		Token:    token,
   649  		Headers:  make(http.Header),
   650  	}
   651  	client, err := tfe.NewClient(cfg)
   652  	if err != nil {
   653  		diags = diags.Append(fmt.Errorf("Failed to create API client: %s", err))
   654  		return "", diags
   655  	}
   656  	user, err := client.Users.ReadCurrent(context.Background())
   657  	if err == tfe.ErrUnauthorized {
   658  		diags = diags.Append(fmt.Errorf("Token is invalid: %s", err))
   659  		return "", diags
   660  	} else if err != nil {
   661  		diags = diags.Append(fmt.Errorf("Failed to retrieve user account details: %s", err))
   662  		return "", diags
   663  	}
   664  	c.Ui.Output(fmt.Sprintf(c.Colorize().Color("\nRetrieved token for user [bold]%s[reset]\n"), user.Username))
   665  
   666  	return svcauth.HostCredentialsToken(token), nil
   667  }
   668  
   669  func (c *LoginCommand) interactiveContextConsent(hostname svchost.Hostname, grantType disco.OAuthGrantType, credsCtx *loginCredentialsContext) (bool, tfdiags.Diagnostics) {
   670  	var diags tfdiags.Diagnostics
   671  	mechanism := "OAuth"
   672  	if grantType == "" {
   673  		mechanism = "your browser"
   674  	}
   675  
   676  	c.Ui.Output(fmt.Sprintf("Terraform will request an API token for %s using %s.\n", hostname.ForDisplay(), mechanism))
   677  
   678  	if grantType.UsesAuthorizationEndpoint() {
   679  		c.Ui.Output(
   680  			"This will work only if you are able to use a web browser on this computer to\ncomplete a login process. If not, you must obtain an API token by another\nmeans and configure it in the CLI configuration manually.\n",
   681  		)
   682  	}
   683  
   684  	// credsCtx might not be set if we're using a mock credentials source
   685  	// in a test, but it should always be set in normal use.
   686  	if credsCtx != nil {
   687  		switch credsCtx.Location {
   688  		case cliconfig.CredentialsViaHelper:
   689  			c.Ui.Output(fmt.Sprintf("If login is successful, Terraform will store the token in the configured\n%q credentials helper for use by subsequent commands.\n", credsCtx.HelperType))
   690  		case cliconfig.CredentialsInPrimaryFile, cliconfig.CredentialsNotAvailable:
   691  			c.Ui.Output(fmt.Sprintf("If login is successful, Terraform will store the token in plain text in\nthe following file for use by subsequent commands:\n    %s\n", credsCtx.LocalFilename))
   692  		}
   693  	}
   694  
   695  	v, err := c.UIInput().Input(context.Background(), &terraform.InputOpts{
   696  		Id:          "approve",
   697  		Query:       "Do you want to proceed?",
   698  		Description: `Only 'yes' will be accepted to confirm.`,
   699  	})
   700  	if err != nil {
   701  		// Should not happen because this command checks that input is enabled
   702  		// before we get to this point.
   703  		diags = diags.Append(err)
   704  		return false, diags
   705  	}
   706  
   707  	return strings.ToLower(v) == "yes", diags
   708  }
   709  
   710  func (c *LoginCommand) listenerForCallback(minPort, maxPort uint16) (net.Listener, string, error) {
   711  	if minPort < 1024 || maxPort < 1024 {
   712  		// This should never happen because it should've been checked by
   713  		// the svchost/disco package when reading the service description,
   714  		// but we'll prefer to fail hard rather than inadvertently trying
   715  		// to open an unprivileged port if there are bugs at that layer.
   716  		panic("listenerForCallback called with privileged port number")
   717  	}
   718  
   719  	availCount := int(maxPort) - int(minPort)
   720  
   721  	// We're going to try port numbers within the range at random, so we need
   722  	// to terminate eventually in case _none_ of the ports are available.
   723  	// We'll make that 150% of the number of ports just to give us some room
   724  	// for the random number generator to generate the same port more than
   725  	// once.
   726  	// Note that we don't really care about true randomness here... we're just
   727  	// trying to hop around in the available port space rather than always
   728  	// working up from the lowest, because we have no information to predict
   729  	// that any particular number will be more likely to be available than
   730  	// another.
   731  	maxTries := availCount + (availCount / 2)
   732  
   733  	for tries := 0; tries < maxTries; tries++ {
   734  		port := rand.Intn(availCount) + int(minPort)
   735  		addr := fmt.Sprintf("127.0.0.1:%d", port)
   736  		log.Printf("[TRACE] login: trying %s as a listen address for temporary OAuth callback server", addr)
   737  		l, err := net.Listen("tcp4", addr)
   738  		if err == nil {
   739  			// We use a path that doesn't end in a slash here because some
   740  			// OAuth server implementations don't allow callback URLs to
   741  			// end with slashes.
   742  			callbackURL := fmt.Sprintf("http://localhost:%d/login", port)
   743  			log.Printf("[TRACE] login: callback URL will be %s", callbackURL)
   744  			return l, callbackURL, nil
   745  		}
   746  	}
   747  
   748  	return nil, "", fmt.Errorf("no suitable TCP ports (between %d and %d) are available for the temporary OAuth callback server", minPort, maxPort)
   749  }
   750  
   751  func (c *LoginCommand) proofKey() (key, challenge string, err error) {
   752  	// Wel use a UUID-like string as the "proof key for code exchange" (PKCE)
   753  	// that will eventually authenticate our request to the token endpoint.
   754  	// Standard UUIDs are explicitly not suitable as secrets according to the
   755  	// UUID spec, but our go-uuid just generates totally random number sequences
   756  	// formatted in the conventional UUID syntax, so that concern does not
   757  	// apply here: this is just a 128-bit crypto-random number.
   758  	uu, err := uuid.GenerateUUID()
   759  	if err != nil {
   760  		return "", "", err
   761  	}
   762  
   763  	key = fmt.Sprintf("%s.%09d", uu, rand.Intn(999999999))
   764  
   765  	h := sha256.New()
   766  	h.Write([]byte(key))
   767  	challenge = base64.RawURLEncoding.EncodeToString(h.Sum(nil))
   768  
   769  	return key, challenge, nil
   770  }
   771  
   772  type loginCredentialsContext struct {
   773  	Location      cliconfig.CredentialsLocation
   774  	LocalFilename string
   775  	HelperType    string
   776  }
   777  
   778  const callbackSuccessMessage = `
   779  <html>
   780  <head>
   781  <title>Terraform Login</title>
   782  <style type="text/css">
   783  body {
   784  	font-family: monospace;
   785  	color: #fff;
   786  	background-color: #000;
   787  }
   788  </style>
   789  </head>
   790  <body>
   791  
   792  <p>The login server has returned an authentication code to Terraform.</p>
   793  <p>Now close this page and return to the terminal where <tt>terraform login</tt>
   794  is running to see the result of the login process.</p>
   795  
   796  </body>
   797  </html>
   798  `