From 6bba3ceb4208c470f82b2985747329fbcabbc8f2 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Tue, 9 Jul 2019 12:06:20 -0700 Subject: [PATCH 01/12] command: "terraform login" command --- command/login.go | 410 ++++++++++++++++++ command/meta.go | 8 + commands.go | 12 + go.mod | 1 + go.sum | 42 +- vendor/github.com/pkg/browser/LICENSE | 23 + vendor/github.com/pkg/browser/README.md | 55 +++ vendor/github.com/pkg/browser/browser.go | 63 +++ .../github.com/pkg/browser/browser_darwin.go | 9 + .../github.com/pkg/browser/browser_linux.go | 9 + .../github.com/pkg/browser/browser_openbsd.go | 16 + .../pkg/browser/browser_unsupported.go | 15 + .../github.com/pkg/browser/browser_windows.go | 16 + vendor/modules.txt | 2 + 14 files changed, 641 insertions(+), 40 deletions(-) create mode 100644 command/login.go create mode 100644 vendor/github.com/pkg/browser/LICENSE create mode 100644 vendor/github.com/pkg/browser/README.md create mode 100644 vendor/github.com/pkg/browser/browser.go create mode 100644 vendor/github.com/pkg/browser/browser_darwin.go create mode 100644 vendor/github.com/pkg/browser/browser_linux.go create mode 100644 vendor/github.com/pkg/browser/browser_openbsd.go create mode 100644 vendor/github.com/pkg/browser/browser_unsupported.go create mode 100644 vendor/github.com/pkg/browser/browser_windows.go diff --git a/command/login.go b/command/login.go new file mode 100644 index 000000000..e795a1085 --- /dev/null +++ b/command/login.go @@ -0,0 +1,410 @@ +package command + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "fmt" + "log" + "math/rand" + "net" + "net/http" + "path/filepath" + "strings" + + "github.com/hashicorp/terraform/httpclient" + "github.com/hashicorp/terraform/svchost" + "github.com/hashicorp/terraform/svchost/disco" + "github.com/hashicorp/terraform/tfdiags" + + uuid "github.com/hashicorp/go-uuid" + "github.com/pkg/browser" + "golang.org/x/oauth2" +) + +// 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, err := c.Meta.process(args, false) + if err != nil { + return 1 + } + + cmdFlags := c.Meta.defaultFlagSet("login") + var intoFile string + cmdFlags.StringVar(&intoFile, "into-file", "", "set the file that the credentials will be appended to") + 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 at most one argument: the host to log in to.") + cmdFlags.Usage() + return 1 + } + + var diags tfdiags.Diagnostics + + givenHostname := "app.terraform.io" + if len(args) != 0 { + 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()), + )) + } + + // 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()+".", + )) + } + + clientConfig, err := host.ServiceOAuthClient("login.v1") + switch err.(type) { + case nil: + // Great! No problem, then. + case *disco.ErrServiceNotProvided: + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Host does not support Terraform login", + fmt.Sprintf("The given hostname %q does not allow creating Terraform authorization tokens.", dispHostname), + )) + case *disco.ErrVersionNotSupported: + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Host does not support Terraform login", + fmt.Sprintf("The given hostname %q allows creating Terraform authorization tokens, but requires a newer version of Terraform CLI to do so.", dispHostname), + )) + default: + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Host does not support Terraform login", + fmt.Sprintf("The given hostname %q cannot support \"terraform login\": %s.", dispHostname, err), + )) + } + + if diags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + + token, tokenDiags := c.interactiveGetToken(hostname, clientConfig) + diags = diags.Append(tokenDiags) + if tokenDiags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + + // TODO: Save the token in the CLI config. + // Also, if the token has an expiration time associated with it, prompt + // the user that they will need to log in again after that time. + fmt.Printf("Token is %#v\n", token) + + c.showDiagnostics(diags) + if diags.HasErrors() { + return 1 + } + return 0 +} + +// 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" + } + + helpText := fmt.Sprintf(` +Usage: terraform login [options] [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 no hostname is provided, the default hostname is app.terraform.io, to + log in to Terraform Cloud. + + If not overridden by the -into-file option, the output file is: + %s + +Options: + + -into-file=.... Override which file the credentials block will be written + to. If this file already exists then it must have valid + HCL syntax and Terraform will update it in-place. +`, 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") +} + +func (c *LoginCommand) interactiveGetToken(hostname svchost.Hostname, clientConfig *disco.OAuthClient) (*oauth2.Token, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + // 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) { + 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 + } + + // Send the code to our blocking wait below, so that the token + // fetching process can continue. + codeCh <- gotCode + close(codeCh) + + resp.Header().Add("Content-Type", "text/html") + resp.WriteHeader(200) + resp.Write([]byte(callbackSuccessMessage)) + }), + } + go func() { + 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, + } + + authCodeURL := oauthConfig.AuthCodeURL( + reqState, + oauth2.SetAuthURLParam("code_challenge", proofKeyChallenge), + oauth2.SetAuthURLParam("code_challenge_method", "S256"), + ) + err = browser.OpenURL(authCodeURL) + if err == nil { + c.Ui.Output(fmt.Sprintf("Terraform 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. + 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("Terraform will now wait for the host to signal that login was successful.\n") + + 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 + } + + err = server.Close() + if 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) + } + + 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) 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) { + // Wel 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. + key, err = uuid.GenerateUUID() + if err != nil { + return "", "", err + } + + h := sha256.New() + h.Write([]byte(key)) + challenge = base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + + return key, challenge, nil +} + +const callbackSuccessMessage = ` + + +Terraform Login + + + + +

The login server has returned an authentication code to Terraform.

+

Now close this page and return to the terminal where terraform login +is running to see the result of the login process.

