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.
This commit is contained in:
parent
7ccd6204c4
commit
8381112a5c
|
@ -92,7 +92,7 @@ func (c *LoginCommand) Run(args []string) int {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
diags = diags.Append(tfdiags.Sourceless(
|
diags = diags.Append(tfdiags.Sourceless(
|
||||||
tfdiags.Error,
|
tfdiags.Error,
|
||||||
"Service discovery failed for"+dispHostname,
|
"Service discovery failed for "+dispHostname,
|
||||||
|
|
||||||
// Contrary to usual Go idiom, the Discover function returns
|
// Contrary to usual Go idiom, the Discover function returns
|
||||||
// full sentences with initial capitalization in its error messages,
|
// 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)
|
codeCh := make(chan string)
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Handler: http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
|
Handler: http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
|
||||||
|
log.Printf("[TRACE] login: request to callback server")
|
||||||
err := req.ParseForm()
|
err := req.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[ERROR] login: cannot ParseForm on callback request: %s", err)
|
log.Printf("[ERROR] login: cannot ParseForm on callback request: %s", err)
|
||||||
|
@ -338,11 +339,15 @@ func (c *LoginCommand) interactiveGetTokenByCode(hostname svchost.Hostname, cred
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("[TRACE] login: request contains an authorization code")
|
||||||
|
|
||||||
// Send the code to our blocking wait below, so that the token
|
// Send the code to our blocking wait below, so that the token
|
||||||
// fetching process can continue.
|
// fetching process can continue.
|
||||||
codeCh <- gotCode
|
codeCh <- gotCode
|
||||||
close(codeCh)
|
close(codeCh)
|
||||||
|
|
||||||
|
log.Printf("[TRACE] login: returning response from callback server")
|
||||||
|
|
||||||
resp.Header().Add("Content-Type", "text/html")
|
resp.Header().Add("Content-Type", "text/html")
|
||||||
resp.WriteHeader(200)
|
resp.WriteHeader(200)
|
||||||
resp.Write([]byte(callbackSuccessMessage))
|
resp.Write([]byte(callbackSuccessMessage))
|
||||||
|
@ -563,7 +568,7 @@ func (c *LoginCommand) proofKey() (key, challenge string, err error) {
|
||||||
|
|
||||||
h := sha256.New()
|
h := sha256.New()
|
||||||
h.Write([]byte(key))
|
h.Write([]byte(key))
|
||||||
challenge = base64.RawURLEncoding.EncodeToString(h.Sum(nil))
|
challenge = base64.URLEncoding.EncodeToString(h.Sum(nil))
|
||||||
|
|
||||||
return key, challenge, nil
|
return key, challenge, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
|
@ -3,8 +3,14 @@
|
||||||
package oauthserver
|
package oauthserver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler is an implementation of net/http.Handler that provides a stub
|
// 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) {
|
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(`<a href="%s">Log In and Consent</a>`, 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) {
|
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")
|
grantType := req.Form.Get("grant_type")
|
||||||
log.Printf("/token: grant_type is %q", grantType)
|
log.Printf("/token: grant_type is %q", grantType)
|
||||||
switch 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":
|
case "password":
|
||||||
username := req.Form.Get("username")
|
username := req.Form.Get("username")
|
||||||
password := req.Form.Get("password")
|
password := req.Form.Get("password")
|
||||||
|
|
Loading…
Reference in New Issue