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 `