Extend TestE2EFullIntegration to test manual OIDC flow.
Using the same fake TTY trick we used to test LDAP login, this new subtest runs through the "manual"/"jump box" login flow. It runs the login with a `--skip-listen` flag set, causing the CLI to skip opening the localhost listener. We can then wait for the login URL to be printed, visit it with the browser and log in, and finally simulate "manually" copying the auth code from the browser and entering it into the waiting CLI prompt. Signed-off-by: Matt Moyer <moyerm@vmware.com>
This commit is contained in:
parent
91a1fec5cf
commit
43f66032a9
@ -109,7 +109,7 @@ func TestE2EFullIntegration(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Add an OIDC upstream IDP and try using it to authenticate during kubectl commands.
|
// 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
|
expectedUsername := env.SupervisorUpstreamOIDC.Username
|
||||||
expectedGroups := env.SupervisorUpstreamOIDC.ExpectedGroups
|
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.
|
// 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) {
|
t.Run("with Supervisor LDAP upstream IDP", func(t *testing.T) {
|
||||||
if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) {
|
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 := ""
|
readFromFile := ""
|
||||||
|
|
||||||
testlib.RequireEventuallyWithoutError(t, func() (bool, error) {
|
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
|
return false, nil // keep waiting and reading
|
||||||
}, 1*time.Minute, 1*time.Second)
|
}, 1*time.Minute, 1*time.Second)
|
||||||
|
return readFromFile
|
||||||
}
|
}
|
||||||
|
|
||||||
func readAvailableOutput(t *testing.T, r io.Reader) (string, bool) {
|
func readAvailableOutput(t *testing.T, r io.Reader) (string, bool) {
|
||||||
|
@ -4,10 +4,10 @@
|
|||||||
package integration
|
package integration
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@ -71,7 +71,8 @@ func TestFormPostHTML(t *testing.T) {
|
|||||||
expectCallback(t, responseParams)
|
expectCallback(t, responseParams)
|
||||||
|
|
||||||
// This failure should cause the UI to enter the "manual" state.
|
// 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) {
|
t.Run("timeout", func(t *testing.T) {
|
||||||
@ -84,7 +85,8 @@ func TestFormPostHTML(t *testing.T) {
|
|||||||
time.Sleep(3 * time.Second)
|
time.Sleep(3 * time.Second)
|
||||||
|
|
||||||
// Assert that the timeout fires and we see the manual instructions.
|
// 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
|
// Now simulate the callback finally succeeding, in which case
|
||||||
// the manual instructions should disappear and we should see the success
|
// 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, "✅")
|
formpostExpectFavicon(t, page, "✅")
|
||||||
}
|
}
|
||||||
|
|
||||||
// formpostExpectManualState asserts that the page is in the "manual" state.
|
// formpostExpectManualState asserts that the page is in the "manual" state and returns the auth code.
|
||||||
func formpostExpectManualState(t *testing.T, page *agouti.Page, code string) {
|
func formpostExpectManualState(t *testing.T, page *agouti.Page) string {
|
||||||
t.Logf("expecting to see manual message become visible...")
|
t.Logf("expecting to see manual message become visible...")
|
||||||
browsertest.WaitForVisibleElements(t, page, "#manual")
|
browsertest.WaitForVisibleElements(t, page, "#manual")
|
||||||
manualDivText, err := page.First("#manual").Text()
|
manualDivText, err := page.First("#manual").Text()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Contains(t, manualDivText, "Finish your login")
|
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, "To finish logging in, paste this authorization code into your command-line session:")
|
||||||
require.Contains(t, manualDivText, code)
|
|
||||||
formpostExpectTitle(t, page, "Finish your login")
|
formpostExpectTitle(t, page, "Finish your login")
|
||||||
formpostExpectFavicon(t, page, "⌛")
|
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.
|
// console.log() statement that happens at the same time.
|
||||||
t.Logf("clicking the 'copy' button and expecting the clipboard event to fire...")
|
t.Logf("clicking the 'copy' button and expecting the clipboard event to fire...")
|
||||||
require.NoError(t, page.First("#manual-copy-button").Click())
|
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) {
|
testlib.RequireEventually(t, func(requireEventually *require.Assertions) {
|
||||||
logs, err := page.ReadNewLogs("browser")
|
logs, err := page.ReadNewLogs("browser")
|
||||||
requireEventually.NoError(err)
|
requireEventually.NoError(err)
|
||||||
|
|
||||||
expectedConsoleLog := fmt.Sprintf("code %s to clipboard", code)
|
|
||||||
for _, log := range logs {
|
for _, log := range logs {
|
||||||
if strings.Contains(log.Message, expectedConsoleLog) {
|
if match := consoleLogPattern.FindStringSubmatch(log.Message); match != nil {
|
||||||
|
authCode = match[1]
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
requireEventually.FailNow("expected console log was not found")
|
requireEventually.FailNow("expected console log was not found")
|
||||||
}, 3*time.Second, 100*time.Millisecond)
|
}, 3*time.Second, 100*time.Millisecond)
|
||||||
|
return authCode
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user