Merge branch 'main' into jtc/add-importas-linter

This commit is contained in:
Joshua Casey
2024-06-11 09:39:48 -05:00
357 changed files with 23832 additions and 1310 deletions

View File

@@ -160,7 +160,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
Groups: env.SupervisorUpstreamOIDC.GroupsClaim,
},
Client: idpv1alpha1.OIDCClient{
SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name,
SecretName: testlib.CreateOIDCClientCredentialsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name,
},
}, idpv1alpha1.PhaseReady)
testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, supervisorconfigv1alpha1.FederationDomainPhaseReady)
@@ -246,7 +246,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
Groups: env.SupervisorUpstreamOIDC.GroupsClaim,
},
Client: idpv1alpha1.OIDCClient{
SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name,
SecretName: testlib.CreateOIDCClientCredentialsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name,
},
}, idpv1alpha1.PhaseReady)
testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, supervisorconfigv1alpha1.FederationDomainPhaseReady)
@@ -334,7 +334,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
Groups: env.SupervisorUpstreamOIDC.GroupsClaim,
},
Client: idpv1alpha1.OIDCClient{
SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name,
SecretName: testlib.CreateOIDCClientCredentialsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name,
},
}, idpv1alpha1.PhaseReady)
testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, supervisorconfigv1alpha1.FederationDomainPhaseReady)
@@ -458,7 +458,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
Groups: env.SupervisorUpstreamOIDC.GroupsClaim,
},
Client: idpv1alpha1.OIDCClient{
SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name,
SecretName: testlib.CreateOIDCClientCredentialsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name,
},
}, idpv1alpha1.PhaseReady)
testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, supervisorconfigv1alpha1.FederationDomainPhaseReady)
@@ -589,7 +589,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
Groups: env.SupervisorUpstreamOIDC.GroupsClaim,
},
Client: idpv1alpha1.OIDCClient{
SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name,
SecretName: testlib.CreateOIDCClientCredentialsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name,
},
}, idpv1alpha1.PhaseReady)
testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, supervisorconfigv1alpha1.FederationDomainPhaseReady)
@@ -662,7 +662,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
Groups: env.SupervisorUpstreamOIDC.GroupsClaim,
},
Client: idpv1alpha1.OIDCClient{
SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name,
SecretName: testlib.CreateOIDCClientCredentialsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name,
},
}, idpv1alpha1.PhaseReady)
testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, supervisorconfigv1alpha1.FederationDomainPhaseReady)
@@ -1209,6 +1209,93 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes)
})
t.Run("with Supervisor GitHub upstream IDP and browser flow with with form_post automatic authcode delivery to CLI", func(t *testing.T) {
testlib.SkipTestWhenGitHubIsUnavailable(t)
testCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
t.Cleanup(cancel)
tempDir := t.TempDir() // per-test tmp dir to avoid sharing files between tests
// Start a fresh browser driver because we don't want to share cookies between the various tests in this file.
browser := browsertest.OpenBrowser(t)
expectedUsername := env.SupervisorUpstreamGithub.TestUserUsername + ":" + env.SupervisorUpstreamGithub.TestUserID
expectedGroups := env.SupervisorUpstreamGithub.TestUserExpectedTeamSlugs
// 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 GitHub provider and wait for it to become ready.
createdProvider := testlib.CreateTestGitHubIdentityProvider(t, idpv1alpha1.GitHubIdentityProviderSpec{
AllowAuthentication: idpv1alpha1.GitHubAllowAuthenticationSpec{
Organizations: idpv1alpha1.GitHubOrganizationsSpec{
Policy: ptr.To(idpv1alpha1.GitHubAllowedAuthOrganizationsPolicyAllGitHubUsers),
},
},
Claims: idpv1alpha1.GitHubClaims{
Username: ptr.To(idpv1alpha1.GitHubUsernameLoginAndID),
Groups: ptr.To(idpv1alpha1.GitHubUseTeamSlugForGroupName),
},
Client: idpv1alpha1.GitHubClientSpec{
SecretName: testlib.CreateGitHubClientCredentialsSecret(t,
env.SupervisorUpstreamGithub.GithubAppClientID,
env.SupervisorUpstreamGithub.GithubAppClientSecret,
).Name,
},
}, idpv1alpha1.GitHubPhaseReady)
testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, configv1alpha1.FederationDomainPhaseReady)
testlib.WaitForJWTAuthenticatorStatusPhase(testCtx, t, authenticator.Name, authv1alpha.JWTAuthenticatorPhaseReady)
// Use a specific session cache for this test.
sessionCachePath := tempDir + "/test-sessions.yaml"
credentialCachePath := tempDir + "/test-credentials.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,
"--credential-cache", credentialCachePath,
// use default for --oidc-scopes, which is to request all relevant scopes
})
// Run "kubectl get namespaces" which should trigger a browser login via the plugin.
kubectlCmd := exec.CommandContext(testCtx, "kubectl", "get", "namespace", "--kubeconfig", kubeconfigPath, "-v", "6")
kubectlCmd.Env = append(os.Environ(), env.ProxyEnv()...)
// Run the kubectl command, wait for the Pinniped CLI to print the authorization URL, and open it in the browser.
kubectlOutputChan := startKubectlAndOpenAuthorizationURLInBrowser(testCtx, t, kubectlCmd, browser)
// Confirm that we got to the upstream IDP's login page, fill out the form, and submit the form.
browsertest.LoginToUpstreamGitHub(t, browser, env.SupervisorUpstreamGithub)
// Expect to be redirected to the downstream callback which is serving the form_post HTML.
t.Logf("waiting for response page %s", federationDomain.Spec.Issuer)
browser.WaitForURL(t, regexp.MustCompile(regexp.QuoteMeta(federationDomain.Spec.Issuer)))
// 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, browser)
requireKubectlGetNamespaceOutput(t, env, waitForKubectlOutput(t, kubectlOutputChan))
requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, federationDomain, createdProvider.Name, kubeconfigPath,
sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes)
})
t.Run("with multiple IDPs: one OIDC and one LDAP", func(t *testing.T) {
testlib.SkipTestWhenLDAPIsUnavailable(t, env)
@@ -1270,7 +1357,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
Groups: env.SupervisorUpstreamOIDC.GroupsClaim,
},
Client: idpv1alpha1.OIDCClient{
SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name,
SecretName: testlib.CreateOIDCClientCredentialsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name,
},
}, idpv1alpha1.PhaseReady)
@@ -1591,7 +1678,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
Groups: env.SupervisorUpstreamOIDC.GroupsClaim,
},
Client: idpv1alpha1.OIDCClient{
SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name,
SecretName: testlib.CreateOIDCClientCredentialsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name,
},
}, idpv1alpha1.PhaseReady)

View File

