diff --git a/go.mod b/go.mod index f2c36c58..0b476b08 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.14 require ( github.com/MakeNowJust/heredoc/v2 v2.0.1 + github.com/blang/semver v3.5.1+incompatible // indirect github.com/coreos/go-oidc v2.2.1+incompatible github.com/davecgh/go-spew v1.1.1 github.com/ghodss/yaml v1.0.0 @@ -13,6 +14,7 @@ require ( github.com/golangci/golangci-lint v1.31.0 github.com/google/go-cmp v0.5.2 github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 + github.com/sclevine/agouti v3.0.0+incompatible github.com/sclevine/spec v1.4.0 github.com/spf13/cobra v1.0.0 github.com/spf13/pflag v1.0.5 diff --git a/go.sum b/go.sum index dae855b9..a51ae7d4 100644 --- a/go.sum +++ b/go.sum @@ -71,6 +71,8 @@ github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kB github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= github.com/blang/semver v3.5.0+incompatible h1:CGxCgetQ64DKk7rdZ++Vfnb1+ogGNnB17OJKJXD2Cfs= github.com/blang/semver v3.5.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/bombsimon/wsl/v3 v3.1.0 h1:E5SRssoBgtVFPcYWUOFJEcgaySgdtTNYzsSKDOY7ss8= github.com/bombsimon/wsl/v3 v3.1.0/go.mod h1:st10JtZYLE4D5sC7b8xV4zTKZwAQjCH/Hy2Pm1FNZIc= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -498,6 +500,7 @@ github.com/ryancurrah/gomodguard v1.1.0/go.mod h1:4O8tr7hBODaGE6VIhfJDHcwzh5GUcc github.com/ryanrolds/sqlclosecheck v0.3.0 h1:AZx+Bixh8zdUBxUA1NxbxVAS78vTPq4rCb8OUZI9xFw= github.com/ryanrolds/sqlclosecheck v0.3.0/go.mod h1:1gREqxyTGR3lVtpngyFo3hZAgk0KCtEdgEkHwDbigdA= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/sclevine/agouti v3.0.0+incompatible h1:8IBJS6PWz3uTlMP3YBIR5f+KAldcGuOeFkFbUWfBgK4= github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw= github.com/sclevine/spec v1.4.0 h1:z/Q9idDcay5m5irkZ28M7PtQM4aOISzOpj4bUPkDee8= github.com/sclevine/spec v1.4.0/go.mod h1:LvpgJaFyvQzRvc1kaDs0bulYwzC70PbiYjC4QnFHkOM= diff --git a/test/integration/cli_test.go b/test/integration/cli_test.go index 9bb6502e..8365d8c0 100644 --- a/test/integration/cli_test.go +++ b/test/integration/cli_test.go @@ -3,15 +3,25 @@ package integration import ( + "bufio" "context" + "encoding/json" + "io" "io/ioutil" "os" "os/exec" "path/filepath" + "regexp" + "strconv" + "strings" + "sync" "testing" "time" + "github.com/sclevine/agouti" "github.com/stretchr/testify/require" + "gopkg.in/square/go-jose.v2" + clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" "go.pinniped.dev/test/library" ) @@ -26,8 +36,7 @@ func TestCLIGetKubeconfig(t *testing.T) { idp := library.CreateTestWebhookIDP(ctx, t) // Build pinniped CLI. - pinnipedExe, cleanupFunc := buildPinnipedCLI(t) - defer cleanupFunc() + pinnipedExe := buildPinnipedCLI(t) // Run pinniped CLI to get kubeconfig. kubeConfigYAML := runPinnipedCLIGetKubeconfig(t, pinnipedExe, env.TestUser.Token, env.ConciergeNamespace, "webhook", idp.Name) @@ -58,11 +67,12 @@ func TestCLIGetKubeconfig(t *testing.T) { } } -func buildPinnipedCLI(t *testing.T) (string, func()) { +func buildPinnipedCLI(t *testing.T) string { t.Helper() pinnipedExeDir, err := ioutil.TempDir("", "pinniped-cli-test-*") require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, os.RemoveAll(pinnipedExeDir)) }) pinnipedExe := filepath.Join(pinnipedExeDir, "pinniped") output, err := exec.Command( @@ -73,10 +83,7 @@ func buildPinnipedCLI(t *testing.T) (string, func()) { "go.pinniped.dev/cmd/pinniped", ).CombinedOutput() require.NoError(t, err, string(output)) - - return pinnipedExe, func() { - require.NoError(t, os.RemoveAll(pinnipedExeDir)) - } + return pinnipedExe } func runPinnipedCLIGetKubeconfig(t *testing.T, pinnipedExe, token, namespaceName, idpType, idpName string) string { @@ -94,3 +101,180 @@ func runPinnipedCLIGetKubeconfig(t *testing.T, pinnipedExe, token, namespaceName return string(output) } + +func TestCLILoginOIDC(t *testing.T) { + var ( + oktaURLPattern = regexp.MustCompile(`\Ahttps://.+.okta.com/.+\z`) + localURLPattern = regexp.MustCompile(`\Ahttp://127.0.0.1.+\z`) + ) + + env := library.IntegrationEnv(t).WithCapability(library.ExternalOIDCProviderIsAvailable) + + // Build pinniped CLI. + t.Logf("building CLI binary") + pinnipedExe := buildPinnipedCLI(t) + + cmd := exec.Command(pinnipedExe, "alpha", "login", "oidc", + "--issuer", env.OIDCUpstream.Issuer, + "--client-id", env.OIDCUpstream.ClientID, + "--listen-port", strconv.Itoa(env.OIDCUpstream.LocalhostPort), + "--skip-browser", + ) + + // Create a WaitGroup that will wait for all child goroutines to finish, so they can assert errors. + var wg sync.WaitGroup + defer wg.Wait() + + // Start a background goroutine to read stderr from the CLI and parse out the login URL. + loginURLChan := make(chan string) + stderr, err := cmd.StderrPipe() + require.NoError(t, err) + wg.Add(1) + go func() { + r := bufio.NewReader(stderr) + line, err := r.ReadString('\n') + require.NoError(t, err) + const prompt = "Please log in: " + require.Truef(t, strings.HasPrefix(line, prompt), "expected %q to have prefix %q", line, prompt) + loginURLChan <- strings.TrimPrefix(line, prompt) + _, err = io.Copy(ioutil.Discard, r) + t.Logf("stderr stream closed") + require.NoError(t, err) + wg.Done() + }() + + // Start a background goroutine to read stdout from the CLI and parse out an ExecCredential. + credOutputChan := make(chan clientauthenticationv1beta1.ExecCredential) + stdout, err := cmd.StdoutPipe() + require.NoError(t, err) + wg.Add(1) + go func() { + r := bufio.NewReader(stdout) + + var out clientauthenticationv1beta1.ExecCredential + require.NoError(t, json.NewDecoder(r).Decode(&out)) + credOutputChan <- out + + _, err = io.Copy(ioutil.Discard, r) + t.Logf("stdout stream closed") + require.NoError(t, err) + wg.Done() + }() + + t.Logf("starting CLI subprocess") + require.NoError(t, cmd.Start()) + wg.Add(1) + defer func() { + err := cmd.Wait() + t.Logf("CLI subprocess exited") + require.NoError(t, err) + wg.Done() + }() + + // Start the browser driver. + t.Logf("opening browser driver") + agoutiDriver := agouti.ChromeDriver( + // Comment out this line to see the tests happen in a visible browser window. + agouti.ChromeOptions("args", []string{"--headless"}), + ) + require.NoError(t, agoutiDriver.Start()) + t.Cleanup(func() { require.NoError(t, agoutiDriver.Stop()) }) + page, err := agoutiDriver.NewPage(agouti.Browser("chrome")) + require.NoError(t, err) + + // Wait for the CLI to print out the login URL and open the browser to it. + t.Logf("waiting for CLI to output login URL") + var loginURL string + select { + case <-time.After(1 * time.Minute): + require.Fail(t, "timed out waiting for login URL") + case loginURL = <-loginURLChan: + } + t.Logf("navigating to login page") + require.NoError(t, page.Navigate(loginURL)) + + // Expect to be redirected to the Okta login page. + t.Logf("waiting for redirect to Okta login page") + waitForURL(t, page, oktaURLPattern) + + // Wait for the login page to be rendered. + waitForVisibleElements(t, page, + "input#okta-signin-username", + "input#okta-signin-password", + "input#okta-signin-submit", + ) + + // Fill in the username and password and click "submit". + t.Logf("logging into Okta") + require.NoError(t, page.First("input#okta-signin-username").Fill(env.OIDCUpstream.Username)) + require.NoError(t, page.First("input#okta-signin-password").Fill(env.OIDCUpstream.Password)) + require.NoError(t, page.First("input#okta-signin-submit").Click()) + + // Wait for the login to happen and us be redirected back to a localhost callback. + t.Logf("waiting for redirect to localhost callback") + waitForURL(t, page, localURLPattern) + + // Wait for the "pre" element that gets rendered for a `text/plain` page, and + // assert that it contains the success message. + t.Logf("verifying success page") + waitForVisibleElements(t, page, "pre") + msg, err := page.First("pre").Text() + require.NoError(t, err) + require.Equal(t, "you have been logged in and may now close this tab", msg) + require.NoError(t, page.CloseWindow()) + + // Expect the CLI to output an ExecCredential in JSON format. + t.Logf("waiting for CLI to output ExecCredential JSON") + var credOutput clientauthenticationv1beta1.ExecCredential + select { + case <-time.After(10 * time.Second): + require.Fail(t, "timed out waiting for exec credential output") + case credOutput = <-credOutputChan: + } + + // Assert some properties of the ExecCredential. + t.Logf("validating ExecCredential") + require.NotNil(t, credOutput.Status) + require.Empty(t, credOutput.Status.ClientKeyData) + require.Empty(t, credOutput.Status.ClientCertificateData) + + // There should be at least 1 minute of remaining expiration (probably more). + require.NotNil(t, credOutput.Status.ExpirationTimestamp) + ttl := time.Until(credOutput.Status.ExpirationTimestamp.Time) + require.Greater(t, ttl.Milliseconds(), (1 * time.Minute).Milliseconds()) + + // Assert some properties about the token, which should be a valid JWT. + require.NotEmpty(t, credOutput.Status.Token) + jws, err := jose.ParseSigned(credOutput.Status.Token) + require.NoError(t, err) + claims := map[string]interface{}{} + require.NoError(t, json.Unmarshal(jws.UnsafePayloadWithoutVerification(), &claims)) + require.Equal(t, env.OIDCUpstream.Issuer, claims["iss"]) + require.Equal(t, env.OIDCUpstream.ClientID, claims["aud"]) + require.Equal(t, env.OIDCUpstream.Username, claims["email"]) + require.NotEmpty(t, claims["nonce"]) +} + +func waitForVisibleElements(t *testing.T, page *agouti.Page, selectors ...string) { + t.Helper() + require.Eventually(t, + func() bool { + for _, sel := range selectors { + vis, err := page.First(sel).Visible() + if !(err == nil && vis) { + return false + } + } + return true + }, + 30*time.Second, + 100*time.Millisecond, + ) +} + +func waitForURL(t *testing.T, page *agouti.Page, pat *regexp.Regexp) { + require.Eventually(t, func() bool { + url, err := page.URL() + return err == nil && pat.MatchString(url) + }, 30*time.Second, 100*time.Millisecond) +} diff --git a/test/library/env.go b/test/library/env.go index 82ab0b46..7858ee41 100644 --- a/test/library/env.go +++ b/test/library/env.go @@ -6,6 +6,7 @@ package library import ( "io/ioutil" "os" + "strconv" "strings" "testing" @@ -18,7 +19,8 @@ import ( type Capability string const ( - ClusterSigningKeyIsAvailable = Capability("clusterSigningKeyIsAvailable") + ClusterSigningKeyIsAvailable Capability = "clusterSigningKeyIsAvailable" + ExternalOIDCProviderIsAvailable Capability = "externalOIDCProviderIsAvailable" ) // TestEnv captures all the external parameters consumed by our integration tests. @@ -38,9 +40,17 @@ type TestEnv struct { ExpectedUsername string `json:"expectedUsername"` ExpectedGroups []string `json:"expectedGroups"` } `json:"testUser"` + + OIDCUpstream struct { + Issuer string `json:"issuer"` + ClientID string `json:"clientID"` + LocalhostPort int `json:"localhostPort"` + Username string `json:"username"` + Password string `json:"password"` + } `json:"oidcUpstream"` } -// IntegrationEnv gets the integration test environment from a Kubernetes Secret in the test cluster. This +// IntegrationEnv gets the integration test environment from OS environment variables. This // method also implies SkipUnlessIntegration(). func IntegrationEnv(t *testing.T) *TestEnv { t.Helper() @@ -79,6 +89,17 @@ func IntegrationEnv(t *testing.T) *TestEnv { result.SupervisorAppName = needEnv("PINNIPED_TEST_SUPERVISOR_APP_NAME") result.SupervisorAddress = needEnv("PINNIPED_TEST_SUPERVISOR_ADDRESS") result.TestWebhook.TLS = &idpv1alpha1.TLSSpec{CertificateAuthorityData: needEnv("PINNIPED_TEST_WEBHOOK_CA_BUNDLE")} + + result.OIDCUpstream.Issuer = os.Getenv("PINNIPED_TEST_CLI_OIDC_ISSUER") + result.OIDCUpstream.ClientID = os.Getenv("PINNIPED_TEST_CLI_OIDC_CLIENT_ID") + result.OIDCUpstream.LocalhostPort, _ = strconv.Atoi(os.Getenv("PINNIPED_TEST_CLI_OIDC_LOCALHOST_PORT")) + result.OIDCUpstream.Username = os.Getenv("PINNIPED_TEST_CLI_OIDC_USERNAME") + result.OIDCUpstream.Password = os.Getenv("PINNIPED_TEST_CLI_OIDC_PASSWORD") + + result.Capabilities[ExternalOIDCProviderIsAvailable] = !(result.OIDCUpstream.Issuer == "" || + result.OIDCUpstream.ClientID == "" || + result.OIDCUpstream.Username == "" || + result.OIDCUpstream.Password == "") result.t = t return &result }