mirror of
https://github.com/vmware-tanzu/pinniped.git
synced 2026-01-07 14:05:50 +00:00
Merge branch 'main' into jtc/add-importas-linter
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
746
test/integration/supervisor_github_idp_test.go
Normal file
746
test/integration/supervisor_github_idp_test.go
Normal 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)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"]))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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": ""},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user