// Copyright (c) The OpenTofu Authors // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2023 HashiCorp, Inc. // SPDX-License-Identifier: MPL-2.0 package command import ( "context" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "fmt" "io" "log" "math/rand" "net" "net/http" "net/url" "path/filepath" "strings" tfe "github.com/hashicorp/go-tfe" svchost "github.com/hashicorp/terraform-svchost" svcauth "github.com/hashicorp/terraform-svchost/auth" "github.com/hashicorp/terraform-svchost/disco" "github.com/opentofu/opentofu/internal/command/cliconfig" "github.com/opentofu/opentofu/internal/httpclient" "github.com/opentofu/opentofu/internal/logging" "github.com/opentofu/opentofu/internal/tfdiags" "github.com/opentofu/opentofu/internal/tofu" "github.com/opentofu/opentofu/version" uuid "github.com/hashicorp/go-uuid" "golang.org/x/oauth2" ) // This is HashiCorp's cloud host. // There are a few special circumstances that depend on this whitelisted hostname. const tfeHost = "app.terraform.io" // LoginCommand is a Command implementation that runs an interactive login // flow for a remote service host. It then stashes credentials in a tfrc // file in the user's home directory. type LoginCommand struct { Meta } // Run implements cli.Command. func (c *LoginCommand) Run(args []string) int { args = c.Meta.process(args) cmdFlags := c.Meta.extendedFlagSet("login") cmdFlags.Usage = func() { c.Ui.Error(c.Help()) } if err := cmdFlags.Parse(args); err != nil { return 1 } args = cmdFlags.Args() if len(args) != 1 { c.Ui.Error( "The login command expects exactly one argument: the host to log in to.") cmdFlags.Usage() return 1 } var diags tfdiags.Diagnostics if !c.input { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Login is an interactive command", "The \"tofu 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.", )) c.showDiagnostics(diags) return 1 } givenHostname := args[0] hostname, err := svchost.ForComparison(givenHostname) if err != nil { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Invalid hostname", fmt.Sprintf("The given hostname %q is not valid: %s.", givenHostname, err.Error()), )) c.showDiagnostics(diags) return 1 } // From now on, since we've validated the given hostname, we should use // dispHostname in the UI to ensure we're presenting it in the canonical // form, in case that helpers users with debugging when things aren't // working as expected. (Perhaps the normalization is part of the cause.) dispHostname := hostname.ForDisplay() host, err := c.Services.Discover(hostname) if err != nil { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Service discovery failed for "+dispHostname, // Contrary to usual Go idiom, the Discover function returns // full sentences with initial capitalization in its error messages, // and they are written with the end-user as the audience. We // only need to add the trailing period to make them consistent // with our usual error reporting standards. err.Error()+".", )) c.showDiagnostics(diags) return 1 } creds := c.Services.CredentialsSource().(*cliconfig.CredentialsSource) filename, _ := creds.CredentialsFilePath() credsCtx := &loginCredentialsContext{ Location: creds.HostCredentialsLocation(hostname), LocalFilename: filename, // empty in the very unlikely event that we can't select a config directory for this user HelperType: creds.CredentialsHelperType(), } clientConfig, err := host.ServiceOAuthClient("login.v1") switch err.(type) { case nil: // Great! No problem, then. case *disco.ErrServiceNotProvided: // This is also fine! We'll try the manual token creation process. case *disco.ErrVersionNotSupported: diags = diags.Append(tfdiags.Sourceless( tfdiags.Warning, "Host does not support OpenTofu login", fmt.Sprintf("The given hostname %q allows creating OpenTofu authorization tokens, but requires a newer version of OpenTofu CLI to do so.", dispHostname), )) default: diags = diags.Append(tfdiags.Sourceless( tfdiags.Warning, "Host does not support OpenTofu login", fmt.Sprintf("The given hostname %q cannot support \"tofu login\": %s.", dispHostname, err), )) } // If login service is unavailable, check for a TFE v2 API as fallback var tfeservice *url.URL if clientConfig == nil { tfeservice, err = host.ServiceURL("tfe.v2") switch err.(type) { case nil: // Success! case *disco.ErrServiceNotProvided: diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Host does not support OpenTofu tokens API", fmt.Sprintf("The given hostname %q does not support creating OpenTofu authorization tokens.", dispHostname), )) case *disco.ErrVersionNotSupported: diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Host does not support OpenTofu tokens API", fmt.Sprintf("The given hostname %q allows creating OpenTofu authorization tokens, but requires a newer version of OpenTofu CLI to do so.", dispHostname), )) default: diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Host does not support OpenTofu tokens API", fmt.Sprintf("The given hostname %q cannot support \"tofu login\": %s.", dispHostname, err), )) } } if credsCtx.Location == cliconfig.CredentialsInOtherFile { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, fmt.Sprintf("Credentials for %s are manually configured", dispHostname), "The \"tofu 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.", )) } if diags.HasErrors() { c.showDiagnostics(diags) return 1 } var token svcauth.HostCredentialsToken var tokenDiags tfdiags.Diagnostics // Prefer OpenTofu login if available if clientConfig != nil { var oauthToken *oauth2.Token switch { case clientConfig.SupportedGrantTypes.Has(disco.OAuthAuthzCodeGrant): // We prefer an OAuth code grant if the server supports it. oauthToken, tokenDiags = c.interactiveGetTokenByCode(hostname, credsCtx, clientConfig) case clientConfig.SupportedGrantTypes.Has(disco.OAuthOwnerPasswordGrant) && hostname == svchost.Hostname(tfeHost): // The password grant type is allowed only for Terraform Cloud SaaS. // Note this case is purely theoretical at this point, as TFC currently uses // its own bespoke login protocol (tfe) oauthToken, tokenDiags = c.interactiveGetTokenByPassword(hostname, credsCtx, clientConfig) default: tokenDiags = tokenDiags.Append(tfdiags.Sourceless( tfdiags.Error, "Host does not support OpenTofu login", fmt.Sprintf("The given hostname %q does not allow any OAuth grant types that are supported by this version of OpenTofu.", dispHostname), )) } if oauthToken != nil { token = svcauth.HostCredentialsToken(oauthToken.AccessToken) } } else if tfeservice != nil { token, tokenDiags = c.interactiveGetTokenByUI(hostname, credsCtx, tfeservice) } diags = diags.Append(tokenDiags) if diags.HasErrors() { c.showDiagnostics(diags) return 1 } err = creds.StoreForHost(hostname, token) if err != nil { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Failed to save API token", fmt.Sprintf("The given host returned an API token, but OpenTofu failed to save it: %s.", err), )) } c.showDiagnostics(diags) if diags.HasErrors() { return 1 } c.Ui.Output("\n---------------------------------------------------------------------------------\n") if hostname == tfeHost { // Terraform Cloud var motd struct { Message string `json:"msg"` Errors []interface{} `json:"errors"` } // Throughout the entire process of fetching a MOTD from TFC, use a default // message if the platform-provided message is unavailable for any reason - // be it the service isn't provided, the request failed, or any sort of // platform error returned. motdServiceURL, err := host.ServiceURL("motd.v1") if err != nil { c.logMOTDError(err) c.outputDefaultTFCLoginSuccess() return 0 } req, err := http.NewRequest("GET", motdServiceURL.String(), nil) if err != nil { c.logMOTDError(err) c.outputDefaultTFCLoginSuccess() return 0 } req.Header.Set("Authorization", "Bearer "+token.Token()) resp, err := httpclient.New().Do(req) if err != nil { c.logMOTDError(err) c.outputDefaultTFCLoginSuccess() return 0 } body, err := io.ReadAll(resp.Body) if err != nil { c.logMOTDError(err) c.outputDefaultTFCLoginSuccess() return 0 } defer resp.Body.Close() json.Unmarshal(body, &motd) if motd.Errors == nil && motd.Message != "" { c.Ui.Output( c.Colorize().Color(motd.Message), ) return 0 } else { c.logMOTDError(fmt.Errorf("platform responded with errors or an empty message")) c.outputDefaultTFCLoginSuccess() return 0 } } if tfeservice != nil { // Terraform Enterprise c.outputDefaultTFELoginSuccess(dispHostname) } else { c.Ui.Output( fmt.Sprintf( c.Colorize().Color(strings.TrimSpace(` [green][bold]Success![reset] [bold]OpenTofu has obtained and saved an API token.[reset] The new API token will be used for any future OpenTofu command that must make authenticated requests to %s. `)), dispHostname, ) + "\n", ) } return 0 } func (c *LoginCommand) outputDefaultTFELoginSuccess(dispHostname string) { c.Ui.Output( fmt.Sprintf( c.Colorize().Color(strings.TrimSpace(` [green][bold]Success![reset] [bold]Logged in to the cloud backend (%s)[reset] `)), dispHostname, ) + "\n", ) } func (c *LoginCommand) outputDefaultTFCLoginSuccess() { c.Ui.Output(c.Colorize().Color(strings.TrimSpace(` [green][bold]Success![reset] [bold]Logged in to cloud backend[reset] ` + "\n"))) } func (c *LoginCommand) logMOTDError(err error) { log.Printf("[TRACE] login: An error occurred attempting to fetch a message of the day for cloud backend: %s", err) } // Help implements cli.Command. func (c *LoginCommand) Help() string { defaultFile := c.defaultOutputFile() if defaultFile == "" { // Because this is just for the help message and it's very unlikely // that a user wouldn't have a functioning home directory anyway, // we'll just use a placeholder here. The real command has some // more complex behavior for this case. This result is not correct // on all platforms, but given how unlikely we are to hit this case // that seems okay. defaultFile = "~/.terraform/credentials.tfrc.json" } helpText := fmt.Sprintf(` Usage: tofu [global options] login [hostname] Retrieves an authentication token for the given hostname, if it supports automatic login, and saves it in a credentials file in your home directory. If not overridden by credentials helper settings in the CLI configuration, the credentials will be written to the following local file: %s `, defaultFile) return strings.TrimSpace(helpText) } // Synopsis implements cli.Command. func (c *LoginCommand) Synopsis() string { return "Obtain and save credentials for a remote host" } func (c *LoginCommand) defaultOutputFile() string { if c.CLIConfigDir == "" { return "" // no default available } return filepath.Join(c.CLIConfigDir, "credentials.tfrc.json") } func (c *LoginCommand) interactiveGetTokenByCode(hostname svchost.Hostname, credsCtx *loginCredentialsContext, clientConfig *disco.OAuthClient) (*oauth2.Token, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics confirm, confirmDiags := c.interactiveContextConsent(hostname, disco.OAuthAuthzCodeGrant, credsCtx) diags = diags.Append(confirmDiags) if !confirm { diags = diags.Append(errors.New("Login cancelled")) return nil, diags } // We'll use an entirely pseudo-random UUID for our temporary request // state. The OAuth server must echo this back to us in the callback // request to make it difficult for some other running process to // interfere by sending its own request to our temporary server. reqState, err := uuid.GenerateUUID() if err != nil { // This should be very unlikely, but could potentially occur if e.g. // there's not enough pseudo-random entropy available. diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Can't generate login request state", fmt.Sprintf("Cannot generate random request identifier for login request: %s.", err), )) return nil, diags } proofKey, proofKeyChallenge, err := c.proofKey() if err != nil { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Can't generate login request state", fmt.Sprintf("Cannot generate random prrof key for login request: %s.", err), )) return nil, diags } listener, callbackURL, err := c.listenerForCallback(clientConfig.MinPort, clientConfig.MaxPort) if err != nil { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Can't start temporary login server", fmt.Sprintf( "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.", clientConfig.MinPort, clientConfig.MaxPort, ), )) return nil, diags } // codeCh will allow our temporary HTTP server to transmit the OAuth code // to the main execution path that follows. codeCh := make(chan string) server := &http.Server{ Handler: http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { log.Printf("[TRACE] login: request to callback server") err := req.ParseForm() if err != nil { log.Printf("[ERROR] login: cannot ParseForm on callback request: %s", err) resp.WriteHeader(400) return } gotState := req.Form.Get("state") if gotState != reqState { log.Printf("[ERROR] login: incorrect \"state\" value in callback request") resp.WriteHeader(400) return } gotCode := req.Form.Get("code") if gotCode == "" { log.Printf("[ERROR] login: no \"code\" argument in callback request") resp.WriteHeader(400) return } log.Printf("[TRACE] login: request contains an authorization code") // Send the code to our blocking wait below, so that the token // fetching process can continue. codeCh <- gotCode close(codeCh) log.Printf("[TRACE] login: returning response from callback server") resp.Header().Add("Content-Type", "text/html") resp.WriteHeader(200) resp.Write([]byte(callbackSuccessMessage)) }), } panicHandler := logging.PanicHandlerWithTraceFn() go func() { defer panicHandler() err := server.Serve(listener) if err != nil && err != http.ErrServerClosed { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Can't start temporary login server", fmt.Sprintf( "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.", clientConfig.MinPort, clientConfig.MaxPort, ), )) close(codeCh) } }() oauthConfig := &oauth2.Config{ ClientID: clientConfig.ID, Endpoint: clientConfig.Endpoint(), RedirectURL: callbackURL, Scopes: clientConfig.Scopes, } authCodeURL := oauthConfig.AuthCodeURL( reqState, oauth2.SetAuthURLParam("code_challenge", proofKeyChallenge), oauth2.SetAuthURLParam("code_challenge_method", "S256"), ) launchBrowserManually := false if c.BrowserLauncher != nil { err = c.BrowserLauncher.OpenURL(authCodeURL) if err == nil { c.Ui.Output(fmt.Sprintf("OpenTofu must now open a web browser to the login page for %s.\n", hostname.ForDisplay())) c.Ui.Output(fmt.Sprintf("If a browser does not open this automatically, open the following URL to proceed:\n %s\n", authCodeURL)) } else { // Assume we're on a platform where opening a browser isn't possible. launchBrowserManually = true } } else { launchBrowserManually = true } if launchBrowserManually { c.Ui.Output(fmt.Sprintf("Open the following URL to access the login page for %s:\n %s\n", hostname.ForDisplay(), authCodeURL)) } c.Ui.Output("OpenTofu will now wait for the host to signal that login was successful.\n") var code string var ok bool select { case <-c.ShutdownCh: diags = diags.Append( tfdiags.Sourceless( tfdiags.Error, "Action aborted", "Current command was aborted by the calling code.", ), ) code, ok = "", true close(codeCh) case code, ok = <-codeCh: } if !ok { // If we got no code at all then the server wasn't able to start // up, so we'll just give up. return nil, diags } if err := server.Close(); err != nil { // The server will close soon enough when our process exits anyway, // so we won't fuss about it for right now. log.Printf("[WARN] login: callback server can't shut down: %s", err) } if code == "" { // empty code is not possible in happy path as it is validated in the HTTP handler of our callback server // so it means, the current command was interrupted by the shutdown signal return nil, diags } ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpclient.New()) token, err := oauthConfig.Exchange( ctx, code, oauth2.SetAuthURLParam("code_verifier", proofKey), ) if err != nil { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Failed to obtain auth token", fmt.Sprintf("The remote server did not assign an auth token: %s.", err), )) return nil, diags } return token, diags } func (c *LoginCommand) interactiveGetTokenByPassword(hostname svchost.Hostname, credsCtx *loginCredentialsContext, clientConfig *disco.OAuthClient) (*oauth2.Token, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics confirm, confirmDiags := c.interactiveContextConsent(hostname, disco.OAuthOwnerPasswordGrant, credsCtx) diags = diags.Append(confirmDiags) if !confirm { diags = diags.Append(errors.New("Login cancelled")) return nil, diags } c.Ui.Output("\n---------------------------------------------------------------------------------\n") c.Ui.Output("OpenTofu must temporarily use your password to request an API token.\nThis password will NOT be saved locally.\n") username, err := c.UIInput().Input(context.Background(), &tofu.InputOpts{ Id: "username", Query: fmt.Sprintf("Username for %s:", hostname.ForDisplay()), }) if err != nil { diags = diags.Append(fmt.Errorf("Failed to request username: %w", err)) return nil, diags } password, err := c.UIInput().Input(context.Background(), &tofu.InputOpts{ Id: "password", Query: fmt.Sprintf("Password for %s:", hostname.ForDisplay()), Secret: true, }) if err != nil { diags = diags.Append(fmt.Errorf("Failed to request password: %w", err)) return nil, diags } oauthConfig := &oauth2.Config{ ClientID: clientConfig.ID, Endpoint: clientConfig.Endpoint(), Scopes: clientConfig.Scopes, } token, err := oauthConfig.PasswordCredentialsToken(context.Background(), username, password) if err != nil { // FIXME: The OAuth2 library generates errors that are not appropriate // for a Terraform end-user audience, so once we have more experience // with which errors are most common we should try to recognize them // here and produce better error messages for them. diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Failed to retrieve API token", fmt.Sprintf("The remote host did not issue an API token: %s.", err), )) } return token, diags } func (c *LoginCommand) interactiveGetTokenByUI(hostname svchost.Hostname, credsCtx *loginCredentialsContext, service *url.URL) (svcauth.HostCredentialsToken, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics confirm, confirmDiags := c.interactiveContextConsent(hostname, disco.OAuthGrantType(""), credsCtx) diags = diags.Append(confirmDiags) if !confirm { diags = diags.Append(errors.New("Login cancelled")) return "", diags } c.Ui.Output("\n---------------------------------------------------------------------------------\n") tokensURL := url.URL{ Scheme: "https", Host: service.Hostname(), Path: "/app/settings/tokens", RawQuery: "source=terraform-login", } launchBrowserManually := false if c.BrowserLauncher != nil { err := c.BrowserLauncher.OpenURL(tokensURL.String()) if err == nil { c.Ui.Output(fmt.Sprintf("OpenTofu must now open a web browser to the tokens page for %s.\n", hostname.ForDisplay())) c.Ui.Output(fmt.Sprintf("If a browser does not open this automatically, open the following URL to proceed:\n %s\n", tokensURL.String())) } else { log.Printf("[DEBUG] error opening web browser: %s", err) // Assume we're on a platform where opening a browser isn't possible. launchBrowserManually = true } } else { launchBrowserManually = true } if launchBrowserManually { c.Ui.Output(fmt.Sprintf("Open the following URL to access the tokens page for %s:\n %s\n", hostname.ForDisplay(), tokensURL.String())) } c.Ui.Output("\n---------------------------------------------------------------------------------\n") c.Ui.Output("Generate a token using your browser, and copy-paste it into this prompt.\n") // credsCtx might not be set if we're using a mock credentials source // in a test, but it should always be set in normal use. if credsCtx != nil { switch credsCtx.Location { case cliconfig.CredentialsViaHelper: c.Ui.Output(fmt.Sprintf("OpenTofu will store the token in the configured %q credentials helper\nfor use by subsequent commands.\n", credsCtx.HelperType)) case cliconfig.CredentialsInPrimaryFile, cliconfig.CredentialsNotAvailable: c.Ui.Output(fmt.Sprintf("OpenTofu will store the token in plain text in the following file\nfor use by subsequent commands:\n %s\n", credsCtx.LocalFilename)) } } token, err := c.UIInput().Input(context.Background(), &tofu.InputOpts{ Id: "token", Query: fmt.Sprintf("Token for %s:", hostname.ForDisplay()), Secret: true, }) if err != nil { diags := diags.Append(fmt.Errorf("Failed to retrieve token: %w", err)) return "", diags } token = strings.TrimSpace(token) cfg := &tfe.Config{ Address: service.String(), BasePath: service.Path, Token: token, Headers: make(http.Header), } // Update user-agent from 'go-tfe' to opentofu cfg.Headers.Set("User-Agent", httpclient.OpenTofuUserAgent(version.String())) client, err := tfe.NewClient(cfg) if err != nil { diags = diags.Append(fmt.Errorf("Failed to create API client: %w", err)) return "", diags } user, err := client.Users.ReadCurrent(context.Background()) if err == tfe.ErrUnauthorized { diags = diags.Append(fmt.Errorf("Token is invalid: %w", err)) return "", diags } else if err != nil { diags = diags.Append(fmt.Errorf("Failed to retrieve user account details: %w", err)) return "", diags } c.Ui.Output(fmt.Sprintf(c.Colorize().Color("\nRetrieved token for user [bold]%s[reset]\n"), user.Username)) return svcauth.HostCredentialsToken(token), nil } func (c *LoginCommand) interactiveContextConsent(hostname svchost.Hostname, grantType disco.OAuthGrantType, credsCtx *loginCredentialsContext) (bool, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics mechanism := "OAuth" if grantType == "" { mechanism = "your browser" } c.Ui.Output(fmt.Sprintf("OpenTofu will request an API token for %s using %s.\n", hostname.ForDisplay(), mechanism)) if grantType.UsesAuthorizationEndpoint() { c.Ui.Output( "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", ) } // credsCtx might not be set if we're using a mock credentials source // in a test, but it should always be set in normal use. if credsCtx != nil { switch credsCtx.Location { case cliconfig.CredentialsViaHelper: c.Ui.Output(fmt.Sprintf("If login is successful, OpenTofu will store the token in the configured\n%q credentials helper for use by subsequent commands.\n", credsCtx.HelperType)) case cliconfig.CredentialsInPrimaryFile, cliconfig.CredentialsNotAvailable: c.Ui.Output(fmt.Sprintf("If login is successful, OpenTofu will store the token in plain text in\nthe following file for use by subsequent commands:\n %s\n", credsCtx.LocalFilename)) } } v, err := c.UIInput().Input(context.Background(), &tofu.InputOpts{ Id: "approve", Query: "Do you want to proceed?", Description: `Only 'yes' will be accepted to confirm.`, }) if err != nil { // Should not happen because this command checks that input is enabled // before we get to this point. diags = diags.Append(err) return false, diags } return strings.ToLower(v) == "yes", diags } func (c *LoginCommand) listenerForCallback(minPort, maxPort uint16) (net.Listener, string, error) { if minPort < 1024 || maxPort < 1024 { // This should never happen because it should've been checked by // the svchost/disco package when reading the service description, // but we'll prefer to fail hard rather than inadvertently trying // to open an unprivileged port if there are bugs at that layer. panic("listenerForCallback called with privileged port number") } availCount := int(maxPort) - int(minPort) // We're going to try port numbers within the range at random, so we need // to terminate eventually in case _none_ of the ports are available. // We'll make that 150% of the number of ports just to give us some room // for the random number generator to generate the same port more than // once. // Note that we don't really care about true randomness here... we're just // trying to hop around in the available port space rather than always // working up from the lowest, because we have no information to predict // that any particular number will be more likely to be available than // another. maxTries := availCount + (availCount / 2) for tries := 0; tries < maxTries; tries++ { port := rand.Intn(availCount) + int(minPort) addr := fmt.Sprintf("127.0.0.1:%d", port) log.Printf("[TRACE] login: trying %s as a listen address for temporary OAuth callback server", addr) l, err := net.Listen("tcp4", addr) if err == nil { // We use a path that doesn't end in a slash here because some // OAuth server implementations don't allow callback URLs to // end with slashes. callbackURL := fmt.Sprintf("http://localhost:%d/login", port) log.Printf("[TRACE] login: callback URL will be %s", callbackURL) return l, callbackURL, nil } } return nil, "", fmt.Errorf("no suitable TCP ports (between %d and %d) are available for the temporary OAuth callback server", minPort, maxPort) } func (c *LoginCommand) proofKey() (key, challenge string, err error) { // We'll use a UUID-like string as the "proof key for code exchange" (PKCE) // that will eventually authenticate our request to the token endpoint. // Standard UUIDs are explicitly not suitable as secrets according to the // UUID spec, but our go-uuid just generates totally random number sequences // formatted in the conventional UUID syntax, so that concern does not // apply here: this is just a 128-bit crypto-random number. uu, err := uuid.GenerateUUID() if err != nil { return "", "", err } key = fmt.Sprintf("%s.%09d", uu, rand.Intn(999999999)) h := sha256.New() h.Write([]byte(key)) challenge = base64.RawURLEncoding.EncodeToString(h.Sum(nil)) return key, challenge, nil } type loginCredentialsContext struct { Location cliconfig.CredentialsLocation LocalFilename string HelperType string } const callbackSuccessMessage = `
The login server has returned an authentication code to OpenTofu.
Now close this page and return to the terminal where tofu login is running to see the result of the login process.
`