diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 553a8745..80f3846f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -99,11 +99,12 @@ docker build . - [`tilt`](https://docs.tilt.dev/install.html) - [`ytt`](https://carvel.dev/#getting-started) - [`kubectl`](https://kubernetes.io/docs/tasks/tools/install-kubectl/) + - [`chromedriver`](https://chromedriver.chromium.org/) (and [Chrome](https://www.google.com/chrome/)) - On macOS, these tools can be installed with [Homebrew](https://brew.sh/): + On macOS, these tools can be installed with [Homebrew](https://brew.sh/) (assuming you have Chrome installed already): ```bash - brew install kind tilt-dev/tap/tilt k14s/tap/ytt kubectl + brew install kind tilt-dev/tap/tilt k14s/tap/ytt kubectl chromedriver ``` 1. Create a local Kubernetes cluster using `kind`: diff --git a/hack/lib/kind-config/multi-node.yaml b/hack/lib/kind-config/multi-node.yaml index b9bdaf79..2bfa337b 100644 --- a/hack/lib/kind-config/multi-node.yaml +++ b/hack/lib/kind-config/multi-node.yaml @@ -4,4 +4,12 @@ nodes: - role: control-plane - role: worker - role: worker - extraPortMappings: [{containerPort: 31234, hostPort: 12345, protocol: TCP}] + extraPortMappings: + - protocol: TCP + containerPort: 31234 + hostPort: 12345 + listenAddress: 127.0.0.1 + - protocol: TCP + containerPort: 31235 + hostPort: 12346 + listenAddress: 127.0.0.1 diff --git a/hack/lib/kind-config/single-node.yaml b/hack/lib/kind-config/single-node.yaml index d62f3a59..5fb71715 100644 --- a/hack/lib/kind-config/single-node.yaml +++ b/hack/lib/kind-config/single-node.yaml @@ -2,4 +2,12 @@ kind: Cluster apiVersion: kind.x-k8s.io/v1alpha4 nodes: - role: control-plane - extraPortMappings: [{containerPort: 31234, hostPort: 12345, protocol: TCP}] + extraPortMappings: + - protocol: TCP + containerPort: 31234 + hostPort: 12345 + listenAddress: 127.0.0.1 + - protocol: TCP + containerPort: 31235 + hostPort: 12346 + listenAddress: 127.0.0.1 diff --git a/hack/lib/tilt/Tiltfile b/hack/lib/tilt/Tiltfile index 26213238..4366db38 100644 --- a/hack/lib/tilt/Tiltfile +++ b/hack/lib/tilt/Tiltfile @@ -18,6 +18,23 @@ local_resource( deps=['../../../cmd', '../../../internal', '../../../pkg', '../../../generated'], ) +##################################################################################################### +# Dex +# + +# Render the Dex installation manifest using ytt. +k8s_yaml(local(['ytt','--file', '../../../test/deploy/dex'])) + +# Collect all the deployed Dex resources under a "dex" resource tab. +k8s_resource( + workload='dex', # this is the deployment name + objects=[ + # these are the objects that would otherwise appear in the "uncategorized" tab in the tilt UI + 'dex:namespace', + 'dex-config:configmap', + ], +) + ##################################################################################################### # Local-user-authenticator app # diff --git a/hack/prepare-for-integration-tests.sh b/hack/prepare-for-integration-tests.sh index 2037bb41..f0eb92ca 100755 --- a/hack/prepare-for-integration-tests.sh +++ b/hack/prepare-for-integration-tests.sh @@ -119,7 +119,7 @@ if ! tilt_mode; then log_note "Checking for running kind clusters..." if ! kind get clusters | grep -q -e '^pinniped$'; then log_note "Creating a kind cluster..." - # single-node.yaml exposes node port 31234 as 127.0.0.1:12345 + # single-node.yaml exposes node port 31234 as 127.0.0.1:12345 and port 31235 as 127.0.0.1:12346 kind create cluster --config "$pinniped_path/hack/lib/kind-config/single-node.yaml" --name pinniped else if ! kubectl cluster-info | grep master | grep -q 127.0.0.1; then @@ -176,6 +176,17 @@ if ! tilt_mode; then popd >/dev/null + # + # Deploy dex + # + pushd test/deploy/dex >/dev/null + + log_note "Deploying Dex to the cluster..." + ytt --file . >"$manifest" + kubectl apply --dry-run=client -f "$manifest" # Validate manifest schema. + kapp deploy --yes --app dex --diff-changes --file "$manifest" + + popd >/dev/null fi test_username="test-username" @@ -261,6 +272,11 @@ export PINNIPED_TEST_WEBHOOK_CA_BUNDLE=${webhook_ca_bundle} export PINNIPED_TEST_SUPERVISOR_NAMESPACE=${supervisor_namespace} export PINNIPED_TEST_SUPERVISOR_APP_NAME=${supervisor_app_name} export PINNIPED_TEST_SUPERVISOR_ADDRESS="127.0.0.1:12345" +export PINNIPED_TEST_CLI_OIDC_ISSUER=http://127.0.0.1:12346/dex +export PINNIPED_TEST_CLI_OIDC_CLIENT_ID=pinniped-cli +export PINNIPED_TEST_CLI_OIDC_LOCALHOST_PORT=48095 +export PINNIPED_TEST_CLI_OIDC_USERNAME=pinny@example.com +export PINNIPED_TEST_CLI_OIDC_PASSWORD=password read -r -d '' PINNIPED_TEST_CLUSTER_CAPABILITY_YAML << PINNIPED_TEST_CLUSTER_CAPABILITY_YAML_EOF || true ${pinniped_cluster_capability_file_content} diff --git a/test/deploy/dex/dex.yaml b/test/deploy/dex/dex.yaml new file mode 100644 index 00000000..dcea584f --- /dev/null +++ b/test/deploy/dex/dex.yaml @@ -0,0 +1,102 @@ +#! Copyright 2020 the Pinniped contributors. All Rights Reserved. +#! SPDX-License-Identifier: Apache-2.0 + +#@ load("@ytt:data", "data") +#@ load("@ytt:sha256", "sha256") +#@ load("@ytt:yaml", "yaml") + +#@ def dexConfig(): +issuer: #@ "http://127.0.0.1:" + str(data.values.ports.local) + "/dex" +storage: + type: sqlite3 + config: + file: ":memory:" +web: + http: 0.0.0.0:5556 +oauth2: + skipApprovalScreen: true +staticClients: +- id: pinniped-cli + name: 'Pinniped CLI' + #! we can't have "public: true" until https://github.com/dexidp/dex/pull/1822 lands in Dex. + redirectURIs: + - #@ "http://127.0.0.1:" + str(data.values.ports.cli) + "/callback" + - #@ "http://[::1]:" + str(data.values.ports.cli) + "/callback" +enablePasswordDB: true +staticPasswords: +- username: "pinny" + email: "pinny@example.com" + hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" #! bcrypt("password") + userID: "061d23d1-fe1e-4777-9ae9-59cd12abeaaa" +#@ end + +--- +apiVersion: v1 +kind: Namespace +metadata: + name: dex + labels: + name: dex +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: dex-config + namespace: dex + labels: + app: dex +data: + config.yaml: #@ yaml.encode(dexConfig()) +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: dex + namespace: dex + labels: + app: dex +spec: + replicas: 1 + selector: + matchLabels: + app: dex + template: + metadata: + labels: + app: dex + annotations: + dexConfigHash: #@ sha256.sum(yaml.encode(dexConfig())) + spec: + containers: + - name: dex + image: quay.io/dexidp/dex:v2.10.0 + imagePullPolicy: IfNotPresent + command: + - /usr/local/bin/dex + - serve + - /etc/dex/cfg/config.yaml + ports: + - name: http + containerPort: 5556 + volumeMounts: + - name: config + mountPath: /etc/dex/cfg + volumes: + - name: config + configMap: + name: dex-config +--- +apiVersion: v1 +kind: Service +metadata: + name: dex + namespace: dex + labels: + app: dex +spec: + type: NodePort + selector: + app: dex + ports: + - port: 5556 + nodePort: #@ data.values.ports.node diff --git a/test/deploy/dex/values.yaml b/test/deploy/dex/values.yaml new file mode 100644 index 00000000..7d04cdb1 --- /dev/null +++ b/test/deploy/dex/values.yaml @@ -0,0 +1,17 @@ +#! Copyright 2020 the Pinniped contributors. All Rights Reserved. +#! SPDX-License-Identifier: Apache-2.0 + +#@data/values +--- +ports: + #! Port on which the Pinniped CLI is listening for a callback (`--listen-port` flag value) + #! Used in the Dex configuration to form the valid redirect URIs for our test client. + cli: 48095 + + #! Kubernetes NodePort that should be forwarded to the Dex service. + #! Used to create a Service of type: NodePort + node: 31235 + + #! External port where Dex ends up exposed on localhost during tests. This value comes from our + #! Kind configuration which maps 127.0.0.1:12346 to port 31235 on the Kind worker node. + local: 12346 diff --git a/test/integration/cli_test.go b/test/integration/cli_test.go index 8365d8c0..45ed4476 100644 --- a/test/integration/cli_test.go +++ b/test/integration/cli_test.go @@ -102,19 +102,58 @@ 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`) - ) +type loginProviderPatterns struct { + Name string + IssuerPattern *regexp.Regexp + LoginPagePattern *regexp.Regexp + UsernameSelector string + PasswordSelector string + LoginButtonSelector string +} - env := library.IntegrationEnv(t).WithCapability(library.ExternalOIDCProviderIsAvailable) +func getLoginProvider(t *testing.T) *loginProviderPatterns { + t.Helper() + issuer := library.IntegrationEnv(t).OIDCUpstream.Issuer + for _, p := range []loginProviderPatterns{ + { + Name: "Okta", + IssuerPattern: regexp.MustCompile(`\Ahttps://.+\.okta\.com/.+\z`), + LoginPagePattern: regexp.MustCompile(`\Ahttps://.+\.okta\.com/.+\z`), + UsernameSelector: "input#okta-signin-username", + PasswordSelector: "input#okta-signin-password", + LoginButtonSelector: "input#okta-signin-submit", + }, + { + Name: "Dex", + IssuerPattern: regexp.MustCompile(`\Ahttp://127\.0\.0\.1.+/dex.*\z`), + LoginPagePattern: regexp.MustCompile(`\Ahttp://127\.0\.0\.1.+/dex/auth/local.+\z`), + UsernameSelector: "input#login", + PasswordSelector: "input#password", + LoginButtonSelector: "button#submit-login", + }, + } { + if p.IssuerPattern.MatchString(issuer) { + return &p + } + } + require.Failf(t, "could not find login provider for issuer %q", issuer) + return nil +} + +func TestCLILoginOIDC(t *testing.T) { + env := library.IntegrationEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + // Find the login CSS selectors for the test issuer, or fail fast. + loginProvider := getLoginProvider(t) // Build pinniped CLI. t.Logf("building CLI binary") pinnipedExe := buildPinnipedCLI(t) - cmd := exec.Command(pinnipedExe, "alpha", "login", "oidc", + cmd := exec.CommandContext(ctx, pinnipedExe, "alpha", "login", "oidc", "--issuer", env.OIDCUpstream.Issuer, "--client-id", env.OIDCUpstream.ClientID, "--listen-port", strconv.Itoa(env.OIDCUpstream.LocalhostPort), @@ -123,7 +162,7 @@ func TestCLILoginOIDC(t *testing.T) { // Create a WaitGroup that will wait for all child goroutines to finish, so they can assert errors. var wg sync.WaitGroup - defer wg.Wait() + t.Cleanup(wg.Wait) // Start a background goroutine to read stderr from the CLI and parse out the login URL. loginURLChan := make(chan string) @@ -131,6 +170,7 @@ func TestCLILoginOIDC(t *testing.T) { require.NoError(t, err) wg.Add(1) go func() { + defer wg.Done() r := bufio.NewReader(stderr) line, err := r.ReadString('\n') require.NoError(t, err) @@ -138,9 +178,9 @@ func TestCLILoginOIDC(t *testing.T) { 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. @@ -149,6 +189,7 @@ func TestCLILoginOIDC(t *testing.T) { require.NoError(t, err) wg.Add(1) go func() { + defer wg.Done() r := bufio.NewReader(stdout) var out clientauthenticationv1beta1.ExecCredential @@ -158,18 +199,15 @@ func TestCLILoginOIDC(t *testing.T) { _, 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() { + t.Cleanup(func() { err := cmd.Wait() t.Logf("CLI subprocess exited") require.NoError(t, err) - wg.Done() - }() + }) // Start the browser driver. t.Logf("opening browser driver") @@ -193,26 +231,23 @@ func TestCLILoginOIDC(t *testing.T) { 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) + // Expect to be redirected to the login page. + t.Logf("waiting for redirect to %s login page", loginProvider.Name) + waitForURL(t, page, loginProvider.LoginPagePattern) // Wait for the login page to be rendered. - waitForVisibleElements(t, page, - "input#okta-signin-username", - "input#okta-signin-password", - "input#okta-signin-submit", - ) + waitForVisibleElements(t, page, loginProvider.UsernameSelector, loginProvider.PasswordSelector, loginProvider.LoginButtonSelector) // 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()) + t.Logf("logging into %s", loginProvider.Name) + require.NoError(t, page.First(loginProvider.UsernameSelector).Fill(env.OIDCUpstream.Username)) + require.NoError(t, page.First(loginProvider.PasswordSelector).Fill(env.OIDCUpstream.Password)) + require.NoError(t, page.First(loginProvider.LoginButtonSelector).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) + callbackURLPattern := regexp.MustCompile(`\Ahttp://127.0.0.1:` + strconv.Itoa(env.OIDCUpstream.LocalhostPort) + `/.+\z`) + waitForURL(t, page, callbackURLPattern) // Wait for the "pre" element that gets rendered for a `text/plain` page, and // assert that it contains the success message. @@ -221,7 +256,6 @@ func TestCLILoginOIDC(t *testing.T) { 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") @@ -267,7 +301,7 @@ func waitForVisibleElements(t *testing.T, page *agouti.Page, selectors ...string } return true }, - 30*time.Second, + 10*time.Second, 100*time.Millisecond, ) } @@ -276,5 +310,5 @@ 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) + }, 10*time.Second, 100*time.Millisecond) } diff --git a/test/library/env.go b/test/library/env.go index 7858ee41..eba2a0d1 100644 --- a/test/library/env.go +++ b/test/library/env.go @@ -19,8 +19,7 @@ import ( type Capability string const ( - ClusterSigningKeyIsAvailable Capability = "clusterSigningKeyIsAvailable" - ExternalOIDCProviderIsAvailable Capability = "externalOIDCProviderIsAvailable" + ClusterSigningKeyIsAvailable Capability = "clusterSigningKeyIsAvailable" ) // TestEnv captures all the external parameters consumed by our integration tests. @@ -90,16 +89,11 @@ func IntegrationEnv(t *testing.T) *TestEnv { 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.OIDCUpstream.Issuer = needEnv("PINNIPED_TEST_CLI_OIDC_ISSUER") + result.OIDCUpstream.ClientID = needEnv("PINNIPED_TEST_CLI_OIDC_CLIENT_ID") + result.OIDCUpstream.LocalhostPort, _ = strconv.Atoi(needEnv("PINNIPED_TEST_CLI_OIDC_LOCALHOST_PORT")) + result.OIDCUpstream.Username = needEnv("PINNIPED_TEST_CLI_OIDC_USERNAME") + result.OIDCUpstream.Password = needEnv("PINNIPED_TEST_CLI_OIDC_PASSWORD") result.t = t return &result }