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