@@ -248,6 +248,20 @@ func TestGetAPIResourceList(t *testing.T) { //nolint:gocyclo // each t.Run is pr
Kind: "ActiveDirectoryIdentityProvider",
Verbs: []string{"get", "patch", "update"},
},
{
Name: "githubidentityproviders",
SingularName: "githubidentityprovider",
Namespaced: true,
Kind: "GitHubIdentityProvider",
Verbs: []string{"delete", "deletecollection", "get", "list", "patch", "create", "update", "watch"},
Categories: []string{"pinniped", "pinniped-idp", "pinniped-idps"},
},
{
Name: "githubidentityproviders/status",
Namespaced: true,
Kind: "GitHubIdentityProvider",
Verbs: []string{"get", "patch", "update"},
},
},
},
},
@@ -438,7 +452,7 @@ func TestGetAPIResourceList(t *testing.T) { //nolint:gocyclo // each t.Run is pr
}
// manually update this value whenever you add additional fields to an API resource and then run the generator
totalExpectedAPIFields := 263
totalExpectedAPIFields := 289
// Because we are parsing text from `kubectl explain` and because the format of that text can change
// over time, make a rudimentary assertion that this test exercised the whole tree of all fields of all
@@ -578,6 +592,13 @@ func TestCRDAdditionalPrinterColumns_Parallel(t *testing.T) {
{Name: "Age", Type: "date", JSONPath: ".metadata.creationTimestamp"},
},
},
addSuffix("githubidentityproviders.idp.supervisor"): {
"v1alpha1": []apiextensionsv1.CustomResourceColumnDefinition{
{Name: "Host", Type: "string", JSONPath: ".spec.githubAPI.host"},
{Name: "Status", Type: "string", JSONPath: ".status.phase"},
{Name: "Age", Type: "date", JSONPath: ".metadata.creationTimestamp"},
},
},
addSuffix("oidcclients.config.supervisor"): {
"v1alpha1": []apiextensionsv1.CustomResourceColumnDefinition{
{Name: "Privileged Scopes", Type: "string", JSONPath: `.spec.allowedScopes[?(@ == "pinniped:request-audience")]`},
@@ -588,8 +609,20 @@ func TestCRDAdditionalPrinterColumns_Parallel(t *testing.T) {
},
}
actualPinnipedCRDCount := 0
expectedPinnipedCRDCount := 8 // the current number of CRDs that we ship as part of Pinniped
// the current CRDs that we ship as part of Pinniped
expectedPinnipedCRDNames := []string{
"activedirectoryidentityproviders.idp.supervisor." + env.APIGroupSuffix,
"credentialissuers.config.concierge." + env.APIGroupSuffix,
"federationdomains.config.supervisor." + env.APIGroupSuffix,
"githubidentityproviders.idp.supervisor." + env.APIGroupSuffix,
"jwtauthenticators.authentication.concierge." + env.APIGroupSuffix,
"ldapidentityproviders.idp.supervisor." + env.APIGroupSuffix,
"oidcclients.config.supervisor." + env.APIGroupSuffix,
"oidcidentityproviders.idp.supervisor." + env.APIGroupSuffix,
"webhookauthenticators.authentication.concierge." + env.APIGroupSuffix,
}
actualPinnipedCRDNames := make([]string, 0)
for _, crd := range crdList.Items {
if !strings.Contains(crd.Spec.Group, env.APIGroupSuffix) {
@@ -597,7 +630,7 @@ func TestCRDAdditionalPrinterColumns_Parallel(t *testing.T) {
}
// Found a Pinniped CRD, so let's check it for AdditionalPrinterColumns.
actualPinnipedCRDCount++
actualPinnipedCRDNames = append(actualPinnipedCRDNames, crd.Name)
for _, version := range crd.Spec.Versions {
expectedColumns, ok := expectedColumnsPerCRDVersion[crd.Name][version.Name]
@@ -611,7 +644,7 @@ func TestCRDAdditionalPrinterColumns_Parallel(t *testing.T) {
}
// Make sure that the logic of this test did not accidentally skip a CRD that it should have interrogated.
require.Equal(t, expectedPinnipedCRDCount, actualPinnipedCRDCount,
require.ElementsMatch(t, expectedPinnipedCRDNames, actualPinnipedCRDNames,
"did not find expected number of Pinniped CRDs to check for additionalPrinterColumns")
}

View File

@@ -7,6 +7,7 @@ import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/json"
"fmt"
"io"
@@ -24,6 +25,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/util/retry"
"k8s.io/utils/ptr"
supervisorconfigv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1"
idpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1"
@@ -92,7 +94,7 @@ func TestSupervisorOIDCDiscovery_Disruptive(t *testing.T) {
// Test that there is no default discovery endpoint available when there are no FederationDomains.
requireDiscoveryEndpointsAreNotFound(t, scheme, addr, caBundle, fmt.Sprintf("%s://%s", scheme, addr))
// Define several unique issuer strings. Always use https in the issuer name even when we are accessing the http port.
// Define several unique issuer URLs. Always use https in the issuer URL even when we are accessing the http port.
issuer1 := fmt.Sprintf("https://%s/nested/issuer1", addr)
issuer2 := fmt.Sprintf("https://%s/nested/issuer2", addr)
issuer3 := fmt.Sprintf("https://%s/issuer3", addr)
@@ -101,7 +103,7 @@ func TestSupervisorOIDCDiscovery_Disruptive(t *testing.T) {
issuer6 := fmt.Sprintf("https://%s/issuer6", addr)
badIssuer := fmt.Sprintf("https://%s/badIssuer?cannot-use=queries", addr)
// When FederationDomain are created in sequence they each cause a discovery endpoint to appear only for as long as the FederationDomain exists.
// When FederationDomains are created in sequence they each cause a discovery endpoint to appear only for as long as the FederationDomain exists.
config1, jwks1 := requireCreatingFederationDomainCausesDiscoveryEndpointsToAppear(ctx, t, scheme, addr, caBundle, issuer1, client)
requireDeletingFederationDomainCausesDiscoveryEndpointsToDisappear(t, config1, client, ns, scheme, addr, caBundle, issuer1)
config2, jwks2 := requireCreatingFederationDomainCausesDiscoveryEndpointsToAppear(ctx, t, scheme, addr, caBundle, issuer2, client)
@@ -113,15 +115,15 @@ func TestSupervisorOIDCDiscovery_Disruptive(t *testing.T) {
// When multiple FederationDomains exist at the same time they each serve a unique discovery endpoint.
config3, jwks3 := requireCreatingFederationDomainCausesDiscoveryEndpointsToAppear(ctx, t, scheme, addr, caBundle, issuer3, client)
config4, jwks4 := requireCreatingFederationDomainCausesDiscoveryEndpointsToAppear(ctx, t, scheme, addr, caBundle, issuer4, client)
requireDiscoveryEndpointsAreWorking(t, scheme, addr, caBundle, issuer3, nil) // discovery for issuer3 is still working after issuer4 started working
requireStandardDiscoveryEndpointsAreWorking(t, scheme, addr, caBundle, issuer3, nil) // discovery for issuer3 is still working after issuer4 started working
// The auto-created JWK's were different from each other.
require.NotEqual(t, jwks3.Keys[0]["x"], jwks4.Keys[0]["x"])
require.NotEqual(t, jwks3.Keys[0]["y"], jwks4.Keys[0]["y"])
// Editing a provider to change the issuer name updates the endpoints that are being served.
// Editing a FederationDomain to change the issuer URL updates the endpoints that are being served.
updatedConfig4 := editFederationDomainIssuerName(t, config4, client, ns, issuer5)
requireDiscoveryEndpointsAreNotFound(t, scheme, addr, caBundle, issuer4)
jwks5 := requireDiscoveryEndpointsAreWorking(t, scheme, addr, caBundle, issuer5, nil)
jwks5 := requireStandardDiscoveryEndpointsAreWorking(t, scheme, addr, caBundle, issuer5, nil)
// The JWK did not change when the issuer name was updated.
require.Equal(t, jwks4.Keys[0], jwks5.Keys[0])
@@ -129,31 +131,37 @@ func TestSupervisorOIDCDiscovery_Disruptive(t *testing.T) {
requireDeletingFederationDomainCausesDiscoveryEndpointsToDisappear(t, config3, client, ns, scheme, addr, caBundle, issuer3)
requireDeletingFederationDomainCausesDiscoveryEndpointsToDisappear(t, updatedConfig4, client, ns, scheme, addr, caBundle, issuer5)
// When the same issuer is added twice, both issuers are marked as duplicates, and neither provider is serving.
// When the same issuer URL is added to two FederationDomains, both FederationDomains are marked as duplicates, and neither is serving.
config6Duplicate1, _ := requireCreatingFederationDomainCausesDiscoveryEndpointsToAppear(ctx, t, scheme, addr, caBundle, issuer6, client)
config6Duplicate2 := testlib.CreateTestFederationDomain(ctx, t, supervisorconfigv1alpha1.FederationDomainSpec{Issuer: issuer6}, supervisorconfigv1alpha1.FederationDomainPhaseError)
requireStatus(t, client, ns, config6Duplicate1.Name, supervisorconfigv1alpha1.FederationDomainPhaseError, withFalseConditions([]string{"Ready", "IssuerIsUnique"}))
requireStatus(t, client, ns, config6Duplicate2.Name, supervisorconfigv1alpha1.FederationDomainPhaseError, withFalseConditions([]string{"Ready", "IssuerIsUnique"}))
requireDiscoveryEndpointsAreNotFound(t, scheme, addr, caBundle, issuer6)
// If we delete the first duplicate issuer, the second duplicate issuer starts serving.
// If we delete the first duplicate FederationDomain, the second duplicate FederationDomain starts serving.
requireDelete(t, client, ns, config6Duplicate1.Name)
requireWellKnownEndpointIsWorking(t, scheme, addr, caBundle, issuer6, nil)
requireStatus(t, client, ns, config6Duplicate2.Name, supervisorconfigv1alpha1.FederationDomainPhaseReady, withAllSuccessfulConditions())
// When we finally delete all issuers, the endpoint should be down.
// When we finally delete all FederationDomains, the discovery endpoints should be down.
requireDeletingFederationDomainCausesDiscoveryEndpointsToDisappear(t, config6Duplicate2, client, ns, scheme, addr, caBundle, issuer6)
// "Host" headers can be used to send requests to discovery endpoints when the public address is different from the issuer name.
// "Host" headers can be used to send requests to discovery endpoints when the public address is different from the issuer URL.
issuer7 := "https://some-issuer-host-and-port-that-doesnt-match-public-supervisor-address.com:2684/issuer7"
config7, _ := requireCreatingFederationDomainCausesDiscoveryEndpointsToAppear(ctx, t, scheme, addr, caBundle, issuer7, client)
requireDeletingFederationDomainCausesDiscoveryEndpointsToDisappear(t, config7, client, ns, scheme, addr, caBundle, issuer7)
// When we create a provider with an invalid issuer, the status is set to invalid.
// When we create a FederationDomain with an invalid issuer url, the status is set to invalid.
badConfig := testlib.CreateTestFederationDomain(ctx, t, supervisorconfigv1alpha1.FederationDomainSpec{Issuer: badIssuer}, supervisorconfigv1alpha1.FederationDomainPhaseError)
requireStatus(t, client, ns, badConfig.Name, supervisorconfigv1alpha1.FederationDomainPhaseError, withFalseConditions([]string{"Ready", "IssuerURLValid"}))
requireDiscoveryEndpointsAreNotFound(t, scheme, addr, caBundle, badIssuer)
requireDeletingFederationDomainCausesDiscoveryEndpointsToDisappear(t, badConfig, client, ns, scheme, addr, caBundle, badIssuer)
issuer8 := fmt.Sprintf("https://%s/issuer8multipleIDP", addr)
config8 := requireIDPsListedByIDPDiscoveryEndpoint(t, env, ctx, kubeClient, ns, scheme, addr, caBundle, issuer8)
// requireJWKSEndpointIsWorking() will give us a bit of an idea what to do...
requireDeletingFederationDomainCausesDiscoveryEndpointsToDisappear(t, config8, client, ns, scheme, addr, caBundle, issuer8)
})
}
}
@@ -198,7 +206,7 @@ func TestSupervisorTLSTerminationWithSNI_Disruptive(t *testing.T) {
ca1 := createTLSCertificateSecret(ctx, t, ns, hostname1, nil, certSecretName1, kubeClient)
// Now that the Secret exists, we should be able to access the endpoints by hostname using the CA.
_ = requireDiscoveryEndpointsAreWorking(t, scheme, address, string(ca1.Bundle()), issuer1, nil)
_ = requireStandardDiscoveryEndpointsAreWorking(t, scheme, address, string(ca1.Bundle()), issuer1, nil)
// Update the config to with a new .spec.tls.secretName.
certSecretName1update := "integration-test-cert-1-update"
@@ -219,7 +227,7 @@ func TestSupervisorTLSTerminationWithSNI_Disruptive(t *testing.T) {
ca1update := createTLSCertificateSecret(ctx, t, ns, hostname1, nil, certSecretName1update, kubeClient)
// Now that the Secret exists at the new name, we should be able to access the endpoints by hostname using the CA.
_ = requireDiscoveryEndpointsAreWorking(t, scheme, address, string(ca1update.Bundle()), issuer1, nil)
_ = requireStandardDiscoveryEndpointsAreWorking(t, scheme, address, string(ca1update.Bundle()), issuer1, nil)
// To test SNI virtual hosting, send requests to discovery endpoints when the public address is different from the issuer name.
hostname2 := "some-issuer-host-and-port-that-doesnt-match-public-supervisor-address.com"
@@ -239,7 +247,7 @@ func TestSupervisorTLSTerminationWithSNI_Disruptive(t *testing.T) {
ca2 := createTLSCertificateSecret(ctx, t, ns, hostname2, nil, certSecretName2, kubeClient)
// Now that the Secret exists, we should be able to access the endpoints by hostname using the CA.
_ = requireDiscoveryEndpointsAreWorking(t, scheme, hostname2+":"+hostnamePort2, string(ca2.Bundle()), issuer2, map[string]string{
_ = requireStandardDiscoveryEndpointsAreWorking(t, scheme, hostname2+":"+hostnamePort2, string(ca2.Bundle()), issuer2, map[string]string{
hostname2 + ":" + hostnamePort2: address,
})
}
@@ -292,7 +300,7 @@ func TestSupervisorTLSTerminationWithDefaultCerts_Disruptive(t *testing.T) {
defaultCA := createTLSCertificateSecret(ctx, t, ns, "cert-hostname-doesnt-matter", []net.IP{ips[0]}, defaultTLSCertSecretName(env), kubeClient)
// Now that the Secret exists, we should be able to access the endpoints by IP address using the CA.
_ = requireDiscoveryEndpointsAreWorking(t, scheme, ipWithPort, string(defaultCA.Bundle()), issuerUsingIPAddress, nil)
_ = requireStandardDiscoveryEndpointsAreWorking(t, scheme, ipWithPort, string(defaultCA.Bundle()), issuerUsingIPAddress, nil)
// Create an FederationDomain with a spec.tls.secretName.
certSecretName := "integration-test-cert-1"
@@ -309,10 +317,10 @@ func TestSupervisorTLSTerminationWithDefaultCerts_Disruptive(t *testing.T) {
// Now that the Secret exists, we should be able to access the endpoints by hostname using the CA from the SNI cert.
// Hostnames are case-insensitive, so the request should still work even if the case of the hostname is different
// from the case of the issuer URL's hostname.
_ = requireDiscoveryEndpointsAreWorking(t, scheme, strings.ToUpper(hostname)+":"+port, string(certCA.Bundle()), issuerUsingHostname, nil)
_ = requireStandardDiscoveryEndpointsAreWorking(t, scheme, strings.ToUpper(hostname)+":"+port, string(certCA.Bundle()), issuerUsingHostname, nil)
// And we can still access the other issuer using the default cert.
_ = requireDiscoveryEndpointsAreWorking(t, scheme, ipWithPort, string(defaultCA.Bundle()), issuerUsingIPAddress, nil)
_ = requireStandardDiscoveryEndpointsAreWorking(t, scheme, ipWithPort, string(defaultCA.Bundle()), issuerUsingIPAddress, nil)
}
func defaultTLSCertSecretName(env *testlib.TestEnv) string {
@@ -488,12 +496,12 @@ func requireCreatingFederationDomainCausesDiscoveryEndpointsToAppear(
) (*supervisorconfigv1alpha1.FederationDomain, *ExpectedJWKSResponseFormat) {
t.Helper()
newFederationDomain := testlib.CreateTestFederationDomain(ctx, t, supervisorconfigv1alpha1.FederationDomainSpec{Issuer: issuerName}, supervisorconfigv1alpha1.FederationDomainPhaseReady)
jwksResult := requireDiscoveryEndpointsAreWorking(t, supervisorScheme, supervisorAddress, supervisorCABundle, issuerName, nil)
jwksResult := requireStandardDiscoveryEndpointsAreWorking(t, supervisorScheme, supervisorAddress, supervisorCABundle, issuerName, nil)
requireStatus(t, client, newFederationDomain.Namespace, newFederationDomain.Name, supervisorconfigv1alpha1.FederationDomainPhaseReady, withAllSuccessfulConditions())
return newFederationDomain, jwksResult
}
func requireDiscoveryEndpointsAreWorking(t *testing.T, supervisorScheme, supervisorAddress, supervisorCABundle, issuerName string, dnsOverrides map[string]string) *ExpectedJWKSResponseFormat {
func requireStandardDiscoveryEndpointsAreWorking(t *testing.T, supervisorScheme, supervisorAddress, supervisorCABundle, issuerName string, dnsOverrides map[string]string) *ExpectedJWKSResponseFormat {
requireWellKnownEndpointIsWorking(t, supervisorScheme, supervisorAddress, supervisorCABundle, issuerName, dnsOverrides)
jwksResult := requireJWKSEndpointIsWorking(t, supervisorScheme, supervisorAddress, supervisorCABundle, issuerName, dnsOverrides)
return jwksResult
@@ -737,3 +745,192 @@ func newHTTPClient(t *testing.T, caBundle string, dnsOverrides map[string]string
return c
}
func requireIDPsListedByIDPDiscoveryEndpoint(
t *testing.T,
env *testlib.TestEnv,
ctx context.Context,
kubeClient kubernetes.Interface,
ns, supervisorScheme, supervisorAddress, supervisorCABundle, issuerName string) *supervisorconfigv1alpha1.FederationDomain {
// github
gitHubIDPSecretName := "github-idp-secret" //nolint:gosec // this is not a credential
_, err := kubeClient.CoreV1().Secrets(ns).Create(ctx, &corev1.Secret{
Type: "secrets.pinniped.dev/github-client",
TypeMeta: metav1.TypeMeta{},
ObjectMeta: metav1.ObjectMeta{
Name: gitHubIDPSecretName,
Namespace: ns,
},
StringData: map[string]string{
"clientID": "foo",
"clientSecret": "bar",
},
}, metav1.CreateOptions{})
require.NoError(t, err)
t.Cleanup(func() {
_ = kubeClient.CoreV1().Secrets(ns).Delete(ctx, gitHubIDPSecretName, metav1.DeleteOptions{})
})
ghIDP := testlib.CreateGitHubIdentityProvider(t, idpv1alpha1.GitHubIdentityProviderSpec{
Client: idpv1alpha1.GitHubClientSpec{
SecretName: gitHubIDPSecretName,
},
AllowAuthentication: idpv1alpha1.GitHubAllowAuthenticationSpec{
Organizations: idpv1alpha1.GitHubOrganizationsSpec{
Policy: ptr.To(idpv1alpha1.GitHubAllowedAuthOrganizationsPolicyAllGitHubUsers),
},
},
}, idpv1alpha1.GitHubPhaseReady)
ldapBindSecret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ldap-service-account", corev1.SecretTypeBasicAuth,
map[string]string{
corev1.BasicAuthUsernameKey: env.SupervisorUpstreamLDAP.BindUsername,
corev1.BasicAuthPasswordKey: env.SupervisorUpstreamLDAP.BindPassword,
},
)
ldapIDP := testlib.CreateTestLDAPIdentityProvider(t, idpv1alpha1.LDAPIdentityProviderSpec{
Host: env.SupervisorUpstreamLDAP.Host,
TLS: &idpv1alpha1.TLSSpec{
CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.CABundle)),
},
Bind: idpv1alpha1.LDAPIdentityProviderBind{
SecretName: ldapBindSecret.Name,
},
UserSearch: idpv1alpha1.LDAPIdentityProviderUserSearch{
Base: env.SupervisorUpstreamLDAP.UserSearchBase,
Filter: "",
Attributes: idpv1alpha1.LDAPIdentityProviderUserSearchAttributes{
Username: env.SupervisorUpstreamLDAP.TestUserMailAttributeName,
UID: env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeName,
},
},
GroupSearch: idpv1alpha1.LDAPIdentityProviderGroupSearch{
Base: env.SupervisorUpstreamLDAP.GroupSearchBase,
Filter: "", // use the default value of "member={}"
Attributes: idpv1alpha1.LDAPIdentityProviderGroupSearchAttributes{
GroupName: "", // use the default value of "dn"
},
},
}, idpv1alpha1.LDAPPhaseReady)
oidcIDP := 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.CreateOIDCClientCredentialsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name,
},
}, idpv1alpha1.PhaseReady)
var adIDP *idpv1alpha1.ActiveDirectoryIdentityProvider
if activeDirectoryAvailable(t, env) {
activeDirectoryBindSecret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ldap-service-account", corev1.SecretTypeBasicAuth,
map[string]string{
corev1.BasicAuthUsernameKey: env.SupervisorUpstreamActiveDirectory.BindUsername,
corev1.BasicAuthPasswordKey: env.SupervisorUpstreamActiveDirectory.BindPassword,
},
)
adIDP = testlib.CreateTestActiveDirectoryIdentityProvider(t, idpv1alpha1.ActiveDirectoryIdentityProviderSpec{
Host: env.SupervisorUpstreamActiveDirectory.Host,
TLS: &idpv1alpha1.TLSSpec{
CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamActiveDirectory.CABundle)),
},
Bind: idpv1alpha1.ActiveDirectoryIdentityProviderBind{
SecretName: activeDirectoryBindSecret.Name,
},
}, idpv1alpha1.ActiveDirectoryPhaseReady)
}
idpsForFD := []supervisorconfigv1alpha1.FederationDomainIdentityProvider{{
DisplayName: ghIDP.Name,
ObjectRef: corev1.TypedLocalObjectReference{
APIGroup: ptr.To("idp.supervisor." + env.APIGroupSuffix),
Kind: "GitHubIdentityProvider",
Name: ghIDP.Name,
},
}, {
DisplayName: ldapIDP.Name,
ObjectRef: corev1.TypedLocalObjectReference{
APIGroup: ptr.To("idp.supervisor." + env.APIGroupSuffix),
Kind: "LDAPIdentityProvider",
Name: ldapIDP.Name,
},
}, {
DisplayName: oidcIDP.Name,
ObjectRef: corev1.TypedLocalObjectReference{
APIGroup: ptr.To("idp.supervisor." + env.APIGroupSuffix),
Kind: "OIDCIdentityProvider",
Name: oidcIDP.Name,
},
}}
if activeDirectoryAvailable(t, env) {
idpsForFD = append(idpsForFD, supervisorconfigv1alpha1.FederationDomainIdentityProvider{
DisplayName: adIDP.Name,
ObjectRef: corev1.TypedLocalObjectReference{
APIGroup: ptr.To("idp.supervisor." + env.APIGroupSuffix),
Kind: "ActiveDirectoryIdentityProvider",
Name: adIDP.Name,
},
})
}
federationDomainConfig := testlib.CreateTestFederationDomain(ctx, t, supervisorconfigv1alpha1.FederationDomainSpec{
Issuer: issuerName,
IdentityProviders: idpsForFD,
}, supervisorconfigv1alpha1.FederationDomainPhaseReady)
requireStandardDiscoveryEndpointsAreWorking(t, supervisorScheme, supervisorAddress, supervisorCABundle, issuerName, nil)
issuer8URL, err := url.Parse(issuerName)
require.NoError(t, err)
wellKnownURL := wellKnownURLForIssuer(supervisorScheme, supervisorAddress, issuer8URL.Path)
_, wellKnownResponseBody := requireSuccessEndpointResponse(t, wellKnownURL, issuerName, supervisorCABundle, nil) //nolint:bodyclose
type WellKnownResponse struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
JWKSUri string `json:"jwks_uri"`
DiscoverySupervisor struct {
IdentityProvidersEndpoint string `json:"pinniped_identity_providers_endpoint"`
} `json:"discovery.supervisor.pinniped.dev/v1alpha1"`
}
var wellKnownResponse WellKnownResponse
err = json.Unmarshal([]byte(wellKnownResponseBody), &wellKnownResponse)
require.NoError(t, err)
discoveryIDPEndpoint := wellKnownResponse.DiscoverySupervisor.IdentityProvidersEndpoint
_, discoveryIDPResponseBody := requireSuccessEndpointResponse(t, discoveryIDPEndpoint, issuerName, supervisorCABundle, nil) //nolint:bodyclose
type IdentityProviderListResponse struct {
IdentityProviders []struct {
Name string `json:"name"`
Type string `json:"type"`
} `json:"pinniped_identity_providers"`
}
var identityProviderListResponse IdentityProviderListResponse
err = json.Unmarshal([]byte(discoveryIDPResponseBody), &identityProviderListResponse)
require.NoError(t, err)
allIDPs := []string{ghIDP.Name, ldapIDP.Name, oidcIDP.Name}
if activeDirectoryAvailable(t, env) {
allIDPs = append(allIDPs, adIDP.Name)
}
require.Equal(t, len(identityProviderListResponse.IdentityProviders), len(allIDPs), "all IDPs should be listed by idp discovery endpoint")
for _, provider := range identityProviderListResponse.IdentityProviders {
require.Contains(t, allIDPs, provider.Name, fmt.Sprintf("provider name should be listed in IDP discovery: %s", provider.Name))
}
return federationDomainConfig
}
func activeDirectoryAvailable(t *testing.T, env *testlib.TestEnv) bool {
t.Helper()
hasLDAPPorts := env.HasCapability(testlib.CanReachInternetLDAPPorts)
hasADHost := testlib.IntegrationEnv(t).SupervisorUpstreamActiveDirectory.Host != ""
return hasLDAPPorts && hasADHost
}

View File

@@ -362,7 +362,7 @@ func TestSupervisorFederationDomainStatus_Disruptive(t *testing.T) {
{
Type: "IdentityProvidersObjectRefKindValid", Status: "False", Reason: "KindUnrecognized",
Message: `some kinds specified by .spec.identityProviders[].objectRef.kind are not recognized ` +
`(should be one of "ActiveDirectoryIdentityProvider", "LDAPIdentityProvider", "OIDCIdentityProvider"): "this is the wrong kind"`,
`(should be one of "ActiveDirectoryIdentityProvider", "GitHubIdentityProvider", "LDAPIdentityProvider", "OIDCIdentityProvider"): "this is the wrong kind"`,
},
{
Type: "Ready", Status: "False", Reason: "NotReady",
@@ -493,7 +493,7 @@ func TestSupervisorFederationDomainStatus_Disruptive(t *testing.T) {
{
Type: "IdentityProvidersObjectRefKindValid", Status: "False", Reason: "KindUnrecognized",
Message: `some kinds specified by .spec.identityProviders[].objectRef.kind are not recognized ` +
`(should be one of "ActiveDirectoryIdentityProvider", "LDAPIdentityProvider", "OIDCIdentityProvider"): "this is the wrong kind"`,
`(should be one of "ActiveDirectoryIdentityProvider", "GitHubIdentityProvider", "LDAPIdentityProvider", "OIDCIdentityProvider"): "this is the wrong kind"`,
},
{
Type: "Ready", Status: "False", Reason: "NotReady",

View File

@@ -0,0 +1,746 @@
// Copyright 2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package integration
import (
"context"
"encoding/base64"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/utils/ptr"
idpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1"
"go.pinniped.dev/internal/here"
"go.pinniped.dev/internal/testutil"
"go.pinniped.dev/test/testlib"
)
const generateNamePrefix = "integration-test-github-idp-"
func TestGitHubIDPStaticValidationOnCreate_Parallel(t *testing.T) {
adminClient := testlib.NewKubernetesClientset(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
t.Cleanup(cancel)
namespaceClient := adminClient.CoreV1().Namespaces()
skipCELTests := !testutil.KubeServerMinorVersionAtLeastInclusive(t, adminClient.Discovery(), 26)
ns, err := namespaceClient.Create(ctx, &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
GenerateName: generateNamePrefix,
},
}, metav1.CreateOptions{})
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, namespaceClient.Delete(ctx, ns.Name, metav1.DeleteOptions{}))
})
gitHubIDPClient := testlib.NewSupervisorClientset(t).IDPV1alpha1().GitHubIdentityProviders(ns.Name)
tests := []struct {
name string
usesCELValidation bool
inputSpec idpv1alpha1.GitHubIdentityProviderSpec
wantSpec idpv1alpha1.GitHubIdentityProviderSpec
wantErr string
}{
{
name: "all fields set",
inputSpec: idpv1alpha1.GitHubIdentityProviderSpec{
GitHubAPI: idpv1alpha1.GitHubAPIConfig{
Host: ptr.To("some-host.example.com"),
TLS: &idpv1alpha1.TLSSpec{
CertificateAuthorityData: func() string {
return base64.StdEncoding.EncodeToString([]byte("-----BEGIN CERTIFICATE-----\ndata goes here"))
}(),
},
},
AllowAuthentication: idpv1alpha1.GitHubAllowAuthenticationSpec{
Organizations: idpv1alpha1.GitHubOrganizationsSpec{
Allowed: []string{
"org1",
"that-other-org",
},
Policy: ptr.To(idpv1alpha1.GitHubAllowedAuthOrganizationsPolicyOnlyUsersFromAllowedOrganizations),
},
},
Claims: idpv1alpha1.GitHubClaims{
Username: ptr.To(idpv1alpha1.GitHubUsernameLoginAndID),
Groups: ptr.To(idpv1alpha1.GitHubUseTeamSlugForGroupName),
},
Client: idpv1alpha1.GitHubClientSpec{
SecretName: "any-name-goes-here",
},
},
wantSpec: idpv1alpha1.GitHubIdentityProviderSpec{
GitHubAPI: idpv1alpha1.GitHubAPIConfig{
Host: ptr.To("some-host.example.com"),
TLS: &idpv1alpha1.TLSSpec{
CertificateAuthorityData: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCmRhdGEgZ29lcyBoZXJl",
},
},
AllowAuthentication: idpv1alpha1.GitHubAllowAuthenticationSpec{
Organizations: idpv1alpha1.GitHubOrganizationsSpec{
Allowed: []string{
"org1",
"that-other-org",
},
Policy: ptr.To(idpv1alpha1.GitHubAllowedAuthOrganizationsPolicyOnlyUsersFromAllowedOrganizations),
},
},
Claims: idpv1alpha1.GitHubClaims{
Username: ptr.To(idpv1alpha1.GitHubUsernameLoginAndID),
Groups: ptr.To(idpv1alpha1.GitHubUseTeamSlugForGroupName),
},
Client: idpv1alpha1.GitHubClientSpec{
SecretName: "any-name-goes-here",
},
},
},
{
name: "minimum fields set - inherit defaults",
inputSpec: idpv1alpha1.GitHubIdentityProviderSpec{
AllowAuthentication: idpv1alpha1.GitHubAllowAuthenticationSpec{
Organizations: idpv1alpha1.GitHubOrganizationsSpec{
Policy: ptr.To(idpv1alpha1.GitHubAllowedAuthOrganizationsPolicyAllGitHubUsers),
},
},
Client: idpv1alpha1.GitHubClientSpec{
SecretName: "name-of-a-secret",
},
},
wantSpec: idpv1alpha1.GitHubIdentityProviderSpec{
GitHubAPI: idpv1alpha1.GitHubAPIConfig{
Host: ptr.To("github.com"),
},
AllowAuthentication: idpv1alpha1.GitHubAllowAuthenticationSpec{
Organizations: idpv1alpha1.GitHubOrganizationsSpec{
Policy: ptr.To(idpv1alpha1.GitHubAllowedAuthOrganizationsPolicyAllGitHubUsers),
},
},
Claims: idpv1alpha1.GitHubClaims{
Username: ptr.To(idpv1alpha1.GitHubUsernameLoginAndID),
Groups: ptr.To(idpv1alpha1.GitHubUseTeamSlugForGroupName),
},
Client: idpv1alpha1.GitHubClientSpec{
SecretName: "name-of-a-secret",
},
},
},
{
name: fmt.Sprintf(
"cannot set AllowedOrganizationsPolicy=%s and set AllowedOrganizations",
string(idpv1alpha1.GitHubAllowedAuthOrganizationsPolicyAllGitHubUsers)),
usesCELValidation: true,
inputSpec: idpv1alpha1.GitHubIdentityProviderSpec{
AllowAuthentication: idpv1alpha1.GitHubAllowAuthenticationSpec{
Organizations: idpv1alpha1.GitHubOrganizationsSpec{
Allowed: []string{
"some-org",
},
Policy: ptr.To(idpv1alpha1.GitHubAllowedAuthOrganizationsPolicyAllGitHubUsers),
},
},
Client: idpv1alpha1.GitHubClientSpec{
SecretName: "name-of-a-secret",
},
},
wantErr: "spec.allowAuthentication.organizations.policy must be 'OnlyUsersFromAllowedOrganizations' when spec.allowAuthentication.organizations.allowed has organizations listed",
},
{
name: fmt.Sprintf("spec.allowAuthentication.organizations.policy must be '%s' when spec.allowAuthentication.organizations.allowed is empty (nil)", string(idpv1alpha1.GitHubAllowedAuthOrganizationsPolicyAllGitHubUsers)),
usesCELValidation: true,
inputSpec: idpv1alpha1.GitHubIdentityProviderSpec{
AllowAuthentication: idpv1alpha1.GitHubAllowAuthenticationSpec{
Organizations: idpv1alpha1.GitHubOrganizationsSpec{
Policy: ptr.To(idpv1alpha1.GitHubAllowedAuthOrganizationsPolicyOnlyUsersFromAllowedOrganizations),
},
},
Client: idpv1alpha1.GitHubClientSpec{
SecretName: "name-of-a-secret",
},
},
wantErr: "spec.allowAuthentication.organizations.policy must be 'AllGitHubUsers' when spec.allowAuthentication.organizations.allowed is empty",
},
{
name: fmt.Sprintf("spec.allowAuthentication.organizations.policy must be '%s' when spec.allowAuthentication.organizations.allowed is empty", string(idpv1alpha1.GitHubAllowedAuthOrganizationsPolicyAllGitHubUsers)),
usesCELValidation: true,
inputSpec: idpv1alpha1.GitHubIdentityProviderSpec{
AllowAuthentication: idpv1alpha1.GitHubAllowAuthenticationSpec{
Organizations: idpv1alpha1.GitHubOrganizationsSpec{
Allowed: []string{},
Policy: ptr.To(idpv1alpha1.GitHubAllowedAuthOrganizationsPolicyOnlyUsersFromAllowedOrganizations),
},
},
Client: idpv1alpha1.GitHubClientSpec{
SecretName: "name-of-a-secret",
},
},
wantErr: "spec.allowAuthentication.organizations.policy must be 'AllGitHubUsers' when spec.allowAuthentication.organizations.allowed is empty",
},
{
name: "spec.client.secretName in body should be at least 1 chars long",
inputSpec: idpv1alpha1.GitHubIdentityProviderSpec{},
wantErr: "spec.client.secretName in body should be at least 1 chars long",
},
{
name: "spec.githubAPI.host in body should be at least 1 chars long",
inputSpec: idpv1alpha1.GitHubIdentityProviderSpec{
GitHubAPI: idpv1alpha1.GitHubAPIConfig{
Host: ptr.To(""),
},
AllowAuthentication: idpv1alpha1.GitHubAllowAuthenticationSpec{
Organizations: idpv1alpha1.GitHubOrganizationsSpec{
Policy: ptr.To(idpv1alpha1.GitHubAllowedAuthOrganizationsPolicyAllGitHubUsers),
},
},
Client: idpv1alpha1.GitHubClientSpec{
SecretName: "name-of-a-secret",
},
},
wantErr: "spec.githubAPI.host in body should be at least 1 chars long",
},
{
name: "duplicates not permitted in spec.allowAuthentication.organizations.allowed",
inputSpec: idpv1alpha1.GitHubIdentityProviderSpec{
AllowAuthentication: idpv1alpha1.GitHubAllowAuthenticationSpec{
Organizations: idpv1alpha1.GitHubOrganizationsSpec{
Allowed: []string{
"org1",
"org1",
},
Policy: ptr.To(idpv1alpha1.GitHubAllowedAuthOrganizationsPolicyOnlyUsersFromAllowedOrganizations),
},
},
Client: idpv1alpha1.GitHubClientSpec{
SecretName: "name-of-a-secret",
},
},
wantErr: `spec.allowAuthentication.organizations.allowed[1]: Duplicate value: "org1"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if tt.usesCELValidation && skipCELTests {
t.Skip("CEL is not available for current K8s version")
}
input := &idpv1alpha1.GitHubIdentityProvider{
ObjectMeta: metav1.ObjectMeta{
GenerateName: generateNamePrefix,
},
Spec: tt.inputSpec,
}
outputGitHubIDP, err := gitHubIDPClient.Create(ctx, input, metav1.CreateOptions{})
if tt.wantErr == "" {
require.NoError(t, err)
require.Equal(t, tt.wantSpec, outputGitHubIDP.Spec)
} else {
require.ErrorContains(t, err, tt.wantErr)
}
})
}
}
func TestGitHubIDPSetsDefaultsWithKubectl_Parallel(t *testing.T) {
env := testlib.IntegrationEnv(t)
adminClient := testlib.NewKubernetesClientset(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
t.Cleanup(cancel)
namespaceClient := adminClient.CoreV1().Namespaces()
ns, err := namespaceClient.Create(ctx, &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
GenerateName: generateNamePrefix,
},
}, metav1.CreateOptions{})
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, namespaceClient.Delete(ctx, ns.Name, metav1.DeleteOptions{}))
})
t.Logf("Created namespace %s", ns.Name)
idpName := generateNamePrefix + testlib.RandHex(t, 16)
githubIDPYaml := []byte(here.Doc(fmt.Sprintf(`
---
apiVersion: idp.supervisor.%s/v1alpha1
kind: GitHubIdentityProvider
metadata:
name: %s
namespace: %s
spec:
allowAuthentication:
organizations:
policy: AllGitHubUsers
client:
secretName: any-secret-name`, env.APIGroupSuffix, idpName, ns.Name)))
githubIDPYamlFilepath := filepath.Join(t.TempDir(), "github-idp.yaml")
require.NoError(t, os.WriteFile(githubIDPYamlFilepath, githubIDPYaml, 0600))
stdOut, stdErr := runTestKubectlCommand(t, "create", "-f", githubIDPYamlFilepath)
require.Equal(t, fmt.Sprintf("githubidentityprovider.idp.supervisor.%s/%s created\n", env.APIGroupSuffix, idpName), stdOut)
require.Empty(t, stdErr)
gitHubIDPClient := testlib.NewSupervisorClientset(t).IDPV1alpha1().GitHubIdentityProviders(ns.Name)
idp, err := gitHubIDPClient.Get(ctx, idpName, metav1.GetOptions{})
require.NoError(t, err)
require.Equal(t, idpv1alpha1.GitHubIdentityProviderSpec{
GitHubAPI: idpv1alpha1.GitHubAPIConfig{
Host: ptr.To("github.com"),
},
Claims: idpv1alpha1.GitHubClaims{
Username: ptr.To(idpv1alpha1.GitHubUsernameLoginAndID),
Groups: ptr.To(idpv1alpha1.GitHubUseTeamSlugForGroupName),
},
AllowAuthentication: idpv1alpha1.GitHubAllowAuthenticationSpec{
Organizations: idpv1alpha1.GitHubOrganizationsSpec{
Policy: ptr.To(idpv1alpha1.GitHubAllowedAuthOrganizationsPolicyAllGitHubUsers),
},
},
Client: idpv1alpha1.GitHubClientSpec{
SecretName: "any-secret-name",
},
}, idp.Spec)
}
func TestGitHubIDPPhaseAndConditions_Parallel(t *testing.T) {
// These operations must be performed in the Supervisor's namespace so that the controller can find GitHubIdentityProvider
supervisorNamespace := testlib.IntegrationEnv(t).SupervisorNamespace
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
t.Cleanup(cancel)
kubernetesClient := testlib.NewKubernetesClientset(t)
secretsClient := kubernetesClient.CoreV1().Secrets(supervisorNamespace)
gitHubIDPClient := testlib.NewSupervisorClientset(t).IDPV1alpha1().GitHubIdentityProviders(supervisorNamespace)
happySecretName := generateNamePrefix + testlib.RandHex(t, 16)
invalidSecretName := generateNamePrefix + testlib.RandHex(t, 16)
tests := []struct {
name string
secrets []*corev1.Secret // Secrets will be created first, and the first secret found will be listed as the configured GitHub Client secret
idps []*idpv1alpha1.GitHubIdentityProvider
wantPhase idpv1alpha1.GitHubIdentityProviderPhase
wantConditions []*metav1.Condition
}{
{
name: "Happy Path",
secrets: []*corev1.Secret{
{
ObjectMeta: metav1.ObjectMeta{
Name: happySecretName,
},
Type: "secrets.pinniped.dev/github-client",
Data: map[string][]byte{
"clientID": []byte("foo"),
"clientSecret": []byte("bar"),
},
},
},
idps: []*idpv1alpha1.GitHubIdentityProvider{
{
Spec: idpv1alpha1.GitHubIdentityProviderSpec{
GitHubAPI: idpv1alpha1.GitHubAPIConfig{
Host: ptr.To("github.com"),
},
AllowAuthentication: idpv1alpha1.GitHubAllowAuthenticationSpec{
Organizations: idpv1alpha1.GitHubOrganizationsSpec{
Policy: ptr.To(idpv1alpha1.GitHubAllowedAuthOrganizationsPolicyAllGitHubUsers),
},
},
},
},
},
wantPhase: idpv1alpha1.GitHubPhaseReady,
wantConditions: []*metav1.Condition{
{
Type: "ClaimsValid",
Status: metav1.ConditionTrue,
Reason: "Success",
Message: "spec.claims are valid",
},
{
Type: "ClientCredentialsSecretValid",
Status: metav1.ConditionTrue,
Reason: "Success",
Message: fmt.Sprintf("clientID and clientSecret have been read from spec.client.SecretName (%q)", happySecretName),
},
{
Type: "GitHubConnectionValid",
Status: metav1.ConditionTrue,
Reason: "Success",
Message: `spec.githubAPI.host ("github.com:443") is reachable and TLS verification succeeds`,
},
{
Type: "HostValid",
Status: metav1.ConditionTrue,
Reason: "Success",
Message: `spec.githubAPI.host ("github.com") is valid`,
},
{
Type: "OrganizationsPolicyValid",
Status: metav1.ConditionTrue,
Reason: "Success",
Message: `spec.allowAuthentication.organizations.policy ("AllGitHubUsers") is valid`,
},
{
Type: "TLSConfigurationValid",
Status: metav1.ConditionTrue,
Reason: "Success",
Message: "spec.githubAPI.tls.certificateAuthorityData is valid",
},
},
},
{
name: "Invalid Client Secret",
secrets: []*corev1.Secret{
{
Type: "secrets.pinniped.dev/github-client",
ObjectMeta: metav1.ObjectMeta{
Name: invalidSecretName,
},
},
},
idps: []*idpv1alpha1.GitHubIdentityProvider{
{
Spec: idpv1alpha1.GitHubIdentityProviderSpec{
GitHubAPI: idpv1alpha1.GitHubAPIConfig{
Host: ptr.To("github.com"),
},
AllowAuthentication: idpv1alpha1.GitHubAllowAuthenticationSpec{
Organizations: idpv1alpha1.GitHubOrganizationsSpec{
Policy: ptr.To(idpv1alpha1.GitHubAllowedAuthOrganizationsPolicyAllGitHubUsers),
},
},
Client: idpv1alpha1.GitHubClientSpec{
SecretName: invalidSecretName,
},
},
},
},
wantPhase: idpv1alpha1.GitHubPhaseError,
wantConditions: []*metav1.Condition{
{
Type: "ClaimsValid",
Status: metav1.ConditionTrue,
Reason: "Success",
Message: "spec.claims are valid",
},
{
Type: "ClientCredentialsSecretValid",
Status: metav1.ConditionFalse,
Reason: "SecretNotFound",
Message: fmt.Sprintf(`missing key "clientID": secret from spec.client.SecretName (%q) must be found in namespace %q with type "secrets.pinniped.dev/github-client" and keys "clientID" and "clientSecret"`,
invalidSecretName,
supervisorNamespace),
},
{
Type: "GitHubConnectionValid",
Status: metav1.ConditionTrue,
Reason: "Success",
Message: `spec.githubAPI.host ("github.com:443") is reachable and TLS verification succeeds`,
},
{
Type: "HostValid",
Status: metav1.ConditionTrue,
Reason: "Success",
Message: `spec.githubAPI.host ("github.com") is valid`,
},
{
Type: "OrganizationsPolicyValid",
Status: metav1.ConditionTrue,
Reason: "Success",
Message: `spec.allowAuthentication.organizations.policy ("AllGitHubUsers") is valid`,
},
{
Type: "TLSConfigurationValid",
Status: metav1.ConditionTrue,
Reason: "Success",
Message: `spec.githubAPI.tls.certificateAuthorityData is valid`,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var secretName string
for _, secret := range tt.secrets {
secret.GenerateName = generateNamePrefix
created, err := secretsClient.Create(ctx, secret, metav1.CreateOptions{})
require.NoError(t, err)
t.Cleanup(func() {
err := secretsClient.Delete(ctx, created.Name, metav1.DeleteOptions{})
require.NoError(t, err)
})
if secretName == "" {
secretName = created.Name
}
}
for _, idp := range tt.idps {
idp.Name = ""
idp.GenerateName = generateNamePrefix
idp.Spec.Client.SecretName = secretName
created, err := gitHubIDPClient.Create(ctx, idp, metav1.CreateOptions{})
require.NoError(t, err)
t.Cleanup(func() {
err := gitHubIDPClient.Delete(ctx, created.Name, metav1.DeleteOptions{})
require.NoError(t, err)
})
testlib.WaitForGitHubIDPPhase(ctx, t, gitHubIDPClient, created.Name, tt.wantPhase)
testlib.WaitForGitHubIdentityProviderStatusConditions(ctx, t, gitHubIDPClient, created.Name, tt.wantConditions)
}
})
}
}
func TestGitHubIDPInWrongNamespace_Parallel(t *testing.T) {
// The GitHubIdentityProvider must be in the same namespace as the controller
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
t.Cleanup(cancel)
kubernetesClient := testlib.NewKubernetesClientset(t)
namespaceClient := kubernetesClient.CoreV1().Namespaces()
otherNamespace, err := namespaceClient.Create(ctx, &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
GenerateName: generateNamePrefix,
},
}, metav1.CreateOptions{})
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, namespaceClient.Delete(ctx, otherNamespace.Name, metav1.DeleteOptions{}))
})
gitHubIDPClient := testlib.NewSupervisorClientset(t).IDPV1alpha1().GitHubIdentityProviders(otherNamespace.Name)
idp := &idpv1alpha1.GitHubIdentityProvider{
ObjectMeta: metav1.ObjectMeta{
GenerateName: generateNamePrefix,
Namespace: otherNamespace.Name,
},
Spec: idpv1alpha1.GitHubIdentityProviderSpec{
GitHubAPI: idpv1alpha1.GitHubAPIConfig{
Host: ptr.To("github.com"),
},
AllowAuthentication: idpv1alpha1.GitHubAllowAuthenticationSpec{
Organizations: idpv1alpha1.GitHubOrganizationsSpec{
Policy: ptr.To(idpv1alpha1.GitHubAllowedAuthOrganizationsPolicyAllGitHubUsers),
},
},
Client: idpv1alpha1.GitHubClientSpec{
SecretName: "does-not-matter",
},
},
}
createdIDP, err := gitHubIDPClient.Create(ctx, idp, metav1.CreateOptions{})
require.NoError(t, err)
t.Cleanup(func() {
err := gitHubIDPClient.Delete(ctx, createdIDP.Name, metav1.DeleteOptions{})
require.NoError(t, err)
})
// We require that there's never an error
// ... and that the status phase is never anything but Pending
// ... and that there are no status conditions
require.Never(t, func() bool {
idp, err := gitHubIDPClient.Get(ctx, createdIDP.Name, metav1.GetOptions{})
return err != nil && idp.Status.Phase != idpv1alpha1.GitHubPhasePending && len(idp.Status.Conditions) > 0
}, 2*time.Minute, 10*time.Second)
}
func TestGitHubIDPSecretInOtherNamespace_Parallel(t *testing.T) {
// The GitHubIdentityProvider must be in the same namespace as the controller
supervisorNamespace := testlib.IntegrationEnv(t).SupervisorNamespace
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
t.Cleanup(cancel)
kubernetesClient := testlib.NewKubernetesClientset(t)
gitHubIDPClient := testlib.NewSupervisorClientset(t).IDPV1alpha1().GitHubIdentityProviders(supervisorNamespace)
namespaceClient := kubernetesClient.CoreV1().Namespaces()
otherNamespace, err := namespaceClient.Create(ctx, &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
GenerateName: generateNamePrefix,
},
}, metav1.CreateOptions{})
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, namespaceClient.Delete(ctx, otherNamespace.Name, metav1.DeleteOptions{}))
})
secretsClient := kubernetesClient.CoreV1().Secrets(otherNamespace.Name)
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
GenerateName: generateNamePrefix,
Namespace: otherNamespace.Name,
},
Type: "secrets.pinniped.dev/github-client",
Data: map[string][]byte{
"clientID": []byte("foo"),
"clientSecret": []byte("bar"),
},
}
// This secret will be cleaned up when its namespace is deleted
createdSecret, err := secretsClient.Create(ctx, secret, metav1.CreateOptions{})
require.NoError(t, err)
idp := &idpv1alpha1.GitHubIdentityProvider{
ObjectMeta: metav1.ObjectMeta{
GenerateName: generateNamePrefix,
Namespace: supervisorNamespace,
},
Spec: idpv1alpha1.GitHubIdentityProviderSpec{
GitHubAPI: idpv1alpha1.GitHubAPIConfig{
Host: ptr.To("github.com"),
},
AllowAuthentication: idpv1alpha1.GitHubAllowAuthenticationSpec{
Organizations: idpv1alpha1.GitHubOrganizationsSpec{
Policy: ptr.To(idpv1alpha1.GitHubAllowedAuthOrganizationsPolicyAllGitHubUsers),
},
},
Client: idpv1alpha1.GitHubClientSpec{
SecretName: createdSecret.Name,
},
},
}
created, err := gitHubIDPClient.Create(ctx, idp, metav1.CreateOptions{})
require.NoError(t, err)
t.Cleanup(func() {
err := gitHubIDPClient.Delete(ctx, created.Name, metav1.DeleteOptions{})
require.NoError(t, err)
})
testlib.WaitForGitHubIDPPhase(ctx, t, gitHubIDPClient, created.Name, idpv1alpha1.GitHubPhaseError)
testlib.WaitForGitHubIdentityProviderStatusConditions(ctx, t, gitHubIDPClient, created.Name, []*metav1.Condition{
{
Type: "ClaimsValid",
Status: metav1.ConditionTrue,
Reason: "Success",
Message: "spec.claims are valid",
},
{
Type: "ClientCredentialsSecretValid",
Status: metav1.ConditionFalse,
Reason: "SecretNotFound",
Message: fmt.Sprintf(`secret %q not found: secret from spec.client.SecretName (%q) must be found in namespace %q with type "secrets.pinniped.dev/github-client" and keys "clientID" and "clientSecret"`,
idp.Spec.Client.SecretName,
idp.Spec.Client.SecretName,
supervisorNamespace),
},
{
Type: "GitHubConnectionValid",
Status: metav1.ConditionTrue,
Reason: "Success",
Message: `spec.githubAPI.host ("github.com:443") is reachable and TLS verification succeeds`,
},
{
Type: "HostValid",
Status: metav1.ConditionTrue,
Reason: "Success",
Message: `spec.githubAPI.host ("github.com") is valid`,
},
{
Type: "OrganizationsPolicyValid",
Status: metav1.ConditionTrue,
Reason: "Success",
Message: `spec.allowAuthentication.organizations.policy ("AllGitHubUsers") is valid`,
},
{
Type: "TLSConfigurationValid",
Status: metav1.ConditionTrue,
Reason: "Success",
Message: "spec.githubAPI.tls.certificateAuthorityData is valid",
},
})
}
func TestGitHubIDPTooManyOrganizationsStaticValidationOnCreate_Parallel(t *testing.T) {
adminClient := testlib.NewKubernetesClientset(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
t.Cleanup(cancel)
namespaceClient := adminClient.CoreV1().Namespaces()
ns, err := namespaceClient.Create(ctx, &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
GenerateName: generateNamePrefix,
},
}, metav1.CreateOptions{})
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, namespaceClient.Delete(ctx, ns.Name, metav1.DeleteOptions{}))
})
gitHubIDPClient := testlib.NewSupervisorClientset(t).IDPV1alpha1().GitHubIdentityProviders(ns.Name)
input := &idpv1alpha1.GitHubIdentityProvider{
ObjectMeta: metav1.ObjectMeta{
GenerateName: generateNamePrefix,
},
Spec: idpv1alpha1.GitHubIdentityProviderSpec{
AllowAuthentication: idpv1alpha1.GitHubAllowAuthenticationSpec{
Organizations: idpv1alpha1.GitHubOrganizationsSpec{
Allowed: func() []string {
orgs := make([]string, 100)
for i := range 100 {
orgs[i] = fmt.Sprintf("org-%d", i)
}
return orgs
}(),
Policy: ptr.To(idpv1alpha1.GitHubAllowedAuthOrganizationsPolicyOnlyUsersFromAllowedOrganizations),
},
},
Client: idpv1alpha1.GitHubClientSpec{
SecretName: "name-of-a-secret",
},
},
}
_, err = gitHubIDPClient.Create(ctx, input, metav1.CreateOptions{})
wantErr := "spec.allowAuthentication.organizations.allowed: Invalid value: 100: spec.allowAuthentication.organizations.allowed in body should have at most 64 items"
if testutil.KubeServerMinorVersionAtLeastInclusive(t, adminClient.Discovery(), 24) {
wantErr = "spec.allowAuthentication.organizations.allowed: Too many: 100: must have at most 64 items"
}
require.ErrorContains(t, err, wantErr)
}

View File

@@ -44,6 +44,113 @@ import (
"go.pinniped.dev/test/testlib/browsertest"
)
// These tests attempt to exercise the entire login and refresh flow of the Supervisor for various cases.
// They do not use the Pinniped CLI as the client, which allows them to exercise the Supervisor as an
// OIDC provider in ways that the CLI might not use. Similar tests exist using the CLI in e2e_test.go.
//
// Each of these tests perform the following flow:
// 1. Configure an IDP CR.
// 2. Create a FederationDomain with TLS configured and wait for its JWKS endpoint to be available.
// 3. Call the authorization endpoint and log in as a specific user.
// Note that these tests do not use form_post response type (which is tested by e2e_test.go).
// 4. Listen on a local callback server for the authorization redirect, and assert that it was success or failure.
// 5. Call the token endpoint to exchange the authcode.
// 6. Call the token endpoint to perform the RFC8693 token exchange for the cluster-scoped ID token.
// 7. Potentially edit the refresh session data or IDP settings before the refresh.
// 8. Call the token endpoint to perform a refresh, and expect it to succeed.
// 9. Call the token endpoint again to perform another RFC8693 token exchange for the cluster-scoped ID token,
// this time using the recently refreshed tokens when submitting the request.
// 10. Potentially edit the refresh session data or IDP settings again, this time in such a way that the next
// refresh should fail. If done, then perform one more refresh and expect failure.
type supervisorLoginTestcase struct {
name string
// This required function might choose to skip the test case, for example if the LDAP server is not
// available for an LDAP test.
maybeSkip func(t *testing.T)
// This required function should configure an IDP CR. It should also wait for it to be ready and schedule
// its cleanup. Return the name of the IDP CR.
createIDP func(t *testing.T) string
// Optionally specify the identityProviders part of the FederationDomain's spec by returning it from this function.
// Also return the displayName of the IDP that should be used during authentication (or empty string for no IDP name in the auth request).
// This function takes the name of the IDP CR which was returned by createIDP() as as argument.
federationDomainIDPs func(t *testing.T, idpName string) (idps []configv1alpha1.FederationDomainIdentityProvider, useIDPDisplayName string)
// Optionally create an OIDCClient CR for the test to use. Return the client ID and client secret for the
// test to use. When not set, the test will default to using the "pinniped-cli" static client with no secret.
// When a client secret is returned, it will be used for authcode exchange, refresh requests, and RFC8693
// token exchanges for cluster-scoped tokens (client secrets are not needed in authorization requests).
createOIDCClient func(t *testing.T, callbackURL string) (string, string)
// Optionally return the username and password for the test to use when logging in. This username/password
// will be passed to requestAuthorization(), or empty strings will be passed to indicate that the defaults
// should be used. If there is any cleanup required, then this function should also schedule that cleanup.
testUser func(t *testing.T) (string, string)
// This required function should call the authorization endpoint using the given URL and also perform whatever
// interactions are needed to log in as the user.
requestAuthorization func(t *testing.T, downstreamIssuer, downstreamAuthorizeURL, downstreamCallbackURL, username, password string, httpClient *http.Client)
// This string will be used as the requested audience in the RFC8693 token exchange for
// the cluster-scoped ID token. When it is not specified, a default string will be used.
requestTokenExchangeAud string
// The scopes to request from the authorization endpoint. Defaults will be used when not specified.
downstreamScopes []string
// The scopes to want granted from the authorization endpoint. Defaults to the downstreamScopes value when not,
// specified, i.e. by default it expects that all requested scopes were granted.
wantDownstreamScopes []string
// When we want the localhost callback to have never happened, then the flow will stop there. The login was
// unable to finish so there is nothing to assert about what should have happened with the callback, and there
// won't be any error sent to the callback either. This would happen, for example, when the user fails to log
// in at the LDAP/AD login page, because then they would be redirected back to that page again, instead of
// getting a callback success/error redirect.
wantLocalhostCallbackToNeverHappen bool
// The expected ID token subject claim value as a regexp, for the original ID token and the refreshed ID token.
wantDownstreamIDTokenSubjectToMatch string
// The expected ID token username claim value as a regexp, for the original ID token and the refreshed ID token.
// This function should return an empty string when there should be no username claim in the ID tokens.
wantDownstreamIDTokenUsernameToMatch func(username string) string
// The expected ID token groups claim value, for the original ID token and the refreshed ID token.
wantDownstreamIDTokenGroups []string
// The expected ID token additional claims, which will be nested under claim "additionalClaims",
// for the original ID token and the refreshed ID token.
wantDownstreamIDTokenAdditionalClaims map[string]interface{}
// The expected ID token lifetime, as calculated by token claim 'exp' subtracting token claim 'iat'.
// ID tokens issued through authcode exchange or token refresh should have the configured lifetime (or default if not configured).
// ID tokens issued through a token exchange should have the default lifetime.
wantDownstreamIDTokenLifetime *time.Duration
// Want the authorization endpoint to redirect to the callback with this error type.
// The rest of the flow will be skipped since the initial authorization failed.
wantAuthorizationErrorType string
// Want the authorization endpoint to redirect to the callback with this error description.
// Should be used with wantAuthorizationErrorType.
wantAuthorizationErrorDescription string
// Optionally want to the authcode exchange at the token endpoint to fail. The rest of the flow will be
// skipped since the authcode exchange failed.
wantAuthcodeExchangeError string
// Optionally make all required assertions about the response of the RFC8693 token exchange for
// the cluster-scoped ID token, given the http response status and response body from the token endpoint.
// When this is not specified then the appropriate default assertions for a successful exchange are made.
// Even if this expects failures, the rest of the flow will continue.
wantTokenExchangeResponse func(t *testing.T, status int, body string)
// Optionally edit the refresh session data between the initial login and the first refresh,
// which is still expected to succeed after these edits. Returns the group memberships expected after the
// refresh is performed.
editRefreshSessionDataWithoutBreaking func(t *testing.T, sessionData *psession.PinnipedSession, idpName, username string) []string
// Optionally either revoke the user's session on the upstream provider, or manipulate the user's session
// data in such a way that it should cause the next upstream refresh attempt to fail.
breakRefreshSessionData func(t *testing.T, sessionData *psession.PinnipedSession, idpName, username string)
}
func TestSupervisorLogin_Browser(t *testing.T) {
env := testlib.IntegrationEnv(t)
@@ -56,6 +163,16 @@ func TestSupervisorLogin_Browser(t *testing.T) {
testlib.SkipTestWhenLDAPIsUnavailable(t, env)
}
skipAnyGitHubTests := func(t *testing.T) {
t.Helper()
testlib.SkipTestWhenGitHubIsUnavailable(t)
}
skipGitHubOAuthAppTestsButRunOtherGitHubTests := func(t *testing.T) {
t.Helper()
testlib.SkipTestWhenGitHubOAuthClientCallbackDoesNotMatchFederationDomainIssuerCallback(t)
}
skipActiveDirectoryTests := func(t *testing.T) {
t.Helper()
testlib.SkipTestWhenActiveDirectoryIsUnavailable(t, env)
@@ -68,7 +185,7 @@ func TestSupervisorLogin_Browser(t *testing.T) {
CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)),
},
Client: idpv1alpha1.OIDCClient{
SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name,
SecretName: testlib.CreateOIDCClientCredentialsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name,
},
}
}
@@ -205,112 +322,7 @@ func TestSupervisorLogin_Browser(t *testing.T) {
regexp.QuoteMeta("&sub=") + ".+" +
"$"
// These tests attempt to exercise the entire login and refresh flow of the Supervisor for various cases.
// They do not use the Pinniped CLI as the client, which allows them to exercise the Supervisor as an
// OIDC provider in ways that the CLI might not use. Similar tests exist using the CLI in e2e_test.go.
//
// Each of these tests perform the following flow:
// 1. Configure an IDP CR.
// 2. Create a FederationDomain with TLS configured and wait for its JWKS endpoint to be available.
// 3. Call the authorization endpoint and log in as a specific user.
// Note that these tests do not use form_post response type (which is tested by e2e_test.go).
// 4. Listen on a local callback server for the authorization redirect, and assert that it was success or failure.
// 5. Call the token endpoint to exchange the authcode.
// 6. Call the token endpoint to perform the RFC8693 token exchange for the cluster-scoped ID token.
// 7. Potentially edit the refresh session data or IDP settings before the refresh.
// 8. Call the token endpoint to perform a refresh, and expect it to succeed.
// 9. Call the token endpoint again to perform another RFC8693 token exchange for the cluster-scoped ID token,
// this time using the recently refreshed tokens when submitting the request.
// 10. Potentially edit the refresh session data or IDP settings again, this time in such a way that the next
// refresh should fail. If done, then perform one more refresh and expect failure.
tests := []struct {
name string
// This required function might choose to skip the test case, for example if the LDAP server is not
// available for an LDAP test.
maybeSkip func(t *testing.T)
// This required function should configure an IDP CR. It should also wait for it to be ready and schedule
// its cleanup. Return the name of the IDP CR.
createIDP func(t *testing.T) string
// Optionally specify the identityProviders part of the FederationDomain's spec by returning it from this function.
// Also return the displayName of the IDP that should be used during authentication (or empty string for no IDP name in the auth request).
// This function takes the name of the IDP CR which was returned by createIDP() as as argument.
federationDomainIDPs func(t *testing.T, idpName string) (idps []supervisorconfigv1alpha1.FederationDomainIdentityProvider, useIDPDisplayName string)
// Optionally create an OIDCClient CR for the test to use. Return the client ID and client secret for the
// test to use. When not set, the test will default to using the "pinniped-cli" static client with no secret.
// When a client secret is returned, it will be used for authcode exchange, refresh requests, and RFC8693
// token exchanges for cluster-scoped tokens (client secrets are not needed in authorization requests).
createOIDCClient func(t *testing.T, callbackURL string) (string, string)
// Optionally return the username and password for the test to use when logging in. This username/password
// will be passed to requestAuthorization(), or empty strings will be passed to indicate that the defaults
// should be used. If there is any cleanup required, then this function should also schedule that cleanup.
testUser func(t *testing.T) (string, string)
// This required function should call the authorization endpoint using the given URL and also perform whatever
// interactions are needed to log in as the user.
requestAuthorization func(t *testing.T, downstreamIssuer, downstreamAuthorizeURL, downstreamCallbackURL, username, password string, httpClient *http.Client)
// This string will be used as the requested audience in the RFC8693 token exchange for
// the cluster-scoped ID token. When it is not specified, a default string will be used.
requestTokenExchangeAud string
// The scopes to request from the authorization endpoint. Defaults will be used when not specified.
downstreamScopes []string
// The scopes to want granted from the authorization endpoint. Defaults to the downstreamScopes value when not,
// specified, i.e. by default it expects that all requested scopes were granted.
wantDownstreamScopes []string
// When we want the localhost callback to have never happened, then the flow will stop there. The login was
// unable to finish so there is nothing to assert about what should have happened with the callback, and there
// won't be any error sent to the callback either. This would happen, for example, when the user fails to log
// in at the LDAP/AD login page, because then they would be redirected back to that page again, instead of
// getting a callback success/error redirect.
wantLocalhostCallbackToNeverHappen bool
// The expected ID token subject claim value as a regexp, for the original ID token and the refreshed ID token.
wantDownstreamIDTokenSubjectToMatch string
// The expected ID token username claim value as a regexp, for the original ID token and the refreshed ID token.
// This function should return an empty string when there should be no username claim in the ID tokens.
wantDownstreamIDTokenUsernameToMatch func(username string) string
// The expected ID token groups claim value, for the original ID token and the refreshed ID token.
wantDownstreamIDTokenGroups []string
// The expected ID token additional claims, which will be nested under claim "additionalClaims",
// for the original ID token and the refreshed ID token.
wantDownstreamIDTokenAdditionalClaims map[string]any
// The expected ID token lifetime, as calculated by token claim 'exp' subtracting token claim 'iat'.
// ID tokens issued through authcode exchange or token refresh should have the configured lifetime (or default if not configured).
// ID tokens issued through a token exchange should have the default lifetime.
wantDownstreamIDTokenLifetime *time.Duration
// Want the authorization endpoint to redirect to the callback with this error type.
// The rest of the flow will be skipped since the initial authorization failed.
wantAuthorizationErrorType string
// Want the authorization endpoint to redirect to the callback with this error description.
// Should be used with wantAuthorizationErrorType.
wantAuthorizationErrorDescription string
// Optionally want to the authcode exchange at the token endpoint to fail. The rest of the flow will be
// skipped since the authcode exchange failed.
wantAuthcodeExchangeError string
// Optionally make all required assertions about the response of the RFC8693 token exchange for
// the cluster-scoped ID token, given the http response status and response body from the token endpoint.
// When this is not specified then the appropriate default assertions for a successful exchange are made.
// Even if this expects failures, the rest of the flow will continue.
wantTokenExchangeResponse func(t *testing.T, status int, body string)
// Optionally edit the refresh session data between the initial login and the first refresh,
// which is still expected to succeed after these edits. Returns the group memberships expected after the
// refresh is performed.
editRefreshSessionDataWithoutBreaking func(t *testing.T, sessionData *psession.PinnipedSession, idpName, username string) []string
// Optionally either revoke the user's session on the upstream provider, or manipulate the user's session
// data in such a way that it should cause the next upstream refresh attempt to fail.
breakRefreshSessionData func(t *testing.T, sessionData *psession.PinnipedSession, idpName, username string)
}{
tests := []*supervisorLoginTestcase{
{
name: "oidc with default username and groups claim settings",
maybeSkip: skipNever,
@@ -2174,6 +2186,24 @@ func TestSupervisorLogin_Browser(t *testing.T) {
},
}
// Append testcases for GitHub using a GitHub App as the client.
tests = append(tests,
supervisorLoginGithubTestcases(env,
env.SupervisorUpstreamGithub.GithubAppClientID,
env.SupervisorUpstreamGithub.GithubAppClientSecret,
"using GitHub App as client",
skipAnyGitHubTests)...,
)
// Append those same testcases for GitHub again, but this time using one of GitHub's old-style OAuth Apps as the client.
tests = append(tests,
supervisorLoginGithubTestcases(env,
env.SupervisorUpstreamGithub.GithubOAuthAppClientID,
env.SupervisorUpstreamGithub.GithubOAuthAppClientSecret,
"using old-style GitHub OAuth App as client",
skipGitHubOAuthAppTestsButRunOtherGitHubTests)...,
)
for _, test := range tests {
tt := test
t.Run(tt.name, func(t *testing.T) {
@@ -2206,7 +2236,163 @@ func TestSupervisorLogin_Browser(t *testing.T) {
}
}
func wantGroupsInAdditionalClaimsIfGroupsExist(additionalClaims map[string]any, wantGroupsAdditionalClaimName string, wantGroups []string) map[string]any {
func supervisorLoginGithubTestcases(
env *testlib.TestEnv,
clientID string,
clientSecret string,
nameNote string,
maybeSkip func(t *testing.T),
) []*supervisorLoginTestcase {
basicGitHubIdentityProviderSpec := func(t *testing.T, clientID, clientSecret string) idpv1alpha1.GitHubIdentityProviderSpec {
return idpv1alpha1.GitHubIdentityProviderSpec{
AllowAuthentication: idpv1alpha1.GitHubAllowAuthenticationSpec{
Organizations: idpv1alpha1.GitHubOrganizationsSpec{
Policy: ptr.To(idpv1alpha1.GitHubAllowedAuthOrganizationsPolicyAllGitHubUsers),
},
},
Client: idpv1alpha1.GitHubClientSpec{
SecretName: testlib.CreateGitHubClientCredentialsSecret(t, clientID, clientSecret).Name,
},
}
}
// The downstream ID token Subject should include the upstream user ID after the upstream issuer name
// and IDP display name.
expectedIDTokenSubjectRegexForUpstreamGitHub := "^" +
regexp.QuoteMeta("https://api.github.com?idpName=test-upstream-github-idp-") + `[\w]+` +
regexp.QuoteMeta("&login=") + regexp.QuoteMeta(env.SupervisorUpstreamGithub.TestUserUsername) +
regexp.QuoteMeta("&id=") + regexp.QuoteMeta(env.SupervisorUpstreamGithub.TestUserID) +
"$"
return []*supervisorLoginTestcase{
{
name: fmt.Sprintf("github with all orgs allowed and default claim settings (%s)", nameNote),
maybeSkip: maybeSkip,
createIDP: func(t *testing.T) string {
return testlib.CreateTestGitHubIdentityProvider(t,
basicGitHubIdentityProviderSpec(t, clientID, clientSecret),
idpv1alpha1.GitHubPhaseReady).Name
},
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowGitHub,
editRefreshSessionDataWithoutBreaking: func(t *testing.T, sessionData *psession.PinnipedSession, _, _ string) []string {
// Even if we update this group to some names that did not come from the GitHub API,
// we expect that it will return to the real groups from the GitHub API after we refresh.
initialGroupMembership := []string{"some-wrong-group", "some-other-group"}
sessionData.Custom.UpstreamGroups = initialGroupMembership // upstream group names in session
sessionData.Fosite.Claims.Extra["groups"] = initialGroupMembership // downstream group names in session
return env.SupervisorUpstreamGithub.TestUserExpectedTeamSlugs
},
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
// Pretend that the github access token was revoked or expired by changing it to an
// invalid access token in the user's session data. This should cause refresh to fail because
// during the refresh the GitHub API will not accept this bad access token.
customSessionData := pinnipedSession.Custom
require.Equal(t, psession.ProviderTypeGitHub, customSessionData.ProviderType)
require.NotEmpty(t, customSessionData.GitHub.UpstreamAccessToken)
customSessionData.GitHub.UpstreamAccessToken = "purposely-using-bad-access-token-during-an-automated-integration-test"
},
wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamGitHub,
wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
return "^" + regexp.QuoteMeta(env.SupervisorUpstreamGithub.TestUserUsername+":"+env.SupervisorUpstreamGithub.TestUserID) + "$"
},
wantDownstreamIDTokenGroups: env.SupervisorUpstreamGithub.TestUserExpectedTeamSlugs,
},
{
name: fmt.Sprintf("github with list of allowed orgs, username as login, and groups as names (%s)", nameNote),
maybeSkip: maybeSkip,
createIDP: func(t *testing.T) string {
spec := basicGitHubIdentityProviderSpec(t, clientID, clientSecret)
spec.AllowAuthentication = idpv1alpha1.GitHubAllowAuthenticationSpec{
Organizations: idpv1alpha1.GitHubOrganizationsSpec{
Policy: ptr.To(idpv1alpha1.GitHubAllowedAuthOrganizationsPolicyOnlyUsersFromAllowedOrganizations),
Allowed: []string{env.SupervisorUpstreamGithub.TestUserOrganization, "some-unrelated-org"},
},
}
spec.Claims = idpv1alpha1.GitHubClaims{
Username: ptr.To(idpv1alpha1.GitHubUsernameLogin),
Groups: ptr.To(idpv1alpha1.GitHubUseTeamNameForGroupName),
}
return testlib.CreateTestGitHubIdentityProvider(t, spec, idpv1alpha1.GitHubPhaseReady).Name
},
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowGitHub,
wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamGitHub,
wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
return "^" + regexp.QuoteMeta(env.SupervisorUpstreamGithub.TestUserUsername) + "$"
},
wantDownstreamIDTokenGroups: env.SupervisorUpstreamGithub.TestUserExpectedTeamNames,
},
{
name: fmt.Sprintf("github with list of allowed orgs differently cased, username as id, and groups as names (%s)", nameNote),
maybeSkip: maybeSkip,
createIDP: func(t *testing.T) string {
spec := basicGitHubIdentityProviderSpec(t, clientID, clientSecret)
spec.AllowAuthentication = idpv1alpha1.GitHubAllowAuthenticationSpec{
Organizations: idpv1alpha1.GitHubOrganizationsSpec{
Policy: ptr.To(idpv1alpha1.GitHubAllowedAuthOrganizationsPolicyOnlyUsersFromAllowedOrganizations),
Allowed: []string{strings.ToUpper(env.SupervisorUpstreamGithub.TestUserOrganization), "some-unrelated-org"},
},
}
spec.Claims = idpv1alpha1.GitHubClaims{
Username: ptr.To(idpv1alpha1.GitHubUsernameID),
Groups: ptr.To(idpv1alpha1.GitHubUseTeamNameForGroupName),
}
return testlib.CreateTestGitHubIdentityProvider(t, spec, idpv1alpha1.GitHubPhaseReady).Name
},
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowGitHub,
wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamGitHub,
wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
return "^" + regexp.QuoteMeta(env.SupervisorUpstreamGithub.TestUserID) + "$"
},
wantDownstreamIDTokenGroups: env.SupervisorUpstreamGithub.TestUserExpectedTeamNames,
},
{
name: fmt.Sprintf("github when user does not belong to any of the allowed orgs, should fail at the Supervisor callback endpoint (%s)", nameNote),
maybeSkip: maybeSkip,
createIDP: func(t *testing.T) string {
spec := basicGitHubIdentityProviderSpec(t, clientID, clientSecret)
spec.AllowAuthentication = idpv1alpha1.GitHubAllowAuthenticationSpec{
Organizations: idpv1alpha1.GitHubOrganizationsSpec{
Policy: ptr.To(idpv1alpha1.GitHubAllowedAuthOrganizationsPolicyOnlyUsersFromAllowedOrganizations),
Allowed: []string{"some-unrelated-org"},
},
}
return testlib.CreateTestGitHubIdentityProvider(t, spec, idpv1alpha1.GitHubPhaseReady).Name
},
federationDomainIDPs: func(t *testing.T, idpName string) ([]configv1alpha1.FederationDomainIdentityProvider, string) {
displayName := "some-github-identity-provider-name"
return []configv1alpha1.FederationDomainIdentityProvider{
{
DisplayName: displayName,
ObjectRef: corev1.TypedLocalObjectReference{
APIGroup: ptr.To("idp.supervisor." + env.APIGroupSuffix),
Kind: "GitHubIdentityProvider",
Name: idpName,
},
},
},
displayName
},
requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, downstreamCallbackURL, _, _ string, httpClient *http.Client) {
t.Helper()
browser := openBrowserAndNavigateToAuthorizeURL(t, downstreamAuthorizeURL, httpClient)
// Expect to be redirected to the upstream provider and log in.
browsertest.LoginToUpstreamGitHub(t, browser, env.SupervisorUpstreamGithub)
// Wait for the login to happen and us be redirected back to the Supervisor callback with an error showing.
t.Logf("waiting for redirect to Supervisor callback endpoint, which should be showing an error")
callbackURLPattern := regexp.MustCompile(`\A` + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.CallbackURL) + `\?.+\z`)
browser.WaitForURL(t, callbackURLPattern)
// Get the text of the preformatted error message showing on the page.
textOfPreTag := browser.TextOfFirstMatch(t, "pre")
require.Equal(t,
`Forbidden: login denied due to configuration on GitHubIdentityProvider with display name "some-github-identity-provider-name": user is not allowed to log in due to organization membership policy`+"\n",
textOfPreTag)
},
wantLocalhostCallbackToNeverHappen: true,
},
}
}
func wantGroupsInAdditionalClaimsIfGroupsExist(additionalClaims map[string]interface{}, wantGroupsAdditionalClaimName string, wantGroups []string) map[string]interface{} {
if len(wantGroups) > 0 {
var wantGroupsAnyType []any
for _, group := range wantGroups {
@@ -2360,7 +2546,7 @@ func testSupervisorLogin(
) {
env := testlib.IntegrationEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), 7*time.Minute)
defer cancel()
// Infer the downstream issuer URL from the callback associated with the upstream test client registration.
@@ -2427,7 +2613,7 @@ func testSupervisorLogin(
}
// Create the downstream FederationDomain and expect it to go into the appropriate status condition.
downstream := testlib.CreateTestFederationDomain(ctx, t,
federationDomain := testlib.CreateTestFederationDomain(ctx, t,
supervisorconfigv1alpha1.FederationDomainSpec{
Issuer: issuerURL.String(),
TLS: &supervisorconfigv1alpha1.FederationDomainTLSSpec{SecretName: certSecret.Name},
@@ -2476,7 +2662,7 @@ func testSupervisorLogin(
var discovery *coreosoidc.Provider
testlib.RequireEventually(t, func(requireEventually *require.Assertions) {
var err error
discovery, err = coreosoidc.NewProvider(oidcHTTPClientContext, downstream.Spec.Issuer)
discovery, err = coreosoidc.NewProvider(oidcHTTPClientContext, federationDomain.Spec.Issuer)
requireEventually.NoError(err)
}, 30*time.Second, 200*time.Millisecond)
@@ -2527,7 +2713,7 @@ func testSupervisorLogin(
downstreamAuthorizeURL := downstreamOAuth2Config.AuthCodeURL(stateParam.String(), authorizeRequestParams...)
// Perform parameterized auth code acquisition.
requestAuthorization(t, downstream.Spec.Issuer, downstreamAuthorizeURL, localCallbackServer.URL, username, password, httpClient)
requestAuthorization(t, federationDomain.Spec.Issuer, downstreamAuthorizeURL, localCallbackServer.URL, username, password, httpClient)
// Expect that our callback handler was invoked.
callback, err := localCallbackServer.waitForCallback(10 * time.Second)
@@ -2718,6 +2904,7 @@ func testSupervisorLogin(
// Should have got an error since the upstream refresh should have failed.
require.Error(t, err)
require.EqualError(t, err, `oauth2: "error" "Error during upstream refresh. Upstream refresh failed."`)
t.Log("successfully confirmed that breaking the refresh session data caused the refresh to fail")
}
}
@@ -2884,6 +3071,19 @@ func loginToUpstreamOIDCAndWaitForCallback(t *testing.T, b *browsertest.Browser,
b.WaitForURL(t, callbackURLPattern)
}
func loginToUpstreamGitHubAndWaitForCallback(t *testing.T, b *browsertest.Browser, downstreamCallbackURL string) {
t.Helper()
env := testlib.IntegrationEnv(t)
// Expect to be redirected to the upstream provider and log in.
browsertest.LoginToUpstreamGitHub(t, b, env.SupervisorUpstreamGithub)
// 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(downstreamCallbackURL) + `\?.+\z`)
b.WaitForURL(t, callbackURLPattern)
}
func requestAuthorizationUsingBrowserAuthcodeFlowOIDC(t *testing.T, _, downstreamAuthorizeURL, downstreamCallbackURL, _, _ string, httpClient *http.Client) {
t.Helper()
@@ -2892,6 +3092,14 @@ func requestAuthorizationUsingBrowserAuthcodeFlowOIDC(t *testing.T, _, downstrea
loginToUpstreamOIDCAndWaitForCallback(t, browser, downstreamCallbackURL)
}
func requestAuthorizationUsingBrowserAuthcodeFlowGitHub(t *testing.T, _, downstreamAuthorizeURL, downstreamCallbackURL, _, _ string, httpClient *http.Client) {
t.Helper()
browser := openBrowserAndNavigateToAuthorizeURL(t, downstreamAuthorizeURL, httpClient)
loginToUpstreamGitHubAndWaitForCallback(t, browser, downstreamCallbackURL)
}
func requestAuthorizationUsingBrowserAuthcodeFlowOIDCWithIDPChooserPage(t *testing.T, downstreamIssuer, downstreamAuthorizeURL, downstreamCallbackURL, _, _ string, httpClient *http.Client) {
t.Helper()

View File

@@ -93,7 +93,7 @@ func TestAuthorizeCodeStorage(t *testing.T) {
// Note that CreateAuthorizeCodeSession() sets Active to true and also sets the Version before storing the session,
// so expect those here.
session.Active = true
session.Version = "7" // this is the value of the authorizationcode.authorizeCodeStorageVersion constant
session.Version = "8" // this is the value of the authorizationcode.authorizeCodeStorageVersion constant
expectedSessionStorageJSON, err := json.Marshal(session)
require.NoError(t, err)
require.JSONEq(t, string(expectedSessionStorageJSON), string(initialSecret.Data["pinniped-storage-data"]))

View File

@@ -28,7 +28,7 @@ func TestSupervisorUpstreamOIDCDiscovery(t *testing.T) {
upstream := testlib.CreateTestOIDCIdentityProvider(t, spec, idpv1alpha1.PhaseError)
expectUpstreamConditions(t, upstream, []metav1.Condition{
{
Type: "ClientCredentialsValid",
Type: "ClientCredentialsSecretValid",
Status: metav1.ConditionFalse,
Reason: "SecretNotFound",
Message: `secret "does-not-exist" not found`,
@@ -60,13 +60,13 @@ Get "https://127.0.0.1:444444/invalid-url-that-is-really-really-long-nananananan
AdditionalScopes: []string{"email", "profile"},
},
Client: idpv1alpha1.OIDCClient{
SecretName: testlib.CreateClientCredsSecret(t, "test-client-id", "test-client-secret").Name,
SecretName: testlib.CreateOIDCClientCredentialsSecret(t, "test-client-id", "test-client-secret").Name,
},
}
upstream := testlib.CreateTestOIDCIdentityProvider(t, spec, idpv1alpha1.PhaseError)
expectUpstreamConditions(t, upstream, []metav1.Condition{
{
Type: "ClientCredentialsValid",
Type: "ClientCredentialsSecretValid",
Status: metav1.ConditionTrue,
Reason: "Success",
Message: "loaded client credentials",
@@ -98,13 +98,13 @@ oidc: issuer did not match the issuer returned by provider, expected "` + env.Su
AdditionalScopes: []string{"email", "profile"},
},
Client: idpv1alpha1.OIDCClient{
SecretName: testlib.CreateClientCredsSecret(t, "test-client-id", "test-client-secret").Name,
SecretName: testlib.CreateOIDCClientCredentialsSecret(t, "test-client-id", "test-client-secret").Name,
},
}
upstream := testlib.CreateTestOIDCIdentityProvider(t, spec, idpv1alpha1.PhaseReady)
expectUpstreamConditions(t, upstream, []metav1.Condition{
{
Type: "ClientCredentialsValid",
Type: "ClientCredentialsSecretValid",
Status: metav1.ConditionTrue,
Reason: "Success",
Message: "loaded client credentials",

View File

@@ -418,7 +418,7 @@ func TestSupervisorWarnings_Browser(t *testing.T) {
Groups: env.SupervisorUpstreamOIDC.GroupsClaim,
},
Client: idpv1alpha1.OIDCClient{
SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name,
SecretName: testlib.CreateOIDCClientCredentialsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name,
},
}, idpv1alpha1.PhaseReady)
testlib.WaitForFederationDomainStatusPhase(ctx, t, downstream.Name, supervisorconfigv1alpha1.FederationDomainPhaseReady)

View File

@@ -5,7 +5,10 @@
package browsertest
import (
"bytes"
"compress/gzip"
"context"
"encoding/base64"
"fmt"
"log"
"regexp"
@@ -16,10 +19,12 @@ import (
"time"
chromedpbrowser "github.com/chromedp/cdproto/browser"
"github.com/chromedp/cdproto/dom"
chromedpruntime "github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp"
"github.com/stretchr/testify/require"
"go.pinniped.dev/internal/testutil/totp"
"go.pinniped.dev/test/testlib"
)
@@ -162,12 +167,52 @@ func OpenBrowser(t *testing.T) *Browser {
for _, e := range b.exceptionEvents {
t.Logf("exception: %s", e)
}
// If the test failed, dump helpful debugging info from the browser's final page.
if t.Failed() {
b.dumpPage(t)
}
})
// Done. The browser is ready to be driven by the test.
return b
}
func (b *Browser) dumpPage(t *testing.T) {
// Log the URL of the current page.
var url string
b.runWithTimeout(t, b.timeout(), chromedp.Location(&url))
t.Logf("Browser URL from end of test %q: %s", t.Name(), url)
// Log the title of the current page.
t.Logf("Browser page title from end of test %q: %q", t.Name(), b.Title(t))
// Log a screenshot of the current page.
var screenBuf []byte
b.runWithTimeout(t, b.timeout(), chromedp.FullScreenshot(&screenBuf, 10)) // low quality to make it smaller
t.Logf("Browser screenshot (base64 encoded jpeg format) from end of test %q:\n%s\n",
t.Name(), base64.StdEncoding.EncodeToString(screenBuf))
// Log the HTML of the current page.
var html string
b.runWithTimeout(t, b.timeout(), chromedp.ActionFunc(func(ctx context.Context) error {
node, err := dom.GetDocument().Do(ctx)
if err != nil {
return err
}
html, err = dom.GetOuterHTML().WithNodeID(node.NodeID).Do(ctx)
return err
}))
var htmlBuf bytes.Buffer
gz := gzip.NewWriter(&htmlBuf)
_, err := gz.Write([]byte(html))
require.NoError(t, err)
err = gz.Close()
require.NoError(t, err)
t.Logf("Browser html (gzip and base64 encoded) from end of test %q:\n%s\n",
t.Name(), base64.StdEncoding.EncodeToString(htmlBuf.Bytes()))
}
func (b *Browser) timeout() time.Duration {
return 30 * time.Second
}
@@ -357,6 +402,154 @@ func LoginToUpstreamOIDC(t *testing.T, b *Browser, upstream testlib.TestOIDCUpst
b.ClickFirstMatch(t, cfg.LoginButtonSelector)
}
// LoginToUpstreamGitHub expects the page to be redirected to GitHub.
// It knows how to enter the test username/password and submit the upstream login form.
func LoginToUpstreamGitHub(t *testing.T, b *Browser, upstream testlib.TestGithubUpstream) {
t.Helper()
// Expect to be redirected to the login page.
t.Logf("waiting for redirect to GitHub login page")
b.WaitForURL(t, regexp.MustCompile(`\Ahttps://github\.com/login.+\z`))
usernameSelector := "input#login_field"
passwordSelector := "input#password"
loginButtonSelector := "input[type=submit]"
// Wait for the login page to be rendered.
b.WaitForVisibleElements(t, usernameSelector, passwordSelector, loginButtonSelector)
// Fill in the username and password and click "submit".
t.Logf("logging into GitHub")
b.SendKeysToFirstMatch(t, usernameSelector, upstream.TestUserUsername)
b.SendKeysToFirstMatch(t, passwordSelector, upstream.TestUserPassword)
b.ClickFirstMatch(t, loginButtonSelector)
handleGithubOTPLoginPage(t, b, upstream)
// Keep looping until we get to a page that we do not know how to handle. Then return to allow the test to move on.
for handleOccasionalGithubLoginPage(t, b, upstream) {
continue
}
}
func handleGithubOTPLoginPage(t *testing.T, b *Browser, upstream testlib.TestGithubUpstream) {
// Next, GitHub should go to a new page and prompt for the six digit MFA/OTP code.
otpSelector := "input#app_totp"
// Wait for the MFA page to be rendered.
t.Logf("waiting for GitHub MFA page")
b.WaitForVisibleElements(t, otpSelector)
// Sleep for a bit to make it less likely that we use the same OTP code twice when multiple tests are run in serial.
// GitHub gets upset when the same OTP code gets reused.
// GitHub seems to also get upset when any OTP codes are used often, like when all our GitHub tests run sequentially,
// because sometimes auth will go to a GitHub page that says: "We were unable to authenticate your request because too
// many codes have been submitted. Please wait a few minutes and contact support if you continue to have problems."
otpSleepSeconds := 60
t.Logf("sleeping %d seconds before generating a GitHub OTP code", otpSleepSeconds)
time.Sleep(time.Duration(otpSleepSeconds) * time.Second)
code, codeRemainingLifetimeSeconds := totp.GenerateOTPCode(t, upstream.TestUserOTPSecret, time.Now())
if codeRemainingLifetimeSeconds < 2 {
t.Log("sleeping for 2 seconds before generating another OTP code")
time.Sleep(2 * time.Second)
code, _ = totp.GenerateOTPCode(t, upstream.TestUserOTPSecret, time.Now())
}
// Fill in the OTP code. We do not need to click "verify" because entering the code automatically submits the page.
t.Logf("entering GitHub OTP code")
b.SendKeysToFirstMatch(t, otpSelector, code)
}
// handleOccasionalGithubLoginPage handles the interstitial pages which GitHub might show during a login flow.
// None of these will always happen.
func handleOccasionalGithubLoginPage(t *testing.T, b *Browser, upstream testlib.TestGithubUpstream) bool {
t.Helper()
t.Log("sleeping for 2 seconds before looking at page title")
time.Sleep(2 * time.Second)
pageTitle := b.Title(t)
t.Logf("saw page title %q", pageTitle)
lowercaseTitle := strings.ToLower(pageTitle)
switch {
case strings.HasPrefix(lowercaseTitle, "authorize "): // the title is "Authorize <App Name>"
// Next GitHub might go to another page asking if you authorize the GitHub App to act on your behalf,
// if this user has never authorized this app.
// Wait for the authorize app page to be rendered.
t.Logf("waiting for GitHub authorize button")
// There are unfortunately two very similar buttons on this page:
// <button name="authorize" value="0" type="submit" data-view-component="true" class="ws-normal btn width-full mr-2">Cancel
// <button name="authorize" value="1" type="submit" data-view-component="true" class="js-oauth-authorize-btn ws-normal btn-primary btn width-full">Authorize
submitAuthorizeAppButtonSelector := "button.btn-primary"
b.WaitForVisibleElements(t, submitAuthorizeAppButtonSelector)
t.Logf("clicking authorize button")
b.ClickFirstMatch(t, submitAuthorizeAppButtonSelector)
return true
case strings.HasPrefix(lowercaseTitle, "confirm your account recovery settings"):
// Next GitHub might occasionally as you to confirm your recovery settings.
// Wait for the page to be rendered.
t.Logf("waiting for GitHub confirm button")
// There are several buttons and links. We want to click this confirm button to confirm our settings:
// <button type="submit" name="type" value="confirmed" class="btn btn-block btn-primary ml-3">Confirm</button>
submitConfirmButtonSelector := "button.btn-primary"
b.WaitForVisibleElements(t, submitConfirmButtonSelector)
t.Logf("clicking confirm button")
b.ClickFirstMatch(t, submitConfirmButtonSelector)
return true
case strings.HasPrefix(lowercaseTitle, "verify two-factor authentication"):
// Next GitHub might occasionally as you to confirm your MFA settings.
// Wait for the page to be rendered.
t.Logf("waiting for GitHub skip link")
// There are several buttons and links. We want to click this link to "skip 2FA verification":
// <button type="submit" data-view-component="true" class="Button--link Button--medium Button">
submitSkipButtonSelector := "button.Button--link[type=submit]"
b.WaitForVisibleElements(t, submitSkipButtonSelector)
t.Logf("clicking skip link")
b.ClickFirstMatch(t, submitSkipButtonSelector)
return true
case strings.HasPrefix(lowercaseTitle, "configure passwordless authentication"):
// Next GitHub might occasionally ask if we want to configure a passkey for auth.
// The URL bar shows https://github.com/sessions/trusted-device for this page.
// The link that we want to click looks like this:
// <input class="btn-link" type="submit" value="Don't ask again for this browser">
dontAskAgainLinkSelector := `input[value="Don't ask again for this browser"]`
// Wait for the passkey page to be rendered.
t.Logf("waiting for GitHub's don't ask again button")
b.WaitForVisibleElements(t, dontAskAgainLinkSelector)
// Tell it that we do not want to use a passkey.
t.Logf("clicking don't ask again button")
b.ClickFirstMatch(t, dontAskAgainLinkSelector)
return true
case strings.HasPrefix(lowercaseTitle, "two-factor authentication"):
// Sometimes this happens after the OTP page when we try to use the same OTP code again too quickly.
// GitHub stays on the same page and shows an error banner saying that we used the same code again.
// Sleep for a long time to try to avoid this error from GitHub, which seems to be some type of rate limiting on OTP codes:
// "We were unable to authenticate your request because too many codes have been submitted".
otpSleepSeconds := 60
t.Logf("sleeping %d seconds before generating another GitHub OTP code after a previous code failed", otpSleepSeconds)
time.Sleep(time.Duration(otpSleepSeconds) * time.Second)
handleGithubOTPLoginPage(t, b, upstream)
return true
case strings.HasPrefix(lowercaseTitle, "server error"):
// Sometimes this happens after the OTP page. Not sure why. The page has a cute cartoon, but no helpful information.
// The URL bar shows https://github.com/sessions/trusted-device for this error page, which is the URL that usually
// asks if you want to configure passwordless authentication (aka passkey).
t.Fatal("Got GitHub server internal error page during login flow. This is not expected, but is unfortunately unrecoverable.")
return false // we recognized the title, but we don't know how to handle this page because it has no buttons or other way forward
default:
// We did not know how to handle the page given its title.
// Maybe we successfully got through all the interstitial pages and finished the login.
return false
}
}
// LoginToUpstreamLDAP expects the page to be redirected to the Supervisor's login UI for an LDAP/AD IDP.
// It knows how to enter the test username/password and submit the upstream login form.
func LoginToUpstreamLDAP(t *testing.T, b *Browser, issuer, username, password string) {

View File

@@ -34,6 +34,7 @@ import (
idpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1"
conciergeclientset "go.pinniped.dev/generated/latest/client/concierge/clientset/versioned"
supervisorclientset "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned"
alpha1 "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/typed/idp/v1alpha1"
"go.pinniped.dev/internal/groupsuffix"
"go.pinniped.dev/internal/kubeclient"
@@ -185,7 +186,7 @@ func CreateTestWebhookAuthenticator(
defer cancel()
webhook, err := webhooks.Create(createContext, &authenticationv1alpha1.WebhookAuthenticator{
ObjectMeta: testObjectMeta(t, "webhook"),
ObjectMeta: TestObjectMeta(t, "webhook"),
Spec: *webhookSpec,
}, metav1.CreateOptions{})
require.NoError(t, err, "could not create test WebhookAuthenticator")
@@ -294,7 +295,7 @@ func CreateTestJWTAuthenticator(
defer cancel()
jwtAuthenticator, err := jwtAuthenticators.Create(createContext, &authenticationv1alpha1.JWTAuthenticator{
ObjectMeta: testObjectMeta(t, "jwt-authenticator"),
ObjectMeta: TestObjectMeta(t, "jwt-authenticator"),
Spec: spec,
}, metav1.CreateOptions{})
require.NoError(t, err, "could not create test JWTAuthenticator")
@@ -368,7 +369,7 @@ func CreateTestFederationDomain(
federationDomainsClient := NewSupervisorClientset(t).ConfigV1alpha1().FederationDomains(testEnv.SupervisorNamespace)
federationDomain, err := federationDomainsClient.Create(createContext, &supervisorconfigv1alpha1.FederationDomain{
ObjectMeta: testObjectMeta(t, "oidc-provider"),
ObjectMeta: TestObjectMeta(t, "oidc-provider"),
Spec: spec,
}, metav1.CreateOptions{})
require.NoError(t, err, "could not create test FederationDomain")
@@ -459,7 +460,7 @@ func CreateTestSecret(t *testing.T, namespace string, baseName string, secretTyp
defer cancel()
created, err := client.CoreV1().Secrets(namespace).Create(ctx, &corev1.Secret{
ObjectMeta: testObjectMeta(t, baseName),
ObjectMeta: TestObjectMeta(t, baseName),
Type: secretType,
StringData: stringData,
}, metav1.CreateOptions{})
@@ -481,7 +482,7 @@ func CreateTestSecretBytes(t *testing.T, namespace string, baseName string, secr
defer cancel()
created, err := client.CoreV1().Secrets(namespace).Create(ctx, &corev1.Secret{
ObjectMeta: testObjectMeta(t, baseName),
ObjectMeta: TestObjectMeta(t, baseName),
Type: secretType,
Data: data,
}, metav1.CreateOptions{})
@@ -496,13 +497,23 @@ func CreateTestSecretBytes(t *testing.T, namespace string, baseName string, secr
return created
}
func CreateClientCredsSecret(t *testing.T, clientID string, clientSecret string) *corev1.Secret {
func CreateOIDCClientCredentialsSecret(t *testing.T, clientID string, clientSecret string) *corev1.Secret {
t.Helper()
return createClientCredentialsSecret(t, clientID, clientSecret, "secrets.pinniped.dev/oidc-client")
}
func CreateGitHubClientCredentialsSecret(t *testing.T, clientID string, clientSecret string) *corev1.Secret {
t.Helper()
return createClientCredentialsSecret(t, clientID, clientSecret, "secrets.pinniped.dev/github-client")
}
func createClientCredentialsSecret(t *testing.T, clientID string, clientSecret string, secretType string) *corev1.Secret {
t.Helper()
env := IntegrationEnv(t)
return CreateTestSecret(t,
env.SupervisorNamespace,
"client-creds",
"secrets.pinniped.dev/oidc-client",
corev1.SecretType(secretType),
map[string]string{
"clientID": clientID,
"clientSecret": clientSecret,
@@ -584,9 +595,53 @@ func createOIDCClientSecret(t *testing.T, forOIDCClient *supervisorconfigv1alpha
return generatedSecret
}
func CreateTestGitHubIdentityProvider(t *testing.T, spec idpv1alpha1.GitHubIdentityProviderSpec, expectedPhase idpv1alpha1.GitHubIdentityProviderPhase) *idpv1alpha1.GitHubIdentityProvider {
t.Helper()
return CreateTestGitHubIdentityProviderWithObjectMeta(t, spec, TestObjectMeta(t, "upstream-github-idp"), expectedPhase)
}
func CreateTestGitHubIdentityProviderWithObjectMeta(t *testing.T, spec idpv1alpha1.GitHubIdentityProviderSpec, objectMeta metav1.ObjectMeta, expectedPhase idpv1alpha1.GitHubIdentityProviderPhase) *idpv1alpha1.GitHubIdentityProvider {
t.Helper()
env := IntegrationEnv(t)
client := NewSupervisorClientset(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
upstreams := client.IDPV1alpha1().GitHubIdentityProviders(env.SupervisorNamespace)
// Create the GitHubIdentityProvider using GenerateName to get a random name.
created, err := upstreams.Create(ctx, &idpv1alpha1.GitHubIdentityProvider{
ObjectMeta: objectMeta,
Spec: spec,
}, metav1.CreateOptions{})
require.NoError(t, err)
// Always clean this up after this point.
t.Cleanup(func() {
t.Logf("cleaning up test GitHubIdentityProvider %s/%s", created.Namespace, created.Name)
err := upstreams.Delete(context.Background(), created.Name, metav1.DeleteOptions{})
notFound := k8serrors.IsNotFound(err)
// It's okay if it is not found, because it might have been deleted by another part of this test.
if !notFound {
require.NoErrorf(t, err, "could not cleanup test GitHubIdentityProvider %s/%s", created.Namespace, created.Name)
}
})
t.Logf("created test GitHubIdentityProvider %s", created.Name)
// Wait for the GitHubIdentityProvider to enter the expected phase (or time out).
var result *idpv1alpha1.GitHubIdentityProvider
RequireEventuallyf(t, func(requireEventually *require.Assertions) {
var err error
result, err = upstreams.Get(ctx, created.Name, metav1.GetOptions{})
requireEventually.NoErrorf(err, "error while getting GitHubIdentityProvider %s/%s", created.Namespace, created.Name)
requireEventually.Equal(expectedPhase, result.Status.Phase)
}, 60*time.Second, 1*time.Second, "expected the GitHubIdentityProvider to go into phase %s, GitHubIdentityProvider was: %s", expectedPhase, Sdump(result))
return result
}
func CreateTestOIDCIdentityProvider(t *testing.T, spec idpv1alpha1.OIDCIdentityProviderSpec, expectedPhase idpv1alpha1.OIDCIdentityProviderPhase) *idpv1alpha1.OIDCIdentityProvider {
t.Helper()
return CreateTestOIDCIdentityProviderWithObjectMeta(t, spec, testObjectMeta(t, "upstream-oidc-idp"), expectedPhase)
return CreateTestOIDCIdentityProviderWithObjectMeta(t, spec, TestObjectMeta(t, "upstream-oidc-idp"), expectedPhase)
}
func CreateTestOIDCIdentityProviderWithObjectMeta(t *testing.T, spec idpv1alpha1.OIDCIdentityProviderSpec, objectMeta metav1.ObjectMeta, expectedPhase idpv1alpha1.OIDCIdentityProviderPhase) *idpv1alpha1.OIDCIdentityProvider {
@@ -639,7 +694,7 @@ func CreateTestLDAPIdentityProvider(t *testing.T, spec idpv1alpha1.LDAPIdentityP
// Create the LDAPIdentityProvider using GenerateName to get a random name.
created, err := upstreams.Create(ctx, &idpv1alpha1.LDAPIdentityProvider{
ObjectMeta: testObjectMeta(t, "upstream-ldap-idp"),
ObjectMeta: TestObjectMeta(t, "upstream-ldap-idp"),
Spec: spec,
}, metav1.CreateOptions{})
require.NoError(t, err)
@@ -680,7 +735,7 @@ func CreateTestActiveDirectoryIdentityProvider(t *testing.T, spec idpv1alpha1.Ac
// Create the ActiveDirectoryIdentityProvider using GenerateName to get a random name.
created, err := upstreams.Create(ctx, &idpv1alpha1.ActiveDirectoryIdentityProvider{
ObjectMeta: testObjectMeta(t, "upstream-ad-idp"),
ObjectMeta: TestObjectMeta(t, "upstream-ad-idp"),
Spec: spec,
}, metav1.CreateOptions{})
require.NoError(t, err)
@@ -710,6 +765,41 @@ func CreateTestActiveDirectoryIdentityProvider(t *testing.T, spec idpv1alpha1.Ac
return result
}
func CreateGitHubIdentityProvider(t *testing.T, spec idpv1alpha1.GitHubIdentityProviderSpec, expectedPhase idpv1alpha1.GitHubIdentityProviderPhase) *idpv1alpha1.GitHubIdentityProvider {
t.Helper()
env := IntegrationEnv(t)
client := NewSupervisorClientset(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
upstreams := client.IDPV1alpha1().GitHubIdentityProviders(env.SupervisorNamespace)
// Create the GitHubIdentityProvider using GenerateName to get a random name.
created, err := upstreams.Create(ctx, &idpv1alpha1.GitHubIdentityProvider{
ObjectMeta: TestObjectMeta(t, "upstream-github-idp"),
Spec: spec,
}, metav1.CreateOptions{})
require.NoError(t, err)
// Always clean this up after this point.
t.Cleanup(func() {
t.Logf("cleaning up test GitHubIdentityProvider %s/%s", created.Namespace, created.Name)
err := upstreams.Delete(context.Background(), created.Name, metav1.DeleteOptions{})
require.NoError(t, err)
})
t.Logf("created test GitHubIdentityProvider %s", created.Name)
// Wait for the GitHubIdentityProvider to enter the expected phase (or time out).
var result *idpv1alpha1.GitHubIdentityProvider
RequireEventuallyf(t, func(requireEventually *require.Assertions) {
var err error
result, err = upstreams.Get(ctx, created.Name, metav1.GetOptions{})
requireEventually.NoErrorf(err, "error while getting GitHubIdentityProvider %s/%s", created.Namespace, created.Name)
requireEventually.Equal(expectedPhase, result.Status.Phase)
}, 60*time.Second, 1*time.Second, "expected the GitHubIdentityProvider to go into phase %s, GitHubIdentityProvider was: %s", expectedPhase, Sdump(result))
return result
}
func CreateTestClusterRoleBinding(t *testing.T, subject rbacv1.Subject, roleRef rbacv1.RoleRef) *rbacv1.ClusterRoleBinding {
t.Helper()
client := NewKubernetesClientset(t)
@@ -720,7 +810,7 @@ func CreateTestClusterRoleBinding(t *testing.T, subject rbacv1.Subject, roleRef
// Create the ClusterRoleBinding using GenerateName to get a random name.
created, err := clusterRoles.Create(ctx, &rbacv1.ClusterRoleBinding{
ObjectMeta: testObjectMeta(t, "cluster-role"),
ObjectMeta: TestObjectMeta(t, "cluster-role"),
Subjects: []rbacv1.Subject{subject},
RoleRef: roleRef,
}, metav1.CreateOptions{})
@@ -771,7 +861,7 @@ func CreatePod(ctx context.Context, t *testing.T, name, namespace string, spec c
ctx, cancel := context.WithTimeout(ctx, podCreateTimeout+time.Minute)
defer cancel()
created, err := pods.Create(ctx, &corev1.Pod{ObjectMeta: testObjectMeta(t, name), Spec: spec}, metav1.CreateOptions{})
created, err := pods.Create(ctx, &corev1.Pod{ObjectMeta: TestObjectMeta(t, name), Spec: spec}, metav1.CreateOptions{})
require.NoError(t, err)
t.Logf("created test Pod %s", created.Name)
@@ -836,7 +926,60 @@ func WaitForUserToHaveAccess(t *testing.T, user string, groups []string, shouldH
}, time.Minute, 500*time.Millisecond)
}
func testObjectMeta(t *testing.T, baseName string) metav1.ObjectMeta {
func WaitForGitHubIDPPhase(
ctx context.Context,
t *testing.T,
client alpha1.GitHubIdentityProviderInterface,
gitHubIDPName string,
expectPhase idpv1alpha1.GitHubIdentityProviderPhase,
) {
t.Helper()
RequireEventuallyf(t, func(requireEventually *require.Assertions) {
idp, err := client.Get(ctx, gitHubIDPName, metav1.GetOptions{})
requireEventually.NoError(err)
requireEventually.Equalf(expectPhase, idp.Status.Phase, "actual status conditions were: %#v", idp.Status.Conditions)
}, 60*time.Second, 1*time.Second, "expected the GitHubIDP to have status %q", expectPhase)
}
func WaitForGitHubIdentityProviderStatusConditions(
ctx context.Context,
t *testing.T,
client alpha1.GitHubIdentityProviderInterface,
gitHubIDPName string,
expectConditions []*metav1.Condition,
) {
t.Helper()
RequireEventuallyf(t, func(requireEventually *require.Assertions) {
idp, err := client.Get(ctx, gitHubIDPName, metav1.GetOptions{})
requireEventually.NoError(err)
actualConditions := make([]*metav1.Condition, len(idp.Status.Conditions))
for i, c := range idp.Status.Conditions {
actualConditions[i] = c.DeepCopy()
}
requireEventually.Lenf(actualConditions, len(expectConditions),
"wanted status conditions: %#v", expectConditions)
for i, wantCond := range expectConditions {
actualCond := actualConditions[i]
// This is a cheat to avoid needing to make equality assertions on these fields.
requireEventually.NotZero(actualCond.LastTransitionTime)
wantCond.LastTransitionTime = actualCond.LastTransitionTime
requireEventually.NotZero(actualCond.ObservedGeneration)
wantCond.ObservedGeneration = actualCond.ObservedGeneration
requireEventually.Equalf(wantCond, actualCond,
"wanted status conditions: %#v\nactual status conditions were: %#v\nnot equal at index %d",
expectConditions, &actualConditions, i)
}
}, 60*time.Second, 1*time.Second, "wanted conditions for GitHubIdentityProvider %q", gitHubIDPName)
}
func TestObjectMeta(t *testing.T, baseName string) metav1.ObjectMeta {
return metav1.ObjectMeta{
GenerateName: fmt.Sprintf("test-%s-", baseName),
Labels: map[string]string{"pinniped.dev/test": ""},

View File

@@ -62,10 +62,11 @@ type TestEnv struct {
ExpectedGroups []string `json:"expectedGroups"`
} `json:"testUser"`
CLIUpstreamOIDC TestOIDCUpstream `json:"cliOIDCUpstream"`
SupervisorUpstreamOIDC TestOIDCUpstream `json:"supervisorOIDCUpstream"`
SupervisorUpstreamLDAP TestLDAPUpstream `json:"supervisorLDAPUpstream"`
SupervisorUpstreamActiveDirectory TestLDAPUpstream `json:"supervisorActiveDirectoryUpstream"`
CLIUpstreamOIDC TestOIDCUpstream `json:"cliOIDCUpstream"`
SupervisorUpstreamOIDC TestOIDCUpstream `json:"supervisorOIDCUpstream"`
SupervisorUpstreamLDAP TestLDAPUpstream `json:"supervisorLDAPUpstream"`
SupervisorUpstreamActiveDirectory TestLDAPUpstream `json:"supervisorActiveDirectoryUpstream"`
SupervisorUpstreamGithub TestGithubUpstream `json:"supervisorGithubUpstream"`
}
type TestOIDCUpstream struct {
@@ -110,6 +111,21 @@ type TestLDAPUpstream struct {
TestDeactivatedUserPassword string `json:"TestDeactivatedUserPassword"`
}
type TestGithubUpstream struct {
GithubAppClientID string `json:"githubAppClientId"` // GitHub's new-style GitHub App
GithubAppClientSecret string `json:"githubAppClientSecret"`
GithubOAuthAppClientID string `json:"githubOAuthAppClientId"` // GitHub's old-style OAuth App
GithubOAuthAppClientSecret string `json:"githubOAuthAppClientSecret"`
GithubOAuthAppAllowedCallbackURL string `json:"githubOAuthAppAllowedCallbackURL"` // the callback URL that was configured in GitHub for this App
TestUserUsername string `json:"testUserUsername"` // the "login" attribute value for the user
TestUserPassword string `json:"testUserPassword"`
TestUserOTPSecret string `json:"testUserOTPSecret"`
TestUserID string `json:"testUserID"` // the "id" attribute value for the user
TestUserOrganization string `json:"testUserOrganization"` // an org to which the user belongs
TestUserExpectedTeamNames []string `json:"testUserExpectedTeamNames"`
TestUserExpectedTeamSlugs []string `json:"testUserExpectedTeamSlugs"`
}
// ProxyEnv returns a set of environment variable strings (e.g., to combine with os.Environ()) which set up the configured test HTTP proxy.
func (e *TestEnv) ProxyEnv() []string {
if e.Proxy == "" {
@@ -319,11 +335,28 @@ func loadEnvVars(t *testing.T, result *TestEnv) {
GroupSearchBase: wantEnv("PINNIPED_TEST_AD_USERS_DN", ""),
}
result.SupervisorUpstreamGithub = TestGithubUpstream{
GithubAppClientID: wantEnv("PINNIPED_TEST_GITHUB_APP_CLIENT_ID", ""),
GithubAppClientSecret: wantEnv("PINNIPED_TEST_GITHUB_APP_CLIENT_SECRET", ""),
GithubOAuthAppClientID: wantEnv("PINNIPED_TEST_GITHUB_OAUTH_APP_CLIENT_ID", ""),
GithubOAuthAppClientSecret: wantEnv("PINNIPED_TEST_GITHUB_OAUTH_APP_CLIENT_SECRET", ""),
GithubOAuthAppAllowedCallbackURL: wantEnv("PINNIPED_TEST_GITHUB_OAUTH_APP_ALLOWED_CALLBACK_URL", ""),
TestUserUsername: wantEnv("PINNIPED_TEST_GITHUB_USER_USERNAME", ""),
TestUserPassword: wantEnv("PINNIPED_TEST_GITHUB_USER_PASSWORD", ""),
TestUserOTPSecret: wantEnv("PINNIPED_TEST_GITHUB_USER_OTP_SECRET", ""),
TestUserID: wantEnv("PINNIPED_TEST_GITHUB_USERID", ""),
TestUserOrganization: wantEnv("PINNIPED_TEST_GITHUB_ORG", ""),
TestUserExpectedTeamNames: filterEmpty(strings.Split(wantEnv("PINNIPED_TEST_GITHUB_EXPECTED_TEAM_NAMES", ""), ",")),
TestUserExpectedTeamSlugs: filterEmpty(strings.Split(wantEnv("PINNIPED_TEST_GITHUB_EXPECTED_TEAM_SLUGS", ""), ",")),
}
sort.Strings(result.SupervisorUpstreamLDAP.TestUserDirectGroupsCNs)
sort.Strings(result.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs)
sort.Strings(result.SupervisorUpstreamActiveDirectory.TestUserDirectGroupsCNs)
sort.Strings(result.SupervisorUpstreamActiveDirectory.TestUserDirectGroupsDNs)
sort.Strings(result.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountNames)
sort.Strings(result.SupervisorUpstreamGithub.TestUserExpectedTeamNames)
sort.Strings(result.SupervisorUpstreamGithub.TestUserExpectedTeamSlugs)
}
func (e *TestEnv) HasCapability(cap Capability) bool {

View File

@@ -1,4 +1,4 @@
// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved.
// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package testlib
@@ -33,3 +33,22 @@ func SkipTestWhenActiveDirectoryIsUnavailable(t *testing.T, env *TestEnv) {
t.Skip("Active Directory hostname not specified")
}
}
func SkipTestWhenGitHubIsUnavailable(t *testing.T) {
t.Helper()
if IntegrationEnv(t).SupervisorUpstreamGithub.GithubAppClientID == "" {
t.Skip("GitHub test env vars not specified")
}
}
func SkipTestWhenGitHubOAuthClientCallbackDoesNotMatchFederationDomainIssuerCallback(t *testing.T) {
t.Helper()
SkipTestWhenGitHubIsUnavailable(t)
env := IntegrationEnv(t)
if env.SupervisorUpstreamGithub.GithubOAuthAppAllowedCallbackURL != env.SupervisorUpstreamOIDC.CallbackURL {
t.Skip("GitHub OAuth App client allowed callback URL does not match the callback URL for the FederationDomain")
}
}