github.com/iaas-resource-provision/iaas-rpc@v1.0.7-0.20211021023331-ed21f798c408/internal/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/iaas-resource-provision/iaas-rpc-svchost"
    21  	svcauth "github.com/iaas-resource-provision/iaas-rpc-svchost/auth"
    22  	"github.com/iaas-resource-provision/iaas-rpc-svchost/disco"
    23  	"github.com/iaas-resource-provision/iaas-rpc/internal/command/cliconfig"
    24  	"github.com/iaas-resource-provision/iaas-rpc/internal/httpclient"
    25  	"github.com/iaas-resource-provision/iaas-rpc/internal/terraform"
    26  	"github.com/iaas-resource-provision/iaas-rpc/internal/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(
   315  		fmt.Sprintf(
   316  			c.Colorize().Color(strings.TrimSpace(`
   317  [green][bold]Success![reset] [bold]Logged in to Terraform Cloud[reset]
   318  `)),
   319  		) + "\n",
   320  	)
   321  }
   322  
   323  func (c *LoginCommand) logMOTDError(err error) {
   324  	log.Printf("[TRACE] login: An error occurred attempting to fetch a message of the day for Terraform Cloud: %s", err)
   325  }
   326  
   327  // Help implements cli.Command.
   328  func (c *LoginCommand) Help() string {
   329  	defaultFile := c.defaultOutputFile()
   330  	if defaultFile == "" {
   331  		// Because this is just for the help message and it's very unlikely
   332  		// that a user wouldn't have a functioning home directory anyway,
   333  		// we'll just use a placeholder here. The real command has some
   334  		// more complex behavior for this case. This result is not correct
   335  		// on all platforms, but given how unlikely we are to hit this case
   336  		// that seems okay.
   337  		defaultFile = "~/.terraform/credentials.tfrc.json"
   338  	}
   339  
   340  	helpText := fmt.Sprintf(`
   341  Usage: terraform [global options] login [hostname]
   342  
   343    Retrieves an authentication token for the given hostname, if it supports
   344    automatic login, and saves it in a credentials file in your home directory.
   345  
   346    If no hostname is provided, the default hostname is app.terraform.io, to
   347    log in to Terraform Cloud.
   348  
   349    If not overridden by credentials helper settings in the CLI configuration,
   350    the credentials will be written to the following local file:
   351        %s
   352  `, defaultFile)
   353  	return strings.TrimSpace(helpText)
   354  }
   355  
   356  // Synopsis implements cli.Command.
   357  func (c *LoginCommand) Synopsis() string {
   358  	return "Obtain and save credentials for a remote host"
   359  }
   360  
   361  func (c *LoginCommand) defaultOutputFile() string {
   362  	if c.CLIConfigDir == "" {
   363  		return "" // no default available
   364  	}
   365  	return filepath.Join(c.CLIConfigDir, "credentials.tfrc.json")
   366  }
   367  
   368  func (c *LoginCommand) interactiveGetTokenByCode(hostname svchost.Hostname, credsCtx *loginCredentialsContext, clientConfig *disco.OAuthClient) (*oauth2.Token, tfdiags.Diagnostics) {
   369  	var diags tfdiags.Diagnostics
   370  
   371  	confirm, confirmDiags := c.interactiveContextConsent(hostname, disco.OAuthAuthzCodeGrant, credsCtx)
   372  	diags = diags.Append(confirmDiags)
   373  	if !confirm {
   374  		diags = diags.Append(errors.New("Login cancelled"))
   375  		return nil, diags
   376  	}
   377  
   378  	// We'll use an entirely pseudo-random UUID for our temporary request
   379  	// state. The OAuth server must echo this back to us in the callback
   380  	// request to make it difficult for some other running process to
   381  	// interfere by sending its own request to our temporary server.
   382  	reqState, err := uuid.GenerateUUID()
   383  	if err != nil {
   384  		// This should be very unlikely, but could potentially occur if e.g.
   385  		// there's not enough pseudo-random entropy available.
   386  		diags = diags.Append(tfdiags.Sourceless(
   387  			tfdiags.Error,
   388  			"Can't generate login request state",
   389  			fmt.Sprintf("Cannot generate random request identifier for login request: %s.", err),
   390  		))
   391  		return nil, diags
   392  	}
   393  
   394  	proofKey, proofKeyChallenge, err := c.proofKey()
   395  	if err != nil {
   396  		diags = diags.Append(tfdiags.Sourceless(
   397  			tfdiags.Error,
   398  			"Can't generate login request state",
   399  			fmt.Sprintf("Cannot generate random prrof key for login request: %s.", err),
   400  		))
   401  		return nil, diags
   402  	}
   403  
   404  	listener, callbackURL, err := c.listenerForCallback(clientConfig.MinPort, clientConfig.MaxPort)
   405  	if err != nil {
   406  		diags = diags.Append(tfdiags.Sourceless(
   407  			tfdiags.Error,
   408  			"Can't start temporary login server",
   409  			fmt.Sprintf(
   410  				"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.",
   411  				clientConfig.MinPort, clientConfig.MaxPort,
   412  			),
   413  		))
   414  		return nil, diags
   415  	}
   416  
   417  	// codeCh will allow our temporary HTTP server to transmit the OAuth code
   418  	// to the main execution path that follows.
   419  	codeCh := make(chan string)
   420  	server := &http.Server{
   421  		Handler: http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
   422  			log.Printf("[TRACE] login: request to callback server")
   423  			err := req.ParseForm()
   424  			if err != nil {
   425  				log.Printf("[ERROR] login: cannot ParseForm on callback request: %s", err)
   426  				resp.WriteHeader(400)
   427  				return
   428  			}
   429  			gotState := req.Form.Get("state")
   430  			if gotState != reqState {
   431  				log.Printf("[ERROR] login: incorrect \"state\" value in callback request")
   432  				resp.WriteHeader(400)
   433  				return
   434  			}
   435  			gotCode := req.Form.Get("code")
   436  			if gotCode == "" {
   437  				log.Printf("[ERROR] login: no \"code\" argument in callback request")
   438  				resp.WriteHeader(400)
   439  				return
   440  			}
   441  
   442  			log.Printf("[TRACE] login: request contains an authorization code")
   443  
   444  			// Send the code to our blocking wait below, so that the token
   445  			// fetching process can continue.
   446  			codeCh <- gotCode
   447  			close(codeCh)
   448  
   449  			log.Printf("[TRACE] login: returning response from callback server")
   450  
   451  			resp.Header().Add("Content-Type", "text/html")
   452  			resp.WriteHeader(200)
   453  			resp.Write([]byte(callbackSuccessMessage))
   454  		}),
   455  	}
   456  	go func() {
   457  		err := server.Serve(listener)
   458  		if err != nil && err != http.ErrServerClosed {
   459  			diags = diags.Append(tfdiags.Sourceless(
   460  				tfdiags.Error,
   461  				"Can't start temporary login server",
   462  				fmt.Sprintf(
   463  					"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.",
   464  					clientConfig.MinPort, clientConfig.MaxPort,
   465  				),
   466  			))
   467  			close(codeCh)
   468  		}
   469  	}()
   470  
   471  	oauthConfig := &oauth2.Config{
   472  		ClientID:    clientConfig.ID,
   473  		Endpoint:    clientConfig.Endpoint(),
   474  		RedirectURL: callbackURL,
   475  		Scopes:      clientConfig.Scopes,
   476  	}
   477  
   478  	authCodeURL := oauthConfig.AuthCodeURL(
   479  		reqState,
   480  		oauth2.SetAuthURLParam("code_challenge", proofKeyChallenge),
   481  		oauth2.SetAuthURLParam("code_challenge_method", "S256"),
   482  	)
   483  
   484  	launchBrowserManually := false
   485  	if c.BrowserLauncher != nil {
   486  		err = c.BrowserLauncher.OpenURL(authCodeURL)
   487  		if err == nil {
   488  			c.Ui.Output(fmt.Sprintf("Terraform must now open a web browser to the login page for %s.\n", hostname.ForDisplay()))
   489  			c.Ui.Output(fmt.Sprintf("If a browser does not open this automatically, open the following URL to proceed:\n    %s\n", authCodeURL))
   490  		} else {
   491  			// Assume we're on a platform where opening a browser isn't possible.
   492  			launchBrowserManually = true
   493  		}
   494  	} else {
   495  		launchBrowserManually = true
   496  	}
   497  
   498  	if launchBrowserManually {
   499  		c.Ui.Output(fmt.Sprintf("Open the following URL to access the login page for %s:\n    %s\n", hostname.ForDisplay(), authCodeURL))
   500  	}
   501  
   502  	c.Ui.Output("Terraform will now wait for the host to signal that login was successful.\n")
   503  
   504  	code, ok := <-codeCh
   505  	if !ok {
   506  		// If we got no code at all then the server wasn't able to start
   507  		// up, so we'll just give up.
   508  		return nil, diags
   509  	}
   510  
   511  	if err := server.Close(); err != nil {
   512  		// The server will close soon enough when our process exits anyway,
   513  		// so we won't fuss about it for right now.
   514  		log.Printf("[WARN] login: callback server can't shut down: %s", err)
   515  	}
   516  
   517  	ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpclient.New())
   518  	token, err := oauthConfig.Exchange(
   519  		ctx, code,
   520  		oauth2.SetAuthURLParam("code_verifier", proofKey),
   521  	)
   522  	if err != nil {
   523  		diags = diags.Append(tfdiags.Sourceless(
   524  			tfdiags.Error,
   525  			"Failed to obtain auth token",
   526  			fmt.Sprintf("The remote server did not assign an auth token: %s.", err),
   527  		))
   528  		return nil, diags
   529  	}
   530  
   531  	return token, diags
   532  }
   533  
   534  func (c *LoginCommand) interactiveGetTokenByPassword(hostname svchost.Hostname, credsCtx *loginCredentialsContext, clientConfig *disco.OAuthClient) (*oauth2.Token, tfdiags.Diagnostics) {
   535  	var diags tfdiags.Diagnostics
   536  
   537  	confirm, confirmDiags := c.interactiveContextConsent(hostname, disco.OAuthOwnerPasswordGrant, credsCtx)
   538  	diags = diags.Append(confirmDiags)
   539  	if !confirm {
   540  		diags = diags.Append(errors.New("Login cancelled"))
   541  		return nil, diags
   542  	}
   543  
   544  	c.Ui.Output("\n---------------------------------------------------------------------------------\n")
   545  	c.Ui.Output("Terraform must temporarily use your password to request an API token.\nThis password will NOT be saved locally.\n")
   546  
   547  	username, err := c.UIInput().Input(context.Background(), &terraform.InputOpts{
   548  		Id:    "username",
   549  		Query: fmt.Sprintf("Username for %s:", hostname.ForDisplay()),
   550  	})
   551  	if err != nil {
   552  		diags = diags.Append(fmt.Errorf("Failed to request username: %s", err))
   553  		return nil, diags
   554  	}
   555  	password, err := c.UIInput().Input(context.Background(), &terraform.InputOpts{
   556  		Id:     "password",
   557  		Query:  fmt.Sprintf("Password for %s:", hostname.ForDisplay()),
   558  		Secret: true,
   559  	})
   560  	if err != nil {
   561  		diags = diags.Append(fmt.Errorf("Failed to request password: %s", err))
   562  		return nil, diags
   563  	}
   564  
   565  	oauthConfig := &oauth2.Config{
   566  		ClientID: clientConfig.ID,
   567  		Endpoint: clientConfig.Endpoint(),
   568  		Scopes:   clientConfig.Scopes,
   569  	}
   570  	token, err := oauthConfig.PasswordCredentialsToken(context.Background(), username, password)
   571  	if err != nil {
   572  		// FIXME: The OAuth2 library generates errors that are not appropriate
   573  		// for a Terraform end-user audience, so once we have more experience
   574  		// with which errors are most common we should try to recognize them
   575  		// here and produce better error messages for them.
   576  		diags = diags.Append(tfdiags.Sourceless(
   577  			tfdiags.Error,
   578  			"Failed to retrieve API token",
   579  			fmt.Sprintf("The remote host did not issue an API token: %s.", err),
   580  		))
   581  	}
   582  
   583  	return token, diags
   584  }
   585  
   586  func (c *LoginCommand) interactiveGetTokenByUI(hostname svchost.Hostname, credsCtx *loginCredentialsContext, service *url.URL) (svcauth.HostCredentialsToken, tfdiags.Diagnostics) {
   587  	var diags tfdiags.Diagnostics
   588  
   589  	confirm, confirmDiags := c.interactiveContextConsent(hostname, disco.OAuthGrantType(""), credsCtx)
   590  	diags = diags.Append(confirmDiags)
   591  	if !confirm {
   592  		diags = diags.Append(errors.New("Login cancelled"))
   593  		return "", diags
   594  	}
   595  
   596  	c.Ui.Output("\n---------------------------------------------------------------------------------\n")
   597  
   598  	tokensURL := url.URL{
   599  		Scheme:   "https",
   600  		Host:     service.Hostname(),
   601  		Path:     "/app/settings/tokens",
   602  		RawQuery: "source=terraform-login",
   603  	}
   604  
   605  	launchBrowserManually := false
   606  	if c.BrowserLauncher != nil {
   607  		err := c.BrowserLauncher.OpenURL(tokensURL.String())
   608  		if err == nil {
   609  			c.Ui.Output(fmt.Sprintf("Terraform must now open a web browser to the tokens page for %s.\n", hostname.ForDisplay()))
   610  			c.Ui.Output(fmt.Sprintf("If a browser does not open this automatically, open the following URL to proceed:\n    %s\n", tokensURL.String()))
   611  		} else {
   612  			log.Printf("[DEBUG] error opening web browser: %s", err)
   613  			// Assume we're on a platform where opening a browser isn't possible.
   614  			launchBrowserManually = true
   615  		}
   616  	} else {
   617  		launchBrowserManually = true
   618  	}
   619  
   620  	if launchBrowserManually {
   621  		c.Ui.Output(fmt.Sprintf("Open the following URL to access the tokens page for %s:\n    %s\n", hostname.ForDisplay(), tokensURL.String()))
   622  	}
   623  
   624  	c.Ui.Output("\n---------------------------------------------------------------------------------\n")
   625  	c.Ui.Output("Generate a token using your browser, and copy-paste it into this prompt.\n")
   626  
   627  	// credsCtx might not be set if we're using a mock credentials source
   628  	// in a test, but it should always be set in normal use.
   629  	if credsCtx != nil {
   630  		switch credsCtx.Location {
   631  		case cliconfig.CredentialsViaHelper:
   632  			c.Ui.Output(fmt.Sprintf("Terraform will store the token in the configured %q credentials helper\nfor use by subsequent commands.\n", credsCtx.HelperType))
   633  		case cliconfig.CredentialsInPrimaryFile, cliconfig.CredentialsNotAvailable:
   634  			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))
   635  		}
   636  	}
   637  
   638  	token, err := c.UIInput().Input(context.Background(), &terraform.InputOpts{
   639  		Id:     "token",
   640  		Query:  fmt.Sprintf("Token for %s:", hostname.ForDisplay()),
   641  		Secret: true,
   642  	})
   643  	if err != nil {
   644  		diags := diags.Append(fmt.Errorf("Failed to retrieve token: %s", err))
   645  		return "", diags
   646  	}
   647  
   648  	token = strings.TrimSpace(token)
   649  	cfg := &tfe.Config{
   650  		Address:  service.String(),
   651  		BasePath: service.Path,
   652  		Token:    token,
   653  		Headers:  make(http.Header),
   654  	}
   655  	client, err := tfe.NewClient(cfg)
   656  	if err != nil {
   657  		diags = diags.Append(fmt.Errorf("Failed to create API client: %s", err))
   658  		return "", diags
   659  	}
   660  	user, err := client.Users.ReadCurrent(context.Background())
   661  	if err == tfe.ErrUnauthorized {
   662  		diags = diags.Append(fmt.Errorf("Token is invalid: %s", err))
   663  		return "", diags
   664  	} else if err != nil {
   665  		diags = diags.Append(fmt.Errorf("Failed to retrieve user account details: %s", err))
   666  		return "", diags
   667  	}
   668  	c.Ui.Output(fmt.Sprintf(c.Colorize().Color("\nRetrieved token for user [bold]%s[reset]\n"), user.Username))
   669  
   670  	return svcauth.HostCredentialsToken(token), nil
   671  }
   672  
   673  func (c *LoginCommand) interactiveContextConsent(hostname svchost.Hostname, grantType disco.OAuthGrantType, credsCtx *loginCredentialsContext) (bool, tfdiags.Diagnostics) {
   674  	var diags tfdiags.Diagnostics
   675  	mechanism := "OAuth"
   676  	if grantType == "" {
   677  		mechanism = "your browser"
   678  	}
   679  
   680  	c.Ui.Output(fmt.Sprintf("Terraform will request an API token for %s using %s.\n", hostname.ForDisplay(), mechanism))
   681  
   682  	if grantType.UsesAuthorizationEndpoint() {
   683  		c.Ui.Output(
   684  			"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",
   685  		)
   686  	}
   687  
   688  	// credsCtx might not be set if we're using a mock credentials source
   689  	// in a test, but it should always be set in normal use.
   690  	if credsCtx != nil {
   691  		switch credsCtx.Location {
   692  		case cliconfig.CredentialsViaHelper:
   693  			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))
   694  		case cliconfig.CredentialsInPrimaryFile, cliconfig.CredentialsNotAvailable:
   695  			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))
   696  		}
   697  	}
   698  
   699  	v, err := c.UIInput().Input(context.Background(), &terraform.InputOpts{
   700  		Id:          "approve",
   701  		Query:       "Do you want to proceed?",
   702  		Description: `Only 'yes' will be accepted to confirm.`,
   703  	})
   704  	if err != nil {
   705  		// Should not happen because this command checks that input is enabled
   706  		// before we get to this point.
   707  		diags = diags.Append(err)
   708  		return false, diags
   709  	}
   710  
   711  	return strings.ToLower(v) == "yes", diags
   712  }
   713  
   714  func (c *LoginCommand) listenerForCallback(minPort, maxPort uint16) (net.Listener, string, error) {
   715  	if minPort < 1024 || maxPort < 1024 {
   716  		// This should never happen because it should've been checked by
   717  		// the svchost/disco package when reading the service description,
   718  		// but we'll prefer to fail hard rather than inadvertently trying
   719  		// to open an unprivileged port if there are bugs at that layer.
   720  		panic("listenerForCallback called with privileged port number")
   721  	}
   722  
   723  	availCount := int(maxPort) - int(minPort)
   724  
   725  	// We're going to try port numbers within the range at random, so we need
   726  	// to terminate eventually in case _none_ of the ports are available.
   727  	// We'll make that 150% of the number of ports just to give us some room
   728  	// for the random number generator to generate the same port more than
   729  	// once.
   730  	// Note that we don't really care about true randomness here... we're just
   731  	// trying to hop around in the available port space rather than always
   732  	// working up from the lowest, because we have no information to predict
   733  	// that any particular number will be more likely to be available than
   734  	// another.
   735  	maxTries := availCount + (availCount / 2)
   736  
   737  	for tries := 0; tries < maxTries; tries++ {
   738  		port := rand.Intn(availCount) + int(minPort)
   739  		addr := fmt.Sprintf("127.0.0.1:%d", port)
   740  		log.Printf("[TRACE] login: trying %s as a listen address for temporary OAuth callback server", addr)
   741  		l, err := net.Listen("tcp4", addr)
   742  		if err == nil {
   743  			// We use a path that doesn't end in a slash here because some
   744  			// OAuth server implementations don't allow callback URLs to
   745  			// end with slashes.
   746  			callbackURL := fmt.Sprintf("http://localhost:%d/login", port)
   747  			log.Printf("[TRACE] login: callback URL will be %s", callbackURL)
   748  			return l, callbackURL, nil
   749  		}
   750  	}
   751  
   752  	return nil, "", fmt.Errorf("no suitable TCP ports (between %d and %d) are available for the temporary OAuth callback server", minPort, maxPort)
   753  }
   754  
   755  func (c *LoginCommand) proofKey() (key, challenge string, err error) {
   756  	// Wel use a UUID-like string as the "proof key for code exchange" (PKCE)
   757  	// that will eventually authenticate our request to the token endpoint.
   758  	// Standard UUIDs are explicitly not suitable as secrets according to the
   759  	// UUID spec, but our go-uuid just generates totally random number sequences
   760  	// formatted in the conventional UUID syntax, so that concern does not
   761  	// apply here: this is just a 128-bit crypto-random number.
   762  	uu, err := uuid.GenerateUUID()
   763  	if err != nil {
   764  		return "", "", err
   765  	}
   766  
   767  	key = fmt.Sprintf("%s.%09d", uu, rand.Intn(999999999))
   768  
   769  	h := sha256.New()
   770  	h.Write([]byte(key))
   771  	challenge = base64.RawURLEncoding.EncodeToString(h.Sum(nil))
   772  
   773  	return key, challenge, nil
   774  }
   775  
   776  type loginCredentialsContext struct {
   777  	Location      cliconfig.CredentialsLocation
   778  	LocalFilename string
   779  	HelperType    string
   780  }
   781  
   782  const callbackSuccessMessage = `
   783  <html>
   784  <head>
   785  <title>Terraform Login</title>
   786  <style type="text/css">
   787  body {
   788  	font-family: monospace;
   789  	color: #fff;
   790  	background-color: #000;
   791  }
   792  </style>
   793  </head>
   794  <body>
   795  
   796  <p>The login server has returned an authentication code to Terraform.</p>
   797  <p>Now close this page and return to the terminal where <tt>terraform login</tt>
   798  is running to see the result of the login process.</p>
   799  
   800  </body>
   801  </html>
   802  `