+ + + +` diff --git a/command/meta.go b/command/meta.go index e95ea67ae..9e1d8c4cb 100644 --- a/command/meta.go +++ b/command/meta.go @@ -61,6 +61,14 @@ type Meta struct { // the specific commands being run. RunningInAutomation bool + // CLIConfigDir is the directory from which CLI configuration files were + // read by the caller and the directory where any changes to CLI + // configuration files by commands should be made. + // + // If this is empty then no configuration directory is available and + // commands which require one cannot proceed. + CLIConfigDir string + // PluginCacheDir, if non-empty, enables caching of downloaded plugins // into the given directory. PluginCacheDir string diff --git a/commands.go b/commands.go index 0a363124f..1aefd5eb2 100644 --- a/commands.go +++ b/commands.go @@ -50,6 +50,11 @@ func initCommands(config *Config, services *disco.Disco) { services.ForceHostServices(host, hostConfig.Services) } + configDir, err := ConfigDir() + if err != nil { + configDir = "" // No config dir available (e.g. looking up a home directory failed) + } + dataDir := os.Getenv("TF_DATA_DIR") meta := command.Meta{ @@ -61,6 +66,7 @@ func initCommands(config *Config, services *disco.Disco) { Services: services, RunningInAutomation: inAutomation, + CLIConfigDir: configDir, PluginCacheDir: config.PluginCacheDir, OverrideDataDir: dataDir, @@ -172,6 +178,12 @@ func initCommands(config *Config, services *disco.Disco) { }, nil }, + "login": func() (cli.Command, error) { + return &command.LoginCommand{ + Meta: meta, + }, nil + }, + "output": func() (cli.Command, error) { return &command.OutputCommand{ Meta: meta, diff --git a/go.mod b/go.mod index 8ed72f9d5..677c77cf9 100644 --- a/go.mod +++ b/go.mod @@ -103,6 +103,7 @@ require ( github.com/modern-go/reflect2 v1.0.1 // indirect github.com/packer-community/winrmcp v0.0.0-20180102160824-81144009af58 github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c // indirect + github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 github.com/pkg/errors v0.8.0 github.com/posener/complete v1.2.1 github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829 // indirect diff --git a/go.sum b/go.sum index ca6aee163..cfe54cdc3 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,3 @@ -cloud.google.com/go v0.26.0 h1:e0WKqKTd5BnrG8aKH3J3h+QvEIQtSUcf2n5UZ5ZgLtQ= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= @@ -22,7 +21,6 @@ github.com/Unknwon/com v0.0.0-20151008135407-28b053d5a292 h1:tuQ7w+my8a8mkwN7x2T github.com/Unknwon/com v0.0.0-20151008135407-28b053d5a292/go.mod h1:KYCjqMOeHpNuTOiFQU6WEcTG7poCJrUs0YgyHNtn1no= github.com/abdullin/seq v0.0.0-20160510034733-d5467c17e7af h1:DBNMBMuMiWYu0b+8KMJuWmfCkcxl09JwdlqwDZZ6U14= github.com/abdullin/seq v0.0.0-20160510034733-d5467c17e7af/go.mod h1:5Jv4cbFiHJMsVxt52+i0Ha45fjshj6wxYr1r19tB9bw= -github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8= github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/agext/levenshtein v1.2.2 h1:0S/Yg6LYmFJ5stwQeRp6EeOcCbj7xiqQSdNelsXvaqE= github.com/agext/levenshtein v1.2.2/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= @@ -42,7 +40,6 @@ github.com/antchfx/xquery v0.0.0-20180515051857-ad5b8c7a47b0 h1:JaCC8jz0zdMLk2m+ github.com/antchfx/xquery v0.0.0-20180515051857-ad5b8c7a47b0/go.mod h1:LzD22aAzDP8/dyiCKFp31He4m2GPjl0AFyzDtZzUu9M= github.com/apparentlymart/go-cidr v1.0.0 h1:lGDvXx8Lv9QHjrAVP7jyzleG4F9+FkRhJcEsDFxeb8w= github.com/apparentlymart/go-cidr v1.0.0/go.mod h1:EBcsNrHc3zQeuaeCeCtQruQm+n9/YjEn/vI25Lg7Gwc= -github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3 h1:ZSTrOEhiM5J5RFxEaFvMZVEAM1KvT1YzbEOwB2EAGjA= github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3/go.mod h1:oL81AME2rN47vu18xqj1S1jPIPuN7afo62yKTNn3XMM= github.com/apparentlymart/go-dump v0.0.0-20190214190832-042adf3cf4a0 h1:MzVXffFUye+ZcSR6opIgz9Co7WcDx6ZcY+RjfFHoA0I= github.com/apparentlymart/go-dump v0.0.0-20190214190832-042adf3cf4a0/go.mod h1:oL81AME2rN47vu18xqj1S1jPIPuN7afo62yKTNn3XMM= @@ -56,7 +53,6 @@ github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/aws/aws-sdk-go v1.15.78/go.mod h1:E3/ieXAlvM0XWO57iftYVDLLvQ824smPP3ATZkfNZeM= -github.com/aws/aws-sdk-go v1.16.36 h1:POeH34ZME++pr7GBGh+ZO6Y5kOwSMQpqp5BGUgooJ6k= github.com/aws/aws-sdk-go v1.16.36/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= github.com/aws/aws-sdk-go v1.22.0 h1:e88V6+dSEyBibUy0ekOydtTfNWzqG3hrtCR8SF6UqqY= github.com/aws/aws-sdk-go v1.22.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= @@ -124,23 +120,19 @@ github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfU github.com/golang/groupcache v0.0.0-20180513044358-24b0969c4cb7 h1:u4bArs140e9+AfE52mFHOXVFnOSBJBRlzTHrOPLOIhE= github.com/golang/groupcache v0.0.0-20180513044358-24b0969c4cb7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0 h1:28o5sBqPkBsMGnC6b4MvE2TzSr5/AT4c/1fLqVGIwlk= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.3.1 h1:qGJ6qTW+x6xX/my+8YUVl4WNpX9B7+/l2tRsHGZ7f2s= github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db h1:woRePGFeVFfLKN/pOkfl+p/TAqKOfFu+7KPlMVpok/w= github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c h1:964Od4U6p2jUkFxvCydnIczKteheJEzHRToSGK3Bnlw= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0 h1:0udJVsspx3VBr5FwtLhQQtuAsVc79tTq0ocGIPAU6qo= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -208,13 +200,11 @@ github.com/hashicorp/go-sockaddr v0.0.0-20180320115054-6d291a969b86 h1:7YOlAIO2Y github.com/hashicorp/go-sockaddr v0.0.0-20180320115054-6d291a969b86/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-tfe v0.3.16 h1:GS2yv580p0co4j3FBVaC6Zahd9mxdCGehhJ0qqzFMH0= github.com/hashicorp/go-tfe v0.3.16/go.mod h1:SuPHR+OcxvzBZNye7nGPfwZTEyd3rWPfLVbCgyZPezM= -github.com/hashicorp/go-uuid v1.0.0 h1:RS8zrF7PhGwyNPOtxSClXXj9HA8feRnJzgnI1RJCSnM= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.1 h1:fv1ep09latC32wFoVwnqcnKJGnMSdBanPczbHAYm1BE= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.1.0 h1:bPIoEKD27tNdebFGGxxYwcL4nepeY4j1QP23PFRGzg0= github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= @@ -251,7 +241,6 @@ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1 github.com/jtolds/gls v4.2.1+incompatible h1:fSuqC+Gmlu6l/ZYAoZzx2pyucC8Xza35fpRVWLVmUEE= github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/kardianos/osext v0.0.0-20170510131534-ae77be60afb1 h1:PJPDf8OUfOK1bb/NeTKd4f1QXZItOX389VN3B6qC8ro= github.com/kardianos/osext v0.0.0-20170510131534-ae77be60afb1/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA= github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= @@ -265,7 +254,6 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348 h1:MtvEpTB6LX3vkb4ax0b5D2DHbNAUsen0Gx5wZoq3lV4= github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= @@ -279,12 +267,10 @@ github.com/masterzen/simplexml v0.0.0-20160608183007-4572e39b1ab9 h1:SmVbOZFWAly github.com/masterzen/simplexml v0.0.0-20160608183007-4572e39b1ab9/go.mod h1:kCEbxUJlNDEBNbdQMkPSp6yaKcRXVI6f4ddk8Riv4bc= github.com/masterzen/winrm v0.0.0-20190223112901-5e5c9a7fe54b h1:/1RFh2SLCJ+tEnT73+Fh5R2AO89sQqs8ba7o+hx1G0Y= github.com/masterzen/winrm v0.0.0-20190223112901-5e5c9a7fe54b/go.mod h1:wr1VqkwW0AB5JS0QLy5GpVMS9E3VtRoSYXUYyVk46KY= -github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.1 h1:G1f5SKeVxmagw/IyvzvtZE4Gybcc4Tr1tf7I8z0XgOg= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.5 h1:tHXDdz1cpzGaovsTB+TVB8q90WEokoVmfMqoVcrLUgw= github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= @@ -301,13 +287,11 @@ github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2Em github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ= github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= -github.com/mitchellh/go-homedir v1.0.0 h1:vKb8ShqSby24Yrqr/yDYkuFz8d0WUjys40rvnGC8aR0= github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-linereader v0.0.0-20190213213312-1b945b3263eb h1:GRiLv4rgyqjqzxbhJke65IYUf4NCOOvrPOJbV/sPxkM= github.com/mitchellh/go-linereader v0.0.0-20190213213312-1b945b3263eb/go.mod h1:OaY7UOoTkkrX3wRwjpYRKafIkkyeD0UtweSHAWWiqQM= -github.com/mitchellh/go-testing-interface v0.0.0-20171004221916-a61a99592b77 h1:7GoSOOW2jpsfkntVKaS2rAr1TJqfcxotyaUcuxoZSzg= github.com/mitchellh/go-testing-interface v0.0.0-20171004221916-a61a99592b77/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= github.com/mitchellh/go-testing-interface v1.0.0 h1:fzU/JVNcaqHQEcVFAKeR41fkiLdIPrefOvVG1VZ96U0= github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= @@ -340,18 +324,18 @@ github.com/packer-community/winrmcp v0.0.0-20180102160824-81144009af58 h1:m3CEgv github.com/packer-community/winrmcp v0.0.0-20180102160824-81144009af58/go.mod h1:f6Izs6JvFTdnRbziASagjZ2vmf55NSIkC/weStxCHqk= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c h1:Lgl0gzECD8GnQ5QCWA8o6BtfL6mDH5rQgM4/fX3avOs= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 h1:49lOXmGaUpV9Fz3gd7TFZY106KVlPVa5jcYD1gaQf98= +github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA= github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/posener/complete v1.1.1 h1:ccV59UEOTzVDnDUEFdT95ZzHVZ+5+158q8+SJb2QV5w= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posener/complete v1.2.1 h1:LrvDIY//XNo65Lq84G/akBuMGlawHvGBABv8f/ZN6DI= github.com/posener/complete v1.2.1/go.mod h1:6gapUrK/U1TAN7ciCoNRIdVC5sbdBTUh1DKN0g6uH7E= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829 h1:D+CiwcpGTW6pL6bv6KI3KbyEyCKyS+1JWS2h8PNDnGA= github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910 h1:idejC8f05m9MGOsuEi1ATq9shN03HrxNkD/luQvxCv8= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f h1:BVwpUVJDADN2ufcGik7W992pyps0wZ888b/y9GXcLTU= github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= @@ -377,9 +361,7 @@ github.com/soheilhy/cmux v0.1.4 h1:0HKaf1o97UwFjHH9o5XsHUOF+tqmdA7KEzXLpiyaw0E= github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= github.com/spf13/afero v1.2.1 h1:qgMbHoJbPbw579P+1zVY+6n4nIFuIchaIjzZ/I/Yq8M= github.com/spf13/afero v1.2.1/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= -github.com/spf13/pflag v1.0.2 h1:Fy0orTDgHdbnzHcsOgfCN4LtHf0ec3wwtiwJqwvf3Gc= github.com/spf13/pflag v1.0.2/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -405,7 +387,6 @@ github.com/xiang90/probing v0.0.0-20160813154853-07dd2e8dfe18 h1:MPPkRncZLN9Kh4M github.com/xiang90/probing v0.0.0-20160813154853-07dd2e8dfe18/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xlab/treeprint v0.0.0-20161029104018-1d6e34225557 h1:Jpn2j6wHkC9wJv5iMfJhKqrZJx3TahFx+7sbZ7zQdxs= github.com/xlab/treeprint v0.0.0-20161029104018-1d6e34225557/go.mod h1:ce1O1j6UtZfjr22oyGxGLbauSBp2YVXpARAosm7dHBg= -github.com/zclconf/go-cty v1.0.0 h1:EWtv3gKe2wPLIB9hQRQJa7k/059oIfAqcEkCNnaVckk= github.com/zclconf/go-cty v1.0.0/go.mod h1:xnAOWiHeOqg2nWS62VtQ7pbOu17FtxJNW8RLEih+O3s= github.com/zclconf/go-cty v1.0.1-0.20190708163926-19588f92a98f h1:sq2p8SN6ji66CFEQFIWLlD/gFmGtr5hBrOzv5nLlGfA= github.com/zclconf/go-cty v1.0.1-0.20190708163926-19588f92a98f/go.mod h1:xnAOWiHeOqg2nWS62VtQ7pbOu17FtxJNW8RLEih+O3s= @@ -422,13 +403,9 @@ go.uber.org/zap v1.9.1 h1:XCJQEf3W6eZaVwhRBof6ImoYGJSITeKWsyeh3HFu/5o= go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181112202954-3d3f9f413869/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2 h1:NwxKRvbkH5MsNkvOtPZi3/3kmI8CAzs3mtv+GLQMkNo= golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190222235706-ffb98f73852f h1:qWFY9ZxP3tfI37wYIs/MnIAqK0vlXp1xnYEa5HxFSSY= golang.org/x/crypto v0.0.0-20190222235706-ffb98f73852f/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734 h1:p/H982KKEjUnLJkM3tt/LemDnOc1GiZL5FCVlORJ5zo= golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 h1:HuIa8hRrWRSrqYzx1qI49NNxhdi2PrY7gxVSq1JjLDc= @@ -450,28 +427,23 @@ golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd h1:HuTn7WObtcDo9uEEU7rEqL0jYthdXAmZ6PP+meazmaU= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190502183928-7f726cade0ab h1:9RfW3ktsOZxgo9YNbBAjq1FWzc/igwEcUzZz8IXgSbk= golang.org/x/net v0.0.0-20190502183928-7f726cade0ab/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421 h1:Wo7BWFiOk0QRFMLYMqJGFMd9CgUAcGx7V+qEg/h5IBI= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 h1:YUO/7uOKsKeq9UokNS62b8FYywz3ker1l1vDZRCRefw= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -479,30 +451,23 @@ golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc h1:WiYx1rIFmx8c0mXAFtv5D/mHyKe1+jmuP7PViuwqwuQ= golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190221075227-b4e8571b14e0 h1:bzeyCHgoAyjZjAhvTpks+qM7sdlh4cCSitmXeCEO3B4= golang.org/x/sys v0.0.0-20190221075227-b4e8571b14e0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223 h1:DH4skfRX4EBpamg7iV4ZlCpblAHI6s6TDM39bFZumv8= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502175342-a43fa875dd82 h1:vsphBvatvfbhlb4PO1BYSr9dzugGxJ/SQHoNufZJq1w= golang.org/x/sys v0.0.0-20190502175342-a43fa875dd82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa h1:KIDDMLT1O0Nr7TSxp8xM5tJcdn8tgyAONntO829og1M= golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2 h1:z99zHgr7hKfrUcX/KsoJk5FJfjTceCKIp96+biqP4To= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c h1:fqgJT0MGcGpPgpWU7VRdRjuArfcOvC4AoJmILihzhDg= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -522,13 +487,11 @@ google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEn google.golang.org/api v0.9.0 h1:jbyannxz0XFD3zdjgrSUsaJbgpH4eTrkdhRChkHPfO8= google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.1 h1:QzqyMA1tlu6CgqCDUtU9V+ZKhLFT2dkJuANu5QaxI3I= google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19 h1:Lj2SnHtxkRGJDqnGaSjo+CCdIieEnwVazbOXILwQemk= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= @@ -536,7 +499,6 @@ google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRn google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55 h1:gSJIx1SDwno+2ElGhA4+qG2zF97qiUzTM+rQ0klBOcE= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/grpc v1.14.0 h1:ArxJuB1NWfPY6r9Gp9gqwplT0Ge7nqv9msgu03lHLmo= google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= diff --git a/vendor/github.com/pkg/browser/LICENSE b/vendor/github.com/pkg/browser/LICENSE new file mode 100644 index 000000000..65f78fb62 --- /dev/null +++ b/vendor/github.com/pkg/browser/LICENSE @@ -0,0 +1,23 @@ +Copyright (c) 2014, Dave Cheney +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/github.com/pkg/browser/README.md b/vendor/github.com/pkg/browser/README.md new file mode 100644 index 000000000..72b1976e3 --- /dev/null +++ b/vendor/github.com/pkg/browser/README.md @@ -0,0 +1,55 @@ + +# browser + import "github.com/pkg/browser" + +Package browser provides helpers to open files, readers, and urls in a browser window. + +The choice of which browser is started is entirely client dependant. + + + + + +## Variables +``` go +var Stderr io.Writer = os.Stderr +``` +Stderr is the io.Writer to which executed commands write standard error. + +``` go +var Stdout io.Writer = os.Stdout +``` +Stdout is the io.Writer to which executed commands write standard output. + + +## func OpenFile +``` go +func OpenFile(path string) error +``` +OpenFile opens new browser window for the file path. + + +## func OpenReader +``` go +func OpenReader(r io.Reader) error +``` +OpenReader consumes the contents of r and presents the +results in a new browser window. + + +## func OpenURL +``` go +func OpenURL(url string) error +``` +OpenURL opens a new browser window pointing to url. + + + + + + + + + +- - - +Generated by [godoc2md](http://godoc.org/github.com/davecheney/godoc2md) diff --git a/vendor/github.com/pkg/browser/browser.go b/vendor/github.com/pkg/browser/browser.go new file mode 100644 index 000000000..3e5969064 --- /dev/null +++ b/vendor/github.com/pkg/browser/browser.go @@ -0,0 +1,63 @@ +// Package browser provides helpers to open files, readers, and urls in a browser window. +// +// The choice of which browser is started is entirely client dependant. +package browser + +import ( + "fmt" + "io" + "io/ioutil" + "os" + "os/exec" + "path/filepath" +) + +// Stdout is the io.Writer to which executed commands write standard output. +var Stdout io.Writer = os.Stdout + +// Stderr is the io.Writer to which executed commands write standard error. +var Stderr io.Writer = os.Stderr + +// OpenFile opens new browser window for the file path. +func OpenFile(path string) error { + path, err := filepath.Abs(path) + if err != nil { + return err + } + return OpenURL("file://" + path) +} + +// OpenReader consumes the contents of r and presents the +// results in a new browser window. +func OpenReader(r io.Reader) error { + f, err := ioutil.TempFile("", "browser") + if err != nil { + return fmt.Errorf("browser: could not create temporary file: %v", err) + } + if _, err := io.Copy(f, r); err != nil { + f.Close() + return fmt.Errorf("browser: caching temporary file failed: %v", err) + } + if err := f.Close(); err != nil { + return fmt.Errorf("browser: caching temporary file failed: %v", err) + } + oldname := f.Name() + newname := oldname + ".html" + if err := os.Rename(oldname, newname); err != nil { + return fmt.Errorf("browser: renaming temporary file failed: %v", err) + } + return OpenFile(newname) +} + +// OpenURL opens a new browser window pointing to url. +func OpenURL(url string) error { + return openBrowser(url) +} + +func runCmd(prog string, args ...string) error { + cmd := exec.Command(prog, args...) + cmd.Stdout = Stdout + cmd.Stderr = Stderr + setFlags(cmd) + return cmd.Run() +} diff --git a/vendor/github.com/pkg/browser/browser_darwin.go b/vendor/github.com/pkg/browser/browser_darwin.go new file mode 100644 index 000000000..6dff0403c --- /dev/null +++ b/vendor/github.com/pkg/browser/browser_darwin.go @@ -0,0 +1,9 @@ +package browser + +import "os/exec" + +func openBrowser(url string) error { + return runCmd("open", url) +} + +func setFlags(cmd *exec.Cmd) {} diff --git a/vendor/github.com/pkg/browser/browser_linux.go b/vendor/github.com/pkg/browser/browser_linux.go new file mode 100644 index 000000000..656c693ba --- /dev/null +++ b/vendor/github.com/pkg/browser/browser_linux.go @@ -0,0 +1,9 @@ +package browser + +import "os/exec" + +func openBrowser(url string) error { + return runCmd("xdg-open", url) +} + +func setFlags(cmd *exec.Cmd) {} diff --git a/vendor/github.com/pkg/browser/browser_openbsd.go b/vendor/github.com/pkg/browser/browser_openbsd.go new file mode 100644 index 000000000..8cc0a7f53 --- /dev/null +++ b/vendor/github.com/pkg/browser/browser_openbsd.go @@ -0,0 +1,16 @@ +package browser + +import ( + "errors" + "os/exec" +) + +func openBrowser(url string) error { + err := runCmd("xdg-open", url) + if e, ok := err.(*exec.Error); ok && e.Err == exec.ErrNotFound { + return errors.New("xdg-open: command not found - install xdg-utils from ports(8)") + } + return err +} + +func setFlags(cmd *exec.Cmd) {} diff --git a/vendor/github.com/pkg/browser/browser_unsupported.go b/vendor/github.com/pkg/browser/browser_unsupported.go new file mode 100644 index 000000000..0e1e530c5 --- /dev/null +++ b/vendor/github.com/pkg/browser/browser_unsupported.go @@ -0,0 +1,15 @@ +// +build !linux,!windows,!darwin,!openbsd + +package browser + +import ( + "fmt" + "os/exec" + "runtime" +) + +func openBrowser(url string) error { + return fmt.Errorf("openBrowser: unsupported operating system: %v", runtime.GOOS) +} + +func setFlags(cmd *exec.Cmd) {} diff --git a/vendor/github.com/pkg/browser/browser_windows.go b/vendor/github.com/pkg/browser/browser_windows.go new file mode 100644 index 000000000..a964c7b91 --- /dev/null +++ b/vendor/github.com/pkg/browser/browser_windows.go @@ -0,0 +1,16 @@ +package browser + +import ( + "os/exec" + "strings" + "syscall" +) + +func openBrowser(url string) error { + r := strings.NewReplacer("&", "^&") + return runCmd("cmd", "/c", "start", r.Replace(url)) +} + +func setFlags(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} +} diff --git a/vendor/modules.txt b/vendor/modules.txt index cda61eb84..8dac64c8e 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -437,6 +437,8 @@ github.com/nu7hatch/gouuid github.com/oklog/run # github.com/packer-community/winrmcp v0.0.0-20180102160824-81144009af58 github.com/packer-community/winrmcp/winrmcp +# github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 +github.com/pkg/browser # github.com/pkg/errors v0.8.0 github.com/pkg/errors # github.com/posener/complete v1.2.1 From 0b346e589a27c296d650e39b5ab23a2080be66c6 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Thu, 8 Aug 2019 17:11:37 -0700 Subject: [PATCH 02/12] command/login: Show login consent prompt before proceeding Because we're going to pass the credentials we obtain on to some credentials store (either a credentials helper or a local file on disk) we ought to disclose that first and give the user a chance to cancel out and set up a different credentials storage mechanism first if desired. This also includes the very beginnings of support for the owner password grant type when running against app.terraform.io. This will be used only temporarily at initial release to allow a faster initial release without blocking on implementation of a full OAuth flow in Terraform Cloud. --- command/login.go | 143 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 138 insertions(+), 5 deletions(-) diff --git a/command/login.go b/command/login.go index e795a1085..f373690de 100644 --- a/command/login.go +++ b/command/login.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/base64" + "errors" "fmt" "log" "math/rand" @@ -12,9 +13,11 @@ import ( "path/filepath" "strings" + "github.com/hashicorp/terraform/command/cliconfig" "github.com/hashicorp/terraform/httpclient" "github.com/hashicorp/terraform/svchost" "github.com/hashicorp/terraform/svchost/disco" + "github.com/hashicorp/terraform/terraform" "github.com/hashicorp/terraform/tfdiags" uuid "github.com/hashicorp/go-uuid" @@ -36,7 +39,7 @@ func (c *LoginCommand) Run(args []string) int { return 1 } - cmdFlags := c.Meta.defaultFlagSet("login") + cmdFlags := c.Meta.extendedFlagSet("login") var intoFile string cmdFlags.StringVar(&intoFile, "into-file", "", "set the file that the credentials will be appended to") cmdFlags.Usage = func() { c.Ui.Error(c.Help()) } @@ -54,6 +57,16 @@ func (c *LoginCommand) Run(args []string) int { var diags tfdiags.Diagnostics + if !c.input { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Login is an interactive command", + "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.", + )) + c.showDiagnostics(diags) + return 1 + } + givenHostname := "app.terraform.io" if len(args) != 0 { givenHostname = args[0] @@ -66,6 +79,8 @@ func (c *LoginCommand) Run(args []string) int { "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 @@ -87,6 +102,25 @@ func (c *LoginCommand) Run(args []string) int { // with our usual error reporting standards. err.Error()+".", )) + c.showDiagnostics(diags) + return 1 + } + + creds := c.Services.CredentialsSource() + + // In normal use (i.e. without test mocks/fakes) creds will be an instance + // of the command/cliconfig.CredentialsSource type, which has some extra + // methods we can use to give the user better feedback about what we're + // going to do. credsCtx will be nil if it's any other implementation, + // though. + var credsCtx *loginCredentialsContext + if c, ok := creds.(*cliconfig.CredentialsSource); ok { + filename, _ := c.CredentialsFilePath() + credsCtx = &loginCredentialsContext{ + Location: c.HostCredentialsLocation(hostname), + LocalFilename: filename, // empty in the very unlikely event that we can't select a config directory for this user + HelperType: c.CredentialsHelperType(), + } } clientConfig, err := host.ServiceOAuthClient("login.v1") @@ -113,14 +147,45 @@ func (c *LoginCommand) Run(args []string) int { )) } + if credsCtx.Location == cliconfig.CredentialsInOtherFile { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + fmt.Sprintf("Credentials for %s are manually configured", dispHostname), + "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.", + )) + } + if diags.HasErrors() { c.showDiagnostics(diags) return 1 } - token, tokenDiags := c.interactiveGetToken(hostname, clientConfig) - diags = diags.Append(tokenDiags) - if tokenDiags.HasErrors() { + var token *oauth2.Token + switch { + case clientConfig.SupportedGrantTypes.Has(disco.OAuthAuthzCodeGrant): + // We prefer an OAuth code grant if the server supports it. + var tokenDiags tfdiags.Diagnostics + token, tokenDiags = c.interactiveGetTokenByCode(hostname, credsCtx, clientConfig) + diags = diags.Append(tokenDiags) + if tokenDiags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + case clientConfig.SupportedGrantTypes.Has(disco.OAuthOwnerPasswordGrant) && hostname == svchost.Hostname("app.terraform.io"): + // The password grant type is allowed only for Terraform Cloud SaaS. + var tokenDiags tfdiags.Diagnostics + token, tokenDiags = c.interactiveGetTokenByPassword(hostname, credsCtx, clientConfig) + diags = diags.Append(tokenDiags) + if tokenDiags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + default: + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Host does not support Terraform login", + fmt.Sprintf("The given hostname %q does not allow any OAuth grant types that are supported by this version of Terraform.", dispHostname), + )) c.showDiagnostics(diags) return 1 } @@ -183,9 +248,16 @@ func (c *LoginCommand) defaultOutputFile() string { return filepath.Join(c.CLIConfigDir, "credentials.tfrc") } -func (c *LoginCommand) interactiveGetToken(hostname svchost.Hostname, clientConfig *disco.OAuthClient) (*oauth2.Token, tfdiags.Diagnostics) { +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 @@ -327,6 +399,61 @@ func (c *LoginCommand) interactiveGetToken(hostname svchost.Hostname, clientConf 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 + } + + return nil, diags +} + +func (c *LoginCommand) interactiveContextConsent(hostname svchost.Hostname, grantType disco.OAuthGrantType, credsCtx *loginCredentialsContext) (bool, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + c.Ui.Output(fmt.Sprintf("Terraform will request an API token for %s using OAuth.\n", hostname.ForDisplay())) + + 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, Terraform 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, Terraform 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(), &terraform.InputOpts{ + Id: "confirm", + Query: "Do you want to proceed with login and store the new credentials?", + Description: "Enter 'y' or 'yes' 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 + } + + switch strings.ToLower(v) { + case "y", "yes": + return true, diags + default: + return false, 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 @@ -387,6 +514,12 @@ func (c *LoginCommand) proofKey() (key, challenge string, err error) { return key, challenge, nil } +type loginCredentialsContext struct { + Location cliconfig.CredentialsLocation + LocalFilename string + HelperType string +} + const callbackSuccessMessage = ` From cfc1c4900d41b73337c9bc805967870da312c24c Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Fri, 9 Aug 2019 17:58:03 -0700 Subject: [PATCH 03/12] command/login: Use Cli.Ask to request confirmation This is more straightforward than using readline because it already works properly with panicwrap. --- command/login.go | 7 +------ main.go | 5 ++++- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/command/login.go b/command/login.go index f373690de..eb6df895d 100644 --- a/command/login.go +++ b/command/login.go @@ -17,7 +17,6 @@ import ( "github.com/hashicorp/terraform/httpclient" "github.com/hashicorp/terraform/svchost" "github.com/hashicorp/terraform/svchost/disco" - "github.com/hashicorp/terraform/terraform" "github.com/hashicorp/terraform/tfdiags" uuid "github.com/hashicorp/go-uuid" @@ -434,11 +433,7 @@ func (c *LoginCommand) interactiveContextConsent(hostname svchost.Hostname, gran } } - v, err := c.UIInput().Input(context.Background(), &terraform.InputOpts{ - Id: "confirm", - Query: "Do you want to proceed with login and store the new credentials?", - Description: "Enter 'y' or 'yes' to confirm.", - }) + v, err := c.prompt("Do you want to proceed? (y/n)", false) if err != nil { // Should not happen because this command checks that input is enabled // before we get to this point. diff --git a/main.go b/main.go index 5412a5b72..3cc867801 100644 --- a/main.go +++ b/main.go @@ -106,7 +106,10 @@ func init() { OutputPrefix: OutputPrefix, InfoPrefix: OutputPrefix, ErrorPrefix: ErrorPrefix, - Ui: &cli.BasicUi{Writer: os.Stdout}, + Ui: &cli.BasicUi{ + Writer: os.Stdout, + Reader: os.Stdin, + }, } } From f605bde56264ec69d5a96a1497c9b618cd950aa9 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Tue, 20 Aug 2019 16:10:37 -0700 Subject: [PATCH 04/12] command/login: Password-based credentials request --- command/login.go | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/command/login.go b/command/login.go index eb6df895d..e765712c3 100644 --- a/command/login.go +++ b/command/login.go @@ -408,7 +408,37 @@ func (c *LoginCommand) interactiveGetTokenByPassword(hostname svchost.Hostname, return nil, diags } - return nil, diags + c.Ui.Output(fmt.Sprintf("Terraform will use your %s login temporarily to request an API token.\n", hostname.ForDisplay())) + + username, err := c.Ui.Ask(fmt.Sprintf("Username for %s:", hostname.ForDisplay())) + if err != nil { + diags = diags.Append(fmt.Errorf("Failed to request username: %s", err)) + return nil, diags + } + password, err := c.Ui.AskSecret(fmt.Sprintf("Password for %s:", username)) + if err != nil { + diags = diags.Append(fmt.Errorf("Failed to request password: %s", err)) + return nil, diags + } + + oauthConfig := &oauth2.Config{ + ClientID: clientConfig.ID, + Endpoint: clientConfig.Endpoint(), + } + 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) interactiveContextConsent(hostname svchost.Hostname, grantType disco.OAuthGrantType, credsCtx *loginCredentialsContext) (bool, tfdiags.Diagnostics) { @@ -433,7 +463,7 @@ func (c *LoginCommand) interactiveContextConsent(hostname svchost.Hostname, gran } } - v, err := c.prompt("Do you want to proceed? (y/n)", false) + v, err := c.Ui.Ask("Do you want to proceed? (y/n)") if err != nil { // Should not happen because this command checks that input is enabled // before we get to this point. From a1e387a0e5e39a3204b5763eab2d585ff4e99bfb Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Wed, 21 Aug 2019 15:05:13 -0700 Subject: [PATCH 05/12] command: A stub OAuth server implementation for login testing --- command/testdata/login-oauth-server/main.go | 71 +++++++++++++++ .../login-oauth-server/oauthserver.go | 88 +++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 command/testdata/login-oauth-server/main.go create mode 100644 command/testdata/login-oauth-server/oauthserver.go diff --git a/command/testdata/login-oauth-server/main.go b/command/testdata/login-oauth-server/main.go new file mode 100644 index 000000000..70333b61a --- /dev/null +++ b/command/testdata/login-oauth-server/main.go @@ -0,0 +1,71 @@ +// +build ignore + +// This file is a helper for those doing _manual_ testing of "terraform login" +// and/or "terraform logout" and want to start up a test OAuth server in a +// separate process for convenience: +// +// go run ./command/testdata/login-oauth-server/main.go :8080 +// +// This is _not_ the main way to use this oauthserver package. For automated +// test code, import it as a normal Go package instead: +// +// import oauthserver "github.com/hashicorp/terraform/command/testdata/login-oauth-server" + +package main + +import ( + "fmt" + "net" + "net/http" + "os" + + oauthserver "github.com/hashicorp/terraform/command/testdata/login-oauth-server" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "Usage: go run ./command/testdata/login-oauth-server/main.go ") + os.Exit(1) + } + + host, port, err := net.SplitHostPort(os.Args[1]) + if err != nil { + fmt.Fprintln(os.Stderr, "Invalid address: %s", err) + os.Exit(1) + } + + if host == "" { + host = "127.0.0.1" + } + addr := fmt.Sprintf("%s:%s", host, port) + + fmt.Printf("Will listen on %s...\n", addr) + fmt.Printf( + configExampleFmt, + fmt.Sprintf("http://%s:%s/authz", host, port), + fmt.Sprintf("http://%s:%s/token", host, port), + fmt.Sprintf("http://%s:%s/revoke", host, port), + ) + + server := &http.Server{ + Addr: addr, + Handler: oauthserver.Handler, + } + err = server.ListenAndServe() + fmt.Fprintln(os.Stderr, err.Error()) +} + +const configExampleFmt = ` +host "login-test.example.com" { + services = { + "login.v1" = { + authz = %q + token = %q + client = "placeholder" + grant_types = ["code", "password"] + } + "logout.v1" = %q + } +} + +` diff --git a/command/testdata/login-oauth-server/oauthserver.go b/command/testdata/login-oauth-server/oauthserver.go new file mode 100644 index 000000000..15fe32939 --- /dev/null +++ b/command/testdata/login-oauth-server/oauthserver.go @@ -0,0 +1,88 @@ +// Package oauthserver is a very simplistic OAuth server used only for +// the testing of the "terraform login" and "terraform logout" commands. +package oauthserver + +import ( + "log" + "net/http" +) + +// Handler is an implementation of net/http.Handler that provides a stub +// OAuth server implementation with the following endpoints: +// +// /authz - authorization endpoint +// /token - token endpoint +// /revoke - token revocation (logout) endpoint +// +// The authorization endpoint returns HTML per normal OAuth conventions, but +// it also includes an HTTP header X-Redirect-To giving the same URL that the +// link in the HTML indicates, allowing a non-browser user-agent to traverse +// this robotically in automated tests. +var Handler http.Handler + +type handler struct{} + +func (h handler) ServeHTTP(resp http.ResponseWriter, req *http.Request) { + switch req.URL.Path { + case "/authz": + h.serveAuthz(resp, req) + case "/token": + h.serveToken(resp, req) + case "/revoke": + h.serveRevoke(resp, req) + default: + resp.WriteHeader(404) + } +} + +func (h handler) serveAuthz(resp http.ResponseWriter, req *http.Request) { + resp.WriteHeader(404) +} + +func (h handler) serveToken(resp http.ResponseWriter, req *http.Request) { + if req.Method != "POST" { + resp.WriteHeader(405) + log.Printf("/token: unsupported request method %q", req.Method) + return + } + + if err := req.ParseForm(); err != nil { + resp.WriteHeader(500) + log.Printf("/token: error parsing body: %s", err) + return + } + + grantType := req.Form.Get("grant_type") + log.Printf("/token: grant_type is %q", grantType) + switch grantType { + case "password": + username := req.Form.Get("username") + password := req.Form.Get("password") + + if username == "wrong" || password == "wrong" { + // These special "credentials" allow testing for the error case. + resp.Header().Set("Content-Type", "application/json") + resp.WriteHeader(400) + resp.Write([]byte(`{"error":"invalid_grant"}`)) + log.Println("/token: 'wrong' credentials") + return + } + + resp.Header().Set("Content-Type", "application/json") + resp.WriteHeader(200) + resp.Write([]byte(`{"access_token":"good-token","token_type":"bearer"}`)) + log.Println("/token: successful request") + + default: + resp.WriteHeader(400) + log.Printf("/token: unsupported grant type %q", grantType) + } +} + +func (h handler) serveRevoke(resp http.ResponseWriter, req *http.Request) { + resp.WriteHeader(404) +} + +func init() { + Handler = handler{} +} From f25cb008f1ef61b44fdf67682a7c481c5a7500a1 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Wed, 21 Aug 2019 15:18:07 -0700 Subject: [PATCH 06/12] command/login: Save the new API token Once we've successfully obtained an API token, we'll can save it in the credentials store. --- command/login.go | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/command/login.go b/command/login.go index e765712c3..9b5f91eaa 100644 --- a/command/login.go +++ b/command/login.go @@ -16,6 +16,7 @@ import ( "github.com/hashicorp/terraform/command/cliconfig" "github.com/hashicorp/terraform/httpclient" "github.com/hashicorp/terraform/svchost" + svcauth "github.com/hashicorp/terraform/svchost/auth" "github.com/hashicorp/terraform/svchost/disco" "github.com/hashicorp/terraform/tfdiags" @@ -189,15 +190,32 @@ func (c *LoginCommand) Run(args []string) int { return 1 } - // TODO: Save the token in the CLI config. - // Also, if the token has an expiration time associated with it, prompt - // the user that they will need to log in again after that time. - fmt.Printf("Token is %#v\n", token) + err = creds.StoreForHost(hostname, svcauth.HostCredentialsToken(token.AccessToken)) + 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 Terraform failed to save it: %s.", err), + )) + } c.showDiagnostics(diags) if diags.HasErrors() { return 1 } + + c.Ui.Output( + "\n" + fmt.Sprintf( + c.Colorize().Color(strings.TrimSpace(` +[green][bold]Success![reset] [bold]Terraform has obtained and saved an API token.[reset] + +The new API token will be used for any future Terraform command that must make +authenticated requests to %s. +`)), + dispHostname, + ) + "\n", + ) + return 0 } From daf733af33827124608ce319a930db1b757132de Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Wed, 21 Aug 2019 15:37:36 -0700 Subject: [PATCH 07/12] command/login: UI cleanup --- command/login.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/command/login.go b/command/login.go index 9b5f91eaa..3bac99d1c 100644 --- a/command/login.go +++ b/command/login.go @@ -204,8 +204,9 @@ func (c *LoginCommand) Run(args []string) int { return 1 } + c.Ui.Output("\n---------------------------------------------------------------------------------\n") c.Ui.Output( - "\n" + fmt.Sprintf( + fmt.Sprintf( c.Colorize().Color(strings.TrimSpace(` [green][bold]Success![reset] [bold]Terraform has obtained and saved an API token.[reset] @@ -426,7 +427,8 @@ func (c *LoginCommand) interactiveGetTokenByPassword(hostname svchost.Hostname, return nil, diags } - c.Ui.Output(fmt.Sprintf("Terraform will use your %s login temporarily to request an API token.\n", hostname.ForDisplay())) + c.Ui.Output("\n---------------------------------------------------------------------------------\n") + c.Ui.Output("Terraform must temporarily use your password to request an API token.\nThis password will NOT be saved locally.\n") username, err := c.Ui.Ask(fmt.Sprintf("Username for %s:", hostname.ForDisplay())) if err != nil { From 7ccd6204c4663014e9b2ba91dc0078ac879adfb4 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Thu, 29 Aug 2019 15:50:03 -0700 Subject: [PATCH 08/12] command: Swappable implementation of launching web browsers For unit testing in particular we can't launch a real browser for testing, so this indirection is primarily to allow us to substitute a mock when testing a command that can launch a browser. This includes a simple mock implementation that expects to interact with a running web server directly. --- command/login.go | 21 +++-- command/meta.go | 5 + command/webbrowser/mock.go | 155 +++++++++++++++++++++++++++++++ command/webbrowser/mock_test.go | 95 +++++++++++++++++++ command/webbrowser/native.go | 18 ++++ command/webbrowser/webbrowser.go | 19 ++++ commands.go | 4 +- 7 files changed, 310 insertions(+), 7 deletions(-) create mode 100644 command/webbrowser/mock.go create mode 100644 command/webbrowser/mock_test.go create mode 100644 command/webbrowser/native.go create mode 100644 command/webbrowser/webbrowser.go diff --git a/command/login.go b/command/login.go index 3bac99d1c..de7d0afbf 100644 --- a/command/login.go +++ b/command/login.go @@ -21,7 +21,6 @@ import ( "github.com/hashicorp/terraform/tfdiags" uuid "github.com/hashicorp/go-uuid" - "github.com/pkg/browser" "golang.org/x/oauth2" ) @@ -375,12 +374,22 @@ func (c *LoginCommand) interactiveGetTokenByCode(hostname svchost.Hostname, cred oauth2.SetAuthURLParam("code_challenge", proofKeyChallenge), oauth2.SetAuthURLParam("code_challenge_method", "S256"), ) - err = browser.OpenURL(authCodeURL) - if err == nil { - c.Ui.Output(fmt.Sprintf("Terraform 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)) + + launchBrowserManually := false + if c.BrowserLauncher != nil { + err = c.BrowserLauncher.OpenURL(authCodeURL) + if err == nil { + c.Ui.Output(fmt.Sprintf("Terraform 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 { - // Assume we're on a platform where opening a browser isn't possible. + 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)) } diff --git a/command/meta.go b/command/meta.go index 9e1d8c4cb..d55c9f8a5 100644 --- a/command/meta.go +++ b/command/meta.go @@ -18,6 +18,7 @@ import ( "github.com/hashicorp/terraform/backend" "github.com/hashicorp/terraform/backend/local" "github.com/hashicorp/terraform/command/format" + "github.com/hashicorp/terraform/command/webbrowser" "github.com/hashicorp/terraform/configs/configload" "github.com/hashicorp/terraform/helper/experiment" "github.com/hashicorp/terraform/helper/wrappedstreams" @@ -78,6 +79,10 @@ type Meta struct { // is not suitable, e.g. because of a read-only filesystem. OverrideDataDir string + // BrowserLauncher is used by commands that need to open a URL in a + // web browser. + BrowserLauncher webbrowser.Launcher + // When this channel is closed, the command will be cancelled. ShutdownCh <-chan struct{} diff --git a/command/webbrowser/mock.go b/command/webbrowser/mock.go new file mode 100644 index 000000000..ef411ba1e --- /dev/null +++ b/command/webbrowser/mock.go @@ -0,0 +1,155 @@ +package webbrowser + +import ( + "context" + "fmt" + "log" + "net/http" + "net/url" + "sync" + + "github.com/hashicorp/terraform/httpclient" +) + +// NewMockLauncher creates and returns a mock implementation of Launcher, +// with some special behavior designed for use in unit tests. +// +// See the documentation of MockLauncher itself for more information. +func NewMockLauncher(ctx context.Context) *MockLauncher { + client := httpclient.New() + return &MockLauncher{ + Client: client, + Context: ctx, + } +} + +// MockLauncher is a mock implementation of Launcher that has some special +// behavior designed for use in unit tests. +// +// When OpenURL is called, MockLauncher will make an HTTP request to the given +// URL rather than interacting with a "real" browser. +// +// In normal situations it will then return with no further action, but if +// the response to the given URL is either a standard HTTP redirect response +// or includes the custom HTTP header X-Redirect-To then MockLauncher will +// send a follow-up request to that target URL, and continue in this manner +// until it reaches a URL that is not a redirect. (The X-Redirect-To header +// is there so that a server can potentially offer a normal HTML page to +// an actual browser while also giving a next-hop hint for MockLauncher.) +// +// Since MockLauncher is not a full programmable user-agent implementation +// it can't be used for testing of real-world web applications, but it can +// be used for testing against specialized test servers that are written +// with MockLauncher in mind and know how to drive the request flow through +// whatever steps are required to complete the desired test. +// +// All of the actions taken by MockLauncher happen asynchronously in the +// background, to simulate the concurrency of a separate web browser. +// Test code using MockLauncher should provide a context which is cancelled +// when the test completes, to help avoid leaking MockLaunchers. +type MockLauncher struct { + // Client is the HTTP client that MockLauncher will use to make requests. + // By default (if you use NewMockLauncher) this is a new client created + // via httpclient.New, but callers may override it if they need customized + // behavior for a particular test. + // + // Do not use a client that is shared with any other subsystem, because + // MockLauncher will customize the settings of the given client. + Client *http.Client + + // Context can be cancelled in order to abort an OpenURL call before it + // would naturally complete. + Context context.Context + + // Responses is a log of all of the responses recieved from the launcher's + // requests, in the order requested. + Responses []*http.Response + + // done is a waitgroup used internally to signal when the async work is + // complete, in order to make this mock more convenient to use in tests. + done sync.WaitGroup +} + +var _ Launcher = (*MockLauncher)(nil) + +// OpenURL is the mock implementation of Launcher, which has the special +// behavior described for type MockLauncher. +func (l *MockLauncher) OpenURL(u string) error { + // We run our operation in the background because it's supposed to be + // behaving like a web browser running in a separate process. + log.Printf("[TRACE] webbrowser.MockLauncher: OpenURL(%q) starting in the background", u) + l.done.Add(1) + go func() { + err := l.openURL(u) + if err != nil { + // Can't really do anything with this asynchronously, so we'll + // just log it so that someone debugging will be able to see it. + log.Printf("[ERROR] webbrowser.MockLauncher: OpenURL(%q): %s", u, err) + } else { + log.Printf("[TRACE] webbrowser.MockLauncher: OpenURL(%q) has concluded", u) + } + l.done.Done() + }() + return nil +} + +func (l *MockLauncher) openURL(u string) error { + // We need to disable automatic redirect following so that we can implement + // it ourselves below, and thus be able to see the redirects in our + // responses log. + l.Client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + + // We'll keep looping as long as the server keeps giving us new URLs to + // request. + for u != "" { + log.Printf("[DEBUG] webbrowser.MockLauncher: requesting %s", u) + req, err := http.NewRequest("GET", u, nil) + if err != nil { + return fmt.Errorf("failed to construct HTTP request for %s: %s", u, err) + } + resp, err := l.Client.Do(req) + if err != nil { + log.Printf("[DEBUG] webbrowser.MockLauncher: request failed: %s", err) + return fmt.Errorf("error requesting %s: %s", u, err) + } + l.Responses = append(l.Responses, resp) + if resp.StatusCode >= 400 { + log.Printf("[DEBUG] webbrowser.MockLauncher: request failed: %s", resp.Status) + return fmt.Errorf("error requesting %s: %s", u, resp.Status) + } + log.Printf("[DEBUG] webbrowser.MockLauncher: request succeeded: %s", resp.Status) + + u = "" // unless it's a redirect, we'll stop after this + if location := resp.Header.Get("Location"); location != "" { + u = location + } else if redirectTo := resp.Header.Get("X-Redirect-To"); redirectTo != "" { + u = redirectTo + } + + if u != "" { + // HTTP technically doesn't permit relative URLs in Location, but + // browsers tolerate it and so real-world servers do it, and thus + // we'll allow it here too. + oldURL := resp.Request.URL + givenURL, err := url.Parse(u) + if err != nil { + return fmt.Errorf("invalid redirect URL %s: %s", u, err) + } + u = oldURL.ResolveReference(givenURL).String() + log.Printf("[DEBUG] webbrowser.MockLauncher: redirected to %s", u) + } + } + + log.Printf("[DEBUG] webbrowser.MockLauncher: all done") + return nil +} + +// Wait blocks until the MockLauncher has finished its asynchronous work of +// making HTTP requests and following redirects, at which point it will have +// reached a request that didn't redirect anywhere and stopped iterating. +func (l *MockLauncher) Wait() { + log.Printf("[TRACE] webbrowser.MockLauncher: Wait() for current work to complete") + l.done.Wait() +} diff --git a/command/webbrowser/mock_test.go b/command/webbrowser/mock_test.go new file mode 100644 index 000000000..610f83d87 --- /dev/null +++ b/command/webbrowser/mock_test.go @@ -0,0 +1,95 @@ +package webbrowser + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestMockLauncher(t *testing.T) { + s := httptest.NewServer(http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { + resp.Header().Set("Content-Length", "0") + switch req.URL.Path { + case "/standard-redirect-source": + resp.Header().Set("Location", "/standard-redirect-target") + resp.WriteHeader(302) + case "/custom-redirect-source": + resp.Header().Set("X-Redirect-To", "/custom-redirect-target") + resp.WriteHeader(200) + case "/error": + resp.WriteHeader(500) + default: + resp.WriteHeader(200) + } + })) + defer s.Close() + + t.Run("no redirects", func(t *testing.T) { + l := NewMockLauncher(context.Background()) + err := l.OpenURL(s.URL) + if err != nil { + t.Fatal(err) + } + l.Wait() // Let the async work complete + if got, want := len(l.Responses), 1; got != want { + t.Fatalf("wrong number of responses %d; want %d", got, want) + } + if got, want := l.Responses[0].Request.URL.Path, ""; got != want { + t.Fatalf("wrong request URL %q; want %q", got, want) + } + }) + t.Run("error", func(t *testing.T) { + l := NewMockLauncher(context.Background()) + err := l.OpenURL(s.URL + "/error") + if err != nil { + // Th is kind of error is supposed to happen asynchronously, so we + // should not see it here. + t.Fatal(err) + } + l.Wait() // Let the async work complete + if got, want := len(l.Responses), 1; got != want { + t.Fatalf("wrong number of responses %d; want %d", got, want) + } + if got, want := l.Responses[0].Request.URL.Path, "/error"; got != want { + t.Fatalf("wrong request URL %q; want %q", got, want) + } + if got, want := l.Responses[0].StatusCode, 500; got != want { + t.Fatalf("wrong response status %d; want %d", got, want) + } + }) + t.Run("standard redirect", func(t *testing.T) { + l := NewMockLauncher(context.Background()) + err := l.OpenURL(s.URL + "/standard-redirect-source") + if err != nil { + t.Fatal(err) + } + l.Wait() // Let the async work complete + if got, want := len(l.Responses), 2; got != want { + t.Fatalf("wrong number of responses %d; want %d", got, want) + } + if got, want := l.Responses[0].Request.URL.Path, "/standard-redirect-source"; got != want { + t.Fatalf("wrong request 0 URL %q; want %q", got, want) + } + if got, want := l.Responses[1].Request.URL.Path, "/standard-redirect-target"; got != want { + t.Fatalf("wrong request 1 URL %q; want %q", got, want) + } + }) + t.Run("custom redirect", func(t *testing.T) { + l := NewMockLauncher(context.Background()) + err := l.OpenURL(s.URL + "/custom-redirect-source") + if err != nil { + t.Fatal(err) + } + l.Wait() // Let the async work complete + if got, want := len(l.Responses), 2; got != want { + t.Fatalf("wrong number of responses %d; want %d", got, want) + } + if got, want := l.Responses[0].Request.URL.Path, "/custom-redirect-source"; got != want { + t.Fatalf("wrong request 0 URL %q; want %q", got, want) + } + if got, want := l.Responses[1].Request.URL.Path, "/custom-redirect-target"; got != want { + t.Fatalf("wrong request 1 URL %q; want %q", got, want) + } + }) +} diff --git a/command/webbrowser/native.go b/command/webbrowser/native.go new file mode 100644 index 000000000..4e8281ce1 --- /dev/null +++ b/command/webbrowser/native.go @@ -0,0 +1,18 @@ +package webbrowser + +import ( + "github.com/pkg/browser" +) + +// NewNativeLauncher creates and returns a Launcher that will attempt to interact +// with the browser-launching mechanisms of the operating system where the +// program is currently running. +func NewNativeLauncher() Launcher { + return nativeLauncher{} +} + +type nativeLauncher struct{} + +func (l nativeLauncher) OpenURL(url string) error { + return browser.OpenURL(url) +} diff --git a/command/webbrowser/webbrowser.go b/command/webbrowser/webbrowser.go new file mode 100644 index 000000000..8931ec517 --- /dev/null +++ b/command/webbrowser/webbrowser.go @@ -0,0 +1,19 @@ +package webbrowser + +// Launcher is an object that knows how to open a given URL in a new tab in +// some suitable browser on the current system. +// +// Launching of browsers is a very target-platform-sensitive activity, so +// this interface serves as an abstraction over many possible implementations +// which can be selected based on what is appropriate for a specific situation. +type Launcher interface { + // OpenURL opens the given URL in a web browser. + // + // Depending on the circumstances and on the target platform, this may or + // may not cause the browser to take input focus. Because of this + // uncertainty, any caller of this method must be sure to include some + // language in its UI output to let the user know that a browser tab has + // opened somewhere, so that they can go and find it if the focus didn't + // switch automatically. + OpenURL(url string) error +} diff --git a/commands.go b/commands.go index 1aefd5eb2..9ea02c49c 100644 --- a/commands.go +++ b/commands.go @@ -5,6 +5,7 @@ import ( "os/signal" "github.com/hashicorp/terraform/command" + "github.com/hashicorp/terraform/command/webbrowser" pluginDiscovery "github.com/hashicorp/terraform/plugin/discovery" "github.com/hashicorp/terraform/svchost" "github.com/hashicorp/terraform/svchost/auth" @@ -63,7 +64,8 @@ func initCommands(config *Config, services *disco.Disco) { PluginOverrides: &PluginOverrides, Ui: Ui, - Services: services, + Services: services, + BrowserLauncher: webbrowser.NewNativeLauncher(), RunningInAutomation: inAutomation, CLIConfigDir: configDir, From 8381112a5c7cce747028f5c648116cf26f52db7e Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Thu, 29 Aug 2019 18:05:28 -0700 Subject: [PATCH 09/12] command: Tests for the "terraform login" command These run against a stub OAuth server implementation, verifying that we are able to run an end-to-end login transaction for both the authorization code and the password grant types. This includes adding support for authorization code grants to our stub OAuth server implementation; it previously supported only the password grant type. --- command/login.go | 9 +- command/login_test.go | 135 ++++++++++++++++++ .../login-oauth-server/oauthserver.go | 93 +++++++++++- 3 files changed, 234 insertions(+), 3 deletions(-) create mode 100644 command/login_test.go diff --git a/command/login.go b/command/login.go index de7d0afbf..79a4310d3 100644 --- a/command/login.go +++ b/command/login.go @@ -92,7 +92,7 @@ func (c *LoginCommand) Run(args []string) int { if err != nil { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, - "Service discovery failed for"+dispHostname, + "Service discovery failed for "+dispHostname, // Contrary to usual Go idiom, the Discover function returns // full sentences with initial capitalization in its error messages, @@ -319,6 +319,7 @@ func (c *LoginCommand) interactiveGetTokenByCode(hostname svchost.Hostname, cred 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) @@ -338,11 +339,15 @@ func (c *LoginCommand) interactiveGetTokenByCode(hostname svchost.Hostname, cred 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)) @@ -563,7 +568,7 @@ func (c *LoginCommand) proofKey() (key, challenge string, err error) { h := sha256.New() h.Write([]byte(key)) - challenge = base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + challenge = base64.URLEncoding.EncodeToString(h.Sum(nil)) return key, challenge, nil } diff --git a/command/login_test.go b/command/login_test.go new file mode 100644 index 000000000..33d68cb5a --- /dev/null +++ b/command/login_test.go @@ -0,0 +1,135 @@ +package command + +import ( + "bytes" + "context" + "io/ioutil" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mitchellh/cli" + + "github.com/hashicorp/terraform/command/cliconfig" + oauthserver "github.com/hashicorp/terraform/command/testdata/login-oauth-server" + "github.com/hashicorp/terraform/command/webbrowser" + "github.com/hashicorp/terraform/svchost" + "github.com/hashicorp/terraform/svchost/disco" +) + +func TestLogin(t *testing.T) { + // oauthserver.Handler is a stub OAuth server implementation that will, + // on success, always issue a bearer token named "good-token". + s := httptest.NewServer(oauthserver.Handler) + defer s.Close() + + loginTestCase := func(test func(t *testing.T, c *LoginCommand, ui *cli.MockUi, inp func(string))) func(t *testing.T) { + return func(t *testing.T) { + t.Helper() + workDir, err := ioutil.TempDir("", "terraform-test-command-login") + if err != nil { + t.Fatalf("cannot create temporary directory: %s", err) + } + defer os.RemoveAll(workDir) + + // We'll use this context to avoid asynchronous tasks outliving + // a single test run. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + ui := cli.NewMockUi() + browserLauncher := webbrowser.NewMockLauncher(ctx) + creds := cliconfig.EmptyCredentialsSourceForTests(filepath.Join(workDir, "credentials.tfrc.json")) + svcs := disco.NewWithCredentialsSource(creds) + + inputBuf := &bytes.Buffer{} + ui.InputReader = inputBuf + + svcs.ForceHostServices(svchost.Hostname("app.terraform.io"), map[string]interface{}{ + "login.v1": map[string]interface{}{ + // On app.terraform.io we use password-based authorization. + // That's the only hostname that it's permitted for, so we can't + // use a fake hostname here. + "client": "terraformcli", + "token": s.URL + "/token", + "grant_types": []interface{}{"password"}, + }, + }) + svcs.ForceHostServices(svchost.Hostname("example.com"), map[string]interface{}{ + "login.v1": map[string]interface{}{ + // For this fake hostname we'll use a conventional OAuth flow, + // with browser-based consent that we'll mock away using a + // mock browser launcher below. + "client": "anything-goes", + "authz": s.URL + "/authz", + "token": s.URL + "/token", + }, + }) + svcs.ForceHostServices(svchost.Hostname("unsupported.example.net"), map[string]interface{}{ + // This host intentionally left blank. + }) + + c := &LoginCommand{ + Meta: Meta{ + Ui: ui, + BrowserLauncher: browserLauncher, + Services: svcs, + }, + } + + test(t, c, ui, func(data string) { + t.Helper() + inputBuf.WriteString(data) + }) + } + } + + t.Run("defaulting to app.terraform.io with password flow", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi, inp func(string)) { + // Enter "yes" at the consent prompt, then a username and then a password. + inp("yes\nfoo\nbar\n") + status := c.Run(nil) + if status != 0 { + t.Fatalf("unexpected error code %d\nstderr:\n%s", status, ui.ErrorWriter.String()) + } + + credsSrc := c.Services.CredentialsSource() + creds, err := credsSrc.ForHost(svchost.Hostname("app.terraform.io")) + if err != nil { + t.Errorf("failed to retrieve credentials: %s", err) + } + if got, want := creds.Token(), "good-token"; got != want { + t.Errorf("wrong token %q; want %q", got, want) + } + })) + + t.Run("example.com with authorization code flow", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi, inp func(string)) { + // Enter "yes" at the consent prompt. + inp("yes\n") + status := c.Run([]string{"example.com"}) + if status != 0 { + t.Fatalf("unexpected error code %d\nstderr:\n%s", status, ui.ErrorWriter.String()) + } + + credsSrc := c.Services.CredentialsSource() + creds, err := credsSrc.ForHost(svchost.Hostname("example.com")) + if err != nil { + t.Errorf("failed to retrieve credentials: %s", err) + } + if got, want := creds.Token(), "good-token"; got != want { + t.Errorf("wrong token %q; want %q", got, want) + } + })) + + t.Run("host without login support", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi, inp func(string)) { + status := c.Run([]string{"unsupported.example.net"}) + if status == 0 { + t.Fatalf("successful exit; want error") + } + + if got, want := ui.ErrorWriter.String(), "Error: Host does not support Terraform login"; !strings.Contains(got, want) { + t.Fatalf("missing expected error message\nwant: %s\nfull output:\n%s", want, got) + } + })) +} diff --git a/command/testdata/login-oauth-server/oauthserver.go b/command/testdata/login-oauth-server/oauthserver.go index 15fe32939..cde3477b6 100644 --- a/command/testdata/login-oauth-server/oauthserver.go +++ b/command/testdata/login-oauth-server/oauthserver.go @@ -3,8 +3,14 @@ package oauthserver import ( + "crypto/sha256" + "encoding/base64" + "fmt" + "html" "log" "net/http" + "net/url" + "strings" ) // Handler is an implementation of net/http.Handler that provides a stub @@ -36,7 +42,45 @@ func (h handler) ServeHTTP(resp http.ResponseWriter, req *http.Request) { } func (h handler) serveAuthz(resp http.ResponseWriter, req *http.Request) { - resp.WriteHeader(404) + args := req.URL.Query() + if rt := args.Get("response_type"); rt != "code" { + resp.WriteHeader(400) + resp.Write([]byte("wrong response_type")) + log.Printf("/authz: incorrect response type %q", rt) + return + } + redirectURL, err := url.Parse(args.Get("redirect_uri")) + if err != nil { + resp.WriteHeader(400) + resp.Write([]byte(fmt.Sprintf("invalid redirect_uri %s: %s", args.Get("redirect_uri"), err))) + return + } + + state := args.Get("state") + challenge := args.Get("code_challenge") + challengeMethod := args.Get("code_challenge_method") + if challengeMethod == "" { + challengeMethod = "plain" + } + + // NOTE: This is not a suitable implementation for a real OAuth server + // because the code challenge is providing no security whatsoever. This + // is just a simple implementation for this stub server. + code := fmt.Sprintf("%s:%s", challengeMethod, challenge) + + redirectQuery := redirectURL.Query() + redirectQuery.Set("code", code) + if state != "" { + redirectQuery.Set("state", state) + } + redirectURL.RawQuery = redirectQuery.Encode() + + respBody := fmt.Sprintf(`Log In and Consent`, html.EscapeString(redirectURL.String())) + resp.Header().Set("Content-Type", "text/html") + resp.Header().Set("Content-Length", fmt.Sprintf("%d", len(respBody))) + resp.Header().Set("X-Redirect-To", redirectURL.String()) // For robotic clients, using webbrowser.MockLauncher + resp.WriteHeader(200) + resp.Write([]byte(respBody)) } func (h handler) serveToken(resp http.ResponseWriter, req *http.Request) { @@ -55,6 +99,53 @@ func (h handler) serveToken(resp http.ResponseWriter, req *http.Request) { grantType := req.Form.Get("grant_type") log.Printf("/token: grant_type is %q", grantType) switch grantType { + + case "authorization_code": + code := req.Form.Get("code") + codeParts := strings.SplitN(code, ":", 2) + if len(codeParts) != 2 { + log.Printf("/token: invalid code %q", code) + resp.Header().Set("Content-Type", "application/json") + resp.WriteHeader(400) + resp.Write([]byte(`{"error":"invalid_grant"}`)) + return + } + + codeVerifier := req.Form.Get("code_verifier") + + switch codeParts[0] { + case "plain": + if codeParts[1] != codeVerifier { + log.Printf("/token: incorrect code verifier %q; want %q", codeParts[1], codeVerifier) + resp.Header().Set("Content-Type", "application/json") + resp.WriteHeader(400) + resp.Write([]byte(`{"error":"invalid_grant"}`)) + return + } + case "S256": + h := sha256.New() + h.Write([]byte(codeVerifier)) + encVerifier := base64.URLEncoding.EncodeToString(h.Sum(nil)) + if codeParts[1] != encVerifier { + log.Printf("/token: incorrect code verifier %q; want %q", codeParts[1], encVerifier) + resp.Header().Set("Content-Type", "application/json") + resp.WriteHeader(400) + resp.Write([]byte(`{"error":"invalid_grant"}`)) + return + } + default: + log.Printf("/token: unsupported challenge method %q", codeParts[0]) + resp.Header().Set("Content-Type", "application/json") + resp.WriteHeader(400) + resp.Write([]byte(`{"error":"invalid_grant"}`)) + return + } + + resp.Header().Set("Content-Type", "application/json") + resp.WriteHeader(200) + resp.Write([]byte(`{"access_token":"good-token","token_type":"bearer"}`)) + log.Println("/token: successful request") + case "password": username := req.Form.Get("username") password := req.Form.Get("password") From 0ca6b578f5aee31343da7d347bf870a525e3a20c Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Fri, 30 Aug 2019 15:12:07 -0700 Subject: [PATCH 10/12] command/login: Remove unimplemented -into-file option This was a vestige from earlier prototyping when we were considering supporting adding credentials to existing .tfrc native syntax files. However, that proved impractical because the CLI config format is still HCL 1.0 and that can't reliably perform programmatic surgical updates, so we'll remove this option for now. We might add it back in later if it becomes more practical to support it. --- command/login.go | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/command/login.go b/command/login.go index 79a4310d3..527d43643 100644 --- a/command/login.go +++ b/command/login.go @@ -39,8 +39,6 @@ func (c *LoginCommand) Run(args []string) int { } cmdFlags := c.Meta.extendedFlagSet("login") - var intoFile string - cmdFlags.StringVar(&intoFile, "into-file", "", "set the file that the credentials will be appended to") cmdFlags.Usage = func() { c.Ui.Error(c.Help()) } if err := cmdFlags.Parse(args); err != nil { return 1 @@ -229,11 +227,11 @@ func (c *LoginCommand) Help() string { // 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" + defaultFile = "~/.terraform/credentials.tfrc.json" } helpText := fmt.Sprintf(` -Usage: terraform login [options] [hostname] +Usage: terraform 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. @@ -241,14 +239,9 @@ Usage: terraform login [options] [hostname] If no hostname is provided, the default hostname is app.terraform.io, to log in to Terraform Cloud. - If not overridden by the -into-file option, the output file is: + If not overridden by credentials helper settings in the CLI configuration, + the credentials will be written to the following local file: %s - -Options: - - -into-file=.... Override which file the credentials block will be written - to. If this file already exists then it must have valid - HCL syntax and Terraform will update it in-place. `, defaultFile) return strings.TrimSpace(helpText) } @@ -262,7 +255,7 @@ func (c *LoginCommand) defaultOutputFile() string { if c.CLIConfigDir == "" { return "" // no default available } - return filepath.Join(c.CLIConfigDir, "credentials.tfrc") + return filepath.Join(c.CLIConfigDir, "credentials.tfrc.json") } func (c *LoginCommand) interactiveGetTokenByCode(hostname svchost.Hostname, credsCtx *loginCredentialsContext, clientConfig *disco.OAuthClient) (*oauth2.Token, tfdiags.Diagnostics) { From 131656a2372d858a061ca30fef78bdf419fe3707 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Fri, 6 Sep 2019 14:32:23 -0700 Subject: [PATCH 11/12] main: Temporarily disable "terraform login" as a command We're not ready to ship this in a release yet because there's still some remaining work to do on the Terraform Cloud side, but we want to get the implementation work behind this into the master branch so it's easier to maintain it in the mean time, rather than letting this long-lived branch live even longer. We'll continue to iterate on UX polish and other details in subsequent commits, and eventually enable this. --- commands.go | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/commands.go b/commands.go index 9ea02c49c..7acc05c4f 100644 --- a/commands.go +++ b/commands.go @@ -180,11 +180,15 @@ func initCommands(config *Config, services *disco.Disco) { }, nil }, - "login": func() (cli.Command, error) { - return &command.LoginCommand{ - Meta: meta, - }, nil - }, + // "terraform login" is disabled until Terraform Cloud is ready to + // support it. + /* + "login": func() (cli.Command, error) { + return &command.LoginCommand{ + Meta: meta, + }, nil + }, + */ "output": func() (cli.Command, error) { return &command.OutputCommand{ From 67d6f58f31025552cccccbca79c80532d8da8bc5 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Mon, 9 Sep 2019 11:12:57 -0700 Subject: [PATCH 12/12] main: use cliconfig.ConfigDir instead of just ConfigDir main.ConfigDir is just a wrapper around cliconfig.ConfigDir to allow us to gradually clean up the old calls here, but since this is new code we might as well do it right from the start. --- commands.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/commands.go b/commands.go index 7acc05c4f..fffbb3639 100644 --- a/commands.go +++ b/commands.go @@ -4,13 +4,15 @@ import ( "os" "os/signal" + "github.com/mitchellh/cli" + "github.com/hashicorp/terraform/command" + "github.com/hashicorp/terraform/command/cliconfig" "github.com/hashicorp/terraform/command/webbrowser" pluginDiscovery "github.com/hashicorp/terraform/plugin/discovery" "github.com/hashicorp/terraform/svchost" "github.com/hashicorp/terraform/svchost/auth" "github.com/hashicorp/terraform/svchost/disco" - "github.com/mitchellh/cli" ) // runningInAutomationEnvName gives the name of an environment variable that @@ -51,7 +53,7 @@ func initCommands(config *Config, services *disco.Disco) { services.ForceHostServices(host, hostConfig.Services) } - configDir, err := ConfigDir() + configDir, err := cliconfig.ConfigDir() if err != nil { configDir = "" // No config dir available (e.g. looking up a home directory failed) }