diff --git a/test/integration/e2e_test.go b/test/integration/e2e_test.go index 768210a7..5102d59d 100644 --- a/test/integration/e2e_test.go +++ b/test/integration/e2e_test.go @@ -109,7 +109,7 @@ func TestE2EFullIntegration(t *testing.T) { }) // Add an OIDC upstream IDP and try using it to authenticate during kubectl commands. - t.Run("with Supervisor OIDC upstream IDP", func(t *testing.T) { + t.Run("with Supervisor OIDC upstream IDP and automatic flow", func(t *testing.T) { expectedUsername := env.SupervisorUpstreamOIDC.Username expectedGroups := env.SupervisorUpstreamOIDC.ExpectedGroups @@ -270,6 +270,113 @@ func TestE2EFullIntegration(t *testing.T) { ) }) + t.Run("with Supervisor OIDC upstream IDP and manual flow", func(t *testing.T) { + expectedUsername := env.SupervisorUpstreamOIDC.Username + expectedGroups := env.SupervisorUpstreamOIDC.ExpectedGroups + + // Create a ClusterRoleBinding to give our test user from the upstream read-only access to the cluster. + testlib.CreateTestClusterRoleBinding(t, + rbacv1.Subject{Kind: rbacv1.UserKind, APIGroup: rbacv1.GroupName, Name: expectedUsername}, + rbacv1.RoleRef{Kind: "ClusterRole", APIGroup: rbacv1.GroupName, Name: "view"}, + ) + testlib.WaitForUserToHaveAccess(t, expectedUsername, []string{}, &authorizationv1.ResourceAttributes{ + Verb: "get", + Group: "", + Version: "v1", + Resource: "namespaces", + }) + + // Create upstream OIDC provider and wait for it to become ready. + testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{ + Issuer: env.SupervisorUpstreamOIDC.Issuer, + TLS: &idpv1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)), + }, + AuthorizationConfig: idpv1alpha1.OIDCAuthorizationConfig{ + AdditionalScopes: env.SupervisorUpstreamOIDC.AdditionalScopes, + }, + Claims: idpv1alpha1.OIDCClaims{ + Username: env.SupervisorUpstreamOIDC.UsernameClaim, + Groups: env.SupervisorUpstreamOIDC.GroupsClaim, + }, + Client: idpv1alpha1.OIDCClient{ + SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, + }, + }, idpv1alpha1.PhaseReady) + + // Use a specific session cache for this test. + sessionCachePath := tempDir + "/oidc-test-sessions-manual.yaml" + kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ + "get", "kubeconfig", + "--concierge-api-group-suffix", env.APIGroupSuffix, + "--concierge-authenticator-type", "jwt", + "--concierge-authenticator-name", authenticator.Name, + "--oidc-skip-browser", + "--oidc-skip-listen", + "--oidc-ca-bundle", testCABundlePath, + "--oidc-session-cache", sessionCachePath, + }) + + // Run "kubectl get namespaces" which should trigger a browser login via the plugin. + start := time.Now() + kubectlCmd := exec.CommandContext(ctx, "kubectl", "get", "namespace", "--kubeconfig", kubeconfigPath) + kubectlCmd.Env = append(os.Environ(), env.ProxyEnv()...) + + ptyFile, err := pty.Start(kubectlCmd) + require.NoError(t, err) + + // Wait for the subprocess to print the login prompt. + t.Logf("waiting for CLI to output login URL and manual prompt") + output := readFromFileUntilStringIsSeen(t, ptyFile, "If automatic login fails, paste your authorization code to login manually: ") + require.Contains(t, output, "Log in by visiting this link:") + require.Contains(t, output, "If automatic login fails, paste your authorization code to login manually: ") + + // Find the line with the login URL. + var loginURL string + for _, line := range strings.Split(output, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "https://") { + loginURL = trimmed + } + } + require.NotEmptyf(t, loginURL, "didn't find login URL in output: %s", output) + + t.Logf("navigating to login page") + require.NoError(t, page.Navigate(loginURL)) + + // Expect to be redirected to the upstream provider and log in. + browsertest.LoginToUpstream(t, page, env.SupervisorUpstreamOIDC) + + // Expect to be redirected to the downstream callback which is serving the form_post HTML. + t.Logf("waiting for response page %s", downstream.Spec.Issuer) + browsertest.WaitForURL(t, page, regexp.MustCompile(regexp.QuoteMeta(downstream.Spec.Issuer))) + + // The response page should have failed to automatically post, and should now be showing the manual instructions. + authCode := formpostExpectManualState(t, page) + + // Enter the auth code in the waiting prompt, followed by a newline. + t.Logf("'manually' pasting authorization code %q to waiting prompt", authCode) + _, err = ptyFile.WriteString(authCode + "\n") + require.NoError(t, err) + + // Read all of the remaining output from the subprocess until EOF. + t.Logf("waiting for kubectl to output namespace list") + remainingOutput, _ := ioutil.ReadAll(ptyFile) + // Ignore any errors returned because there is always an error on linux. + require.Greaterf(t, len(remainingOutput), 0, "expected to get some more output from the kubectl subcommand, but did not") + require.Greaterf(t, len(strings.Split(string(remainingOutput), "\n")), 2, "expected some namespaces to be returned, got %q", string(remainingOutput)) + t.Logf("first kubectl command took %s", time.Since(start).String()) + + requireUserCanUseKubectlWithoutAuthenticatingAgain(ctx, t, env, + downstream, + kubeconfigPath, + sessionCachePath, + pinnipedExe, + expectedUsername, + expectedGroups, + ) + }) + // Add an LDAP upstream IDP and try using it to authenticate during kubectl commands. t.Run("with Supervisor LDAP upstream IDP", func(t *testing.T) { if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) { @@ -371,7 +478,7 @@ func TestE2EFullIntegration(t *testing.T) { }) } -func readFromFileUntilStringIsSeen(t *testing.T, f *os.File, until string) { +func readFromFileUntilStringIsSeen(t *testing.T, f *os.File, until string) string { readFromFile := "" testlib.RequireEventuallyWithoutError(t, func() (bool, error) { @@ -385,6 +492,7 @@ func readFromFileUntilStringIsSeen(t *testing.T, f *os.File, until string) { } return false, nil // keep waiting and reading }, 1*time.Minute, 1*time.Second) + return readFromFile } func readAvailableOutput(t *testing.T, r io.Reader) (string, bool) { diff --git a/test/integration/formposthtml_test.go b/test/integration/formposthtml_test.go index 743df1a9..f44e1ae5 100644 --- a/test/integration/formposthtml_test.go +++ b/test/integration/formposthtml_test.go @@ -4,10 +4,10 @@ package integration import ( - "fmt" "net/http" "net/http/httptest" "net/url" + "regexp" "strings" "testing" "time" @@ -71,7 +71,8 @@ func TestFormPostHTML(t *testing.T) { expectCallback(t, responseParams) // This failure should cause the UI to enter the "manual" state. - formpostExpectManualState(t, page, responseParams.Get("code")) + actualCode := formpostExpectManualState(t, page) + require.Equal(t, responseParams.Get("code"), actualCode) }) t.Run("timeout", func(t *testing.T) { @@ -84,7 +85,8 @@ func TestFormPostHTML(t *testing.T) { time.Sleep(3 * time.Second) // Assert that the timeout fires and we see the manual instructions. - formpostExpectManualState(t, page, responseParams.Get("code")) + actualCode := formpostExpectManualState(t, page) + require.Equal(t, responseParams.Get("code"), actualCode) // Now simulate the callback finally succeeding, in which case // the manual instructions should disappear and we should see the success @@ -220,15 +222,14 @@ func formpostExpectSuccessState(t *testing.T, page *agouti.Page) { formpostExpectFavicon(t, page, "✅") } -// formpostExpectManualState asserts that the page is in the "manual" state. -func formpostExpectManualState(t *testing.T, page *agouti.Page, code string) { +// formpostExpectManualState asserts that the page is in the "manual" state and returns the auth code. +func formpostExpectManualState(t *testing.T, page *agouti.Page) string { t.Logf("expecting to see manual message become visible...") browsertest.WaitForVisibleElements(t, page, "#manual") manualDivText, err := page.First("#manual").Text() require.NoError(t, err) require.Contains(t, manualDivText, "Finish your login") require.Contains(t, manualDivText, "To finish logging in, paste this authorization code into your command-line session:") - require.Contains(t, manualDivText, code) formpostExpectTitle(t, page, "Finish your login") formpostExpectFavicon(t, page, "⌛") @@ -237,16 +238,20 @@ func formpostExpectManualState(t *testing.T, page *agouti.Page, code string) { // console.log() statement that happens at the same time. t.Logf("clicking the 'copy' button and expecting the clipboard event to fire...") require.NoError(t, page.First("#manual-copy-button").Click()) + + var authCode string + consoleLogPattern := regexp.MustCompile(`code (.+) to clipboard`) testlib.RequireEventually(t, func(requireEventually *require.Assertions) { logs, err := page.ReadNewLogs("browser") requireEventually.NoError(err) - expectedConsoleLog := fmt.Sprintf("code %s to clipboard", code) for _, log := range logs { - if strings.Contains(log.Message, expectedConsoleLog) { + if match := consoleLogPattern.FindStringSubmatch(log.Message); match != nil { + authCode = match[1] return } } requireEventually.FailNow("expected console log was not found") }, 3*time.Second, 100*time.Millisecond) + return authCode }