Files
opentf/internal/command/login.go
Martin Atkins d2bef1fd47 Adopt OpenTofu's own "svchost" module
Previously we were using a third-party library, but that doesn't have any
support for passing context.Context through its API and so isn't suitable
for our goals of adding OpenTelemetry tracing for all outgoing network
requests.

We now have our own fork that is updated to use context.Context. It also
has a slightly reduced scope no longer including various details that
are tightly-coupled to our cliconfig mechanism and so better placed in the
main OpenTofu codebase so we can evolve it in future without making
lockstep library releases.

The "registry-address" library also uses svchost and uses some of its types
in its public API, so this also incorporates v2 of that library that is
updated to use our own svchost module.

Unfortunately this commit is a mix of mechanical updates to the new
libraries and some new code dealing with the functionality that is removed
in our fork of svchost. The new code is primarily in the "svcauthconfig"
package, which is similar in purpose "ociauthconfig" but for OpenTofu's
own auth mechanism instead of the OCI Distribution protocol's auth
mechanism.

This includes some additional plumbing of context.Context where it was
possible to do so without broad changes to files that would not otherwise
have been included in this commit, but there are a few leftover spots that
are context.TODO() which we'll address separately in later commits.

This removes the temporary workaround from d079da6e9e, since we are now
able to plumb the OpenTelemetry span tree all the way to the service
discovery requests.

Signed-off-by: Martin Atkins <mart@degeneration.co.uk>
2025-06-12 09:37:59 -07:00

840 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"
"github.com/opentofu/svchost"
"github.com/opentofu/svchost/disco"
"github.com/opentofu/svchost/svcauth"
"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 {
ctx := c.CommandContext()
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(ctx, 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(ctx, 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(ctx, 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(ctx, hostname, credsCtx, tfeservice)
}
diags = diags.Append(tokenDiags)
if diags.HasErrors() {
c.showDiagnostics(diags)
return 1
}
err = creds.StoreForHost(ctx, 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(ctx).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()
if err := json.Unmarshal(body, &motd); err != nil {
c.logMOTDError(fmt.Errorf("platform responded with invalid motd payload: %w", err))
c.outputDefaultTFCLoginSuccess()
return 0
}
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(ctx context.Context, hostname svchost.Hostname, credsCtx *loginCredentialsContext, clientConfig *disco.OAuthClient) (*oauth2.Token, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
confirm, confirmDiags := c.interactiveContextConsent(ctx, 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)
if _, err := resp.Write([]byte(callbackSuccessMessage)); err != nil {
log.Printf("[ERROR] login: cannot write response: %s", err)
return
}
}),
}
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(ctx, oauth2.HTTPClient, httpclient.New(ctx))
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(ctx context.Context, hostname svchost.Hostname, credsCtx *loginCredentialsContext, clientConfig *disco.OAuthClient) (*oauth2.Token, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
confirm, confirmDiags := c.interactiveContextConsent(ctx, 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(ctx, &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(ctx, &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(ctx, 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(ctx context.Context, hostname svchost.Hostname, credsCtx *loginCredentialsContext, service *url.URL) (svcauth.HostCredentialsToken, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
confirm, confirmDiags := c.interactiveContextConsent(ctx, 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(ctx, &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(ctx)
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(ctx context.Context, 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(ctx, &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>
`