From 8381112a5c7cce747028f5c648116cf26f52db7e Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Thu, 29 Aug 2019 18:05:28 -0700 Subject: [PATCH] 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")