Add end-to-end integration test for CLI-based LDAP login

This commit is contained in:
Ryan Richard 2021-05-11 13:55:46 -07:00
parent f98aa96ed3
commit 6723ed9fd8
3 changed files with 349 additions and 187 deletions

1
go.mod
View File

@ -6,6 +6,7 @@ require (
cloud.google.com/go v0.60.0 // indirect cloud.google.com/go v0.60.0 // indirect
github.com/MakeNowJust/heredoc/v2 v2.0.1 github.com/MakeNowJust/heredoc/v2 v2.0.1
github.com/coreos/go-oidc/v3 v3.0.0 github.com/coreos/go-oidc/v3 v3.0.0
github.com/creack/pty v1.1.11
github.com/davecgh/go-spew v1.1.1 github.com/davecgh/go-spew v1.1.1
github.com/go-ldap/ldap/v3 v3.3.0 github.com/go-ldap/ldap/v3 v3.3.0
github.com/go-logr/logr v0.4.0 github.com/go-logr/logr v0.4.0

1
go.sum
View File

@ -148,6 +148,7 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw=
github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cucumber/godog v0.8.1/go.mod h1:vSh3r/lM+psC1BPXvdkSEuNjmXfpVqrMGYAElF6hxnA= github.com/cucumber/godog v0.8.1/go.mod h1:vSh3r/lM+psC1BPXvdkSEuNjmXfpVqrMGYAElF6hxnA=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

View File

@ -9,6 +9,7 @@ import (
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt" "fmt"
"io"
"io/ioutil" "io/ioutil"
"net/url" "net/url"
"os" "os"
@ -21,6 +22,7 @@ import (
"time" "time"
coreosoidc "github.com/coreos/go-oidc/v3/oidc" coreosoidc "github.com/coreos/go-oidc/v3/oidc"
"github.com/creack/pty"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
authorizationv1 "k8s.io/api/authorization/v1" authorizationv1 "k8s.io/api/authorization/v1"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
@ -30,6 +32,7 @@ import (
configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1"
idpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" idpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1"
"go.pinniped.dev/internal/certauthority" "go.pinniped.dev/internal/certauthority"
"go.pinniped.dev/internal/here"
"go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil"
"go.pinniped.dev/pkg/oidcclient" "go.pinniped.dev/pkg/oidcclient"
@ -92,24 +95,6 @@ func TestE2EFullIntegration(t *testing.T) {
configv1alpha1.SuccessFederationDomainStatusCondition, configv1alpha1.SuccessFederationDomainStatusCondition,
) )
// Create upstream OIDC provider and wait for it to become ready.
library.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: library.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name,
},
}, idpv1alpha1.PhaseReady)
// Create a JWTAuthenticator that will validate the tokens from the downstream issuer. // Create a JWTAuthenticator that will validate the tokens from the downstream issuer.
clusterAudience := "test-cluster-" + library.RandHex(t, 8) clusterAudience := "test-cluster-" + library.RandHex(t, 8)
authenticator := library.CreateTestJWTAuthenticator(ctx, t, authv1alpha.JWTAuthenticatorSpec{ authenticator := library.CreateTestJWTAuthenticator(ctx, t, authv1alpha.JWTAuthenticatorSpec{
@ -118,159 +103,314 @@ func TestE2EFullIntegration(t *testing.T) {
TLS: &authv1alpha.TLSSpec{CertificateAuthorityData: testCABundleBase64}, TLS: &authv1alpha.TLSSpec{CertificateAuthorityData: testCABundleBase64},
}) })
// Create a ClusterRoleBinding to give our test user from the upstream read-only access to the cluster. // Add an OIDC upstream IDP and try using it to authenticate during kubectl commands.
library.CreateTestClusterRoleBinding(t, t.Run("with Supervisor OIDC upstream IDP", func(t *testing.T) {
rbacv1.Subject{Kind: rbacv1.UserKind, APIGroup: rbacv1.GroupName, Name: env.SupervisorUpstreamOIDC.Username}, expectedUsername := env.SupervisorUpstreamOIDC.Username
rbacv1.RoleRef{Kind: "ClusterRole", APIGroup: rbacv1.GroupName, Name: "view"}, expectedGroups := env.SupervisorUpstreamOIDC.ExpectedGroups
)
library.WaitForUserToHaveAccess(t, env.SupervisorUpstreamOIDC.Username, []string{}, &authorizationv1.ResourceAttributes{ // Create a ClusterRoleBinding to give our test user from the upstream read-only access to the cluster.
Verb: "get", library.CreateTestClusterRoleBinding(t,
Group: "", rbacv1.Subject{Kind: rbacv1.UserKind, APIGroup: rbacv1.GroupName, Name: expectedUsername},
Version: "v1", rbacv1.RoleRef{Kind: "ClusterRole", APIGroup: rbacv1.GroupName, Name: "view"},
Resource: "namespaces", )
library.WaitForUserToHaveAccess(t, expectedUsername, []string{}, &authorizationv1.ResourceAttributes{
Verb: "get",
Group: "",
Version: "v1",
Resource: "namespaces",
})
// Create upstream OIDC provider and wait for it to become ready.
library.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: library.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name,
},
}, idpv1alpha1.PhaseReady)
// Use a specific session cache for this test.
sessionCachePath := tempDir + "/oidc-test-sessions.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-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()...)
stderrPipe, err := kubectlCmd.StderrPipe()
require.NoError(t, err)
stdoutPipe, err := kubectlCmd.StdoutPipe()
require.NoError(t, err)
t.Logf("starting kubectl subprocess")
require.NoError(t, kubectlCmd.Start())
t.Cleanup(func() {
err := kubectlCmd.Wait()
t.Logf("kubectl subprocess exited with code %d", kubectlCmd.ProcessState.ExitCode())
stdout, stdoutErr := ioutil.ReadAll(stdoutPipe)
if stdoutErr != nil {
stdout = []byte("<error reading stdout: " + stdoutErr.Error() + ">")
}
stderr, stderrErr := ioutil.ReadAll(stderrPipe)
if stderrErr != nil {
stderr = []byte("<error reading stderr: " + stderrErr.Error() + ">")
}
require.NoErrorf(t, err, "kubectl process did not exit cleanly, stdout/stderr: %q/%q", string(stdout), string(stderr))
})
// Start a background goroutine to read stderr from the CLI and parse out the login URL.
loginURLChan := make(chan string)
spawnTestGoroutine(t, func() (err error) {
defer func() {
closeErr := stderrPipe.Close()
if closeErr == nil || errors.Is(closeErr, os.ErrClosed) {
return
}
if err == nil {
err = fmt.Errorf("stderr stream closed with error: %w", closeErr)
}
}()
reader := bufio.NewReader(library.NewLoggerReader(t, "stderr", stderrPipe))
line, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("could not read login URL line from stderr: %w", err)
}
const prompt = "Please log in: "
if !strings.HasPrefix(line, prompt) {
return fmt.Errorf("expected %q to have prefix %q", line, prompt)
}
loginURLChan <- strings.TrimPrefix(line, prompt)
return readAndExpectEmpty(reader)
})
// Start a background goroutine to read stdout from kubectl and return the result as a string.
kubectlOutputChan := make(chan string)
spawnTestGoroutine(t, func() (err error) {
defer func() {
closeErr := stdoutPipe.Close()
if closeErr == nil || errors.Is(closeErr, os.ErrClosed) {
return
}
if err == nil {
err = fmt.Errorf("stdout stream closed with error: %w", closeErr)
}
}()
output, err := ioutil.ReadAll(stdoutPipe)
if err != nil {
return err
}
t.Logf("kubectl output:\n%s\n", output)
kubectlOutputChan <- string(output)
return nil
})
// 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 upstream provider and log in.
browsertest.LoginToUpstream(t, page, env.SupervisorUpstreamOIDC)
// Expect to be redirected to the localhost callback.
t.Logf("waiting for redirect to callback")
browsertest.WaitForURL(t, page, regexp.MustCompile(`\Ahttp://127\.0\.0\.1:[0-9]+/callback\?.+\z`))
// 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")
browsertest.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)
// Expect the CLI to output a list of namespaces in JSON format.
t.Logf("waiting for kubectl to output namespace list JSON")
var kubectlOutput string
select {
case <-time.After(10 * time.Second):
require.Fail(t, "timed out waiting for kubectl output")
case kubectlOutput = <-kubectlOutputChan:
}
require.Greaterf(t, len(strings.Split(kubectlOutput, "\n")), 2, "expected some namespaces to be returned, got %q", kubectlOutput)
t.Logf("first kubectl command took %s", time.Since(start).String())
requireUserCanUseKubectlWithoutAuthenticatingAgain(ctx, t, env,
downstream,
kubeconfigPath,
sessionCachePath,
pinnipedExe,
expectedUsername,
expectedGroups,
)
}) })
// Use a specific session cache for this test. // Add an LDAP upstream IDP and try using it to authenticate during kubectl commands.
sessionCachePath := tempDir + "/sessions.yaml" t.Run("with Supervisor LDAP upstream IDP", func(t *testing.T) {
expectedUsername := env.SupervisorUpstreamLDAP.TestUserMailAttributeValue
expectedGroups := []string{} // LDAP groups are not implemented yet
// Run "pinniped get kubeconfig" to get a kubeconfig YAML. // Create a ClusterRoleBinding to give our test user from the upstream read-only access to the cluster.
envVarsWithProxy := append(os.Environ(), env.ProxyEnv()...) library.CreateTestClusterRoleBinding(t,
kubeconfigYAML, stderr := runPinnipedCLI(t, envVarsWithProxy, pinnipedExe, "get", "kubeconfig", rbacv1.Subject{Kind: rbacv1.UserKind, APIGroup: rbacv1.GroupName, Name: expectedUsername},
"--concierge-api-group-suffix", env.APIGroupSuffix, rbacv1.RoleRef{Kind: "ClusterRole", APIGroup: rbacv1.GroupName, Name: "view"},
"--concierge-authenticator-type", "jwt", )
"--concierge-authenticator-name", authenticator.Name, library.WaitForUserToHaveAccess(t, expectedUsername, []string{}, &authorizationv1.ResourceAttributes{
"--oidc-skip-browser", Verb: "get",
"--oidc-ca-bundle", testCABundlePath, Group: "",
"--oidc-session-cache", sessionCachePath, Version: "v1",
) Resource: "namespaces",
t.Logf("stderr output from 'pinniped get kubeconfig':\n%s\n\n", stderr) })
t.Logf("test kubeconfig:\n%s\n\n", kubeconfigYAML)
restConfig := library.NewRestConfigFromKubeconfig(t, kubeconfigYAML) // Put the bind service account's info into a Secret.
require.NotNil(t, restConfig.ExecProvider) bindSecret := library.CreateTestSecret(t, env.SupervisorNamespace, "ldap-service-account", corev1.SecretTypeBasicAuth,
require.Equal(t, []string{"login", "oidc"}, restConfig.ExecProvider.Args[:2]) map[string]string{
kubeconfigPath := filepath.Join(tempDir, "kubeconfig.yaml") corev1.BasicAuthUsernameKey: env.SupervisorUpstreamLDAP.BindUsername,
require.NoError(t, ioutil.WriteFile(kubeconfigPath, []byte(kubeconfigYAML), 0600)) corev1.BasicAuthPasswordKey: env.SupervisorUpstreamLDAP.BindPassword,
},
)
// Run "kubectl get namespaces" which should trigger a browser login via the plugin. // Create upstream LDAP provider and wait for it to become ready.
start := time.Now() library.CreateTestLDAPIdentityProvider(t, idpv1alpha1.LDAPIdentityProviderSpec{
Host: env.SupervisorUpstreamLDAP.Host,
TLS: &idpv1alpha1.TLSSpec{
CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.CABundle)),
},
Bind: idpv1alpha1.LDAPIdentityProviderBind{
SecretName: bindSecret.Name,
},
UserSearch: idpv1alpha1.LDAPIdentityProviderUserSearch{
Base: env.SupervisorUpstreamLDAP.UserSearchBase,
Filter: "",
Attributes: idpv1alpha1.LDAPIdentityProviderUserSearchAttributes{
Username: env.SupervisorUpstreamLDAP.TestUserMailAttributeName,
UID: env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeName,
},
},
}, idpv1alpha1.LDAPPhaseReady)
// Use a specific session cache for this test.
sessionCachePath := tempDir + "/ldap-test-sessions.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-session-cache", sessionCachePath,
})
// Run "kubectl get namespaces" which should trigger an LDAP-style login CLI prompt 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 username prompt, then type the user's username.
readFromFileUntilStringIsSeen(t, ptyFile, "Username: ")
_, err = ptyFile.WriteString(expectedUsername + "\n")
require.NoError(t, err)
// Wait for the subprocess to print the password prompt, then type the user's password.
readFromFileUntilStringIsSeen(t, ptyFile, "Password: ")
_, err = ptyFile.WriteString(env.SupervisorUpstreamLDAP.TestUserPassword + "\n")
require.NoError(t, err)
// Read all of the remaining output from the subprocess until EOF.
remainingOutput, err := ioutil.ReadAll(ptyFile)
require.NoError(t, err)
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,
)
})
}
func readFromFileUntilStringIsSeen(t *testing.T, f *os.File, until string) string {
readFromFile := ""
library.RequireEventuallyWithoutError(t, func() (bool, error) {
someOutput, foundEOF := readAvailableOutput(t, f)
readFromFile += someOutput
if strings.Contains(readFromFile, until) {
return true, nil // found it! finished.
}
if foundEOF {
return false, fmt.Errorf("reached EOF of subcommand's output without seeing expected string %q", until)
}
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) {
buf := make([]byte, 1024)
n, err := r.Read(buf)
if err != nil {
if err == io.EOF {
return string(buf[:n]), true
} else {
require.NoError(t, err)
}
}
return string(buf[:n]), false
}
func requireUserCanUseKubectlWithoutAuthenticatingAgain(
ctx context.Context,
t *testing.T,
env *library.TestEnv,
downstream *configv1alpha1.FederationDomain,
kubeconfigPath string,
sessionCachePath string,
pinnipedExe string,
expectedUsername string,
expectedGroups []string,
) {
// Run kubectl, which should work without any prompting for authentication.
kubectlCmd := exec.CommandContext(ctx, "kubectl", "get", "namespace", "--kubeconfig", kubeconfigPath) kubectlCmd := exec.CommandContext(ctx, "kubectl", "get", "namespace", "--kubeconfig", kubeconfigPath)
kubectlCmd.Env = append(os.Environ(), env.ProxyEnv()...) kubectlCmd.Env = append(os.Environ(), env.ProxyEnv()...)
stderrPipe, err := kubectlCmd.StderrPipe() startTime := time.Now()
require.NoError(t, err) kubectlOutput2, err := kubectlCmd.CombinedOutput()
stdoutPipe, err := kubectlCmd.StdoutPipe()
require.NoError(t, err)
t.Logf("starting kubectl subprocess")
require.NoError(t, kubectlCmd.Start())
t.Cleanup(func() {
err := kubectlCmd.Wait()
t.Logf("kubectl subprocess exited with code %d", kubectlCmd.ProcessState.ExitCode())
stdout, stdoutErr := ioutil.ReadAll(stdoutPipe)
if stdoutErr != nil {
stdout = []byte("<error reading stdout: " + stdoutErr.Error() + ">")
}
stderr, stderrErr := ioutil.ReadAll(stderrPipe)
if stderrErr != nil {
stderr = []byte("<error reading stderr: " + stderrErr.Error() + ">")
}
require.NoErrorf(t, err, "kubectl process did not exit cleanly, stdout/stderr: %q/%q", string(stdout), string(stderr))
})
// Start a background goroutine to read stderr from the CLI and parse out the login URL.
loginURLChan := make(chan string)
spawnTestGoroutine(t, func() (err error) {
defer func() {
closeErr := stderrPipe.Close()
if closeErr == nil || errors.Is(closeErr, os.ErrClosed) {
return
}
if err == nil {
err = fmt.Errorf("stderr stream closed with error: %w", closeErr)
}
}()
reader := bufio.NewReader(library.NewLoggerReader(t, "stderr", stderrPipe))
line, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("could not read login URL line from stderr: %w", err)
}
const prompt = "Please log in: "
if !strings.HasPrefix(line, prompt) {
return fmt.Errorf("expected %q to have prefix %q", line, prompt)
}
loginURLChan <- strings.TrimPrefix(line, prompt)
return readAndExpectEmpty(reader)
})
// Start a background goroutine to read stdout from kubectl and return the result as a string.
kubectlOutputChan := make(chan string)
spawnTestGoroutine(t, func() (err error) {
defer func() {
closeErr := stdoutPipe.Close()
if closeErr == nil || errors.Is(closeErr, os.ErrClosed) {
return
}
if err == nil {
err = fmt.Errorf("stdout stream closed with error: %w", closeErr)
}
}()
output, err := ioutil.ReadAll(stdoutPipe)
if err != nil {
return err
}
t.Logf("kubectl output:\n%s\n", output)
kubectlOutputChan <- string(output)
return nil
})
// 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 upstream provider and log in.
browsertest.LoginToUpstream(t, page, env.SupervisorUpstreamOIDC)
// Expect to be redirected to the localhost callback.
t.Logf("waiting for redirect to callback")
browsertest.WaitForURL(t, page, regexp.MustCompile(`\Ahttp://127\.0\.0\.1:[0-9]+/callback\?.+\z`))
// 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")
browsertest.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)
// Expect the CLI to output a list of namespaces in JSON format.
t.Logf("waiting for kubectl to output namespace list JSON")
var kubectlOutput string
select {
case <-time.After(10 * time.Second):
require.Fail(t, "timed out waiting for kubectl output")
case kubectlOutput = <-kubectlOutputChan:
}
require.Greaterf(t, len(strings.Split(kubectlOutput, "\n")), 2, "expected some namespaces to be returned, got %q", kubectlOutput)
t.Logf("first kubectl command took %s", time.Since(start).String())
// Run kubectl again, which should work with no browser interaction.
kubectlCmd2 := exec.CommandContext(ctx, "kubectl", "get", "namespace", "--kubeconfig", kubeconfigPath)
kubectlCmd2.Env = append(os.Environ(), env.ProxyEnv()...)
start = time.Now()
kubectlOutput2, err := kubectlCmd2.CombinedOutput()
require.NoError(t, err) require.NoError(t, err)
require.Greaterf(t, len(bytes.Split(kubectlOutput2, []byte("\n"))), 2, "expected some namespaces to be returned again") require.Greaterf(t, len(bytes.Split(kubectlOutput2, []byte("\n"))), 2, "expected some namespaces to be returned again")
t.Logf("second kubectl command took %s", time.Since(start).String()) t.Logf("second kubectl command took %s", time.Since(startTime).String())
// probe our cache for the current ID token as a proxy for a whoami API // Probe our cache for the current ID token as a proxy for a whoami API.
cache := filesession.New(sessionCachePath, filesession.WithErrorReporter(func(err error) { cache := filesession.New(sessionCachePath, filesession.WithErrorReporter(func(err error) {
require.NoError(t, err) require.NoError(t, err)
})) }))
@ -286,49 +426,52 @@ func TestE2EFullIntegration(t *testing.T) {
require.NotNil(t, token) require.NotNil(t, token)
idTokenClaims := token.IDToken.Claims idTokenClaims := token.IDToken.Claims
require.Equal(t, env.SupervisorUpstreamOIDC.Username, idTokenClaims[oidc.DownstreamUsernameClaim]) require.Equal(t, expectedUsername, idTokenClaims[oidc.DownstreamUsernameClaim])
// The groups claim in the file ends up as an []interface{}, so adjust our expectation to match. // The groups claim in the file ends up as an []interface{}, so adjust our expectation to match.
expectedGroups := make([]interface{}, 0, len(env.SupervisorUpstreamOIDC.ExpectedGroups)) expectedGroupsAsEmptyInterfaces := make([]interface{}, 0, len(expectedGroups))
for _, g := range env.SupervisorUpstreamOIDC.ExpectedGroups { for _, g := range expectedGroups {
expectedGroups = append(expectedGroups, g) expectedGroupsAsEmptyInterfaces = append(expectedGroupsAsEmptyInterfaces, g)
} }
require.Equal(t, expectedGroups, idTokenClaims[oidc.DownstreamGroupsClaim]) require.Equal(t, expectedGroupsAsEmptyInterfaces, idTokenClaims[oidc.DownstreamGroupsClaim])
// confirm we are the right user according to Kube
expectedYAMLGroups := func() string { expectedYAMLGroups := func() string {
var b strings.Builder var b strings.Builder
for _, g := range env.SupervisorUpstreamOIDC.ExpectedGroups { for _, g := range expectedGroups {
b.WriteString("\n") b.WriteString("\n")
b.WriteString(` - `) b.WriteString(` - `)
b.WriteString(g) b.WriteString(g)
} }
return b.String() return b.String()
}() }()
// Confirm we are the right user according to Kube by calling the whoami API.
kubectlCmd3 := exec.CommandContext(ctx, "kubectl", "create", "-f", "-", "-o", "yaml", "--kubeconfig", kubeconfigPath) kubectlCmd3 := exec.CommandContext(ctx, "kubectl", "create", "-f", "-", "-o", "yaml", "--kubeconfig", kubeconfigPath)
kubectlCmd3.Env = append(os.Environ(), env.ProxyEnv()...) kubectlCmd3.Env = append(os.Environ(), env.ProxyEnv()...)
kubectlCmd3.Stdin = strings.NewReader(` kubectlCmd3.Stdin = strings.NewReader(here.Docf(`
apiVersion: identity.concierge.` + env.APIGroupSuffix + `/v1alpha1 apiVersion: identity.concierge.%s/v1alpha1
kind: WhoAmIRequest kind: WhoAmIRequest
`) `, env.APIGroupSuffix))
kubectlOutput3, err := kubectlCmd3.CombinedOutput() kubectlOutput3, err := kubectlCmd3.CombinedOutput()
require.NoError(t, err) require.NoError(t, err)
require.Equal(t,
`apiVersion: identity.concierge.`+env.APIGroupSuffix+`/v1alpha1 require.Equal(t, here.Docf(`
kind: WhoAmIRequest apiVersion: identity.concierge.%s/v1alpha1
metadata: kind: WhoAmIRequest
creationTimestamp: null metadata:
spec: {} creationTimestamp: null
status: spec: {}
kubernetesUserInfo: status:
user: kubernetesUserInfo:
groups:`+expectedYAMLGroups+` user:
- system:authenticated groups:%s
username: `+env.SupervisorUpstreamOIDC.Username+` - system:authenticated
`, username: %s
`, env.APIGroupSuffix, expectedYAMLGroups, expectedUsername),
string(kubectlOutput3)) string(kubectlOutput3))
expectedGroupsPlusAuthenticated := append([]string{}, env.SupervisorUpstreamOIDC.ExpectedGroups...) expectedGroupsPlusAuthenticated := append([]string{}, expectedGroups...)
expectedGroupsPlusAuthenticated = append(expectedGroupsPlusAuthenticated, "system:authenticated") expectedGroupsPlusAuthenticated = append(expectedGroupsPlusAuthenticated, "system:authenticated")
// Validate that `pinniped whoami` returns the correct identity. // Validate that `pinniped whoami` returns the correct identity.
assertWhoami( assertWhoami(
@ -337,7 +480,24 @@ status:
true, true,
pinnipedExe, pinnipedExe,
kubeconfigPath, kubeconfigPath,
env.SupervisorUpstreamOIDC.Username, expectedUsername,
expectedGroupsPlusAuthenticated, expectedGroupsPlusAuthenticated,
) )
} }
func runPinnipedGetKubeconfig(t *testing.T, env *library.TestEnv, pinnipedExe string, tempDir string, pinnipedCLICommand []string) string {
// Run "pinniped get kubeconfig" to get a kubeconfig YAML.
envVarsWithProxy := append(os.Environ(), env.ProxyEnv()...)
kubeconfigYAML, stderr := runPinnipedCLI(t, envVarsWithProxy, pinnipedExe, pinnipedCLICommand...)
t.Logf("stderr output from 'pinniped get kubeconfig':\n%s\n\n", stderr)
t.Logf("test kubeconfig:\n%s\n\n", kubeconfigYAML)
restConfig := library.NewRestConfigFromKubeconfig(t, kubeconfigYAML)
require.NotNil(t, restConfig.ExecProvider)
require.Equal(t, []string{"login", "oidc"}, restConfig.ExecProvider.Args[:2])
kubeconfigPath := filepath.Join(tempDir, "kubeconfig.yaml")
require.NoError(t, ioutil.WriteFile(kubeconfigPath, []byte(kubeconfigYAML), 0600))
return kubeconfigPath
}