diff --git a/command/login.go b/command/login.go index 828eee74a..b77dc342d 100644 --- a/command/login.go +++ b/command/login.go @@ -4,8 +4,10 @@ import ( "context" "crypto/sha256" "encoding/base64" + "encoding/json" "errors" "fmt" + "io/ioutil" "log" "math/rand" "net" @@ -131,9 +133,9 @@ func (c *LoginCommand) Run(args []string) int { } // If login service is unavailable, check for a TFE v2 API as fallback - var service *url.URL + var tfeservice *url.URL if clientConfig == nil { - service, err = host.ServiceURL("tfe.v2") + tfeservice, err = host.ServiceURL("tfe.v2") switch err.(type) { case nil: // Success! @@ -184,6 +186,8 @@ func (c *LoginCommand) Run(args []string) int { oauthToken, tokenDiags = c.interactiveGetTokenByCode(hostname, credsCtx, clientConfig) case clientConfig.SupportedGrantTypes.Has(disco.OAuthOwnerPasswordGrant) && hostname == svchost.Hostname("app.terraform.io"): // The password grant type is allowed only for Terraform Cloud SaaS. + // Note this case is purely theoretical at this point, as TFC currently uses + // its own bespoke login protocol (tfe) oauthToken, tokenDiags = c.interactiveGetTokenByPassword(hostname, credsCtx, clientConfig) default: tokenDiags = tokenDiags.Append(tfdiags.Sourceless( @@ -195,8 +199,8 @@ func (c *LoginCommand) Run(args []string) int { if oauthToken != nil { token = svcauth.HostCredentialsToken(oauthToken.AccessToken) } - } else if service != nil { - token, tokenDiags = c.interactiveGetTokenByUI(hostname, credsCtx, service) + } else if tfeservice != nil { + token, tokenDiags = c.interactiveGetTokenByUI(hostname, credsCtx, tfeservice) } diags = diags.Append(tokenDiags) @@ -220,19 +224,96 @@ func (c *LoginCommand) Run(args []string) int { } c.Ui.Output("\n---------------------------------------------------------------------------------\n") - c.Ui.Output( - fmt.Sprintf( - c.Colorize().Color(strings.TrimSpace(` + if hostname == "app.terraform.io" { // Terraform Cloud + var motd struct { + Message string `json:"msg"` + Errors []interface{} `json:"errors"` + } + + // Throughout the entire process of fetching a MOTD from TFC, use a default + // message if the platform-provided message is unavailable for any reason - + // be it the service isn't provided, the request failed, or any sort of + // platform error returned. + + motdServiceURL, err := host.ServiceURL("motd.v1") + if err != nil { + c.outputDefaultTFCLoginSuccess(err) + return 0 + } + + req, err := http.NewRequest("GET", motdServiceURL.String(), nil) + if err != nil { + c.outputDefaultTFCLoginSuccess(err) + return 0 + } + + req.Header.Set("Authorization", "Bearer "+token.Token()) + + resp, err := httpclient.New().Do(req) + if err != nil { + c.outputDefaultTFCLoginSuccess(err) + return 0 + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + c.outputDefaultTFCLoginSuccess(err) + return 0 + } + + defer resp.Body.Close() + json.Unmarshal(body, &motd) + + if motd.Errors == nil && motd.Message != "" { + c.Ui.Output( + c.Colorize().Color(motd.Message), + ) + return 0 + } else { + c.outputDefaultTFCLoginSuccess(fmt.Errorf("platform responded with errors or an empty message")) + return 0 + } + } + + if tfeservice != nil { // Terraform Enterprise + c.outputDefaultTFELoginSuccess(dispHostname) + } else { + c.Ui.Output( + fmt.Sprintf( + c.Colorize().Color(strings.TrimSpace(` [green][bold]Success![reset] [bold]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 +} + +func (c *LoginCommand) outputDefaultTFELoginSuccess(dispHostname string) { + c.Ui.Output( + fmt.Sprintf( + c.Colorize().Color(strings.TrimSpace(` +[green][bold]Success![reset] [bold]Logged in to Terraform Enterprise (%s)[reset] `)), dispHostname, ) + "\n", ) +} - return 0 +func (c *LoginCommand) outputDefaultTFCLoginSuccess(err error) { + log.Printf("[TRACE] login: An error occurred attempting to fetch a message of the day for Terraform Cloud: %s", err) + c.Ui.Output( + fmt.Sprintf( + c.Colorize().Color(strings.TrimSpace(` +[green][bold]Success![reset] [bold]Logged in to Terraform Cloud[reset] +`)), + ) + "\n", + ) } // Help implements cli.Command. diff --git a/command/login_test.go b/command/login_test.go index d0450099a..9b0e8de96 100644 --- a/command/login_test.go +++ b/command/login_test.go @@ -56,16 +56,6 @@ func TestLogin(t *testing.T) { svcs := disco.NewWithCredentialsSource(creds) svcs.SetUserAgent(httpclient.TerraformUserAgent(version.String())) - 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, @@ -86,9 +76,17 @@ func TestLogin(t *testing.T) { "scopes": []interface{}{"app1.full_access", "app2.read_only"}, }, }) + svcs.ForceHostServices(svchost.Hostname("app.terraform.io"), map[string]interface{}{ + // This represents Terraform Cloud, which does not yet support the + // login API, but does support its own bespoke tokens API. + "tfe.v2": ts.URL + "/api/v2", + "tfe.v2.1": ts.URL + "/api/v2", + "tfe.v2.2": ts.URL + "/api/v2", + "motd.v1": ts.URL + "/api/terraform/motd", + }) svcs.ForceHostServices(svchost.Hostname("tfe.acme.com"), map[string]interface{}{ // This represents a Terraform Enterprise instance which does not - // yet support the login API, but does support the TFE tokens API. + // yet support the login API, but does support its own bespoke tokens API. "tfe.v2": ts.URL + "/api/v2", "tfe.v2.1": ts.URL + "/api/v2", "tfe.v2.2": ts.URL + "/api/v2", @@ -109,13 +107,14 @@ func TestLogin(t *testing.T) { } } - t.Run("defaulting to app.terraform.io with password flow", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi) { + t.Run("app.terraform.io (no login support)", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi) { + // Enter "yes" at the consent prompt, then paste a token with some + // accidental whitespace. defer testInputMap(t, map[string]string{ - "approve": "yes", - "username": "foo", - "password": "bar", + "approve": "yes", + "token": " good-token ", })() - status := c.Run(nil) + status := c.Run([]string{"app.terraform.io"}) if status != 0 { t.Fatalf("unexpected error code %d\nstderr:\n%s", status, ui.ErrorWriter.String()) } @@ -128,6 +127,9 @@ func TestLogin(t *testing.T) { if got, want := creds.Token(), "good-token"; got != want { t.Errorf("wrong token %q; want %q", got, want) } + if got, want := ui.OutputWriter.String(), "Welcome to Terraform Cloud!"; !strings.Contains(got, want) { + t.Errorf("expected output to contain %q, but was:\n%s", want, got) + } })) t.Run("example.com with authorization code flow", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi) { @@ -148,6 +150,10 @@ func TestLogin(t *testing.T) { if got, want := creds.Token(), "good-token"; got != want { t.Errorf("wrong token %q; want %q", got, want) } + + if got, want := ui.OutputWriter.String(), "Terraform has obtained and saved an API token."; !strings.Contains(got, want) { + t.Errorf("expected output to contain %q, but was:\n%s", want, got) + } })) t.Run("example.com results in no scopes", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi) { @@ -179,6 +185,10 @@ func TestLogin(t *testing.T) { if got, want := creds.Token(), "good-token"; got != want { t.Errorf("wrong token %q; want %q", got, want) } + + if got, want := ui.OutputWriter.String(), "Terraform has obtained and saved an API token."; !strings.Contains(got, want) { + t.Errorf("expected output to contain %q, but was:\n%s", want, got) + } })) t.Run("with-scopes.example.com results in expected scopes", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi) { @@ -216,6 +226,10 @@ func TestLogin(t *testing.T) { if got, want := creds.Token(), "good-token"; got != want { t.Errorf("wrong token %q; want %q", got, want) } + + if got, want := ui.OutputWriter.String(), "Logged in to Terraform Enterprise"; !strings.Contains(got, want) { + t.Errorf("expected output to contain %q, but was:\n%s", want, got) + } })) t.Run("TFE host without login support, incorrectly pasted token", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi) { diff --git a/command/testdata/login-tfe-server/tfeserver.go b/command/testdata/login-tfe-server/tfeserver.go index 11d164abc..111c0712c 100644 --- a/command/testdata/login-tfe-server/tfeserver.go +++ b/command/testdata/login-tfe-server/tfeserver.go @@ -11,6 +11,7 @@ import ( const ( goodToken = "good-token" accountDetails = `{"data":{"id":"user-abc123","type":"users","attributes":{"username":"testuser","email":"testuser@example.com"}}}` + MOTD = `{"msg":"Welcome to Terraform Cloud!"}` ) // Handler is an implementation of net/http.Handler that provides a stub @@ -29,6 +30,8 @@ func (h handler) ServeHTTP(resp http.ResponseWriter, req *http.Request) { h.servePing(resp, req) case "/api/v2/account/details": h.serveAccountDetails(resp, req) + case "/api/terraform/motd": + h.serveMOTD(resp, req) default: fmt.Printf("404 when fetching %s\n", req.URL.String()) http.Error(resp, `{"errors":[{"status":"404","title":"not found"}]}`, http.StatusNotFound) @@ -49,6 +52,11 @@ func (h handler) serveAccountDetails(resp http.ResponseWriter, req *http.Request resp.Write([]byte(accountDetails)) } +func (h handler) serveMOTD(resp http.ResponseWriter, req *http.Request) { + resp.WriteHeader(http.StatusOK) + resp.Write([]byte(MOTD)) +} + func init() { Handler = handler{} }