cd686ffdf3
This change updates the TLS config used by all pinniped components. There are no configuration knobs associated with this change. Thus this change tightens our static defaults. There are four TLS config levels: 1. Secure (TLS 1.3 only) 2. Default (TLS 1.2+ best ciphers that are well supported) 3. Default LDAP (TLS 1.2+ with less good ciphers) 4. Legacy (currently unused, TLS 1.2+ with all non-broken ciphers) Highlights per component: 1. pinniped CLI - uses "secure" config against KAS - uses "default" for all other connections 2. concierge - uses "secure" config as an aggregated API server - uses "default" config as a impersonation proxy API server - uses "secure" config against KAS - uses "default" config for JWT authenticater (mostly, see code) - no changes to webhook authenticater (see code) 3. supervisor - uses "default" config as a server - uses "secure" config against KAS - uses "default" config against OIDC IDPs - uses "default LDAP" config against LDAP IDPs Signed-off-by: Monis Khan <mok@vmware.com>
435 lines
16 KiB
Go
435 lines
16 KiB
Go
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
package integration
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/ioutil"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/sync/errgroup"
|
|
"gopkg.in/square/go-jose.v2"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/apimachinery/pkg/runtime/serializer"
|
|
clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1"
|
|
|
|
identityv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/identity/v1alpha1"
|
|
conciergescheme "go.pinniped.dev/internal/concierge/scheme"
|
|
"go.pinniped.dev/internal/testutil"
|
|
"go.pinniped.dev/pkg/oidcclient"
|
|
"go.pinniped.dev/pkg/oidcclient/filesession"
|
|
"go.pinniped.dev/test/testlib"
|
|
"go.pinniped.dev/test/testlib/browsertest"
|
|
)
|
|
|
|
// safe to run in parallel with serial tests since it only interacts with a test local webhook, see main_test.go.
|
|
func TestCLIGetKubeconfigStaticToken_Parallel(t *testing.T) {
|
|
env := testlib.IntegrationEnv(t).WithCapability(testlib.ClusterSigningKeyIsAvailable)
|
|
|
|
// Create a test webhook configuration to use with the CLI.
|
|
ctx, cancelFunc := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
defer cancelFunc()
|
|
|
|
authenticator := testlib.CreateTestWebhookAuthenticator(ctx, t)
|
|
|
|
// Build pinniped CLI.
|
|
pinnipedExe := testlib.PinnipedCLIPath(t)
|
|
|
|
credCacheDir := testutil.TempDir(t)
|
|
stdout, stderr := runPinnipedCLI(t, nil, pinnipedExe, "get", "kubeconfig",
|
|
"--static-token", env.TestUser.Token,
|
|
"--concierge-api-group-suffix", env.APIGroupSuffix,
|
|
"--concierge-authenticator-type", "webhook",
|
|
"--concierge-authenticator-name", authenticator.Name,
|
|
"--credential-cache", credCacheDir+"/credentials.yaml",
|
|
)
|
|
assert.Contains(t, stderr, "discovered CredentialIssuer")
|
|
assert.Contains(t, stderr, "discovered Concierge endpoint")
|
|
assert.Contains(t, stderr, "discovered Concierge certificate authority bundle")
|
|
assert.Contains(t, stderr, "validated connection to the cluster")
|
|
|
|
// Even the deprecated command should now generate a kubeconfig with the new "pinniped login static" command.
|
|
restConfig := testlib.NewRestConfigFromKubeconfig(t, stdout)
|
|
require.NotNil(t, restConfig.ExecProvider)
|
|
require.Equal(t, []string{"login", "static"}, restConfig.ExecProvider.Args[:2])
|
|
|
|
// In addition to the client-go based testing below, also try the kubeconfig
|
|
// with kubectl to validate that it works.
|
|
t.Run(
|
|
"access as user with kubectl",
|
|
testlib.AccessAsUserWithKubectlTest(stdout, env.TestUser.ExpectedUsername, env.ConciergeNamespace),
|
|
)
|
|
for _, group := range env.TestUser.ExpectedGroups {
|
|
group := group
|
|
t.Run(
|
|
"access as group "+group+" with kubectl",
|
|
testlib.AccessAsGroupWithKubectlTest(stdout, group, env.ConciergeNamespace),
|
|
)
|
|
}
|
|
|
|
// Create Kubernetes client with kubeconfig from pinniped CLI.
|
|
kubeClient := testlib.NewClientsetForKubeConfig(t, stdout)
|
|
|
|
// Validate that we can auth to the API via our user.
|
|
t.Run("access as user with client-go", testlib.AccessAsUserTest(ctx, env.TestUser.ExpectedUsername, kubeClient))
|
|
for _, group := range env.TestUser.ExpectedGroups {
|
|
group := group
|
|
t.Run("access as group "+group+" with client-go", testlib.AccessAsGroupTest(ctx, group, kubeClient))
|
|
}
|
|
|
|
t.Run("whoami", func(t *testing.T) {
|
|
// Validate that `pinniped whoami` returns the correct identity.
|
|
kubeconfigPath := filepath.Join(testutil.TempDir(t), "whoami-kubeconfig")
|
|
require.NoError(t, ioutil.WriteFile(kubeconfigPath, []byte(stdout), 0600))
|
|
assertWhoami(
|
|
ctx,
|
|
t,
|
|
false,
|
|
pinnipedExe,
|
|
kubeconfigPath,
|
|
env.TestUser.ExpectedUsername,
|
|
append(env.TestUser.ExpectedGroups, "system:authenticated"),
|
|
)
|
|
})
|
|
}
|
|
|
|
type testingT interface {
|
|
Helper()
|
|
Errorf(format string, args ...interface{})
|
|
FailNow()
|
|
Logf(format string, args ...interface{})
|
|
}
|
|
|
|
func runPinnipedCLI(t testingT, envVars []string, pinnipedExe string, args ...string) (string, string) {
|
|
t.Helper()
|
|
start := time.Now()
|
|
var stdout, stderr bytes.Buffer
|
|
cmd := exec.Command(pinnipedExe, args...)
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
cmd.Env = envVars
|
|
require.NoErrorf(t, cmd.Run(), "stderr:\n%s\n\nstdout:\n%s\n\n", stderr.String(), stdout.String())
|
|
t.Logf("ran %q in %s", testlib.MaskTokens("pinniped "+strings.Join(args, " ")), time.Since(start).Round(time.Millisecond))
|
|
return stdout.String(), stderr.String()
|
|
}
|
|
|
|
func assertWhoami(ctx context.Context, t *testing.T, useProxy bool, pinnipedExe, kubeconfigPath, wantUsername string, wantGroups []string) {
|
|
t.Helper()
|
|
|
|
apiGroupSuffix := testlib.IntegrationEnv(t).APIGroupSuffix
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
cmd := exec.CommandContext(
|
|
ctx,
|
|
pinnipedExe,
|
|
"whoami",
|
|
"--kubeconfig",
|
|
kubeconfigPath,
|
|
"--output",
|
|
"yaml",
|
|
"--api-group-suffix",
|
|
apiGroupSuffix,
|
|
)
|
|
if useProxy {
|
|
cmd.Env = append(os.Environ(), testlib.IntegrationEnv(t).ProxyEnv()...)
|
|
}
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
require.NoErrorf(t, cmd.Run(), "stderr:\n%s\n\nstdout:\n%s\n\n", stderr.String(), stdout.String())
|
|
|
|
whoAmI := deserializeWhoAmIRequest(t, stdout.String(), apiGroupSuffix)
|
|
require.Equal(t, wantUsername, whoAmI.Status.KubernetesUserInfo.User.Username)
|
|
require.ElementsMatch(t, wantGroups, whoAmI.Status.KubernetesUserInfo.User.Groups)
|
|
}
|
|
|
|
func deserializeWhoAmIRequest(t *testing.T, data string, apiGroupSuffix string) *identityv1alpha1.WhoAmIRequest {
|
|
t.Helper()
|
|
|
|
scheme, _, _ := conciergescheme.New(apiGroupSuffix)
|
|
codecs := serializer.NewCodecFactory(scheme)
|
|
respInfo, ok := runtime.SerializerInfoForMediaType(codecs.SupportedMediaTypes(), runtime.ContentTypeYAML)
|
|
require.True(t, ok)
|
|
|
|
obj, err := runtime.Decode(respInfo.Serializer, []byte(data))
|
|
require.NoError(t, err)
|
|
|
|
return obj.(*identityv1alpha1.WhoAmIRequest)
|
|
}
|
|
|
|
func TestCLILoginOIDC(t *testing.T) {
|
|
env := testlib.IntegrationEnv(t)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
defer cancel()
|
|
|
|
// Build pinniped CLI.
|
|
pinnipedExe := testlib.PinnipedCLIPath(t)
|
|
|
|
// Run "pinniped login oidc" to get an ExecCredential struct with an OIDC ID token.
|
|
credOutput, sessionCachePath := runPinnipedLoginOIDC(ctx, t, pinnipedExe)
|
|
|
|
// 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.CLIUpstreamOIDC.Issuer, claims["iss"])
|
|
require.Equal(t, env.CLIUpstreamOIDC.ClientID, claims["aud"])
|
|
require.Equal(t, env.CLIUpstreamOIDC.Username, claims["email"])
|
|
require.NotEmpty(t, claims["nonce"])
|
|
|
|
// Run the CLI again with the same session cache and login parameters.
|
|
t.Logf("starting second CLI subprocess to test session caching")
|
|
cmd2Output, err := oidcLoginCommand(ctx, t, pinnipedExe, sessionCachePath).CombinedOutput()
|
|
require.NoError(t, err, string(cmd2Output))
|
|
|
|
// Expect the CLI to output the same ExecCredential in JSON format.
|
|
t.Logf("validating second ExecCredential")
|
|
var credOutput2 clientauthenticationv1beta1.ExecCredential
|
|
require.NoErrorf(t, json.Unmarshal(cmd2Output, &credOutput2),
|
|
"command returned something other than an ExecCredential:\n%s", string(cmd2Output))
|
|
require.Equal(t, credOutput, credOutput2)
|
|
// the logs contain only the ExecCredential. There are 2 elements because the last one is "".
|
|
require.Len(t, strings.Split(string(cmd2Output), "\n"), 2)
|
|
|
|
// Overwrite the cache entry to remove the access and ID tokens.
|
|
t.Logf("overwriting cache to remove valid ID token")
|
|
cache := filesession.New(sessionCachePath)
|
|
cacheKey := oidcclient.SessionCacheKey{
|
|
Issuer: env.CLIUpstreamOIDC.Issuer,
|
|
ClientID: env.CLIUpstreamOIDC.ClientID,
|
|
Scopes: []string{"email", "offline_access", "openid", "profile"},
|
|
RedirectURI: strings.ReplaceAll(env.CLIUpstreamOIDC.CallbackURL, "127.0.0.1", "localhost"),
|
|
}
|
|
cached := cache.GetToken(cacheKey)
|
|
require.NotNil(t, cached)
|
|
require.NotNil(t, cached.RefreshToken)
|
|
require.NotEmpty(t, cached.RefreshToken.Token)
|
|
cached.IDToken = nil
|
|
cached.AccessToken = nil
|
|
cache.PutToken(cacheKey, cached)
|
|
|
|
// Run the CLI a third time with the same session cache and login parameters.
|
|
t.Logf("starting third CLI subprocess to test refresh flow")
|
|
cmd3Output, err := oidcLoginCommand(ctx, t, pinnipedExe, sessionCachePath).CombinedOutput()
|
|
require.NoError(t, err, string(cmd2Output))
|
|
|
|
// Expect the CLI to output a new ExecCredential in JSON format (different from the one returned the first two times).
|
|
t.Logf("validating third ExecCredential")
|
|
var credOutput3 clientauthenticationv1beta1.ExecCredential
|
|
require.NoErrorf(t, json.Unmarshal(cmd3Output, &credOutput3),
|
|
"command returned something other than an ExecCredential:\n%s", string(cmd2Output))
|
|
require.NotEqual(t, credOutput2.Status.Token, credOutput3.Status.Token)
|
|
// the logs contain only the ExecCredential. There are 2 elements because the last one is "".
|
|
require.Len(t, strings.Split(string(cmd3Output), "\n"), 2)
|
|
|
|
t.Logf("starting fourth CLI subprocess to test debug logging")
|
|
err = os.Setenv("PINNIPED_DEBUG", "true")
|
|
require.NoError(t, err)
|
|
command := oidcLoginCommand(ctx, t, pinnipedExe, sessionCachePath)
|
|
cmd4CombinedOutput, err := command.CombinedOutput()
|
|
cmd4StringOutput := string(cmd4CombinedOutput)
|
|
require.NoError(t, err, cmd4StringOutput)
|
|
|
|
// the logs contain only the 4 debug lines plus the ExecCredential. There are 6 elements because the last one is "".
|
|
require.Len(t, strings.Split(cmd4StringOutput, "\n"), 6)
|
|
require.Contains(t, cmd4StringOutput, "Performing OIDC login")
|
|
require.Contains(t, cmd4StringOutput, "Found unexpired cached token")
|
|
require.Contains(t, cmd4StringOutput, "No concierge configured, skipping token credential exchange")
|
|
require.Contains(t, cmd4StringOutput, "caching cluster credential for future use.")
|
|
require.Contains(t, cmd4StringOutput, credOutput3.Status.Token)
|
|
err = os.Unsetenv("PINNIPED_DEBUG")
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func runPinnipedLoginOIDC(
|
|
ctx context.Context,
|
|
t *testing.T,
|
|
pinnipedExe string,
|
|
) (clientauthenticationv1beta1.ExecCredential, string) {
|
|
t.Helper()
|
|
|
|
env := testlib.IntegrationEnv(t)
|
|
|
|
// Make a temp directory to hold the session cache for this test.
|
|
sessionCachePath := testutil.TempDir(t) + "/sessions.yaml"
|
|
|
|
// Start the browser driver.
|
|
page := browsertest.Open(t)
|
|
|
|
// Start the CLI running the "login oidc [...]" command with stdout/stderr connected to pipes.
|
|
cmd := oidcLoginCommand(ctx, t, pinnipedExe, sessionCachePath)
|
|
stderr, err := cmd.StderrPipe()
|
|
require.NoError(t, err)
|
|
stdout, err := cmd.StdoutPipe()
|
|
require.NoError(t, err)
|
|
t.Logf("starting CLI subprocess")
|
|
require.NoError(t, cmd.Start())
|
|
t.Cleanup(func() {
|
|
err := cmd.Wait()
|
|
t.Logf("CLI subprocess exited with code %d", cmd.ProcessState.ExitCode())
|
|
require.NoErrorf(t, err, "CLI process did not exit cleanly")
|
|
})
|
|
|
|
// 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) {
|
|
t.Helper()
|
|
defer func() {
|
|
closeErr := stderr.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(testlib.NewLoggerReader(t, "stderr", stderr))
|
|
|
|
scanner := bufio.NewScanner(reader)
|
|
for scanner.Scan() {
|
|
loginURL, err := url.Parse(strings.TrimSpace(scanner.Text()))
|
|
if err == nil && loginURL.Scheme == "https" {
|
|
loginURLChan <- loginURL.String()
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("expected stderr to contain login URL")
|
|
})
|
|
|
|
// Start a background goroutine to read stdout from the CLI and parse out an ExecCredential.
|
|
credOutputChan := make(chan clientauthenticationv1beta1.ExecCredential)
|
|
spawnTestGoroutine(t, func() (err error) {
|
|
defer func() {
|
|
closeErr := stdout.Close()
|
|
if closeErr == nil || errors.Is(closeErr, os.ErrClosed) {
|
|
return
|
|
}
|
|
if err == nil {
|
|
err = fmt.Errorf("stdout stream closed with error: %w", closeErr)
|
|
}
|
|
}()
|
|
reader := bufio.NewReader(testlib.NewLoggerReader(t, "stdout", stdout))
|
|
var out clientauthenticationv1beta1.ExecCredential
|
|
if err := json.NewDecoder(reader).Decode(&out); err != nil {
|
|
return fmt.Errorf("could not read ExecCredential from stdout: %w", err)
|
|
}
|
|
credOutputChan <- out
|
|
return readAndExpectEmpty(reader)
|
|
})
|
|
|
|
// 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.CLIUpstreamOIDC)
|
|
|
|
// Expect to be redirected to the localhost callback.
|
|
t.Logf("waiting for redirect to callback")
|
|
callbackURLPattern := regexp.MustCompile(`\A` + regexp.QuoteMeta(env.CLIUpstreamOIDC.CallbackURL) + `(\?.+)?\z`)
|
|
browsertest.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.
|
|
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 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:
|
|
}
|
|
|
|
return credOutput, sessionCachePath
|
|
}
|
|
|
|
func readAndExpectEmpty(r io.Reader) (err error) {
|
|
var remainder bytes.Buffer
|
|
_, err = io.Copy(&remainder, r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if r := remainder.String(); r != "" {
|
|
return fmt.Errorf("expected remainder to be empty, but got %q", r)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func spawnTestGoroutine(t *testing.T, f func() error) {
|
|
t.Helper()
|
|
var eg errgroup.Group
|
|
t.Cleanup(func() {
|
|
require.NoError(t, eg.Wait(), "background goroutine failed")
|
|
})
|
|
eg.Go(f)
|
|
}
|
|
|
|
func oidcLoginCommand(ctx context.Context, t *testing.T, pinnipedExe string, sessionCachePath string) *exec.Cmd {
|
|
env := testlib.IntegrationEnv(t)
|
|
callbackURL, err := url.Parse(env.CLIUpstreamOIDC.CallbackURL)
|
|
require.NoError(t, err)
|
|
cmd := exec.CommandContext(ctx, pinnipedExe, "login", "oidc",
|
|
"--issuer", env.CLIUpstreamOIDC.Issuer,
|
|
"--client-id", env.CLIUpstreamOIDC.ClientID,
|
|
"--scopes", "offline_access,openid,email,profile",
|
|
"--listen-port", callbackURL.Port(),
|
|
"--session-cache", sessionCachePath,
|
|
"--credential-cache", testutil.TempDir(t)+"/credentials.yaml",
|
|
"--skip-browser",
|
|
)
|
|
|
|
// If there is a custom CA bundle, pass it via --ca-bundle and a temporary file.
|
|
if env.CLIUpstreamOIDC.CABundle != "" {
|
|
path := filepath.Join(testutil.TempDir(t), "test-ca.pem")
|
|
require.NoError(t, ioutil.WriteFile(path, []byte(env.CLIUpstreamOIDC.CABundle), 0600))
|
|
cmd.Args = append(cmd.Args, "--ca-bundle", path)
|
|
}
|
|
|
|
// If there is a custom proxy, set it using standard environment variables.
|
|
cmd.Env = append(os.Environ(), env.ProxyEnv()...)
|
|
return cmd
|
|
}
|