Files
opentf/internal/command/login.go
Nathan Baulch ea558d9d4b Fix typos (#1905)
Signed-off-by: Nathan Baulch <nathan.baulch@gmail.com>
Signed-off-by: Christian Mesh <christianmesh1@gmail.com>
Co-authored-by: Christian Mesh <christianmesh1@gmail.com>
2024-08-29 13:20:33 -04:00

831 lines
28 KiB
Go

// 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 = `
<html>
<head>
<title>OpenTofu Login</title>
<style type="text/css">
body {
font-family: monospace;
color: #fff;
background-color: #000;
}
</style>
</head>
<body>
<p>The login server has returned an authentication code to OpenTofu.</p>
<p>Now close this page and return to the terminal where <tt>tofu login</tt>
is running to see the result of the login process.</p>
</body>
</html>
`