Merge branch 'main' into deflake-serving-certificate-rotation-test

This commit is contained in:
Ryan Richard
2021-07-13 14:04:34 -07:00
committed by GitHub
48 changed files with 1533 additions and 208 deletions
+5 -6
View File
@@ -307,16 +307,15 @@ func runPinnipedLoginOIDC(
reader := bufio.NewReader(testlib.NewLoggerReader(t, "stderr", stderr))
scanner := bufio.NewScanner(reader)
const prompt = "Please log in: "
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, prompt) {
loginURLChan <- strings.TrimPrefix(line, prompt)
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 %s", prompt)
return fmt.Errorf("expected stderr to contain login URL")
})
// Start a background goroutine to read stdout from the CLI and parse out an ExecCredential.
@@ -356,7 +355,7 @@ func runPinnipedLoginOIDC(
// 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`)
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
@@ -1088,17 +1088,22 @@ func TestImpersonationProxy(t *testing.T) { //nolint:gocyclo // yeah, it's compl
return proxyURL, nil
}
}
c, r, err := dialer.Dial(dest.String(), http.Header{"Origin": {dest.String()}})
if r != nil {
defer func() {
require.NoError(t, r.Body.Close())
}()
}
if err != nil && r != nil {
body, _ := ioutil.ReadAll(r.Body)
t.Logf("websocket dial failed: %d:%s", r.StatusCode, body)
}
require.NoError(t, err)
var (
resp *http.Response
conn *websocket.Conn
)
testlib.RequireEventually(t, func(requireEventually *require.Assertions) {
var err error
conn, resp, err = dialer.Dial(dest.String(), http.Header{"Origin": {dest.String()}})
if resp != nil {
defer func() { requireEventually.NoError(resp.Body.Close()) }()
}
if err != nil && resp != nil {
body, _ := ioutil.ReadAll(resp.Body)
t.Logf("websocket dial failed: %d:%s", resp.StatusCode, body)
}
requireEventually.NoError(err)
}, time.Minute, time.Second)
// perform a create through the admin client
wantConfigMap := &corev1.ConfigMap{
@@ -1115,7 +1120,7 @@ func TestImpersonationProxy(t *testing.T) { //nolint:gocyclo // yeah, it's compl
})
// see if the websocket client received an event for the create
_, message, err := c.ReadMessage()
_, message, err := conn.ReadMessage()
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
+124 -21
View File
@@ -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
@@ -195,16 +195,15 @@ func TestE2EFullIntegration(t *testing.T) {
}()
reader := bufio.NewReader(testlib.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)
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
}
}
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)
return fmt.Errorf("expected stderr to contain login URL")
})
// Start a background goroutine to read stdout from kubectl and return the result as a string.
@@ -242,17 +241,13 @@ func TestE2EFullIntegration(t *testing.T) {
// 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`))
// 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)))
// 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)
// The response page should have done the background fetch() and POST'ed to the CLI's callback.
// It should now be in the "success" state.
formpostExpectSuccessState(t, page)
// Expect the CLI to output a list of namespaces in JSON format.
t.Logf("waiting for kubectl to output namespace list JSON")
@@ -275,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) {
@@ -376,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) {
@@ -390,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) {
+257
View File
@@ -0,0 +1,257 @@
// Copyright 2021 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package integration
import (
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"strings"
"testing"
"time"
"github.com/ory/fosite"
"github.com/ory/fosite/token/hmac"
"github.com/sclevine/agouti"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.pinniped.dev/internal/httputil/securityheader"
"go.pinniped.dev/internal/oidc/provider/formposthtml"
"go.pinniped.dev/test/testlib"
"go.pinniped.dev/test/testlib/browsertest"
)
func TestFormPostHTML(t *testing.T) {
// Run a mock callback handler, simulating the one running in the CLI.
callbackURL, expectCallback := formpostCallbackServer(t)
// Open a single browser for all subtests to use (in sequence).
page := browsertest.Open(t)
t.Run("success", func(t *testing.T) {
// Serve the form_post template with successful parameters.
responseParams := formpostRandomParams(t)
formpostInitiate(t, page, formpostTemplateServer(t, callbackURL, responseParams))
// Now we handle the callback and assert that we got what we expected. This should transition
// the UI into the success state.
expectCallback(t, responseParams)
formpostExpectSuccessState(t, page)
})
t.Run("callback server error", func(t *testing.T) {
// Serve the form_post template with a redirect URI that will return an HTTP 500 response.
responseParams := formpostRandomParams(t)
formpostInitiate(t, page, formpostTemplateServer(t, callbackURL+"?fail=500", responseParams))
// Now we handle the callback and assert that we got what we expected.
expectCallback(t, responseParams)
// This is not 100% the behavior we'd like, but because our JS is making
// a cross-origin fetch() without CORS, we don't get to know anything
// about the response (even whether it is 200 vs. 500), so this case
// is the same as the success case.
//
// This case is fairly unlikely in practice, and if the CLI encounters
// an error it can also expose it via stderr anyway.
formpostExpectSuccessState(t, page)
})
t.Run("network failure", func(t *testing.T) {
// Serve the form_post template with a redirect URI that will return a network error.
responseParams := formpostRandomParams(t)
formpostInitiate(t, page, formpostTemplateServer(t, callbackURL+"?fail=close", responseParams))
// Now we handle the callback and assert that we got what we expected.
// This will trigger the callback server to close the client connection abruptly because
// of the `?fail=close` parameter above.
expectCallback(t, responseParams)
// This failure should cause the UI to enter the "manual" state.
actualCode := formpostExpectManualState(t, page)
require.Equal(t, responseParams.Get("code"), actualCode)
})
t.Run("timeout", func(t *testing.T) {
// Serve the form_post template with successful parameters.
responseParams := formpostRandomParams(t)
formpostInitiate(t, page, formpostTemplateServer(t, callbackURL, responseParams))
// Sleep for longer than the two second timeout.
// During this sleep we are blocking the callback from returning.
time.Sleep(3 * time.Second)
// Assert that the timeout fires and we see the manual instructions.
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
// div instead.
expectCallback(t, responseParams)
formpostExpectSuccessState(t, page)
})
}
// formpostCallbackServer runs a test server that simulates the CLI's callback handler.
// It returns the URL of the running test server and a function for fetching the next
// received form POST parameters.
//
// The test server supports special `?fail=close` and `?fail=500` to force error cases.
func formpostCallbackServer(t *testing.T) (string, func(*testing.T, url.Values)) {
results := make(chan url.Values)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.NoError(t, r.ParseForm())
// Extract only the POST parameters (r.Form also contains URL query parameters).
postParams := url.Values{}
for k := range r.Form {
if v := r.PostFormValue(k); v != "" {
postParams.Set(k, v)
}
}
// Send the form parameters back on the results channel, giving up if the
// request context is cancelled (such as if the client disconnects).
select {
case results <- postParams:
case <-r.Context().Done():
return
}
switch r.URL.Query().Get("fail") {
case "close": // If "fail=close" is passed, close the connection immediately.
if conn, _, err := w.(http.Hijacker).Hijack(); err == nil {
_ = conn.Close()
}
return
case "500": // If "fail=500" is passed, return a 500 error.
w.WriteHeader(http.StatusInternalServerError)
return
}
}))
t.Cleanup(func() {
close(results)
server.Close()
})
return server.URL, func(t *testing.T, expected url.Values) {
t.Logf("expecting to get a POST callback...")
select {
case actual := <-results:
require.Equal(t, expected, actual, "did not receive expected callback")
case <-time.After(3 * time.Second):
t.Errorf("failed to receive expected callback %v", expected)
t.FailNow()
}
}
}
// formpostTemplateServer runs a test server that serves formposthtml.Template() rendered with test parameters.
func formpostTemplateServer(t *testing.T, redirectURI string, responseParams url.Values) string {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fosite.WriteAuthorizeFormPostResponse(redirectURI, responseParams, formposthtml.Template(), w)
})
server := httptest.NewServer(securityheader.WrapWithCustomCSP(
handler,
formposthtml.ContentSecurityPolicy(),
))
t.Cleanup(server.Close)
return server.URL
}
// formpostRandomParams is a helper to generate random OAuth2 response parameters for testing.
func formpostRandomParams(t *testing.T) url.Values {
generator := &hmac.HMACStrategy{GlobalSecret: testlib.RandBytes(t, 32), TokenEntropy: 32}
authCode, _, err := generator.Generate()
require.NoError(t, err)
return url.Values{
"code": []string{authCode},
"scope": []string{"openid offline_access pinniped:request-audience"},
"state": []string{testlib.RandHex(t, 16)},
}
}
// formpostExpectTitle asserts that the page has the expected title.
func formpostExpectTitle(t *testing.T, page *agouti.Page, expected string) {
actual, err := page.Title()
require.NoError(t, err)
require.Equal(t, expected, actual)
}
// formpostExpectTitle asserts that the page has the expected SVG/emoji favicon.
func formpostExpectFavicon(t *testing.T, page *agouti.Page, expected string) {
iconURL, err := page.First("#favicon").Attribute("href")
require.NoError(t, err)
require.True(t, strings.HasPrefix(iconURL, "data:image/svg+xml,<svg"))
// For some reason chromedriver on Linux returns this attribute urlencoded, but on macOS it contains the
// original emoji bytes (unescaped). To check correctly in both cases we allow either version here.
expectedEscaped := url.QueryEscape(expected)
require.Truef(t,
strings.Contains(iconURL, expected) || strings.Contains(iconURL, expectedEscaped),
"expected %q to contain %q or %q", iconURL, expected, expectedEscaped,
)
}
// formpostInitiate navigates to the template server endpoint and expects the
// loading animation to be shown.
func formpostInitiate(t *testing.T, page *agouti.Page, url string) {
require.NoError(t, page.Reset())
t.Logf("navigating to mock form_post template URL %s...", url)
require.NoError(t, page.Navigate(url))
t.Logf("expecting to see loading animation...")
browsertest.WaitForVisibleElements(t, page, "#loading")
formpostExpectTitle(t, page, "Logging in...")
formpostExpectFavicon(t, page, "⏳")
}
// formpostExpectSuccessState asserts that the page is in the "success" state.
func formpostExpectSuccessState(t *testing.T, page *agouti.Page) {
t.Logf("expecting to see success message become visible...")
browsertest.WaitForVisibleElements(t, page, "#success")
successDivText, err := page.First("#success").Text()
require.NoError(t, err)
require.Contains(t, successDivText, "Login succeeded")
require.Contains(t, successDivText, "You have successfully logged in. You may now close this tab.")
formpostExpectTitle(t, page, "Login succeeded")
formpostExpectFavicon(t, page, "✅")
}
// 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:")
formpostExpectTitle(t, page, "Finish your login")
formpostExpectFavicon(t, page, "⌛")
// Click the copy button and expect that the code is copied to the clipboard. Unfortunately,
// headless Chrome does not have a real clipboard we can check, so we rely on checking a
// 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)
for _, log := range logs {
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
}
@@ -479,6 +479,7 @@ func requireWellKnownEndpointIsWorking(t *testing.T, supervisorScheme, superviso
"jwks_uri": "%s/jwks.json",
"scopes_supported": ["openid", "offline"],
"response_types_supported": ["code"],
"response_modes_supported": ["query", "form_post"],
"claims_supported": ["groups"],
"discovery.supervisor.pinniped.dev/v1alpha1": {"pinniped_identity_providers_endpoint": "%s/v1alpha1/pinniped_identity_providers"},
"subject_types_supported": ["public"],
+1
View File
@@ -65,6 +65,7 @@ func RequireEventuallyf(
msg string,
args ...interface{},
) {
t.Helper()
RequireEventually(t, f, waitFor, tick, fmt.Sprintf(msg, args...))
}
+4
View File
@@ -27,6 +27,10 @@ func Open(t *testing.T) *agouti.Page {
t.Logf("opening browser driver")
env := testlib.IntegrationEnv(t)
caps := agouti.NewCapabilities()
// Capture console.log(), not just console.error().
caps["loggingPrefs"] = map[string]string{"browser": "INFO"}
if env.Proxy != "" {
t.Logf("configuring Chrome to use proxy %q", env.Proxy)
caps = caps.Proxy(agouti.ProxyConfig{
+6 -2
View File
@@ -311,11 +311,15 @@ func CreateTestFederationDomain(ctx context.Context, t *testing.T, issuer string
return federationDomain
}
func RandHex(t *testing.T, numBytes int) string {
func RandBytes(t *testing.T, numBytes int) []byte {
buf := make([]byte, numBytes)
_, err := io.ReadFull(rand.Reader, buf)
require.NoError(t, err)
return hex.EncodeToString(buf)
return buf
}
func RandHex(t *testing.T, numBytes int) string {
return hex.EncodeToString(RandBytes(t, numBytes))
}
func CreateTestSecret(t *testing.T, namespace string, baseName string, secretType corev1.SecretType, stringData map[string]string) *corev1.Secret {