Update TestSupervisorLogin to test the callback flow using a browser.

Signed-off-by: Matt Moyer <moyerm@vmware.com>
This commit is contained in:
Matt Moyer 2020-12-02 15:50:42 -06:00
parent 0ccf14801e
commit f40144e1a9
No known key found for this signature in database
GPG Key ID: EAE88AD172C5AE2D

View File

@ -6,12 +6,12 @@ package integration
import ( import (
"context" "context"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509/pkix"
"encoding/base64" "encoding/base64"
"fmt"
"net/http" "net/http"
"net/http/httptest"
"net/url" "net/url"
"path" "regexp"
"strings" "strings"
"testing" "testing"
"time" "time"
@ -20,216 +20,160 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"golang.org/x/oauth2" "golang.org/x/oauth2"
configv1alpha1 "go.pinniped.dev/generated/1.19/apis/supervisor/config/v1alpha1"
idpv1alpha1 "go.pinniped.dev/generated/1.19/apis/supervisor/idp/v1alpha1" idpv1alpha1 "go.pinniped.dev/generated/1.19/apis/supervisor/idp/v1alpha1"
"go.pinniped.dev/internal/certauthority"
"go.pinniped.dev/pkg/oidcclient/nonce" "go.pinniped.dev/pkg/oidcclient/nonce"
"go.pinniped.dev/pkg/oidcclient/pkce" "go.pinniped.dev/pkg/oidcclient/pkce"
"go.pinniped.dev/pkg/oidcclient/state" "go.pinniped.dev/pkg/oidcclient/state"
"go.pinniped.dev/test/library" "go.pinniped.dev/test/library"
"go.pinniped.dev/test/library/browsertest"
) )
func TestSupervisorLogin(t *testing.T) { func TestSupervisorLogin(t *testing.T) {
env := library.IntegrationEnv(t) env := library.IntegrationEnv(t)
client := library.NewSupervisorClientset(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel() defer cancel()
tests := []struct { // Infer the downstream issuer URL from the callback associated with the upstream test client registration.
Scheme string issuerURL, err := url.Parse(env.SupervisorTestUpstream.CallbackURL)
Address string require.NoError(t, err)
CABundle string require.True(t, strings.HasSuffix(issuerURL.Path, "/callback"))
}{ issuerURL.Path = strings.TrimSuffix(issuerURL.Path, "/callback")
{Scheme: "http", Address: env.SupervisorHTTPAddress}, t.Logf("testing with downstream issuer URL %s", issuerURL.String())
{Scheme: "https", Address: env.SupervisorHTTPSIngressAddress, CABundle: env.SupervisorHTTPSIngressCABundle},
// Generate a CA bundle with which to serve this provider.
t.Logf("generating test CA")
ca, err := certauthority.New(pkix.Name{CommonName: "Downstream Test CA"}, 1*time.Hour)
require.NoError(t, err)
// Create an HTTP client that can reach the downstream discovery endpoint using the CA certs.
httpClient := &http.Client{Transport: &http.Transport{
TLSClientConfig: &tls.Config{RootCAs: ca.Pool()},
Proxy: func(req *http.Request) (*url.URL, error) {
if env.Proxy == "" {
return nil, nil
} }
return url.Parse(env.Proxy)
},
}}
for _, test := range tests { // Use the CA to issue a TLS server cert.
scheme := test.Scheme t.Logf("issuing test certificate")
addr := test.Address tlsCert, err := ca.Issue(
caBundle := test.CABundle pkix.Name{CommonName: issuerURL.Hostname()},
[]string{issuerURL.Hostname()},
nil,
1*time.Hour,
)
require.NoError(t, err)
certPEM, keyPEM, err := certauthority.ToPEM(tlsCert)
require.NoError(t, err)
if addr == "" { // Write the serving cert to a secret.
// Both cases are not required, so when one is empty skip it. certSecret := library.CreateTestSecret(t,
continue env.SupervisorNamespace,
} "oidc-provider-tls",
"kubernetes.io/tls",
// Create downstream OIDC provider (i.e., update supervisor with OIDC provider). map[string]string{"tls.crt": string(certPEM), "tls.key": string(keyPEM)},
path := getDownstreamIssuerPathFromUpstreamRedirectURI(t, env.SupervisorTestUpstream.CallbackURL)
issuer := fmt.Sprintf("https://%s%s", addr, path)
_, _ = requireCreatingOIDCProviderCausesDiscoveryEndpointsToAppear(
ctx,
t,
scheme,
addr,
caBundle,
issuer,
client,
) )
// Create HTTP client. // Create the downstream OIDCProvider and expect it to go into the success status condition.
httpClient := newHTTPClient(t, caBundle, nil) downstream := library.CreateTestOIDCProvider(ctx, t,
httpClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { issuerURL.String(),
// Don't follow any redirects right now, since we simply want to validate that our auth endpoint certSecret.Name,
// redirects us. configv1alpha1.SuccessOIDCProviderStatusCondition,
return http.ErrUseLastResponse )
}
// Declare the downstream auth endpoint url we will use. // Create upstream OIDC provider and wait for it to become ready.
downstreamAuthURL := makeDownstreamAuthURL(t, scheme, addr, path) library.CreateTestUpstreamOIDCProvider(t, idpv1alpha1.UpstreamOIDCProviderSpec{
// Make request to auth endpoint - should fail, since we have no upstreams.
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downstreamAuthURL, nil)
require.NoError(t, err)
rsp, err := httpClient.Do(req)
require.NoError(t, err)
defer rsp.Body.Close()
require.Equal(t, http.StatusUnprocessableEntity, rsp.StatusCode)
// Create upstream OIDC provider.
spec := idpv1alpha1.UpstreamOIDCProviderSpec{
Issuer: env.SupervisorTestUpstream.Issuer, Issuer: env.SupervisorTestUpstream.Issuer,
TLS: &idpv1alpha1.TLSSpec{ TLS: &idpv1alpha1.TLSSpec{
CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorTestUpstream.CABundle)), CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorTestUpstream.CABundle)),
}, },
AuthorizationConfig: idpv1alpha1.OIDCAuthorizationConfig{
AdditionalScopes: []string{"email", "profile"},
},
Client: idpv1alpha1.OIDCClient{ Client: idpv1alpha1.OIDCClient{
SecretName: library.CreateClientCredsSecret(t, env.SupervisorTestUpstream.ClientID, env.SupervisorTestUpstream.ClientSecret).Name, SecretName: library.CreateClientCredsSecret(t, env.SupervisorTestUpstream.ClientID, env.SupervisorTestUpstream.ClientSecret).Name,
}, },
} }, idpv1alpha1.PhaseReady)
upstream := library.CreateTestUpstreamOIDCProvider(t, spec, idpv1alpha1.PhaseReady)
// Make request to authorize endpoint - should pass, since we now have an upstream. // Perform OIDC discovery for our downstream.
req, err = http.NewRequestWithContext(ctx, http.MethodGet, downstreamAuthURL, nil) discovery, err := oidc.NewProvider(oidc.ClientContext(ctx, httpClient), downstream.Spec.Issuer)
require.NoError(t, err)
rsp, err = httpClient.Do(req)
require.NoError(t, err)
defer rsp.Body.Close()
require.Equal(t, http.StatusFound, rsp.StatusCode)
requireValidRedirectLocation(
ctx,
t,
upstream.Spec.Issuer,
env.SupervisorTestUpstream.ClientID,
env.SupervisorTestUpstream.CallbackURL,
rsp.Header.Get("Location"),
)
}
}
func getDownstreamIssuerPathFromUpstreamRedirectURI(t *testing.T, upstreamRedirectURI string) string {
// We need to construct the downstream issuer path from the upstream redirect URI since the two
// are related, and the upstream redirect URI is supplied via a static test environment
// variable. The upstream redirect URI should be something like
// https://supervisor.com/some/supervisor/path/callback
// and therefore the downstream issuer should be something like
// https://supervisor.com/some/supervisor/path
// since the /callback endpoint is placed at the root of the downstream issuer path.
upstreamRedirectURL, err := url.Parse(upstreamRedirectURI)
require.NoError(t, err) require.NoError(t, err)
redirectURIPathWithoutLastSegment, lastUpstreamRedirectURIPathSegment := path.Split(upstreamRedirectURL.Path) // Start a callback server on localhost.
require.Equalf( localCallbackServer := startLocalCallbackServer(t)
t,
"callback",
lastUpstreamRedirectURIPathSegment,
"expected upstream redirect URI (%q) to follow supervisor callback path conventions (i.e., end in /callback)",
upstreamRedirectURI,
)
if strings.HasSuffix(redirectURIPathWithoutLastSegment, "/") { // Form the OAuth2 configuration corresponding to our CLI client.
redirectURIPathWithoutLastSegment = redirectURIPathWithoutLastSegment[:len(redirectURIPathWithoutLastSegment)-1]
}
return redirectURIPathWithoutLastSegment
}
func makeDownstreamAuthURL(t *testing.T, scheme, addr, path string) string {
t.Helper()
downstreamOAuth2Config := oauth2.Config{ downstreamOAuth2Config := oauth2.Config{
// This is the hardcoded public client that the supervisor supports. // This is the hardcoded public client that the supervisor supports.
ClientID: "pinniped-cli", ClientID: "pinniped-cli",
Endpoint: oauth2.Endpoint{ Endpoint: discovery.Endpoint(),
AuthURL: fmt.Sprintf("%s://%s%s/oauth2/authorize", scheme, addr, path), RedirectURL: localCallbackServer.URL,
},
// This is the hardcoded downstream redirect URI that the supervisor supports.
RedirectURL: "http://127.0.0.1/callback",
Scopes: []string{"openid"}, Scopes: []string{"openid"},
} }
state, nonce, pkce := generateAuthRequestParams(t)
return downstreamOAuth2Config.AuthCodeURL( // Build a valid downstream authorize URL for the supervisor.
state.String(), stateParam, err := state.Generate()
nonce.Param(), require.NoError(t, err)
pkce.Challenge(), nonceParam, err := nonce.Generate()
pkce.Method(), require.NoError(t, err)
pkceParam, err := pkce.Generate()
require.NoError(t, err)
downstreamAuthorizeURL := downstreamOAuth2Config.AuthCodeURL(
stateParam.String(),
nonceParam.Param(),
pkceParam.Challenge(),
pkceParam.Method(),
) )
// Open the web browser and navigate to the downstream authorize URL.
page := browsertest.Open(t)
t.Logf("opening browser to downstream authorize URL %s", library.MaskTokens(downstreamAuthorizeURL))
require.NoError(t, page.Navigate(downstreamAuthorizeURL))
// Expect to be redirected to the upstream provider and log in.
browsertest.LoginToUpstream(t, page, env.SupervisorTestUpstream)
// Wait for the login to happen and us be redirected back to a localhost callback.
t.Logf("waiting for redirect to callback")
callbackURLPattern := regexp.MustCompile(`\A` + regexp.QuoteMeta(localCallbackServer.URL) + `\?.+\z`)
browsertest.WaitForURL(t, page, callbackURLPattern)
// Expect that our callback handler was invoked.
callback := localCallbackServer.waitForCallback(10 * time.Second)
t.Logf("got callback request: %s", library.MaskTokens(callback.URL.String()))
require.Equal(t, stateParam.String(), callback.URL.Query().Get("state"))
require.Equal(t, "openid", callback.URL.Query().Get("scope"))
require.NotEmpty(t, callback.URL.Query().Get("code"))
} }
func generateAuthRequestParams(t *testing.T) (state.State, nonce.Nonce, pkce.Code) { func startLocalCallbackServer(t *testing.T) *localCallbackServer {
t.Helper() // Handle the callback by sending the *http.Request object back through a channel.
state, err := state.Generate() callbacks := make(chan *http.Request, 1)
require.NoError(t, err) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nonce, err := nonce.Generate() callbacks <- r
require.NoError(t, err) }))
pkce, err := pkce.Generate() server.URL = server.URL + "/callback"
require.NoError(t, err) t.Cleanup(server.Close)
return state, nonce, pkce t.Cleanup(func() { close(callbacks) })
return &localCallbackServer{Server: server, t: t, callbacks: callbacks}
} }
func requireValidRedirectLocation( type localCallbackServer struct {
ctx context.Context, *httptest.Server
t *testing.T, t *testing.T
issuer, clientID, redirectURI, actualLocation string, callbacks <-chan *http.Request
) {
t.Helper()
env := library.IntegrationEnv(t)
// Do OIDC discovery on our test issuer to get auth endpoint.
transport := http.Transport{}
if env.Proxy != "" {
transport.Proxy = func(_ *http.Request) (*url.URL, error) {
return url.Parse(env.Proxy)
}
}
if env.SupervisorTestUpstream.CABundle != "" {
transport.TLSClientConfig = &tls.Config{RootCAs: x509.NewCertPool()}
transport.TLSClientConfig.RootCAs.AppendCertsFromPEM([]byte(env.SupervisorTestUpstream.CABundle))
} }
ctx = oidc.ClientContext(ctx, &http.Client{Transport: &transport}) func (s *localCallbackServer) waitForCallback(timeout time.Duration) *http.Request {
upstreamProvider, err := oidc.NewProvider(ctx, issuer) select {
require.NoError(t, err) case callback := <-s.callbacks:
return callback
// Parse expected upstream auth URL. case <-time.After(timeout):
expectedLocationURL, err := url.Parse( require.Fail(s.t, "timed out waiting for callback request")
(&oauth2.Config{ return nil
ClientID: clientID, }
Endpoint: upstreamProvider.Endpoint(),
RedirectURL: redirectURI,
Scopes: []string{"openid"},
}).AuthCodeURL("", oauth2.AccessTypeOffline),
)
require.NoError(t, err)
// Parse actual upstream auth URL.
actualLocationURL, err := url.Parse(actualLocation)
require.NoError(t, err)
// First make some assertions on the query values. Note that we will not be able to know what
// certain query values are since they may be random (e.g., state, pkce, nonce).
expectedLocationQuery := expectedLocationURL.Query()
actualLocationQuery := actualLocationURL.Query()
require.NotEmpty(t, actualLocationQuery.Get("state"))
actualLocationQuery.Del("state")
require.NotEmpty(t, actualLocationQuery.Get("code_challenge"))
actualLocationQuery.Del("code_challenge")
require.NotEmpty(t, actualLocationQuery.Get("code_challenge_method"))
actualLocationQuery.Del("code_challenge_method")
require.NotEmpty(t, actualLocationQuery.Get("nonce"))
actualLocationQuery.Del("nonce")
require.Equal(t, expectedLocationQuery, actualLocationQuery)
// Zero-out query values, since we made specific assertions about those above, and assert that the
// URL's are equal otherwise.
expectedLocationURL.RawQuery = ""
actualLocationURL.RawQuery = ""
require.Equal(t, expectedLocationURL, actualLocationURL)
} }