From b7f79f0adc365d9b21c4cf0bc9b8212cbc04beca Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Fri, 17 May 2024 11:21:23 -0500 Subject: [PATCH 01/25] Add github-specific tests in callback_handler_github_test.go Co-authored-by: Ryan Richard --- .../callback/callback_handler_github_test.go | 252 ++++++++++++++++++ .../callback/callback_handler_test.go | 41 ++- .../resolved_github_provider.go | 27 +- .../resolved_github_provider_test.go | 9 +- .../upstreamprovider/upsteam_provider.go | 8 + .../expected_upstream_state_param.go | 11 +- .../oidctestutil/testgithubprovider.go | 61 +++++ .../testutil/oidctestutil/testoidcprovider.go | 14 +- .../testutil/testidplister/testidplister.go | 41 ++- internal/upstreamgithub/upstreamgithub.go | 6 + 10 files changed, 418 insertions(+), 52 deletions(-) create mode 100644 internal/federationdomain/endpoints/callback/callback_handler_github_test.go diff --git a/internal/federationdomain/endpoints/callback/callback_handler_github_test.go b/internal/federationdomain/endpoints/callback/callback_handler_github_test.go new file mode 100644 index 000000000..435ed1f1e --- /dev/null +++ b/internal/federationdomain/endpoints/callback/callback_handler_github_test.go @@ -0,0 +1,252 @@ +// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package callback + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" + + "github.com/gorilla/securecookie" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/fake" + + idpdiscoveryv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1" + supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake" + "go.pinniped.dev/internal/federationdomain/endpoints/jwks" + "go.pinniped.dev/internal/federationdomain/oidc" + "go.pinniped.dev/internal/federationdomain/storage" + "go.pinniped.dev/internal/psession" + "go.pinniped.dev/internal/testutil" + "go.pinniped.dev/internal/testutil/oidctestutil" + "go.pinniped.dev/internal/testutil/testidplister" +) + +var ( + githubIDPName = "upstream-github-idp-name" + githubIDPResourceUID = types.UID("upstream-github-idp-resource-uid") + githubUpstreamUsername = "some-github-login" + githubUpstreamGroups = []string{"org1/team1", "org2/team2"} + githubDownstreamSubject = fmt.Sprintf("https://github.com?idpName=%s&sub=%s", githubIDPName, githubUpstreamUsername) + githubUpstreamAccessToken = "some-opaque-access-token-from-github" + + happyDownstreamGitHubCustomSessionData = &psession.CustomSessionData{ + Username: githubUpstreamUsername, + UpstreamUsername: githubUpstreamUsername, + UpstreamGroups: githubUpstreamGroups, + ProviderUID: githubIDPResourceUID, + ProviderName: githubIDPName, + ProviderType: psession.ProviderTypeGitHub, + GitHub: &psession.GitHubSessionData{ + UpstreamAccessToken: githubUpstreamAccessToken, + }, + } +) + +func TestCallbackEndpointWithGitHubIdentityProviders(t *testing.T) { + require.Len(t, happyDownstreamState, 8, "we expect fosite to allow 8 byte state params, so we want to test that boundary case") + + var stateEncoderHashKey = []byte("fake-hash-secret") + var stateEncoderBlockKey = []byte("0123456789ABCDEF") // block encryption requires 16/24/32 bytes for AES + var cookieEncoderHashKey = []byte("fake-hash-secret2") + var cookieEncoderBlockKey = []byte("0123456789ABCDE2") // block encryption requires 16/24/32 bytes for AES + require.NotEqual(t, stateEncoderHashKey, cookieEncoderHashKey) + require.NotEqual(t, stateEncoderBlockKey, cookieEncoderBlockKey) + + var happyStateCodec = securecookie.New(stateEncoderHashKey, stateEncoderBlockKey) + happyStateCodec.SetSerializer(securecookie.JSONEncoder{}) + var happyCookieCodec = securecookie.New(cookieEncoderHashKey, cookieEncoderBlockKey) + happyCookieCodec.SetSerializer(securecookie.JSONEncoder{}) + + encodedIncomingCookieCSRFValue, err := happyCookieCodec.Encode("csrf", happyDownstreamCSRF) + require.NoError(t, err) + happyCSRFCookie := "__Host-pinniped-csrf=" + encodedIncomingCookieCSRFValue + + happyExchangeAndValidateTokensArgs := &oidctestutil.ExchangeAuthcodeArgs{ + Authcode: happyUpstreamAuthcode, + RedirectURI: happyUpstreamRedirectURI, + } + + tests := []struct { + name string + + idps *testidplister.UpstreamIDPListerBuilder + kubeResources func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) + method string + path string + csrfCookie string + + wantRedirectLocationRegexp string + wantDownstreamGrantedScopes []string + wantDownstreamIDTokenSubject string + wantDownstreamIDTokenUsername string + wantDownstreamIDTokenGroups []string + wantDownstreamRequestedScopes []string + wantDownstreamNonce string + wantDownstreamClientID string + wantDownstreamPKCEChallenge string + wantDownstreamPKCEChallengeMethod string + wantDownstreamCustomSessionData *psession.CustomSessionData + wantDownstreamAdditionalClaims map[string]interface{} + + wantAuthcodeExchangeCall *expectedAuthcodeExchange + }{ + { + name: "GitHub IDP: GET with good state and cookie and successful upstream token exchange returns 303 to downstream client callback", + idps: testidplister.NewUpstreamIDPListerBuilder().WithGitHub( + happyGitHubUpstream(). + WithAccessToken(githubUpstreamAccessToken). + Build()), + method: http.MethodGet, + path: newRequestPath().WithState( + happyUpstreamStateParam(). + WithUpstreamIDPName(githubIDPName). + WithUpstreamIDPType(idpdiscoveryv1alpha1.IDPTypeGitHub). + WithAuthorizeRequestParams( + happyDownstreamRequestParamsQuery.Encode(), + ).Build(t, happyStateCodec), + ).String(), + csrfCookie: happyCSRFCookie, + wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=` + regexp.QuoteMeta(strings.Join(happyDownstreamScopesGranted, "+")) + `&state=` + happyDownstreamState, + wantDownstreamIDTokenSubject: githubDownstreamSubject, + wantDownstreamIDTokenUsername: githubUpstreamUsername, + wantDownstreamIDTokenGroups: githubUpstreamGroups, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamClientID: downstreamPinnipedClientID, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: happyDownstreamGitHubCustomSessionData, + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: githubIDPName, + args: happyExchangeAndValidateTokensArgs, + }, + }, + { + name: "GitHub IDP: GET with good state and cookie and successful upstream token exchange with dynamic client returns 303 to downstream client callback, with dynamic client", + idps: testidplister.NewUpstreamIDPListerBuilder().WithGitHub( + happyGitHubUpstream(). + WithAccessToken(githubUpstreamAccessToken). + Build()), + method: http.MethodGet, + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + path: newRequestPath().WithState( + happyUpstreamStateParam(). + WithUpstreamIDPName(githubIDPName). + WithUpstreamIDPType(idpdiscoveryv1alpha1.IDPTypeGitHub). + WithAuthorizeRequestParams( + shallowCopyAndModifyQuery( + happyDownstreamRequestParamsQuery, + map[string]string{ + "client_id": downstreamDynamicClientID, + }, + ).Encode(), + ).Build(t, happyStateCodec), + ).String(), + csrfCookie: happyCSRFCookie, + wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=` + regexp.QuoteMeta(strings.Join(happyDownstreamScopesGranted, "+")) + `&state=` + happyDownstreamState, + wantDownstreamIDTokenSubject: githubDownstreamSubject, + wantDownstreamIDTokenUsername: githubUpstreamUsername, + wantDownstreamIDTokenGroups: githubUpstreamGroups, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamClientID: downstreamDynamicClientID, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: happyDownstreamGitHubCustomSessionData, + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: githubIDPName, + args: happyExchangeAndValidateTokensArgs, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + kubeClient := fake.NewSimpleClientset() + supervisorClient := supervisorfake.NewSimpleClientset() + secrets := kubeClient.CoreV1().Secrets("some-namespace") + oidcClientsClient := supervisorClient.ConfigV1alpha1().OIDCClients("some-namespace") + + if test.kubeResources != nil { + test.kubeResources(t, supervisorClient, kubeClient) + } + + // Configure fosite the same way that the production code would. + // Inject this into our test subject at the last second, so we get a fresh storage for every test. + timeoutsConfiguration := oidc.DefaultOIDCTimeoutsConfiguration() + // Use lower minimum required bcrypt cost than we would use in production to keep unit the tests fast. + oauthStore := storage.NewKubeStorage(secrets, oidcClientsClient, timeoutsConfiguration, bcrypt.MinCost) + hmacSecretFunc := func() []byte { return []byte("some secret - must have at least 32 bytes") } + require.GreaterOrEqual(t, len(hmacSecretFunc()), 32, "fosite requires that hmac secrets have at least 32 bytes") + jwksProviderIsUnused := jwks.NewDynamicJWKSProvider() + oauthHelper := oidc.FositeOauth2Helper(oauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration) + + subject := NewHandler(test.idps.BuildFederationDomainIdentityProvidersListerFinder(), oauthHelper, happyStateCodec, happyCookieCodec, happyUpstreamRedirectURI) + reqContext := context.WithValue(context.Background(), struct{ name string }{name: "test"}, "request-context") + req := httptest.NewRequest(test.method, test.path, nil).WithContext(reqContext) + if test.csrfCookie != "" { + req.Header.Set("Cookie", test.csrfCookie) + } + rsp := httptest.NewRecorder() + subject.ServeHTTP(rsp, req) + t.Logf("response: %#v", rsp) + t.Logf("response body: %q", rsp.Body.String()) + + testutil.RequireSecurityHeadersWithFormPostPageCSPs(t, rsp) + + require.NotNil(t, test.wantAuthcodeExchangeCall, "wantAuthcodeExchangeCall is required for testing purposes") + + test.wantAuthcodeExchangeCall.args.Ctx = reqContext + test.idps.RequireExactlyOneCallToExchangeAuthcodeAndValidateTokens(t, + test.wantAuthcodeExchangeCall.performedByUpstreamName, + idpdiscoveryv1alpha1.IDPTypeGitHub, + test.wantAuthcodeExchangeCall.args, + ) + + require.Equal(t, http.StatusSeeOther, rsp.Code) + testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), "") + require.Empty(t, rsp.Body.String()) + + require.Len(t, rsp.Header().Values("Location"), 1) + require.NotEmpty(t, test.wantRedirectLocationRegexp, "wantRedirectLocationRegexp is required for testing purposes") + oidctestutil.RequireAuthCodeRegexpMatch( + t, + rsp.Header().Get("Location"), + test.wantRedirectLocationRegexp, + kubeClient, + secrets, + oauthStore, + test.wantDownstreamGrantedScopes, + test.wantDownstreamIDTokenSubject, + test.wantDownstreamIDTokenUsername, + test.wantDownstreamIDTokenGroups, + test.wantDownstreamRequestedScopes, + test.wantDownstreamPKCEChallenge, + test.wantDownstreamPKCEChallengeMethod, + test.wantDownstreamNonce, + test.wantDownstreamClientID, + downstreamRedirectURI, + test.wantDownstreamCustomSessionData, + test.wantDownstreamAdditionalClaims, + ) + }) + } +} + +func happyGitHubUpstream() *oidctestutil.TestUpstreamGitHubIdentityProviderBuilder { + return oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder(). + WithName(githubIDPName). + WithResourceUID(githubIDPResourceUID). + WithClientID("some-client-id"). + WithScopes([]string{"these", "scopes", "appear", "unused"}) +} diff --git a/internal/federationdomain/endpoints/callback/callback_handler_test.go b/internal/federationdomain/endpoints/callback/callback_handler_test.go index d0aa3a6b8..019a32223 100644 --- a/internal/federationdomain/endpoints/callback/callback_handler_test.go +++ b/internal/federationdomain/endpoints/callback/callback_handler_test.go @@ -20,6 +20,7 @@ import ( "k8s.io/client-go/kubernetes/fake" configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" + idpdiscoveryv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1" supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake" "go.pinniped.dev/internal/federationdomain/endpoints/jwks" "go.pinniped.dev/internal/federationdomain/oidc" @@ -92,7 +93,6 @@ var ( happyDownstreamRequestParamsQueryForDynamicClient = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, map[string]string{"client_id": downstreamDynamicClientID}, ) - happyDownstreamRequestParamsForDynamicClient = happyDownstreamRequestParamsQueryForDynamicClient.Encode() happyDownstreamCustomSessionData = &psession.CustomSessionData{ Username: oidcUpstreamUsername, @@ -107,6 +107,7 @@ var ( UpstreamSubject: oidcUpstreamSubject, }, } + happyDownstreamCustomSessionDataWithUsernameAndGroups = func(wantDownstreamUsername, wantUpstreamUsername string, wantUpstreamGroups []string) *psession.CustomSessionData { copyOfCustomSession := *happyDownstreamCustomSessionData copyOfOIDC := *(happyDownstreamCustomSessionData.OIDC) @@ -129,6 +130,14 @@ var ( UpstreamSubject: oidcUpstreamSubject, }, } + + addFullyCapableDynamicClientAndSecretToKubeResources = func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) { + oidcClient, secret := testutil.FullyCapableOIDCClientAndStorageSecret(t, + "some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID, downstreamRedirectURI, nil, + []string{testutil.HashedPassword1AtGoMinCost}, oidcclientvalidator.Validate) + require.NoError(t, supervisorClient.Tracker().Add(oidcClient)) + require.NoError(t, kubeClient.Tracker().Add(secret)) + } ) func TestCallbackEndpoint(t *testing.T) { @@ -153,13 +162,13 @@ func TestCallbackEndpoint(t *testing.T) { happyCookieCodec.SetSerializer(securecookie.JSONEncoder{}) happyState := happyUpstreamStateParam().Build(t, happyStateCodec) - happyStateForDynamicClient := happyUpstreamStateParamForDynamicClient().Build(t, happyStateCodec) + happyStateForDynamicClient := happyUpstreamStateParam().WithAuthorizeRequestParams(happyDownstreamRequestParamsQueryForDynamicClient.Encode()).Build(t, happyStateCodec) encodedIncomingCookieCSRFValue, err := happyCookieCodec.Encode("csrf", happyDownstreamCSRF) require.NoError(t, err) happyCSRFCookie := "__Host-pinniped-csrf=" + encodedIncomingCookieCSRFValue - happyExchangeAndValidateTokensArgs := &oidctestutil.ExchangeAuthcodeAndValidateTokenArgs{ + happyExchangeAndValidateTokensArgs := &oidctestutil.ExchangeAuthcodeArgs{ Authcode: happyUpstreamAuthcode, PKCECodeVerifier: oidcpkce.Code(happyDownstreamPKCE), ExpectedIDTokenNonce: nonce.Nonce(happyDownstreamNonce), @@ -169,14 +178,6 @@ func TestCallbackEndpoint(t *testing.T) { // Note that fosite puts the granted scopes as a param in the redirect URI even though the spec doesn't seem to require it happyDownstreamRedirectLocationRegexp := downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+username\+groups&state=` + happyDownstreamState - addFullyCapableDynamicClientAndSecretToKubeResources := func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) { - oidcClient, secret := testutil.FullyCapableOIDCClientAndStorageSecret(t, - "some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID, downstreamRedirectURI, nil, - []string{testutil.HashedPassword1AtGoMinCost}, oidcclientvalidator.Validate) - require.NoError(t, supervisorClient.Tracker().Add(oidcClient)) - require.NoError(t, kubeClient.Tracker().Add(secret)) - } - prefixUsernameAndGroupsPipeline := transformtestutil.NewPrefixingPipeline(t, transformationUsernamePrefix, transformationGroupsPrefix) rejectAuthPipeline := transformtestutil.NewRejectAllAuthPipeline(t) @@ -753,7 +754,7 @@ func TestCallbackEndpoint(t *testing.T) { kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, method: http.MethodGet, path: newRequestPath().WithState( - happyUpstreamStateParamForDynamicClient(). + happyUpstreamStateParam(). WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQueryForDynamicClient, map[string]string{"scope": "openid groups offline_access"}).Encode()). Build(t, happyStateCodec), @@ -783,7 +784,7 @@ func TestCallbackEndpoint(t *testing.T) { kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, method: http.MethodGet, path: newRequestPath().WithState( - happyUpstreamStateParamForDynamicClient(). + happyUpstreamStateParam(). WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQueryForDynamicClient, map[string]string{"scope": "openid username offline_access"}).Encode()). Build(t, happyStateCodec), @@ -1540,7 +1541,7 @@ func TestCallbackEndpoint(t *testing.T) { } // Configure fosite the same way that the production code would. - // Inject this into our test subject at the last second so we get a fresh storage for every test. + // Inject this into our test subject at the last second, so we get a fresh storage for every test. timeoutsConfiguration := oidc.DefaultOIDCTimeoutsConfiguration() // Use lower minimum required bcrypt cost than we would use in production to keep unit the tests fast. oauthStore := storage.NewKubeStorage(secrets, oidcClientsClient, timeoutsConfiguration, bcrypt.MinCost) @@ -1565,7 +1566,9 @@ func TestCallbackEndpoint(t *testing.T) { if test.wantAuthcodeExchangeCall != nil { test.wantAuthcodeExchangeCall.args.Ctx = reqContext test.idps.RequireExactlyOneCallToExchangeAuthcodeAndValidateTokens(t, - test.wantAuthcodeExchangeCall.performedByUpstreamName, test.wantAuthcodeExchangeCall.args, + test.wantAuthcodeExchangeCall.performedByUpstreamName, + idpdiscoveryv1alpha1.IDPTypeOIDC, + test.wantAuthcodeExchangeCall.args, ) } else { test.idps.RequireExactlyZeroCallsToExchangeAuthcodeAndValidateTokens(t) @@ -1636,7 +1639,7 @@ func TestCallbackEndpoint(t *testing.T) { type expectedAuthcodeExchange struct { performedByUpstreamName string - args *oidctestutil.ExchangeAuthcodeAndValidateTokenArgs + args *oidctestutil.ExchangeAuthcodeArgs } type requestPath struct { @@ -1696,12 +1699,6 @@ func happyUpstreamStateParam() *oidctestutil.UpstreamStateParamBuilder { } } -func happyUpstreamStateParamForDynamicClient() *oidctestutil.UpstreamStateParamBuilder { - p := happyUpstreamStateParam() - p.P = happyDownstreamRequestParamsForDynamicClient - return p -} - func happyUpstream() *oidctestutil.TestUpstreamOIDCIdentityProviderBuilder { return oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder(). WithName(happyUpstreamIDPName). diff --git a/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider.go b/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider.go index 015c5fa6c..4efb1c048 100644 --- a/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider.go +++ b/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider.go @@ -91,13 +91,28 @@ func (p *FederationDomainResolvedGitHubIdentityProvider) Login( } func (p *FederationDomainResolvedGitHubIdentityProvider) LoginFromCallback( - _ context.Context, - _ string, - _ pkce.Code, - _ nonce.Nonce, - _ string, + ctx context.Context, + authCode string, + _ pkce.Code, // GitHub does not support PKCE, see https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps + _ nonce.Nonce, // GitHub does not support OIDC, therefore there is no ID token that could contain the "nonce". + redirectURI string, ) (*resolvedprovider.Identity, *resolvedprovider.IdentityLoginExtras, error) { - return nil, nil, errors.New("function LoginFromCallback not yet implemented for GitHub IDP") + token, _ := p.Provider.ExchangeAuthcode( + ctx, + authCode, + redirectURI, + ) + + return &resolvedprovider.Identity{ + UpstreamUsername: "some-github-login", + UpstreamGroups: []string{"org1/team1", "org2/team2"}, + DownstreamSubject: "https://github.com?idpName=upstream-github-idp-name&sub=some-github-login", + IDPSpecificSessionData: &psession.GitHubSessionData{ + UpstreamAccessToken: token, + }, + }, + &resolvedprovider.IdentityLoginExtras{}, + nil } func (p *FederationDomainResolvedGitHubIdentityProvider) UpstreamRefresh( diff --git a/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider_test.go b/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider_test.go index bf6d98a52..d54b4f57e 100644 --- a/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider_test.go +++ b/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider_test.go @@ -15,6 +15,7 @@ import ( "go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/testutil/transformtestutil" "go.pinniped.dev/internal/upstreamgithub" + "go.pinniped.dev/pkg/oidcclient/oidctypes" ) func TestFederationDomainResolvedGitHubIdentityProvider(t *testing.T) { @@ -58,11 +59,11 @@ func TestFederationDomainResolvedGitHubIdentityProvider(t *testing.T) { originalCustomSession := &psession.CustomSessionData{ Username: "fake-username", UpstreamUsername: "fake-upstream-username", - GitHub: &psession.GitHubSessionData{UpstreamAccessToken: "fake-upstream-access-token"}, + GitHub: &psession.GitHubSessionData{UpstreamAccessToken: &oidctypes.Token{AccessToken: &oidctypes.AccessToken{Token: "fake-upstream-access-token"}}}, } clonedCustomSession := subject.CloneIDPSpecificSessionDataFromSession(originalCustomSession) require.Equal(t, - &psession.GitHubSessionData{UpstreamAccessToken: "fake-upstream-access-token"}, + &psession.GitHubSessionData{UpstreamAccessToken: &oidctypes.Token{AccessToken: &oidctypes.AccessToken{Token: "fake-upstream-access-token"}}}, clonedCustomSession, ) require.NotSame(t, originalCustomSession, clonedCustomSession) @@ -71,11 +72,11 @@ func TestFederationDomainResolvedGitHubIdentityProvider(t *testing.T) { Username: "fake-username2", UpstreamUsername: "fake-upstream-username2", } - subject.ApplyIDPSpecificSessionDataToSession(customSessionToBeMutated, &psession.GitHubSessionData{UpstreamAccessToken: "fake-upstream-access-token2"}) + subject.ApplyIDPSpecificSessionDataToSession(customSessionToBeMutated, &psession.GitHubSessionData{UpstreamAccessToken: &oidctypes.Token{AccessToken: &oidctypes.AccessToken{Token: "OTHER-upstream-access-token"}}}) require.Equal(t, &psession.CustomSessionData{ Username: "fake-username2", UpstreamUsername: "fake-upstream-username2", - GitHub: &psession.GitHubSessionData{UpstreamAccessToken: "fake-upstream-access-token2"}, + GitHub: &psession.GitHubSessionData{UpstreamAccessToken: &oidctypes.Token{AccessToken: &oidctypes.AccessToken{Token: "OTHER-upstream-access-token"}}}, }, customSessionToBeMutated) redirectURL, err := subject.UpstreamAuthorizeRedirectURL( diff --git a/internal/federationdomain/upstreamprovider/upsteam_provider.go b/internal/federationdomain/upstreamprovider/upsteam_provider.go index decee99aa..be3229be3 100644 --- a/internal/federationdomain/upstreamprovider/upsteam_provider.go +++ b/internal/federationdomain/upstreamprovider/upsteam_provider.go @@ -168,4 +168,12 @@ type UpstreamGithubIdentityProviderI interface { // Or maybe higher level interface like this? // ExchangeAuthcode(ctx, authcode, redirectURI) (AccessToken, error) // GetUser(ctx, accessToken) (User, error) // in this case User would include team and org info + + // ExchangeAuthcode performs an upstream GitHub authorization code exchange. + // Returns the raw access token. The access token expiry is not known. + ExchangeAuthcode( + ctx context.Context, + authcode string, + redirectURI string, + ) (string, error) } diff --git a/internal/testutil/oidctestutil/expected_upstream_state_param.go b/internal/testutil/oidctestutil/expected_upstream_state_param.go index f3a0bcb96..72ff6398d 100644 --- a/internal/testutil/oidctestutil/expected_upstream_state_param.go +++ b/internal/testutil/oidctestutil/expected_upstream_state_param.go @@ -8,6 +8,8 @@ import ( "github.com/gorilla/securecookie" "github.com/stretchr/testify/require" + + idpdiscoveryv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1" ) // ExpectedUpstreamStateParamFormat is a separate type from the production code to ensure that the state @@ -52,8 +54,13 @@ func (b *UpstreamStateParamBuilder) WithPKCE(pkce string) *UpstreamStateParamBui return b } -func (b *UpstreamStateParamBuilder) WithUpstreamIDPType(upstreamIDPType string) *UpstreamStateParamBuilder { - b.T = upstreamIDPType +func (b *UpstreamStateParamBuilder) WithUpstreamIDPType(upstreamIDPType idpdiscoveryv1alpha1.IDPType) *UpstreamStateParamBuilder { + b.T = string(upstreamIDPType) + return b +} + +func (b *UpstreamStateParamBuilder) WithUpstreamIDPName(upstreamIDPName string) *UpstreamStateParamBuilder { + b.U = upstreamIDPName return b } diff --git a/internal/testutil/oidctestutil/testgithubprovider.go b/internal/testutil/oidctestutil/testgithubprovider.go index b28a4487f..20b97ad93 100644 --- a/internal/testutil/oidctestutil/testgithubprovider.go +++ b/internal/testutil/oidctestutil/testgithubprovider.go @@ -4,6 +4,8 @@ package oidctestutil import ( + "context" + "k8s.io/apimachinery/pkg/types" "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" @@ -22,6 +24,10 @@ type TestUpstreamGitHubIdentityProviderBuilder struct { groupNameAttribute v1alpha1.GitHubGroupNameAttribute allowedOrganizations []string authorizationURL string + + // Assertions stuff + authcodeExchangeErr error + accessToken string } func (u *TestUpstreamGitHubIdentityProviderBuilder) WithName(value string) *TestUpstreamGitHubIdentityProviderBuilder { @@ -69,6 +75,16 @@ func (u *TestUpstreamGitHubIdentityProviderBuilder) WithAuthorizationURL(value s return u } +func (u *TestUpstreamGitHubIdentityProviderBuilder) WithAccessToken(token string) *TestUpstreamGitHubIdentityProviderBuilder { + u.accessToken = token + return u +} + +func (u *TestUpstreamGitHubIdentityProviderBuilder) WithEmptyAccessToken() *TestUpstreamGitHubIdentityProviderBuilder { + u.accessToken = "" + return u +} + func (u *TestUpstreamGitHubIdentityProviderBuilder) Build() *TestUpstreamGitHubIdentityProvider { if u.displayNameForFederationDomain == "" { // default it to the CR name @@ -89,6 +105,13 @@ func (u *TestUpstreamGitHubIdentityProviderBuilder) Build() *TestUpstreamGitHubI GroupNameAttribute: u.groupNameAttribute, AllowedOrganizations: u.allowedOrganizations, AuthorizationURL: u.authorizationURL, + + ExchangeAuthcodeFunc: func(ctx context.Context, authcode string) (string, error) { + if u.authcodeExchangeErr != nil { + return "", u.authcodeExchangeErr + } + return u.accessToken, nil + }, } } @@ -107,6 +130,16 @@ type TestUpstreamGitHubIdentityProvider struct { GroupNameAttribute v1alpha1.GitHubGroupNameAttribute AllowedOrganizations []string AuthorizationURL string + + authcodeExchangeErr error + + ExchangeAuthcodeFunc func( + ctx context.Context, + authcode string, + ) (string, error) + + exchangeAuthcodeCallCount int + exchangeAuthcodeArgs []*ExchangeAuthcodeArgs } var _ upstreamprovider.UpstreamGithubIdentityProviderI = &TestUpstreamGitHubIdentityProvider{} @@ -142,3 +175,31 @@ func (u *TestUpstreamGitHubIdentityProvider) GetAllowedOrganizations() []string func (u *TestUpstreamGitHubIdentityProvider) GetAuthorizationURL() string { return u.AuthorizationURL } + +func (u *TestUpstreamGitHubIdentityProvider) ExchangeAuthcode( + ctx context.Context, + authcode string, + redirectURI string, +) (string, error) { + if u.exchangeAuthcodeArgs == nil { + u.exchangeAuthcodeArgs = make([]*ExchangeAuthcodeArgs, 0) + } + u.exchangeAuthcodeCallCount++ + u.exchangeAuthcodeArgs = append(u.exchangeAuthcodeArgs, &ExchangeAuthcodeArgs{ + Ctx: ctx, + Authcode: authcode, + RedirectURI: redirectURI, + }) + return u.ExchangeAuthcodeFunc(ctx, authcode) +} + +func (u *TestUpstreamGitHubIdentityProvider) ExchangeAuthcodeCallCount() int { + return u.exchangeAuthcodeCallCount +} + +func (u *TestUpstreamGitHubIdentityProvider) ExchangeAuthcodeArgs(call int) *ExchangeAuthcodeArgs { + if u.exchangeAuthcodeArgs == nil { + u.exchangeAuthcodeArgs = make([]*ExchangeAuthcodeArgs, 0) + } + return u.exchangeAuthcodeArgs[call] +} diff --git a/internal/testutil/oidctestutil/testoidcprovider.go b/internal/testutil/oidctestutil/testoidcprovider.go index 489f6304c..9530db42e 100644 --- a/internal/testutil/oidctestutil/testoidcprovider.go +++ b/internal/testutil/oidctestutil/testoidcprovider.go @@ -18,9 +18,9 @@ import ( oidcpkce "go.pinniped.dev/pkg/oidcclient/pkce" ) -// ExchangeAuthcodeAndValidateTokenArgs is used to spy on calls to +// ExchangeAuthcodeArgs is used to spy on calls to // TestUpstreamOIDCIdentityProvider.ExchangeAuthcodeAndValidateTokensFunc(). -type ExchangeAuthcodeAndValidateTokenArgs struct { +type ExchangeAuthcodeArgs struct { Ctx context.Context Authcode string PKCECodeVerifier oidcpkce.Code @@ -101,7 +101,7 @@ type TestUpstreamOIDCIdentityProvider struct { // Fields for tracking actual calls make to mock functions. exchangeAuthcodeAndValidateTokensCallCount int - exchangeAuthcodeAndValidateTokensArgs []*ExchangeAuthcodeAndValidateTokenArgs + exchangeAuthcodeAndValidateTokensArgs []*ExchangeAuthcodeArgs passwordCredentialsGrantAndValidateTokensCallCount int passwordCredentialsGrantAndValidateTokensArgs []*PasswordCredentialsGrantAndValidateTokensArgs performRefreshCallCount int @@ -180,10 +180,10 @@ func (u *TestUpstreamOIDCIdentityProvider) ExchangeAuthcodeAndValidateTokens( redirectURI string, ) (*oidctypes.Token, error) { if u.exchangeAuthcodeAndValidateTokensArgs == nil { - u.exchangeAuthcodeAndValidateTokensArgs = make([]*ExchangeAuthcodeAndValidateTokenArgs, 0) + u.exchangeAuthcodeAndValidateTokensArgs = make([]*ExchangeAuthcodeArgs, 0) } u.exchangeAuthcodeAndValidateTokensCallCount++ - u.exchangeAuthcodeAndValidateTokensArgs = append(u.exchangeAuthcodeAndValidateTokensArgs, &ExchangeAuthcodeAndValidateTokenArgs{ + u.exchangeAuthcodeAndValidateTokensArgs = append(u.exchangeAuthcodeAndValidateTokensArgs, &ExchangeAuthcodeArgs{ Ctx: ctx, Authcode: authcode, PKCECodeVerifier: pkceCodeVerifier, @@ -197,9 +197,9 @@ func (u *TestUpstreamOIDCIdentityProvider) ExchangeAuthcodeAndValidateTokensCall return u.exchangeAuthcodeAndValidateTokensCallCount } -func (u *TestUpstreamOIDCIdentityProvider) ExchangeAuthcodeAndValidateTokensArgs(call int) *ExchangeAuthcodeAndValidateTokenArgs { +func (u *TestUpstreamOIDCIdentityProvider) ExchangeAuthcodeAndValidateTokensArgs(call int) *ExchangeAuthcodeArgs { if u.exchangeAuthcodeAndValidateTokensArgs == nil { - u.exchangeAuthcodeAndValidateTokensArgs = make([]*ExchangeAuthcodeAndValidateTokenArgs, 0) + u.exchangeAuthcodeAndValidateTokensArgs = make([]*ExchangeAuthcodeArgs, 0) } return u.exchangeAuthcodeAndValidateTokensArgs[call] } diff --git a/internal/testutil/testidplister/testidplister.go b/internal/testutil/testidplister/testidplister.go index 13b94b29f..2823a5188 100644 --- a/internal/testutil/testidplister/testidplister.go +++ b/internal/testutil/testidplister/testidplister.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" + idpdiscoveryv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1" "go.pinniped.dev/internal/federationdomain/dynamicupstreamprovider" "go.pinniped.dev/internal/federationdomain/resolvedprovider" "go.pinniped.dev/internal/federationdomain/resolvedprovider/resolvedgithub" @@ -266,38 +267,56 @@ func (b *UpstreamIDPListerBuilder) RequireExactlyZeroCallsToPasswordCredentialsG func (b *UpstreamIDPListerBuilder) RequireExactlyOneCallToExchangeAuthcodeAndValidateTokens( t *testing.T, expectedPerformedByUpstreamName string, - expectedArgs *oidctestutil.ExchangeAuthcodeAndValidateTokenArgs, + expectedPerformedByUpstreamType idpdiscoveryv1alpha1.IDPType, + expectedArgs *oidctestutil.ExchangeAuthcodeArgs, ) { t.Helper() - var actualArgs *oidctestutil.ExchangeAuthcodeAndValidateTokenArgs + var actualArgs *oidctestutil.ExchangeAuthcodeArgs var actualNameOfUpstreamWhichMadeCall string - actualCallCountAcrossAllOIDCUpstreams := 0 + var actualTypeOfUpstreamWhichMadeCall idpdiscoveryv1alpha1.IDPType + actualCallCountAcrossAllOIDCAndGitHubUpstreams := 0 for _, upstreamOIDC := range b.upstreamOIDCIdentityProviders { callCountOnThisUpstream := upstreamOIDC.ExchangeAuthcodeAndValidateTokensCallCount() - actualCallCountAcrossAllOIDCUpstreams += callCountOnThisUpstream + actualCallCountAcrossAllOIDCAndGitHubUpstreams += callCountOnThisUpstream if callCountOnThisUpstream == 1 { actualNameOfUpstreamWhichMadeCall = upstreamOIDC.Name + actualTypeOfUpstreamWhichMadeCall = idpdiscoveryv1alpha1.IDPTypeOIDC actualArgs = upstreamOIDC.ExchangeAuthcodeAndValidateTokensArgs(0) } } - require.Equal(t, 1, actualCallCountAcrossAllOIDCUpstreams, - "should have been exactly one call to ExchangeAuthcodeAndValidateTokens() by all OIDC upstreams", + for _, upstreamGitHub := range b.upstreamGitHubIdentityProviders { + callCountOnThisUpstream := upstreamGitHub.ExchangeAuthcodeCallCount() + actualCallCountAcrossAllOIDCAndGitHubUpstreams += callCountOnThisUpstream + if callCountOnThisUpstream == 1 { + actualNameOfUpstreamWhichMadeCall = upstreamGitHub.Name + actualTypeOfUpstreamWhichMadeCall = idpdiscoveryv1alpha1.IDPTypeGitHub + actualArgs = upstreamGitHub.ExchangeAuthcodeArgs(0) + } + } + require.Equal(t, 1, actualCallCountAcrossAllOIDCAndGitHubUpstreams, + "expected exactly one call to (OIDC) ExchangeAuthcodeAndValidateTokensCallCount() or (GitHub) ExchangeAuthcodeCallCount()", ) require.Equal(t, expectedPerformedByUpstreamName, actualNameOfUpstreamWhichMadeCall, - "ExchangeAuthcodeAndValidateTokens() was called on the wrong OIDC upstream", + "(OIDC) ExchangeAuthcodeAndValidateTokensCallCount() or (GitHub) ExchangeAuthcodeCallCount() was called on the wrong upstream name", + ) + require.Equal(t, expectedPerformedByUpstreamType, actualTypeOfUpstreamWhichMadeCall, + "(OIDC) ExchangeAuthcodeAndValidateTokensCallCount() or (GitHub) ExchangeAuthcodeCallCount() was called on the wrong upstream type", ) require.Equal(t, expectedArgs, actualArgs) } func (b *UpstreamIDPListerBuilder) RequireExactlyZeroCallsToExchangeAuthcodeAndValidateTokens(t *testing.T) { t.Helper() - actualCallCountAcrossAllOIDCUpstreams := 0 + actualCallCount := 0 for _, upstreamOIDC := range b.upstreamOIDCIdentityProviders { - actualCallCountAcrossAllOIDCUpstreams += upstreamOIDC.ExchangeAuthcodeAndValidateTokensCallCount() + actualCallCount += upstreamOIDC.ExchangeAuthcodeAndValidateTokensCallCount() + } + for _, upstreamGitHub := range b.upstreamGitHubIdentityProviders { + actualCallCount += upstreamGitHub.ExchangeAuthcodeCallCount() } - require.Equal(t, 0, actualCallCountAcrossAllOIDCUpstreams, - "expected exactly zero calls to ExchangeAuthcodeAndValidateTokens()", + require.Equal(t, 0, actualCallCount, + "expected exactly zero calls to (OIDC) ExchangeAuthcodeAndValidateTokensCallCount() or (GitHub) ExchangeAuthcodeCallCount()", ) } diff --git a/internal/upstreamgithub/upstreamgithub.go b/internal/upstreamgithub/upstreamgithub.go index 52e1120b7..f76a02aa5 100644 --- a/internal/upstreamgithub/upstreamgithub.go +++ b/internal/upstreamgithub/upstreamgithub.go @@ -5,6 +5,7 @@ package upstreamgithub import ( + "context" "net/http" "golang.org/x/oauth2" @@ -87,6 +88,11 @@ func (p *Provider) GetAuthorizationURL() string { return p.c.OAuth2Config.Endpoint.AuthURL } +func (p *Provider) ExchangeAuthcode(_ context.Context, _ string, _ string) (string, error) { + //TODO implement me + panic("implement me") +} + // GetConfig returns the config. This is not part of the interface and is mostly just for testing. func (p *Provider) GetConfig() ProviderConfig { return p.c From 49c468f00a59aa012b663acd2baf1034f1777158 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Fri, 17 May 2024 16:37:11 -0700 Subject: [PATCH 02/25] Add GetUser() interface and implement LoginFromCallback() for GitHub ALso fixed some of the GitHub test helpers --- .../callback/callback_handler_github_test.go | 42 +++-- .../callback/callback_handler_test.go | 146 +++++++++-------- .../resolved_github_provider.go | 29 ++-- .../resolved_github_provider_test.go | 149 +++++++++++++++++- .../upstreamprovider/upsteam_provider.go | 26 ++- .../oidctestutil/testgithubprovider.go | 81 ++++++++-- .../testutil/oidctestutil/testoidcprovider.go | 14 +- .../testutil/testidplister/testidplister.go | 66 ++++---- internal/upstreamgithub/upstreamgithub.go | 34 +++- 9 files changed, 422 insertions(+), 165 deletions(-) diff --git a/internal/federationdomain/endpoints/callback/callback_handler_github_test.go b/internal/federationdomain/endpoints/callback/callback_handler_github_test.go index 435ed1f1e..1d506436d 100644 --- a/internal/federationdomain/endpoints/callback/callback_handler_github_test.go +++ b/internal/federationdomain/endpoints/callback/callback_handler_github_test.go @@ -22,7 +22,9 @@ import ( supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake" "go.pinniped.dev/internal/federationdomain/endpoints/jwks" "go.pinniped.dev/internal/federationdomain/oidc" + "go.pinniped.dev/internal/federationdomain/oidcclientvalidator" "go.pinniped.dev/internal/federationdomain/storage" + "go.pinniped.dev/internal/federationdomain/upstreamprovider" "go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil/oidctestutil" @@ -35,7 +37,7 @@ var ( githubUpstreamUsername = "some-github-login" githubUpstreamGroups = []string{"org1/team1", "org2/team2"} githubDownstreamSubject = fmt.Sprintf("https://github.com?idpName=%s&sub=%s", githubIDPName, githubUpstreamUsername) - githubUpstreamAccessToken = "some-opaque-access-token-from-github" + githubUpstreamAccessToken = "some-opaque-access-token-from-github" //nolint:gosec // this is not a credential happyDownstreamGitHubCustomSessionData = &psession.CustomSessionData{ Username: githubUpstreamUsername, @@ -74,6 +76,16 @@ func TestCallbackEndpointWithGitHubIdentityProviders(t *testing.T) { RedirectURI: happyUpstreamRedirectURI, } + // TODO: when we merge this file back into callback_handler_test.go, we do not need to copy this function + // because it is already in callback_handler_test.go + addFullyCapableDynamicClientAndSecretToKubeResources := func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) { + oidcClient, secret := testutil.FullyCapableOIDCClientAndStorageSecret(t, + "some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID, downstreamRedirectURI, nil, + []string{testutil.HashedPassword1AtGoMinCost}, oidcclientvalidator.Validate) + require.NoError(t, supervisorClient.Tracker().Add(oidcClient)) + require.NoError(t, kubeClient.Tracker().Add(secret)) + } + tests := []struct { name string @@ -95,14 +107,18 @@ func TestCallbackEndpointWithGitHubIdentityProviders(t *testing.T) { wantDownstreamPKCEChallengeMethod string wantDownstreamCustomSessionData *psession.CustomSessionData wantDownstreamAdditionalClaims map[string]interface{} - - wantAuthcodeExchangeCall *expectedAuthcodeExchange + wantGitHubAuthcodeExchangeCall *expectedGitHubAuthcodeExchange }{ { name: "GitHub IDP: GET with good state and cookie and successful upstream token exchange returns 303 to downstream client callback", idps: testidplister.NewUpstreamIDPListerBuilder().WithGitHub( happyGitHubUpstream(). WithAccessToken(githubUpstreamAccessToken). + WithUser(&upstreamprovider.GitHubUser{ + Username: githubUpstreamUsername, + Groups: githubUpstreamGroups, + DownstreamSubject: githubDownstreamSubject, + }). Build()), method: http.MethodGet, path: newRequestPath().WithState( @@ -125,7 +141,7 @@ func TestCallbackEndpointWithGitHubIdentityProviders(t *testing.T) { wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamGitHubCustomSessionData, - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantGitHubAuthcodeExchangeCall: &expectedGitHubAuthcodeExchange{ performedByUpstreamName: githubIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -135,6 +151,11 @@ func TestCallbackEndpointWithGitHubIdentityProviders(t *testing.T) { idps: testidplister.NewUpstreamIDPListerBuilder().WithGitHub( happyGitHubUpstream(). WithAccessToken(githubUpstreamAccessToken). + WithUser(&upstreamprovider.GitHubUser{ + Username: githubUpstreamUsername, + Groups: githubUpstreamGroups, + DownstreamSubject: githubDownstreamSubject, + }). Build()), method: http.MethodGet, kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, @@ -163,7 +184,7 @@ func TestCallbackEndpointWithGitHubIdentityProviders(t *testing.T) { wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamGitHubCustomSessionData, - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantGitHubAuthcodeExchangeCall: &expectedGitHubAuthcodeExchange{ performedByUpstreamName: githubIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -204,13 +225,12 @@ func TestCallbackEndpointWithGitHubIdentityProviders(t *testing.T) { testutil.RequireSecurityHeadersWithFormPostPageCSPs(t, rsp) - require.NotNil(t, test.wantAuthcodeExchangeCall, "wantAuthcodeExchangeCall is required for testing purposes") + require.NotNil(t, test.wantGitHubAuthcodeExchangeCall, "wantOIDCAuthcodeExchangeCall is required for testing purposes") - test.wantAuthcodeExchangeCall.args.Ctx = reqContext - test.idps.RequireExactlyOneCallToExchangeAuthcodeAndValidateTokens(t, - test.wantAuthcodeExchangeCall.performedByUpstreamName, - idpdiscoveryv1alpha1.IDPTypeGitHub, - test.wantAuthcodeExchangeCall.args, + test.wantGitHubAuthcodeExchangeCall.args.Ctx = reqContext + test.idps.RequireExactlyOneGitHubAuthcodeExchange(t, + test.wantGitHubAuthcodeExchangeCall.performedByUpstreamName, + test.wantGitHubAuthcodeExchangeCall.args, ) require.Equal(t, http.StatusSeeOther, rsp.Code) diff --git a/internal/federationdomain/endpoints/callback/callback_handler_test.go b/internal/federationdomain/endpoints/callback/callback_handler_test.go index 019a32223..7818e7f0a 100644 --- a/internal/federationdomain/endpoints/callback/callback_handler_test.go +++ b/internal/federationdomain/endpoints/callback/callback_handler_test.go @@ -20,7 +20,6 @@ import ( "k8s.io/client-go/kubernetes/fake" configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" - idpdiscoveryv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1" supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake" "go.pinniped.dev/internal/federationdomain/endpoints/jwks" "go.pinniped.dev/internal/federationdomain/oidc" @@ -93,6 +92,7 @@ var ( happyDownstreamRequestParamsQueryForDynamicClient = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, map[string]string{"client_id": downstreamDynamicClientID}, ) + happyDownstreamRequestParamsForDynamicClient = happyDownstreamRequestParamsQueryForDynamicClient.Encode() happyDownstreamCustomSessionData = &psession.CustomSessionData{ Username: oidcUpstreamUsername, @@ -107,7 +107,6 @@ var ( UpstreamSubject: oidcUpstreamSubject, }, } - happyDownstreamCustomSessionDataWithUsernameAndGroups = func(wantDownstreamUsername, wantUpstreamUsername string, wantUpstreamGroups []string) *psession.CustomSessionData { copyOfCustomSession := *happyDownstreamCustomSessionData copyOfOIDC := *(happyDownstreamCustomSessionData.OIDC) @@ -130,14 +129,6 @@ var ( UpstreamSubject: oidcUpstreamSubject, }, } - - addFullyCapableDynamicClientAndSecretToKubeResources = func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) { - oidcClient, secret := testutil.FullyCapableOIDCClientAndStorageSecret(t, - "some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID, downstreamRedirectURI, nil, - []string{testutil.HashedPassword1AtGoMinCost}, oidcclientvalidator.Validate) - require.NoError(t, supervisorClient.Tracker().Add(oidcClient)) - require.NoError(t, kubeClient.Tracker().Add(secret)) - } ) func TestCallbackEndpoint(t *testing.T) { @@ -162,13 +153,13 @@ func TestCallbackEndpoint(t *testing.T) { happyCookieCodec.SetSerializer(securecookie.JSONEncoder{}) happyState := happyUpstreamStateParam().Build(t, happyStateCodec) - happyStateForDynamicClient := happyUpstreamStateParam().WithAuthorizeRequestParams(happyDownstreamRequestParamsQueryForDynamicClient.Encode()).Build(t, happyStateCodec) + happyStateForDynamicClient := happyUpstreamStateParamForDynamicClient().Build(t, happyStateCodec) encodedIncomingCookieCSRFValue, err := happyCookieCodec.Encode("csrf", happyDownstreamCSRF) require.NoError(t, err) happyCSRFCookie := "__Host-pinniped-csrf=" + encodedIncomingCookieCSRFValue - happyExchangeAndValidateTokensArgs := &oidctestutil.ExchangeAuthcodeArgs{ + happyExchangeAndValidateTokensArgs := &oidctestutil.ExchangeAuthcodeAndValidateTokenArgs{ Authcode: happyUpstreamAuthcode, PKCECodeVerifier: oidcpkce.Code(happyDownstreamPKCE), ExpectedIDTokenNonce: nonce.Nonce(happyDownstreamNonce), @@ -178,6 +169,14 @@ func TestCallbackEndpoint(t *testing.T) { // Note that fosite puts the granted scopes as a param in the redirect URI even though the spec doesn't seem to require it happyDownstreamRedirectLocationRegexp := downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+username\+groups&state=` + happyDownstreamState + addFullyCapableDynamicClientAndSecretToKubeResources := func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) { + oidcClient, secret := testutil.FullyCapableOIDCClientAndStorageSecret(t, + "some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID, downstreamRedirectURI, nil, + []string{testutil.HashedPassword1AtGoMinCost}, oidcclientvalidator.Validate) + require.NoError(t, supervisorClient.Tracker().Add(oidcClient)) + require.NoError(t, kubeClient.Tracker().Add(secret)) + } + prefixUsernameAndGroupsPipeline := transformtestutil.NewPrefixingPipeline(t, transformationUsernamePrefix, transformationGroupsPrefix) rejectAuthPipeline := transformtestutil.NewRejectAllAuthPipeline(t) @@ -206,8 +205,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallengeMethod string wantDownstreamCustomSessionData *psession.CustomSessionData wantDownstreamAdditionalClaims map[string]interface{} - - wantAuthcodeExchangeCall *expectedAuthcodeExchange + wantOIDCAuthcodeExchangeCall *expectedOIDCAuthcodeExchange }{ { name: "GET with good state and cookie and successful upstream token exchange with response_mode=form_post returns 200 with HTML+JS form", @@ -235,7 +233,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -274,7 +272,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -302,7 +300,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -327,7 +325,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -351,7 +349,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamAccessTokenCustomSessionData, - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -386,7 +384,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -423,7 +421,7 @@ func TestCallbackEndpoint(t *testing.T) { UpstreamSubject: oidcUpstreamSubject, }, }, - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -453,7 +451,7 @@ func TestCallbackEndpoint(t *testing.T) { oidcUpstreamIssuer+"?sub="+oidcUpstreamSubjectQueryEscaped, nil, ), - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -483,7 +481,7 @@ func TestCallbackEndpoint(t *testing.T) { "joe@whitehouse.gov", oidcUpstreamGroupMembership, ), - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -515,7 +513,7 @@ func TestCallbackEndpoint(t *testing.T) { "joe@whitehouse.gov", oidcUpstreamGroupMembership, ), - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -548,7 +546,7 @@ func TestCallbackEndpoint(t *testing.T) { "joe", oidcUpstreamGroupMembership, ), - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -565,7 +563,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, wantBody: "Unprocessable Entity: email_verified claim in upstream ID token has invalid format\n", - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -579,7 +577,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, wantBody: "Unprocessable Entity: access token was returned by upstream provider but there was no userinfo endpoint\n", - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -593,7 +591,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, wantBody: "Unprocessable Entity: neither access token nor refresh token returned by upstream provider\n", - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -607,7 +605,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, wantBody: "Unprocessable Entity: neither access token nor refresh token returned by upstream provider\n", - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -621,7 +619,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, wantBody: "Unprocessable Entity: neither access token nor refresh token returned by upstream provider\n", - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -635,7 +633,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, wantBody: "Unprocessable Entity: neither access token nor refresh token returned by upstream provider\n", - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -653,7 +651,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, wantBody: "Unprocessable Entity: email_verified claim in upstream ID token has false value\n", - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -683,7 +681,7 @@ func TestCallbackEndpoint(t *testing.T) { oidcUpstreamSubject, oidcUpstreamGroupMembership, ), - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -713,7 +711,7 @@ func TestCallbackEndpoint(t *testing.T) { oidcUpstreamUsername, []string{"notAnArrayGroup1 notAnArrayGroup2"}, ), - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -743,7 +741,7 @@ func TestCallbackEndpoint(t *testing.T) { oidcUpstreamUsername, []string{"group1", "group2"}, ), - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -754,7 +752,7 @@ func TestCallbackEndpoint(t *testing.T) { kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, method: http.MethodGet, path: newRequestPath().WithState( - happyUpstreamStateParam(). + happyUpstreamStateParamForDynamicClient(). WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQueryForDynamicClient, map[string]string{"scope": "openid groups offline_access"}).Encode()). Build(t, happyStateCodec), @@ -773,7 +771,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -784,7 +782,7 @@ func TestCallbackEndpoint(t *testing.T) { kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, method: http.MethodGet, path: newRequestPath().WithState( - happyUpstreamStateParam(). + happyUpstreamStateParamForDynamicClient(). WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQueryForDynamicClient, map[string]string{"scope": "openid username offline_access"}).Encode()). Build(t, happyStateCodec), @@ -803,7 +801,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -846,7 +844,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -889,7 +887,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -918,7 +916,7 @@ func TestCallbackEndpoint(t *testing.T) { oidcUpstreamUsername, oidcUpstreamGroupMembership, ), - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -1004,7 +1002,7 @@ func TestCallbackEndpoint(t *testing.T) { Build(t, happyStateCodec), ).String(), csrfCookie: happyCSRFCookie, - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -1169,7 +1167,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -1199,7 +1197,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -1228,7 +1226,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -1285,7 +1283,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusBadGateway, wantBody: "Bad Gateway: error exchanging and validating upstream tokens\n", wantContentType: htmlContentType, - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -1301,7 +1299,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusUnprocessableEntity, wantBody: "Unprocessable Entity: required claim in upstream ID token missing\n", wantContentType: htmlContentType, - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -1331,7 +1329,7 @@ func TestCallbackEndpoint(t *testing.T) { oidcUpstreamUsername, nil, ), - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -1347,7 +1345,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, wantBody: "Unprocessable Entity: required claim in upstream ID token has invalid format\n", - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -1363,7 +1361,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, wantBody: "Unprocessable Entity: required claim in upstream ID token is empty\n", - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -1379,7 +1377,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, wantBody: "Unprocessable Entity: required claim in upstream ID token missing\n", - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -1395,7 +1393,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, wantBody: "Unprocessable Entity: required claim in upstream ID token is empty\n", - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -1411,7 +1409,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, wantBody: "Unprocessable Entity: required claim in upstream ID token has invalid format\n", - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -1427,7 +1425,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, wantBody: "Unprocessable Entity: required claim in upstream ID token missing\n", - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -1443,7 +1441,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, wantBody: "Unprocessable Entity: required claim in upstream ID token is empty\n", - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -1459,7 +1457,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, wantBody: "Unprocessable Entity: required claim in upstream ID token has invalid format\n", - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -1475,7 +1473,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, wantBody: "Unprocessable Entity: required claim in upstream ID token has invalid format\n", - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -1491,7 +1489,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, wantBody: "Unprocessable Entity: required claim in upstream ID token has invalid format\n", - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -1507,7 +1505,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, wantBody: "Unprocessable Entity: required claim in upstream ID token has invalid format\n", - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -1522,7 +1520,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, wantBody: "Unprocessable Entity: configured identity policy rejected this authentication: authentication was rejected by a configured policy\n", - wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, }, @@ -1563,15 +1561,14 @@ func TestCallbackEndpoint(t *testing.T) { testutil.RequireSecurityHeadersWithFormPostPageCSPs(t, rsp) - if test.wantAuthcodeExchangeCall != nil { - test.wantAuthcodeExchangeCall.args.Ctx = reqContext - test.idps.RequireExactlyOneCallToExchangeAuthcodeAndValidateTokens(t, - test.wantAuthcodeExchangeCall.performedByUpstreamName, - idpdiscoveryv1alpha1.IDPTypeOIDC, - test.wantAuthcodeExchangeCall.args, + if test.wantOIDCAuthcodeExchangeCall != nil { + test.wantOIDCAuthcodeExchangeCall.args.Ctx = reqContext + test.idps.RequireExactlyOneOIDCAuthcodeExchange(t, + test.wantOIDCAuthcodeExchangeCall.performedByUpstreamName, + test.wantOIDCAuthcodeExchangeCall.args, ) } else { - test.idps.RequireExactlyZeroCallsToExchangeAuthcodeAndValidateTokens(t) + test.idps.RequireExactlyZeroAuthcodeExchanges(t) } require.Equal(t, test.wantStatus, rsp.Code) @@ -1637,7 +1634,12 @@ func TestCallbackEndpoint(t *testing.T) { } } -type expectedAuthcodeExchange struct { +type expectedOIDCAuthcodeExchange struct { + performedByUpstreamName string + args *oidctestutil.ExchangeAuthcodeAndValidateTokenArgs +} + +type expectedGitHubAuthcodeExchange struct { performedByUpstreamName string args *oidctestutil.ExchangeAuthcodeArgs } @@ -1699,6 +1701,12 @@ func happyUpstreamStateParam() *oidctestutil.UpstreamStateParamBuilder { } } +func happyUpstreamStateParamForDynamicClient() *oidctestutil.UpstreamStateParamBuilder { + p := happyUpstreamStateParam() + p.P = happyDownstreamRequestParamsForDynamicClient + return p +} + func happyUpstream() *oidctestutil.TestUpstreamOIDCIdentityProviderBuilder { return oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder(). WithName(happyUpstreamIDPName). diff --git a/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider.go b/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider.go index 4efb1c048..c9de9036e 100644 --- a/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider.go +++ b/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider.go @@ -97,22 +97,29 @@ func (p *FederationDomainResolvedGitHubIdentityProvider) LoginFromCallback( _ nonce.Nonce, // GitHub does not support OIDC, therefore there is no ID token that could contain the "nonce". redirectURI string, ) (*resolvedprovider.Identity, *resolvedprovider.IdentityLoginExtras, error) { - token, _ := p.Provider.ExchangeAuthcode( - ctx, - authCode, - redirectURI, - ) + accessToken, err := p.Provider.ExchangeAuthcode(ctx, authCode, redirectURI) + if err != nil { + return nil, nil, fmt.Errorf("failed to exchange auth code using GitHub API: %w", err) + } + + user, err := p.Provider.GetUser(ctx, accessToken) + if err != nil { + return nil, nil, fmt.Errorf("failed to get user info from GitHub API: %w", err) + } return &resolvedprovider.Identity{ - UpstreamUsername: "some-github-login", - UpstreamGroups: []string{"org1/team1", "org2/team2"}, - DownstreamSubject: "https://github.com?idpName=upstream-github-idp-name&sub=some-github-login", + UpstreamUsername: user.Username, + UpstreamGroups: user.Groups, + DownstreamSubject: user.DownstreamSubject, IDPSpecificSessionData: &psession.GitHubSessionData{ - UpstreamAccessToken: token, + UpstreamAccessToken: accessToken, }, }, - &resolvedprovider.IdentityLoginExtras{}, - nil + &resolvedprovider.IdentityLoginExtras{ + DownstreamAdditionalClaims: nil, // not using this for GitHub + Warnings: nil, // not using this for GitHub + }, + nil // no error } func (p *FederationDomainResolvedGitHubIdentityProvider) UpstreamRefresh( diff --git a/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider_test.go b/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider_test.go index d54b4f57e..3f957ce37 100644 --- a/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider_test.go +++ b/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider_test.go @@ -4,6 +4,8 @@ package resolvedgithub import ( + "context" + "errors" "testing" "github.com/stretchr/testify/require" @@ -12,10 +14,11 @@ import ( idpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" idpdiscoveryv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1" "go.pinniped.dev/internal/federationdomain/resolvedprovider" + "go.pinniped.dev/internal/federationdomain/upstreamprovider" "go.pinniped.dev/internal/psession" + "go.pinniped.dev/internal/testutil/oidctestutil" "go.pinniped.dev/internal/testutil/transformtestutil" "go.pinniped.dev/internal/upstreamgithub" - "go.pinniped.dev/pkg/oidcclient/oidctypes" ) func TestFederationDomainResolvedGitHubIdentityProvider(t *testing.T) { @@ -59,11 +62,11 @@ func TestFederationDomainResolvedGitHubIdentityProvider(t *testing.T) { originalCustomSession := &psession.CustomSessionData{ Username: "fake-username", UpstreamUsername: "fake-upstream-username", - GitHub: &psession.GitHubSessionData{UpstreamAccessToken: &oidctypes.Token{AccessToken: &oidctypes.AccessToken{Token: "fake-upstream-access-token"}}}, + GitHub: &psession.GitHubSessionData{UpstreamAccessToken: "fake-upstream-access-token"}, } clonedCustomSession := subject.CloneIDPSpecificSessionDataFromSession(originalCustomSession) require.Equal(t, - &psession.GitHubSessionData{UpstreamAccessToken: &oidctypes.Token{AccessToken: &oidctypes.AccessToken{Token: "fake-upstream-access-token"}}}, + &psession.GitHubSessionData{UpstreamAccessToken: "fake-upstream-access-token"}, clonedCustomSession, ) require.NotSame(t, originalCustomSession, clonedCustomSession) @@ -72,11 +75,11 @@ func TestFederationDomainResolvedGitHubIdentityProvider(t *testing.T) { Username: "fake-username2", UpstreamUsername: "fake-upstream-username2", } - subject.ApplyIDPSpecificSessionDataToSession(customSessionToBeMutated, &psession.GitHubSessionData{UpstreamAccessToken: &oidctypes.Token{AccessToken: &oidctypes.AccessToken{Token: "OTHER-upstream-access-token"}}}) + subject.ApplyIDPSpecificSessionDataToSession(customSessionToBeMutated, &psession.GitHubSessionData{UpstreamAccessToken: "OTHER-upstream-access-token"}) require.Equal(t, &psession.CustomSessionData{ Username: "fake-username2", UpstreamUsername: "fake-upstream-username2", - GitHub: &psession.GitHubSessionData{UpstreamAccessToken: &oidctypes.Token{AccessToken: &oidctypes.AccessToken{Token: "OTHER-upstream-access-token"}}}, + GitHub: &psession.GitHubSessionData{UpstreamAccessToken: "OTHER-upstream-access-token"}, }, customSessionToBeMutated) redirectURL, err := subject.UpstreamAuthorizeRedirectURL( @@ -101,3 +104,139 @@ func TestFederationDomainResolvedGitHubIdentityProvider(t *testing.T) { redirectURL, ) } + +func TestLoginFromCallback(t *testing.T) { + uniqueCtx := context.WithValue(context.Background(), "some-unique-key", "some-value") //nolint:staticcheck // okay to use string key for test + + tests := []struct { + name string + provider *oidctestutil.TestUpstreamGitHubIdentityProvider + authcode string + redirectURI string + + wantExchangeAuthcodeCall bool + wantExchangeAuthcodeArgs *oidctestutil.ExchangeAuthcodeArgs + wantGetUserCall bool + wantGetUserArgs *oidctestutil.GetUserArgs + wantIdentity *resolvedprovider.Identity + wantExtras *resolvedprovider.IdentityLoginExtras + wantErr string + }{ + { + name: "happy path", + provider: oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder(). + WithAccessToken("fake-access-token"). + WithUser(&upstreamprovider.GitHubUser{ + Username: "fake-username", + Groups: []string{"fake-group1", "fake-group2"}, + DownstreamSubject: "https://fake-downstream-subject", + }). + Build(), + authcode: "fake-authcode", + redirectURI: "https://fake-redirect-uri", + wantExchangeAuthcodeCall: true, + wantExchangeAuthcodeArgs: &oidctestutil.ExchangeAuthcodeArgs{ + Ctx: uniqueCtx, + Authcode: "fake-authcode", + RedirectURI: "https://fake-redirect-uri", + }, + wantGetUserCall: true, + wantGetUserArgs: &oidctestutil.GetUserArgs{ + Ctx: uniqueCtx, + AccessToken: "fake-access-token", + }, + wantIdentity: &resolvedprovider.Identity{ + UpstreamUsername: "fake-username", + UpstreamGroups: []string{"fake-group1", "fake-group2"}, + DownstreamSubject: "https://fake-downstream-subject", + IDPSpecificSessionData: &psession.GitHubSessionData{ + UpstreamAccessToken: "fake-access-token", + }, + }, + wantExtras: &resolvedprovider.IdentityLoginExtras{}, + }, + { + name: "error while exchanging authcode", + provider: oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder(). + WithAuthcodeExchangeError(errors.New("fake authcode exchange error")). + Build(), + authcode: "fake-authcode", + redirectURI: "https://fake-redirect-uri", + wantExchangeAuthcodeCall: true, + wantExchangeAuthcodeArgs: &oidctestutil.ExchangeAuthcodeArgs{ + Ctx: uniqueCtx, + Authcode: "fake-authcode", + RedirectURI: "https://fake-redirect-uri", + }, + wantGetUserCall: false, + wantIdentity: nil, + wantExtras: nil, + wantErr: "failed to exchange auth code using GitHub API: fake authcode exchange error", + }, + { + name: "error while getting user info", + provider: oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder(). + WithAccessToken("fake-access-token"). + WithGetUserError(errors.New("fake user info error")). + Build(), + authcode: "fake-authcode", + redirectURI: "https://fake-redirect-uri", + wantExchangeAuthcodeCall: true, + wantExchangeAuthcodeArgs: &oidctestutil.ExchangeAuthcodeArgs{ + Ctx: uniqueCtx, + Authcode: "fake-authcode", + RedirectURI: "https://fake-redirect-uri", + }, + wantGetUserCall: true, + wantGetUserArgs: &oidctestutil.GetUserArgs{ + Ctx: uniqueCtx, + AccessToken: "fake-access-token", + }, + wantIdentity: nil, + wantExtras: nil, + wantErr: "failed to get user info from GitHub API: fake user info error", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + transforms := transformtestutil.NewRejectAllAuthPipeline(t) + + subject := FederationDomainResolvedGitHubIdentityProvider{ + DisplayName: "fake-display-name", + Provider: test.provider, + SessionProviderType: psession.ProviderTypeGitHub, + Transforms: transforms, + } + + identity, loginExtras, err := subject.LoginFromCallback(uniqueCtx, + test.authcode, + "pkce-will-be-ignored", + "nonce-will-be-ignored", + test.redirectURI, + ) + + if test.wantExchangeAuthcodeCall { + require.Equal(t, 1, test.provider.ExchangeAuthcodeCallCount()) + require.Equal(t, test.wantExchangeAuthcodeArgs, test.provider.ExchangeAuthcodeArgs(0)) + } else { + require.Zero(t, test.provider.ExchangeAuthcodeCallCount()) + } + + if test.wantGetUserCall { + require.Equal(t, 1, test.provider.GetUserCallCount()) + require.Equal(t, test.wantGetUserArgs, test.provider.GetUserArgs(0)) + } else { + require.Zero(t, test.provider.GetUserCallCount()) + } + + if test.wantErr == "" { + require.NoError(t, err) + } else { + require.EqualError(t, err, test.wantErr) + } + require.Equal(t, test.wantExtras, loginExtras) + require.Equal(t, test.wantIdentity, identity) + }) + } +} diff --git a/internal/federationdomain/upstreamprovider/upsteam_provider.go b/internal/federationdomain/upstreamprovider/upsteam_provider.go index be3229be3..cb52dcedf 100644 --- a/internal/federationdomain/upstreamprovider/upsteam_provider.go +++ b/internal/federationdomain/upstreamprovider/upsteam_provider.go @@ -127,6 +127,12 @@ type UpstreamLDAPIdentityProviderI interface { PerformRefresh(ctx context.Context, storedRefreshAttributes RefreshAttributes, idpDisplayName string) (groups []string, err error) } +type GitHubUser struct { + Username string // could be login name, id, or login:id + Groups []string // could be names or slugs + DownstreamSubject string // the whole downstream subject URI +} + type UpstreamGithubIdentityProviderI interface { UpstreamIdentityProviderI @@ -159,21 +165,11 @@ type UpstreamGithubIdentityProviderI interface { // It will never include a username or password in the authority section. GetAuthorizationURL() string - // TODO: This interface should be easily mockable to avoid all interactions with the actual server. - // What interactions with the server do we want to hide behind this interface? Something like this? - // ExchangeAuthcode(ctx, authcode, redirectURI) (AccessToken, error) - // GetUser(ctx, accessToken) (User, error) - // GetUserOrgs(ctx, accessToken) ([]Org, error) - // GetUserTeams(ctx, accessToken) ([]Team, error) - // Or maybe higher level interface like this? - // ExchangeAuthcode(ctx, authcode, redirectURI) (AccessToken, error) - // GetUser(ctx, accessToken) (User, error) // in this case User would include team and org info - // ExchangeAuthcode performs an upstream GitHub authorization code exchange. // Returns the raw access token. The access token expiry is not known. - ExchangeAuthcode( - ctx context.Context, - authcode string, - redirectURI string, - ) (string, error) + ExchangeAuthcode(ctx context.Context, authcode string, redirectURI string) (string, error) + + // GetUser calls the user, orgs, and teams APIs of GitHub using the accessToken. + // It validates any required org memberships. It returns a User or an error. + GetUser(ctx context.Context, accessToken string) (*GitHubUser, error) } diff --git a/internal/testutil/oidctestutil/testgithubprovider.go b/internal/testutil/oidctestutil/testgithubprovider.go index 20b97ad93..1f793aeac 100644 --- a/internal/testutil/oidctestutil/testgithubprovider.go +++ b/internal/testutil/oidctestutil/testgithubprovider.go @@ -13,6 +13,21 @@ import ( "go.pinniped.dev/internal/idtransform" ) +// ExchangeAuthcodeArgs is used to spy on calls to +// TestUpstreamGitHubIdentityProvider.ExchangeAuthcodeFunc(). +type ExchangeAuthcodeArgs struct { + Ctx context.Context + Authcode string + RedirectURI string +} + +// GetUserArgs is used to spy on calls to +// TestUpstreamGitHubIdentityProvider.GetUserFunc(). +type GetUserArgs struct { + Ctx context.Context + AccessToken string +} + type TestUpstreamGitHubIdentityProviderBuilder struct { name string resourceUID types.UID @@ -24,10 +39,10 @@ type TestUpstreamGitHubIdentityProviderBuilder struct { groupNameAttribute v1alpha1.GitHubGroupNameAttribute allowedOrganizations []string authorizationURL string - - // Assertions stuff - authcodeExchangeErr error - accessToken string + authcodeExchangeErr error + accessToken string + getUserErr error + getUserUser *upstreamprovider.GitHubUser } func (u *TestUpstreamGitHubIdentityProviderBuilder) WithName(value string) *TestUpstreamGitHubIdentityProviderBuilder { @@ -80,8 +95,18 @@ func (u *TestUpstreamGitHubIdentityProviderBuilder) WithAccessToken(token string return u } -func (u *TestUpstreamGitHubIdentityProviderBuilder) WithEmptyAccessToken() *TestUpstreamGitHubIdentityProviderBuilder { - u.accessToken = "" +func (u *TestUpstreamGitHubIdentityProviderBuilder) WithAuthcodeExchangeError(err error) *TestUpstreamGitHubIdentityProviderBuilder { + u.authcodeExchangeErr = err + return u +} + +func (u *TestUpstreamGitHubIdentityProviderBuilder) WithUser(user *upstreamprovider.GitHubUser) *TestUpstreamGitHubIdentityProviderBuilder { + u.getUserUser = user + return u +} + +func (u *TestUpstreamGitHubIdentityProviderBuilder) WithGetUserError(err error) *TestUpstreamGitHubIdentityProviderBuilder { + u.getUserErr = err return u } @@ -96,8 +121,8 @@ func (u *TestUpstreamGitHubIdentityProviderBuilder) Build() *TestUpstreamGitHubI } return &TestUpstreamGitHubIdentityProvider{ Name: u.name, - ResourceUID: u.resourceUID, ClientID: u.clientID, + ResourceUID: u.resourceUID, Scopes: u.scopes, DisplayNameForFederationDomain: u.displayNameForFederationDomain, TransformsForFederationDomain: u.transformsForFederationDomain, @@ -105,7 +130,12 @@ func (u *TestUpstreamGitHubIdentityProviderBuilder) Build() *TestUpstreamGitHubI GroupNameAttribute: u.groupNameAttribute, AllowedOrganizations: u.allowedOrganizations, AuthorizationURL: u.authorizationURL, - + GetUserFunc: func(ctx context.Context, accessToken string) (*upstreamprovider.GitHubUser, error) { + if u.getUserErr != nil { + return nil, u.getUserErr + } + return u.getUserUser, nil + }, ExchangeAuthcodeFunc: func(ctx context.Context, authcode string) (string, error) { if u.authcodeExchangeErr != nil { return "", u.authcodeExchangeErr @@ -130,16 +160,14 @@ type TestUpstreamGitHubIdentityProvider struct { GroupNameAttribute v1alpha1.GitHubGroupNameAttribute AllowedOrganizations []string AuthorizationURL string + GetUserFunc func(ctx context.Context, accessToken string) (*upstreamprovider.GitHubUser, error) + ExchangeAuthcodeFunc func(ctx context.Context, authcode string) (string, error) - authcodeExchangeErr error - - ExchangeAuthcodeFunc func( - ctx context.Context, - authcode string, - ) (string, error) - + // Fields for tracking actual calls make to mock functions. exchangeAuthcodeCallCount int exchangeAuthcodeArgs []*ExchangeAuthcodeArgs + getUserCallCount int + getUserArgs []*GetUserArgs } var _ upstreamprovider.UpstreamGithubIdentityProviderI = &TestUpstreamGitHubIdentityProvider{} @@ -203,3 +231,26 @@ func (u *TestUpstreamGitHubIdentityProvider) ExchangeAuthcodeArgs(call int) *Exc } return u.exchangeAuthcodeArgs[call] } + +func (u *TestUpstreamGitHubIdentityProvider) GetUser(ctx context.Context, accessToken string) (*upstreamprovider.GitHubUser, error) { + if u.getUserArgs == nil { + u.getUserArgs = make([]*GetUserArgs, 0) + } + u.getUserCallCount++ + u.getUserArgs = append(u.getUserArgs, &GetUserArgs{ + Ctx: ctx, + AccessToken: accessToken, + }) + return u.GetUserFunc(ctx, accessToken) +} + +func (u *TestUpstreamGitHubIdentityProvider) GetUserCallCount() int { + return u.getUserCallCount +} + +func (u *TestUpstreamGitHubIdentityProvider) GetUserArgs(call int) *GetUserArgs { + if u.getUserArgs == nil { + u.getUserArgs = make([]*GetUserArgs, 0) + } + return u.getUserArgs[call] +} diff --git a/internal/testutil/oidctestutil/testoidcprovider.go b/internal/testutil/oidctestutil/testoidcprovider.go index 9530db42e..489f6304c 100644 --- a/internal/testutil/oidctestutil/testoidcprovider.go +++ b/internal/testutil/oidctestutil/testoidcprovider.go @@ -18,9 +18,9 @@ import ( oidcpkce "go.pinniped.dev/pkg/oidcclient/pkce" ) -// ExchangeAuthcodeArgs is used to spy on calls to +// ExchangeAuthcodeAndValidateTokenArgs is used to spy on calls to // TestUpstreamOIDCIdentityProvider.ExchangeAuthcodeAndValidateTokensFunc(). -type ExchangeAuthcodeArgs struct { +type ExchangeAuthcodeAndValidateTokenArgs struct { Ctx context.Context Authcode string PKCECodeVerifier oidcpkce.Code @@ -101,7 +101,7 @@ type TestUpstreamOIDCIdentityProvider struct { // Fields for tracking actual calls make to mock functions. exchangeAuthcodeAndValidateTokensCallCount int - exchangeAuthcodeAndValidateTokensArgs []*ExchangeAuthcodeArgs + exchangeAuthcodeAndValidateTokensArgs []*ExchangeAuthcodeAndValidateTokenArgs passwordCredentialsGrantAndValidateTokensCallCount int passwordCredentialsGrantAndValidateTokensArgs []*PasswordCredentialsGrantAndValidateTokensArgs performRefreshCallCount int @@ -180,10 +180,10 @@ func (u *TestUpstreamOIDCIdentityProvider) ExchangeAuthcodeAndValidateTokens( redirectURI string, ) (*oidctypes.Token, error) { if u.exchangeAuthcodeAndValidateTokensArgs == nil { - u.exchangeAuthcodeAndValidateTokensArgs = make([]*ExchangeAuthcodeArgs, 0) + u.exchangeAuthcodeAndValidateTokensArgs = make([]*ExchangeAuthcodeAndValidateTokenArgs, 0) } u.exchangeAuthcodeAndValidateTokensCallCount++ - u.exchangeAuthcodeAndValidateTokensArgs = append(u.exchangeAuthcodeAndValidateTokensArgs, &ExchangeAuthcodeArgs{ + u.exchangeAuthcodeAndValidateTokensArgs = append(u.exchangeAuthcodeAndValidateTokensArgs, &ExchangeAuthcodeAndValidateTokenArgs{ Ctx: ctx, Authcode: authcode, PKCECodeVerifier: pkceCodeVerifier, @@ -197,9 +197,9 @@ func (u *TestUpstreamOIDCIdentityProvider) ExchangeAuthcodeAndValidateTokensCall return u.exchangeAuthcodeAndValidateTokensCallCount } -func (u *TestUpstreamOIDCIdentityProvider) ExchangeAuthcodeAndValidateTokensArgs(call int) *ExchangeAuthcodeArgs { +func (u *TestUpstreamOIDCIdentityProvider) ExchangeAuthcodeAndValidateTokensArgs(call int) *ExchangeAuthcodeAndValidateTokenArgs { if u.exchangeAuthcodeAndValidateTokensArgs == nil { - u.exchangeAuthcodeAndValidateTokensArgs = make([]*ExchangeAuthcodeArgs, 0) + u.exchangeAuthcodeAndValidateTokensArgs = make([]*ExchangeAuthcodeAndValidateTokenArgs, 0) } return u.exchangeAuthcodeAndValidateTokensArgs[call] } diff --git a/internal/testutil/testidplister/testidplister.go b/internal/testutil/testidplister/testidplister.go index 2823a5188..d7fed23eb 100644 --- a/internal/testutil/testidplister/testidplister.go +++ b/internal/testutil/testidplister/testidplister.go @@ -9,7 +9,6 @@ import ( "github.com/stretchr/testify/require" - idpdiscoveryv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1" "go.pinniped.dev/internal/federationdomain/dynamicupstreamprovider" "go.pinniped.dev/internal/federationdomain/resolvedprovider" "go.pinniped.dev/internal/federationdomain/resolvedprovider/resolvedgithub" @@ -264,48 +263,59 @@ func (b *UpstreamIDPListerBuilder) RequireExactlyZeroCallsToPasswordCredentialsG ) } -func (b *UpstreamIDPListerBuilder) RequireExactlyOneCallToExchangeAuthcodeAndValidateTokens( +func (b *UpstreamIDPListerBuilder) RequireExactlyOneOIDCAuthcodeExchange( + t *testing.T, + expectedPerformedByUpstreamName string, + expectedArgs *oidctestutil.ExchangeAuthcodeAndValidateTokenArgs, +) { + t.Helper() + var actualArgs *oidctestutil.ExchangeAuthcodeAndValidateTokenArgs + var actualNameOfUpstreamWhichMadeCall string + actualCallCount := 0 + for _, upstream := range b.upstreamOIDCIdentityProviders { + callCountOnThisUpstream := upstream.ExchangeAuthcodeAndValidateTokensCallCount() + actualCallCount += callCountOnThisUpstream + if callCountOnThisUpstream == 1 { + actualNameOfUpstreamWhichMadeCall = upstream.Name + actualArgs = upstream.ExchangeAuthcodeAndValidateTokensArgs(0) + } + } + require.Equal(t, 1, actualCallCount, + "expected exactly one call to OIDC ExchangeAuthcodeAndValidateTokens()", + ) + require.Equal(t, expectedPerformedByUpstreamName, actualNameOfUpstreamWhichMadeCall, + "OIDC ExchangeAuthcodeAndValidateTokens() was called on the wrong upstream name", + ) + require.Equal(t, expectedArgs, actualArgs) +} + +func (b *UpstreamIDPListerBuilder) RequireExactlyOneGitHubAuthcodeExchange( t *testing.T, expectedPerformedByUpstreamName string, - expectedPerformedByUpstreamType idpdiscoveryv1alpha1.IDPType, expectedArgs *oidctestutil.ExchangeAuthcodeArgs, ) { t.Helper() var actualArgs *oidctestutil.ExchangeAuthcodeArgs var actualNameOfUpstreamWhichMadeCall string - var actualTypeOfUpstreamWhichMadeCall idpdiscoveryv1alpha1.IDPType - actualCallCountAcrossAllOIDCAndGitHubUpstreams := 0 - for _, upstreamOIDC := range b.upstreamOIDCIdentityProviders { - callCountOnThisUpstream := upstreamOIDC.ExchangeAuthcodeAndValidateTokensCallCount() - actualCallCountAcrossAllOIDCAndGitHubUpstreams += callCountOnThisUpstream + actualCallCount := 0 + for _, upstream := range b.upstreamGitHubIdentityProviders { + callCountOnThisUpstream := upstream.ExchangeAuthcodeCallCount() + actualCallCount += callCountOnThisUpstream if callCountOnThisUpstream == 1 { - actualNameOfUpstreamWhichMadeCall = upstreamOIDC.Name - actualTypeOfUpstreamWhichMadeCall = idpdiscoveryv1alpha1.IDPTypeOIDC - actualArgs = upstreamOIDC.ExchangeAuthcodeAndValidateTokensArgs(0) + actualNameOfUpstreamWhichMadeCall = upstream.Name + actualArgs = upstream.ExchangeAuthcodeArgs(0) } } - for _, upstreamGitHub := range b.upstreamGitHubIdentityProviders { - callCountOnThisUpstream := upstreamGitHub.ExchangeAuthcodeCallCount() - actualCallCountAcrossAllOIDCAndGitHubUpstreams += callCountOnThisUpstream - if callCountOnThisUpstream == 1 { - actualNameOfUpstreamWhichMadeCall = upstreamGitHub.Name - actualTypeOfUpstreamWhichMadeCall = idpdiscoveryv1alpha1.IDPTypeGitHub - actualArgs = upstreamGitHub.ExchangeAuthcodeArgs(0) - } - } - require.Equal(t, 1, actualCallCountAcrossAllOIDCAndGitHubUpstreams, - "expected exactly one call to (OIDC) ExchangeAuthcodeAndValidateTokensCallCount() or (GitHub) ExchangeAuthcodeCallCount()", + require.Equal(t, 1, actualCallCount, + "expected exactly one call to GitHub ExchangeAuthcode()", ) require.Equal(t, expectedPerformedByUpstreamName, actualNameOfUpstreamWhichMadeCall, - "(OIDC) ExchangeAuthcodeAndValidateTokensCallCount() or (GitHub) ExchangeAuthcodeCallCount() was called on the wrong upstream name", - ) - require.Equal(t, expectedPerformedByUpstreamType, actualTypeOfUpstreamWhichMadeCall, - "(OIDC) ExchangeAuthcodeAndValidateTokensCallCount() or (GitHub) ExchangeAuthcodeCallCount() was called on the wrong upstream type", + "GitHub ExchangeAuthcode() was called on the wrong upstream name", ) require.Equal(t, expectedArgs, actualArgs) } -func (b *UpstreamIDPListerBuilder) RequireExactlyZeroCallsToExchangeAuthcodeAndValidateTokens(t *testing.T) { +func (b *UpstreamIDPListerBuilder) RequireExactlyZeroAuthcodeExchanges(t *testing.T) { t.Helper() actualCallCount := 0 for _, upstreamOIDC := range b.upstreamOIDCIdentityProviders { @@ -316,7 +326,7 @@ func (b *UpstreamIDPListerBuilder) RequireExactlyZeroCallsToExchangeAuthcodeAndV } require.Equal(t, 0, actualCallCount, - "expected exactly zero calls to (OIDC) ExchangeAuthcodeAndValidateTokensCallCount() or (GitHub) ExchangeAuthcodeCallCount()", + "expected exactly zero calls to OIDC ExchangeAuthcodeAndValidateTokens() or GitHub ExchangeAuthcode()", ) } diff --git a/internal/upstreamgithub/upstreamgithub.go b/internal/upstreamgithub/upstreamgithub.go index f76a02aa5..67f8b683d 100644 --- a/internal/upstreamgithub/upstreamgithub.go +++ b/internal/upstreamgithub/upstreamgithub.go @@ -8,6 +8,7 @@ import ( "context" "net/http" + coreosoidc "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" "k8s.io/apimachinery/pkg/types" @@ -88,12 +89,37 @@ func (p *Provider) GetAuthorizationURL() string { return p.c.OAuth2Config.Endpoint.AuthURL } -func (p *Provider) ExchangeAuthcode(_ context.Context, _ string, _ string) (string, error) { - //TODO implement me - panic("implement me") +func (p *Provider) ExchangeAuthcode(ctx context.Context, authcode string, redirectURI string) (string, error) { + // TODO: write tests for this + panic("write some tests for this sketch of the implementation, maybe by running a test server in the unit tests") + //nolint:govet // this code is intentionally unreachable until we resolve the todos + tok, err := p.c.OAuth2Config.Exchange( + coreosoidc.ClientContext(ctx, p.c.HttpClient), + authcode, + oauth2.SetAuthURLParam("redirect_uri", redirectURI), + ) + if err != nil { + return "", err + } + return tok.AccessToken, nil } -// GetConfig returns the config. This is not part of the interface and is mostly just for testing. +func (p *Provider) GetUser(_ctx context.Context, _accessToken string) (*upstreamprovider.GitHubUser, error) { + // TODO Implement this to make several https calls to github to learn about the user, using a lower-level githubclient package. + // Pass the ctx, accessToken, p.c.HttpClient, and p.c.APIBaseURL to the lower-level package's functions. + // TODO: Reject the auth if the user does not belong to any of p.c.AllowedOrganizations (unless p.c.AllowedOrganizations is empty). + // TODO: Make use of p.c.UsernameAttribute and p.c.GroupNameAttribute when deciding the username and group names. + // TODO: Determine the downstream subject by first writing a helper in downstream_subject.go and then calling it here. + panic("implement me") + //nolint:govet // this code is intentionally unreachable until we resolve the todos + return &upstreamprovider.GitHubUser{ + Username: "TODO", + Groups: []string{"org/TODO"}, + DownstreamSubject: "TODO", + }, nil +} + +// GetConfig returns the config. This is not part of the UpstreamGithubIdentityProviderI interface and is just for testing. func (p *Provider) GetConfig() ProviderConfig { return p.c } From c087e33b864e664170e11876388f09cbf3590116 Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Fri, 17 May 2024 00:22:24 -0500 Subject: [PATCH 03/25] Add client wrapper for github.com/google/go-github/v62 --- go.mod | 5 + go.sum | 10 + internal/githubclient/githubclient.go | 160 ++++++ internal/githubclient/githubclient_test.go | 600 +++++++++++++++++++++ 4 files changed, 775 insertions(+) create mode 100644 internal/githubclient/githubclient.go create mode 100644 internal/githubclient/githubclient_test.go diff --git a/go.mod b/go.mod index 81a922ec8..881971541 100644 --- a/go.mod +++ b/go.mod @@ -47,11 +47,13 @@ require ( github.com/gofrs/flock v0.8.1 github.com/google/cel-go v0.20.1 github.com/google/go-cmp v0.6.0 + github.com/google/go-github/v62 v62.0.0 github.com/google/gofuzz v1.2.0 github.com/google/uuid v1.6.0 github.com/gorilla/securecookie v1.1.2 github.com/gorilla/websocket v1.5.1 github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 + github.com/migueleliasweb/go-github-mock v0.0.23 github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 github.com/ory/fosite v0.46.2-0.20240403135905-5e039ca9eef1 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c @@ -120,6 +122,9 @@ require ( github.com/golang/mock v1.6.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/go-github/v59 v59.0.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/gorilla/mux v1.8.0 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.2 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect diff --git a/go.sum b/go.sum index 1eeb8bdb6..29bb12b82 100644 --- a/go.sum +++ b/go.sum @@ -257,6 +257,12 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v59 v59.0.0 h1:7h6bgpF5as0YQLLkEiVqpgtJqjimMYhBkD4jT5aN3VA= +github.com/google/go-github/v59 v59.0.0/go.mod h1:rJU4R0rQHFVFDOkqGWxfLNo6vEk4dv40oDjhV/gH6wM= +github.com/google/go-github/v62 v62.0.0 h1:/6mGCaRywZz9MuHyw9gD1CwsbmBX8GWsbFkwMmHdhl4= +github.com/google/go-github/v62 v62.0.0/go.mod h1:EMxeUqGJq2xRu9DYBMwel/mr7kZrzUOfQmmpYrZn2a4= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= @@ -284,6 +290,8 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+ github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= @@ -440,6 +448,8 @@ github.com/mattn/goveralls v0.0.12/go.mod h1:44ImGEUfmqH8bBtaMrYKsM65LXfNLWmwaxF github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/microcosm-cc/bluemonday v1.0.20/go.mod h1:yfBmMi8mxvaZut3Yytv+jTXRY8mxyjJ0/kQBTElld50= +github.com/migueleliasweb/go-github-mock v0.0.23 h1:GOi9oX/+Seu9JQ19V8bPDLqDI7M9iEOjo3g8v1k6L2c= +github.com/migueleliasweb/go-github-mock v0.0.23/go.mod h1:NsT8FGbkvIZQtDu38+295sZEX8snaUiiQgsGxi6GUxk= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= diff --git a/internal/githubclient/githubclient.go b/internal/githubclient/githubclient.go new file mode 100644 index 000000000..998b7d16b --- /dev/null +++ b/internal/githubclient/githubclient.go @@ -0,0 +1,160 @@ +package githubclient + +import ( + "context" + "fmt" + "net/http" + "slices" + + "github.com/google/go-github/v62/github" +) + +const emptyUserMeansTheAuthenticatedUser = "" + +type UserInfo struct { + ID string + Login string +} + +type TeamInfo struct { + Name string + Slug string + Org string +} + +type GitHubInterface interface { + GetUserInfo() (*UserInfo, error) + GetOrgMembership() ([]string, error) + GetTeamMembership(allowedOrganizations []string) ([]TeamInfo, error) +} + +type githubClient struct { + client *github.Client +} + +var _ GitHubInterface = (*githubClient)(nil) + +func NewGitHubClient(httpClient *http.Client, apiBaseURL, token string) (GitHubInterface, error) { + if httpClient == nil { + return nil, fmt.Errorf("httpClient cannot be nil") + } + + if token == "" { + return nil, fmt.Errorf("token cannot be empty string") + } + + if apiBaseURL == "https://github.com" { + apiBaseURL = "https://api.github.com/" + } + + client, err := github.NewClient(httpClient).WithEnterpriseURLs(apiBaseURL, "") + if err != nil { + return nil, fmt.Errorf("unable to create GitHub client using WithEnterpriseURLs: %w", err) + } + + if client.BaseURL.Scheme != "https" { + return nil, fmt.Errorf(`apiBaseURL must use "https" protocol, found "%s" instead`, client.BaseURL.Scheme) + } + + return &githubClient{ + client: client.WithAuthToken(token), + }, nil +} + +// GetUserInfo returns the "Login" and "ID" attributes of the logged-in user. +// TODO: where should context come from? +func (g *githubClient) GetUserInfo() (*UserInfo, error) { + user, response, err := g.client.Users.Get(context.Background(), emptyUserMeansTheAuthenticatedUser) + if err != nil { + return nil, fmt.Errorf("error fetching authenticated user: %w", err) + } + if user == nil { // untested + return nil, fmt.Errorf("error fetching authenticated user: user is nil") + } + if response == nil { // untested + return nil, fmt.Errorf("error fetching authenticated user: response is nil") + } + if user.ID == nil { + return nil, fmt.Errorf(`the "ID" attribute is missing for authenticated user`) + } + if user.Login == nil { + return nil, fmt.Errorf(`the "login" attribute is missing for authenticated user`) + } + + return &UserInfo{ + Login: user.GetLogin(), + ID: fmt.Sprintf("%d", user.GetID()), + }, nil +} + +// GetOrgMembership returns an array of the "Login" attributes for all organizations to which the authenticated user belongs. +// TODO: where should context come from? +// TODO: what happens if login is nil? +func (g *githubClient) GetOrgMembership() ([]string, error) { + organizationsAsStrings := make([]string, 0) + + opt := &github.ListOptions{PerPage: 10} + // get all pages of results + for { + organizationResults, response, err := g.client.Organizations.List(context.Background(), emptyUserMeansTheAuthenticatedUser, opt) + if err != nil { + return nil, fmt.Errorf("error fetching organizations for authenticated user: %w", err) + } + + for _, organization := range organizationResults { + organizationsAsStrings = append(organizationsAsStrings, organization.GetLogin()) + } + if response.NextPage == 0 { + break + } + opt.Page = response.NextPage + } + + return organizationsAsStrings, nil +} + +// GetTeamMembership returns a description of each team to which the authenticated user belongs, filtered by allowedOrganizations. +// Parent teams will also be returned. +// TODO: where should context come from? +// TODO: what happens if org or login or id are nil? +func (g *githubClient) GetTeamMembership(allowedOrganizations []string) ([]TeamInfo, error) { + teamInfos := make([]TeamInfo, 0) + + opt := &github.ListOptions{PerPage: 10} + // get all pages of results + for { + teamsResults, response, err := g.client.Teams.ListUserTeams(context.Background(), opt) + if err != nil { + return nil, fmt.Errorf("error fetching team membership for authenticated user: %w", err) + } + + for _, team := range teamsResults { + org := team.GetOrganization().GetLogin() + + if !slices.Contains(allowedOrganizations, org) { + continue + } + + teamInfos = append(teamInfos, TeamInfo{ + Name: team.GetName(), + Slug: team.GetSlug(), + Org: org, + }) + + parent := team.GetParent() + if parent != nil { + teamInfos = append(teamInfos, TeamInfo{ + Name: parent.GetName(), + Slug: parent.GetSlug(), + Org: org, + }) + } + } + if response.NextPage == 0 { + break + } + opt.Page = response.NextPage + } + + return teamInfos, nil +} diff --git a/internal/githubclient/githubclient_test.go b/internal/githubclient/githubclient_test.go new file mode 100644 index 000000000..7e0a0af26 --- /dev/null +++ b/internal/githubclient/githubclient_test.go @@ -0,0 +1,600 @@ +package githubclient + +import ( + "net/http" + "strings" + "testing" + + "github.com/google/go-github/v62/github" + "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/stretchr/testify/require" + "k8s.io/client-go/util/cert" + + "go.pinniped.dev/internal/net/phttp" + "go.pinniped.dev/internal/testutil/tlsserver" +) + +func TestNewGitHubClient(t *testing.T) { + t.Parallel() + + t.Run("rejects nil http client", func(t *testing.T) { + _, err := NewGitHubClient(nil, "https://api.github.com/", "") + require.EqualError(t, err, "httpClient cannot be nil") + }) + + tests := []struct { + name string + apiBaseURL string + token string + wantBaseURL string + wantErr string + }{ + { + name: "happy path with https://api.github.com/", + apiBaseURL: "https://api.github.com/", + token: "some-token", + wantBaseURL: "https://api.github.com/", + }, + { + name: "happy path with https://api.github.com", + apiBaseURL: "https://api.github.com", + token: "other-token", + wantBaseURL: "https://api.github.com/", + }, + { + name: "happy path with Enterprise URL https://fake.enterprise.tld", + apiBaseURL: "https://fake.enterprise.tld", + token: "some-enterprise-token", + wantBaseURL: "https://fake.enterprise.tld/api/v3/", + }, + { + name: "coerces https://github.com into https://api.github.com/", + apiBaseURL: "https://github.com", + token: "some-token", + wantBaseURL: "https://api.github.com/", + }, + { + name: "rejects apiBaseURL without https:// scheme", + apiBaseURL: "scp://github.com", + token: "some-token", + wantErr: `apiBaseURL must use "https" protocol, found "scp" instead`, + }, + { + name: "rejects apiBaseURL with empty scheme", + apiBaseURL: "github.com", + token: "some-token", + wantErr: `apiBaseURL must use "https" protocol, found "" instead`, + }, + { + name: "rejects empty token", + apiBaseURL: "https://api.github.com/", + wantErr: "token cannot be empty string", + }, + { + name: "returns errors from WithEnterpriseURLs", + apiBaseURL: "https:// example.com", + token: "some-token", + wantErr: `unable to create GitHub client using WithEnterpriseURLs: parse "https:// example.com": invalid character " " in host name`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + called := false + testServer, testServerCA := tlsserver.TestServerIPv4(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Len(t, r.Header["Authorization"], 1) + require.Equal(t, "Bearer "+test.token, r.Header.Get("Authorization")) + called = true + }), nil) + + t.Cleanup(func() { + require.True(t, (test.wantErr == "" && called) || (test.wantErr != "" && !called)) + }) + + pool, err := cert.NewPoolFromBytes(testServerCA) + require.NoError(t, err) + + httpClient := phttp.Default(pool) + + actualI, err := NewGitHubClient(httpClient, test.apiBaseURL, test.token) + + if test.wantErr != "" { + require.EqualError(t, err, test.wantErr) + return + } + + require.NotNil(t, actualI) + actual, ok := actualI.(*githubClient) + require.True(t, ok) + require.NotNil(t, actual.client.BaseURL) + require.Equal(t, test.wantBaseURL, actual.client.BaseURL.String()) + //require.Equal(t, httpClient, actual.client.Client()) + + // Force the githubClient's httpClient roundTrippers to run and add the Authorization header + _, err = actual.client.Client().Get(testServer.URL) + require.NoError(t, err) + }) + } +} + +func TestGetUser(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + httpClient *http.Client + token string + wantErr string + wantUserInfo UserInfo + }{ + { + name: "happy path", + httpClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetUser, + github.User{ + Login: github.String("some-username"), + ID: github.Int64(12345678), + }, + ), + ), + token: "some-token", + wantUserInfo: UserInfo{ + Login: "some-username", + ID: "12345678", + }, + }, + { + name: "the token is added in the Authorization header", + httpClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetUser, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "Bearer does-this-token-work", r.Header.Get("Authorization")) + _, err := w.Write([]byte(`{"login":"some-authenticated-username","id":999888}`)) + require.NoError(t, err) + }), + ), + ), + token: "does-this-token-work", + wantUserInfo: UserInfo{ + Login: "some-authenticated-username", + ID: "999888", + }, + }, + { + name: "handles missing login", + httpClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetUser, + github.User{ + ID: github.Int64(12345678), + }, + ), + ), + token: "does-this-token-work", + wantErr: `the "login" attribute is missing for authenticated user`, + }, + { + name: "handles missing ID", + httpClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetUser, + github.User{ + Login: github.String("some-username"), + }, + ), + ), + token: "does-this-token-work", + wantErr: `the "ID" attribute is missing for authenticated user`, + }, + { + name: "returns errors from the API", + httpClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetUser, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mock.WriteError( + w, + http.StatusInternalServerError, + "internal server error from the server", + ) + }), + ), + ), + token: "some-token", + wantErr: "error fetching authenticated user: GET {SERVER_URL}/user: 500 internal server error from the server []", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + githubClient := &githubClient{ + client: github.NewClient(test.httpClient).WithAuthToken(test.token), + } + actual, err := githubClient.GetUserInfo() + if test.wantErr != "" { + rt, ok := test.httpClient.Transport.(*mock.EnforceHostRoundTripper) + require.True(t, ok) + test.wantErr = strings.ReplaceAll(test.wantErr, "{SERVER_URL}", rt.Host) + require.EqualError(t, err, test.wantErr) + return + } + + require.NotNil(t, actual) + require.Equal(t, test.wantUserInfo, *actual) + }) + } +} + +func TestGetOrgMembership(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + httpClient *http.Client + token string + wantErr string + wantOrgs []string + }{ + { + name: "happy path", + httpClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetUserOrgs, + []github.Organization{ + {Login: github.String("org1")}, + {Login: github.String("org2")}, + {Login: github.String("org3")}, + }, + ), + ), + token: "some-token", + wantOrgs: []string{"org1", "org2", "org3"}, + }, + { + name: "happy path with pagination", + httpClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchPages( + mock.GetUserOrgs, + []github.Organization{ + {Login: github.String("page1-org1")}, + {Login: github.String("page1-org2")}, + {Login: github.String("page1-org3")}, + }, + []github.Organization{ + {Login: github.String("page2-org1")}, + {Login: github.String("page2-org2")}, + {Login: github.String("page2-org3")}, + }, + ), + ), + token: "some-token", + wantOrgs: []string{"page1-org1", "page1-org2", "page1-org3", "page2-org1", "page2-org2", "page2-org3"}, + }, + { + name: "the token is added in the Authorization header", + httpClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetUserOrgs, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "Bearer does-this-token-work", r.Header.Get("Authorization")) + _, err := w.Write([]byte(`[{"login":"some-org-to-which-the-authenticated-user-belongs"}]`)) + require.NoError(t, err) + }), + ), + ), + token: "does-this-token-work", + wantOrgs: []string{"some-org-to-which-the-authenticated-user-belongs"}, + }, + { + name: "returns errors from the API", + httpClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetUserOrgs, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mock.WriteError( + w, + http.StatusFailedDependency, + "some random client error", + ) + }), + ), + ), + token: "some-token", + wantErr: "error fetching organizations for authenticated user: GET {SERVER_URL}/user/orgs?per_page=10: 424 some random client error []", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + githubClient := &githubClient{ + client: github.NewClient(test.httpClient).WithAuthToken(test.token), + } + actual, err := githubClient.GetOrgMembership() + if test.wantErr != "" { + rt, ok := test.httpClient.Transport.(*mock.EnforceHostRoundTripper) + require.True(t, ok) + test.wantErr = strings.ReplaceAll(test.wantErr, "{SERVER_URL}", rt.Host) + require.EqualError(t, err, test.wantErr) + return + } + + require.NotNil(t, actual) + require.Equal(t, test.wantOrgs, actual) + }) + } +} + +func TestGetTeamMembership(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + httpClient *http.Client + token string + allowedOrganizations []string + wantErr string + wantTeams []TeamInfo + }{ + { + name: "happy path", + httpClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetUserTeams, + []github.Team{ + { + Name: github.String("orgAlpha-team1-name"), + Slug: github.String("orgAlpha-team1-slug"), + Organization: &github.Organization{ + Login: github.String("alpha"), + }, + }, + { + Name: github.String("orgAlpha-team2-name"), + Slug: github.String("orgAlpha-team2-slug"), + Organization: &github.Organization{ + Login: github.String("alpha"), + }, + }, + { + Name: github.String("orgAlpha-team3-name"), + Slug: github.String("orgAlpha-team3-slug"), + Organization: &github.Organization{ + Login: github.String("alpha"), + }, + }, + { + Name: github.String("orgBeta-team1-name"), + Slug: github.String("orgBeta-team1-slug"), + Organization: &github.Organization{ + Login: github.String("beta"), + }, + }, + }, + ), + ), + token: "some-token", + allowedOrganizations: []string{"alpha", "beta"}, + wantTeams: []TeamInfo{ + { + Name: "orgAlpha-team1-name", + Slug: "orgAlpha-team1-slug", + Org: "alpha", + }, + { + Name: "orgAlpha-team2-name", + Slug: "orgAlpha-team2-slug", + Org: "alpha", + }, + { + Name: "orgAlpha-team3-name", + Slug: "orgAlpha-team3-slug", + Org: "alpha", + }, + { + Name: "orgBeta-team1-name", + Slug: "orgBeta-team1-slug", + Org: "beta", + }, + }, + }, + { + name: "filters by allowedOrganizations", + httpClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetUserTeams, + []github.Team{ + { + Name: github.String("team1-name"), + Slug: github.String("team1-slug"), + Organization: &github.Organization{ + Login: github.String("alpha"), + }, + }, + { + Name: github.String("team2-name"), + Slug: github.String("team2-slug"), + Organization: &github.Organization{ + Login: github.String("beta"), + }, + }, + { + Name: github.String("team3-name"), + Slug: github.String("team3-slug"), + Organization: &github.Organization{ + Login: github.String("gamma"), + }, + }, + }, + ), + ), + token: "some-token", + allowedOrganizations: []string{"alpha", "gamma"}, + wantTeams: []TeamInfo{ + { + Name: "team1-name", + Slug: "team1-slug", + Org: "alpha", + }, + { + Name: "team3-name", + Slug: "team3-slug", + Org: "gamma", + }, + }, + }, + { + name: "includes parent team if present", + httpClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetUserTeams, + []github.Team{ + { + Name: github.String("team-name-with-parent"), + Slug: github.String("team-slug-with-parent"), + Parent: &github.Team{ + Name: github.String("parent-team-name"), + Slug: github.String("parent-team-slug"), + Organization: &github.Organization{ + Login: github.String("parent-team-org-that-in-reality-can-never-be-different-than-child-team-org"), + }, + }, + Organization: &github.Organization{ + Login: github.String("org-with-nested-teams"), + }, + }, + { + Name: github.String("team-name-without-parent"), + Slug: github.String("team-slug-without-parent"), + Organization: &github.Organization{ + Login: github.String("beta"), + }, + }, + }, + ), + ), + token: "some-token", + allowedOrganizations: []string{"org-with-nested-teams", "beta"}, + wantTeams: []TeamInfo{ + { + Name: "team-name-with-parent", + Slug: "team-slug-with-parent", + Org: "org-with-nested-teams", + }, + { + Name: "parent-team-name", + Slug: "parent-team-slug", + Org: "org-with-nested-teams", + }, + { + Name: "team-name-without-parent", + Slug: "team-slug-without-parent", + Org: "beta", + }, + }, + }, + { + name: "happy path with pagination", + httpClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchPages( + mock.GetUserTeams, + []github.Team{ + { + Name: github.String("page1-team-name"), + Slug: github.String("page1-team-slug"), + Organization: &github.Organization{ + Login: github.String("page1-org-name"), + }, + }, + }, + []github.Team{ + { + Name: github.String("page2-team-name"), + Slug: github.String("page2-team-slug"), + Organization: &github.Organization{ + Login: github.String("page2-org-name"), + }, + }, + }, + ), + ), + token: "some-token", + allowedOrganizations: []string{"page1-org-name", "page2-org-name"}, + wantTeams: []TeamInfo{ + { + Name: "page1-team-name", + Slug: "page1-team-slug", + Org: "page1-org-name", + }, + { + Name: "page2-team-name", + Slug: "page2-team-slug", + Org: "page2-org-name", + }, + }, + }, + { + name: "the token is added in the Authorization header", + httpClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetUserTeams, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "Bearer does-this-token-work", r.Header.Get("Authorization")) + _, err := w.Write([]byte(`[{"name":"team1-name","slug":"team1-slug","organization":{"login":"org-login"}}]`)) + require.NoError(t, err) + }), + ), + ), + token: "does-this-token-work", + allowedOrganizations: []string{"org-login"}, + wantTeams: []TeamInfo{ + { + Name: "team1-name", + Slug: "team1-slug", + Org: "org-login", + }, + }, + }, + { + name: "returns errors from the API", + httpClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetUserTeams, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mock.WriteError( + w, + http.StatusFailedDependency, + "some random client error", + ) + }), + ), + ), + token: "some-token", + wantErr: "error fetching team membership for authenticated user: GET {SERVER_URL}/user/teams?per_page=10: 424 some random client error []", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + githubClient := &githubClient{ + client: github.NewClient(test.httpClient).WithAuthToken(test.token), + } + actual, err := githubClient.GetTeamMembership(test.allowedOrganizations) + if test.wantErr != "" { + rt, ok := test.httpClient.Transport.(*mock.EnforceHostRoundTripper) + require.True(t, ok) + test.wantErr = strings.ReplaceAll(test.wantErr, "{SERVER_URL}", rt.Host) + require.EqualError(t, err, test.wantErr) + return + } + + require.NotNil(t, actual) + require.Equal(t, test.wantTeams, actual) + }) + } +} From a12a5f387aeb304dfffd6171bef89b4276e61e5d Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Fri, 17 May 2024 13:24:37 -0500 Subject: [PATCH 04/25] Empty allowedOrganizations will return all teams Co-authored-by: Ryan Richard --- internal/githubclient/githubclient.go | 31 +++++-- internal/githubclient/githubclient_test.go | 97 +++++++++++++++++++++- 2 files changed, 115 insertions(+), 13 deletions(-) diff --git a/internal/githubclient/githubclient.go b/internal/githubclient/githubclient.go index 998b7d16b..62d1b00a9 100644 --- a/internal/githubclient/githubclient.go +++ b/internal/githubclient/githubclient.go @@ -4,9 +4,10 @@ import ( "context" "fmt" "net/http" - "slices" "github.com/google/go-github/v62/github" + + "k8s.io/apimachinery/pkg/util/sets" ) const emptyUserMeansTheAuthenticatedUser = "" @@ -25,7 +26,7 @@ type TeamInfo struct { type GitHubInterface interface { GetUserInfo() (*UserInfo, error) GetOrgMembership() ([]string, error) - GetTeamMembership(allowedOrganizations []string) ([]TeamInfo, error) + GetTeamMembership(allowedOrganizations sets.Set[string]) ([]TeamInfo, error) } type githubClient struct { @@ -90,8 +91,9 @@ func (g *githubClient) GetUserInfo() (*UserInfo, error) { // GetOrgMembership returns an array of the "Login" attributes for all organizations to which the authenticated user belongs. // TODO: where should context come from? // TODO: what happens if login is nil? +// TODO: what is a good page size? func (g *githubClient) GetOrgMembership() ([]string, error) { - organizationsAsStrings := make([]string, 0) + organizationLoginStrings := make([]string, 0) opt := &github.ListOptions{PerPage: 10} // get all pages of results @@ -102,7 +104,7 @@ func (g *githubClient) GetOrgMembership() ([]string, error) { } for _, organization := range organizationResults { - organizationsAsStrings = append(organizationsAsStrings, organization.GetLogin()) + organizationLoginStrings = append(organizationLoginStrings, organization.GetLogin()) } if response.NextPage == 0 { break @@ -110,14 +112,20 @@ func (g *githubClient) GetOrgMembership() ([]string, error) { opt.Page = response.NextPage } - return organizationsAsStrings, nil + return organizationLoginStrings, nil } -// GetTeamMembership returns a description of each team to which the authenticated user belongs, filtered by allowedOrganizations. +func isOrgAllowed(allowedOrganizations sets.Set[string], login string) bool { + return len(allowedOrganizations) == 0 || allowedOrganizations.Has(login) +} + +// GetTeamMembership returns a description of each team to which the authenticated user belongs. +// If allowedOrganizations is not empty, will filter the results to only those teams which belong to the allowed organizations. // Parent teams will also be returned. // TODO: where should context come from? // TODO: what happens if org or login or id are nil? -func (g *githubClient) GetTeamMembership(allowedOrganizations []string) ([]TeamInfo, error) { +// TODO: what is a good page size? +func (g *githubClient) GetTeamMembership(allowedOrganizations sets.Set[string]) ([]TeamInfo, error) { teamInfos := make([]TeamInfo, 0) opt := &github.ListOptions{PerPage: 10} @@ -131,7 +139,7 @@ func (g *githubClient) GetTeamMembership(allowedOrganizations []string) ([]TeamI for _, team := range teamsResults { org := team.GetOrganization().GetLogin() - if !slices.Contains(allowedOrganizations, org) { + if !isOrgAllowed(allowedOrganizations, org) { continue } @@ -143,10 +151,15 @@ func (g *githubClient) GetTeamMembership(allowedOrganizations []string) ([]TeamI parent := team.GetParent() if parent != nil { + parentOrg := parent.GetOrganization().GetLogin() + if !isOrgAllowed(allowedOrganizations, parentOrg) { + continue + } + teamInfos = append(teamInfos, TeamInfo{ Name: parent.GetName(), Slug: parent.GetSlug(), - Org: org, + Org: parentOrg, }) } } diff --git a/internal/githubclient/githubclient_test.go b/internal/githubclient/githubclient_test.go index 7e0a0af26..716e3340d 100644 --- a/internal/githubclient/githubclient_test.go +++ b/internal/githubclient/githubclient_test.go @@ -8,6 +8,7 @@ import ( "github.com/google/go-github/v62/github" "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/util/sets" "k8s.io/client-go/util/cert" "go.pinniped.dev/internal/net/phttp" @@ -151,6 +152,7 @@ func TestGetUser(t *testing.T) { mock.WithRequestMatchHandler( mock.GetUser, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Len(t, r.Header["Authorization"], 1) require.Equal(t, "Bearer does-this-token-work", r.Header.Get("Authorization")) _, err := w.Write([]byte(`{"login":"some-authenticated-username","id":999888}`)) require.NoError(t, err) @@ -280,6 +282,7 @@ func TestGetOrgMembership(t *testing.T) { mock.WithRequestMatchHandler( mock.GetUserOrgs, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Len(t, r.Header["Authorization"], 1) require.Equal(t, "Bearer does-this-token-work", r.Header.Get("Authorization")) _, err := w.Write([]byte(`[{"login":"some-org-to-which-the-authenticated-user-belongs"}]`)) require.NoError(t, err) @@ -447,6 +450,67 @@ func TestGetTeamMembership(t *testing.T) { }, }, }, + { + name: "when allowedOrganizations is empty, return all teams", + httpClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetUserTeams, + []github.Team{ + { + Name: github.String("team1-name"), + Slug: github.String("team1-slug"), + Organization: &github.Organization{ + Login: github.String("alpha"), + }, + }, + { + Name: github.String("team2-name"), + Slug: github.String("team2-slug"), + Organization: &github.Organization{ + Login: github.String("beta"), + }, + }, + { + Name: github.String("team3-name"), + Slug: github.String("team3-slug"), + Parent: &github.Team{ + Name: github.String("delta-team-name"), + Slug: github.String("delta-team-slug"), + Organization: &github.Organization{ + Login: github.String("delta"), + }, + }, + Organization: &github.Organization{ + Login: github.String("gamma"), + }, + }, + }, + ), + ), + token: "some-token", + wantTeams: []TeamInfo{ + { + Name: "team1-name", + Slug: "team1-slug", + Org: "alpha", + }, + { + Name: "team2-name", + Slug: "team2-slug", + Org: "beta", + }, + { + Name: "team3-name", + Slug: "team3-slug", + Org: "gamma", + }, + { + Name: "delta-team-name", + Slug: "delta-team-slug", + Org: "delta", + }, + }, + }, { name: "includes parent team if present", httpClient: mock.NewMockedHTTPClient( @@ -474,11 +538,29 @@ func TestGetTeamMembership(t *testing.T) { Login: github.String("beta"), }, }, + { + Name: github.String("team-name-with-parent-in-disallowed-org"), + Slug: github.String("team-slug-with-parent-in-disallowed-org"), + Parent: &github.Team{ + Name: github.String("disallowed-parent-team-name"), + Slug: github.String("disallowed-parent-team-slug"), + Organization: &github.Organization{ + Login: github.String("disallowed-org"), + }, + }, + Organization: &github.Organization{ + Login: github.String("beta"), + }, + }, }, ), ), - token: "some-token", - allowedOrganizations: []string{"org-with-nested-teams", "beta"}, + token: "some-token", + allowedOrganizations: []string{ + "org-with-nested-teams", + "parent-team-org-that-in-reality-can-never-be-different-than-child-team-org", + "beta", + }, wantTeams: []TeamInfo{ { Name: "team-name-with-parent", @@ -488,13 +570,18 @@ func TestGetTeamMembership(t *testing.T) { { Name: "parent-team-name", Slug: "parent-team-slug", - Org: "org-with-nested-teams", + Org: "parent-team-org-that-in-reality-can-never-be-different-than-child-team-org", }, { Name: "team-name-without-parent", Slug: "team-slug-without-parent", Org: "beta", }, + { + Name: "team-name-with-parent-in-disallowed-org", + Slug: "team-slug-with-parent-in-disallowed-org", + Org: "beta", + }, }, }, { @@ -543,6 +630,7 @@ func TestGetTeamMembership(t *testing.T) { mock.WithRequestMatchHandler( mock.GetUserTeams, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Len(t, r.Header["Authorization"], 1) require.Equal(t, "Bearer does-this-token-work", r.Header.Get("Authorization")) _, err := w.Write([]byte(`[{"name":"team1-name","slug":"team1-slug","organization":{"login":"org-login"}}]`)) require.NoError(t, err) @@ -584,7 +672,8 @@ func TestGetTeamMembership(t *testing.T) { githubClient := &githubClient{ client: github.NewClient(test.httpClient).WithAuthToken(test.token), } - actual, err := githubClient.GetTeamMembership(test.allowedOrganizations) + + actual, err := githubClient.GetTeamMembership(sets.New[string](test.allowedOrganizations...)) if test.wantErr != "" { rt, ok := test.httpClient.Transport.(*mock.EnforceHostRoundTripper) require.True(t, ok) From 555b1c80e30fe863deb83005fa19d0e7aef84751 Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Fri, 17 May 2024 14:07:23 -0500 Subject: [PATCH 05/25] Use passed-in context Co-authored-by: Ryan Richard --- internal/githubclient/githubclient.go | 22 ++++---- internal/githubclient/githubclient_test.go | 60 ++++++++++++++++++++-- 2 files changed, 67 insertions(+), 15 deletions(-) diff --git a/internal/githubclient/githubclient.go b/internal/githubclient/githubclient.go index 62d1b00a9..0832e1b83 100644 --- a/internal/githubclient/githubclient.go +++ b/internal/githubclient/githubclient.go @@ -24,9 +24,9 @@ type TeamInfo struct { } type GitHubInterface interface { - GetUserInfo() (*UserInfo, error) - GetOrgMembership() ([]string, error) - GetTeamMembership(allowedOrganizations sets.Set[string]) ([]TeamInfo, error) + GetUserInfo(ctx context.Context) (*UserInfo, error) + GetOrgMembership(ctx context.Context) ([]string, error) + GetTeamMembership(ctx context.Context, allowedOrganizations sets.Set[string]) ([]TeamInfo, error) } type githubClient struct { @@ -63,9 +63,9 @@ func NewGitHubClient(httpClient *http.Client, apiBaseURL, token string) (GitHubI } // GetUserInfo returns the "Login" and "ID" attributes of the logged-in user. -// TODO: where should context come from? -func (g *githubClient) GetUserInfo() (*UserInfo, error) { - user, response, err := g.client.Users.Get(context.Background(), emptyUserMeansTheAuthenticatedUser) +// TODO: should we check ID and Login for nil? +func (g *githubClient) GetUserInfo(ctx context.Context) (*UserInfo, error) { + user, response, err := g.client.Users.Get(ctx, emptyUserMeansTheAuthenticatedUser) if err != nil { return nil, fmt.Errorf("error fetching authenticated user: %w", err) } @@ -89,16 +89,15 @@ func (g *githubClient) GetUserInfo() (*UserInfo, error) { } // GetOrgMembership returns an array of the "Login" attributes for all organizations to which the authenticated user belongs. -// TODO: where should context come from? // TODO: what happens if login is nil? // TODO: what is a good page size? -func (g *githubClient) GetOrgMembership() ([]string, error) { +func (g *githubClient) GetOrgMembership(ctx context.Context) ([]string, error) { organizationLoginStrings := make([]string, 0) opt := &github.ListOptions{PerPage: 10} // get all pages of results for { - organizationResults, response, err := g.client.Organizations.List(context.Background(), emptyUserMeansTheAuthenticatedUser, opt) + organizationResults, response, err := g.client.Organizations.List(ctx, emptyUserMeansTheAuthenticatedUser, opt) if err != nil { return nil, fmt.Errorf("error fetching organizations for authenticated user: %w", err) } @@ -122,16 +121,15 @@ func isOrgAllowed(allowedOrganizations sets.Set[string], login string) bool { // GetTeamMembership returns a description of each team to which the authenticated user belongs. // If allowedOrganizations is not empty, will filter the results to only those teams which belong to the allowed organizations. // Parent teams will also be returned. -// TODO: where should context come from? // TODO: what happens if org or login or id are nil? // TODO: what is a good page size? -func (g *githubClient) GetTeamMembership(allowedOrganizations sets.Set[string]) ([]TeamInfo, error) { +func (g *githubClient) GetTeamMembership(ctx context.Context, allowedOrganizations sets.Set[string]) ([]TeamInfo, error) { teamInfos := make([]TeamInfo, 0) opt := &github.ListOptions{PerPage: 10} // get all pages of results for { - teamsResults, response, err := g.client.Teams.ListUserTeams(context.Background(), opt) + teamsResults, response, err := g.client.Teams.ListUserTeams(ctx, opt) if err != nil { return nil, fmt.Errorf("error fetching team membership for authenticated user: %w", err) } diff --git a/internal/githubclient/githubclient_test.go b/internal/githubclient/githubclient_test.go index 716e3340d..7e2c6987c 100644 --- a/internal/githubclient/githubclient_test.go +++ b/internal/githubclient/githubclient_test.go @@ -1,6 +1,7 @@ package githubclient import ( + "context" "net/http" "strings" "testing" @@ -126,6 +127,7 @@ func TestGetUser(t *testing.T) { name string httpClient *http.Client token string + ctx context.Context wantErr string wantUserInfo UserInfo }{ @@ -191,6 +193,17 @@ func TestGetUser(t *testing.T) { token: "does-this-token-work", wantErr: `the "ID" attribute is missing for authenticated user`, }, + { + name: "passes the context parameter into the API call", + token: "some-token", + httpClient: mock.NewMockedHTTPClient(), + ctx: func() context.Context { + canceledCtx, cancel := context.WithCancel(context.Background()) + cancel() + return canceledCtx + }(), + wantErr: "error fetching authenticated user: context canceled", + }, { name: "returns errors from the API", httpClient: mock.NewMockedHTTPClient( @@ -216,7 +229,13 @@ func TestGetUser(t *testing.T) { githubClient := &githubClient{ client: github.NewClient(test.httpClient).WithAuthToken(test.token), } - actual, err := githubClient.GetUserInfo() + + ctx := context.Background() + if test.ctx != nil { + ctx = test.ctx + } + + actual, err := githubClient.GetUserInfo(ctx) if test.wantErr != "" { rt, ok := test.httpClient.Transport.(*mock.EnforceHostRoundTripper) require.True(t, ok) @@ -238,6 +257,7 @@ func TestGetOrgMembership(t *testing.T) { name string httpClient *http.Client token string + ctx context.Context wantErr string wantOrgs []string }{ @@ -292,6 +312,17 @@ func TestGetOrgMembership(t *testing.T) { token: "does-this-token-work", wantOrgs: []string{"some-org-to-which-the-authenticated-user-belongs"}, }, + { + name: "passes the context parameter into the API call", + token: "some-token", + httpClient: mock.NewMockedHTTPClient(), + ctx: func() context.Context { + canceledCtx, cancel := context.WithCancel(context.Background()) + cancel() + return canceledCtx + }(), + wantErr: "error fetching organizations for authenticated user: context canceled", + }, { name: "returns errors from the API", httpClient: mock.NewMockedHTTPClient( @@ -317,7 +348,13 @@ func TestGetOrgMembership(t *testing.T) { githubClient := &githubClient{ client: github.NewClient(test.httpClient).WithAuthToken(test.token), } - actual, err := githubClient.GetOrgMembership() + + ctx := context.Background() + if test.ctx != nil { + ctx = test.ctx + } + + actual, err := githubClient.GetOrgMembership(ctx) if test.wantErr != "" { rt, ok := test.httpClient.Transport.(*mock.EnforceHostRoundTripper) require.True(t, ok) @@ -339,6 +376,7 @@ func TestGetTeamMembership(t *testing.T) { name string httpClient *http.Client token string + ctx context.Context allowedOrganizations []string wantErr string wantTeams []TeamInfo @@ -647,6 +685,17 @@ func TestGetTeamMembership(t *testing.T) { }, }, }, + { + name: "passes the context parameter into the API call", + token: "some-token", + httpClient: mock.NewMockedHTTPClient(), + ctx: func() context.Context { + canceledCtx, cancel := context.WithCancel(context.Background()) + cancel() + return canceledCtx + }(), + wantErr: "error fetching team membership for authenticated user: context canceled", + }, { name: "returns errors from the API", httpClient: mock.NewMockedHTTPClient( @@ -673,7 +722,12 @@ func TestGetTeamMembership(t *testing.T) { client: github.NewClient(test.httpClient).WithAuthToken(test.token), } - actual, err := githubClient.GetTeamMembership(sets.New[string](test.allowedOrganizations...)) + ctx := context.Background() + if test.ctx != nil { + ctx = test.ctx + } + + actual, err := githubClient.GetTeamMembership(ctx, sets.New[string](test.allowedOrganizations...)) if test.wantErr != "" { rt, ok := test.httpClient.Transport.(*mock.EnforceHostRoundTripper) require.True(t, ok) From 16fa12f4550ab8c7c8dbe1ed81e2bc2732e09d37 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Fri, 17 May 2024 15:03:13 -0500 Subject: [PATCH 06/25] Handle empty or invalid github API responses Co-authored-by: Joshua Casey --- internal/githubclient/githubclient.go | 120 ++++++++----- internal/githubclient/githubclient_test.go | 198 +++++++++++++++++++-- 2 files changed, 260 insertions(+), 58 deletions(-) diff --git a/internal/githubclient/githubclient.go b/internal/githubclient/githubclient.go index 0832e1b83..04e978507 100644 --- a/internal/githubclient/githubclient.go +++ b/internal/githubclient/githubclient.go @@ -2,6 +2,7 @@ package githubclient import ( "context" + "errors" "fmt" "net/http" @@ -10,7 +11,10 @@ import ( "k8s.io/apimachinery/pkg/util/sets" ) -const emptyUserMeansTheAuthenticatedUser = "" +const ( + emptyUserMeansTheAuthenticatedUser = "" + pageSize = 100 +) type UserInfo struct { ID string @@ -25,8 +29,8 @@ type TeamInfo struct { type GitHubInterface interface { GetUserInfo(ctx context.Context) (*UserInfo, error) - GetOrgMembership(ctx context.Context) ([]string, error) - GetTeamMembership(ctx context.Context, allowedOrganizations sets.Set[string]) ([]TeamInfo, error) + GetOrgMembership(ctx context.Context) (sets.Set[string], error) + GetTeamMembership(ctx context.Context, allowedOrganizations sets.Set[string]) ([]*TeamInfo, error) } type githubClient struct { @@ -63,47 +67,46 @@ func NewGitHubClient(httpClient *http.Client, apiBaseURL, token string) (GitHubI } // GetUserInfo returns the "Login" and "ID" attributes of the logged-in user. -// TODO: should we check ID and Login for nil? func (g *githubClient) GetUserInfo(ctx context.Context) (*UserInfo, error) { - user, response, err := g.client.Users.Get(ctx, emptyUserMeansTheAuthenticatedUser) + const errorPrefix = "error fetching authenticated user" + + user, _, err := g.client.Users.Get(ctx, emptyUserMeansTheAuthenticatedUser) if err != nil { - return nil, fmt.Errorf("error fetching authenticated user: %w", err) + return nil, fmt.Errorf("%s: %w", errorPrefix, err) } if user == nil { // untested - return nil, fmt.Errorf("error fetching authenticated user: user is nil") - } - if response == nil { // untested - return nil, fmt.Errorf("error fetching authenticated user: response is nil") - } - if user.ID == nil { - return nil, fmt.Errorf(`the "ID" attribute is missing for authenticated user`) - } - if user.Login == nil { - return nil, fmt.Errorf(`the "login" attribute is missing for authenticated user`) + return nil, fmt.Errorf("%s: user is nil", errorPrefix) } - return &UserInfo{ + userInfo := &UserInfo{ Login: user.GetLogin(), ID: fmt.Sprintf("%d", user.GetID()), - }, nil + } + if userInfo.ID == "0" { + return nil, fmt.Errorf(`%s: the "id" attribute is missing`, errorPrefix) + } + if userInfo.Login == "" { + return nil, fmt.Errorf(`%s: the "login" attribute is missing`, errorPrefix) + } + return userInfo, nil } // GetOrgMembership returns an array of the "Login" attributes for all organizations to which the authenticated user belongs. -// TODO: what happens if login is nil? -// TODO: what is a good page size? -func (g *githubClient) GetOrgMembership(ctx context.Context) ([]string, error) { - organizationLoginStrings := make([]string, 0) +func (g *githubClient) GetOrgMembership(ctx context.Context) (sets.Set[string], error) { + const errorPrefix = "error fetching organizations for authenticated user" - opt := &github.ListOptions{PerPage: 10} + organizationLogins := sets.New[string]() + + opt := &github.ListOptions{PerPage: pageSize} // get all pages of results for { organizationResults, response, err := g.client.Organizations.List(ctx, emptyUserMeansTheAuthenticatedUser, opt) if err != nil { - return nil, fmt.Errorf("error fetching organizations for authenticated user: %w", err) + return nil, fmt.Errorf("%s: %w", errorPrefix, err) } for _, organization := range organizationResults { - organizationLoginStrings = append(organizationLoginStrings, organization.GetLogin()) + organizationLogins.Insert(organization.GetLogin()) } if response.NextPage == 0 { break @@ -111,54 +114,79 @@ func (g *githubClient) GetOrgMembership(ctx context.Context) ([]string, error) { opt.Page = response.NextPage } - return organizationLoginStrings, nil + if organizationLogins.Has("") { + return nil, fmt.Errorf(`%s: one or more organizations is missing the "login" attribute`, errorPrefix) + } + + return organizationLogins, nil } func isOrgAllowed(allowedOrganizations sets.Set[string], login string) bool { return len(allowedOrganizations) == 0 || allowedOrganizations.Has(login) } +func buildAndValidateTeam(githubTeam *github.Team) (*TeamInfo, error) { + if githubTeam.GetOrganization() == nil { + return nil, errors.New(`missing the "organization" attribute for a team`) + } + organizationLogin := githubTeam.GetOrganization().GetLogin() + if organizationLogin == "" { + return nil, errors.New(`missing the organization's "login" attribute for a team`) + } + + teamInfo := &TeamInfo{ + Name: githubTeam.GetName(), + Slug: githubTeam.GetSlug(), + Org: organizationLogin, + } + if teamInfo.Name == "" { + return nil, errors.New(`the "name" attribute is missing for a team`) + } + if teamInfo.Slug == "" { + return nil, errors.New(`the "slug" attribute is missing for a team`) + } + return teamInfo, nil +} + // GetTeamMembership returns a description of each team to which the authenticated user belongs. // If allowedOrganizations is not empty, will filter the results to only those teams which belong to the allowed organizations. // Parent teams will also be returned. -// TODO: what happens if org or login or id are nil? -// TODO: what is a good page size? -func (g *githubClient) GetTeamMembership(ctx context.Context, allowedOrganizations sets.Set[string]) ([]TeamInfo, error) { - teamInfos := make([]TeamInfo, 0) +func (g *githubClient) GetTeamMembership(ctx context.Context, allowedOrganizations sets.Set[string]) ([]*TeamInfo, error) { + const errorPrefix = "error fetching team membership for authenticated user" + teamInfos := make([]*TeamInfo, 0) - opt := &github.ListOptions{PerPage: 10} + opt := &github.ListOptions{PerPage: pageSize} // get all pages of results for { teamsResults, response, err := g.client.Teams.ListUserTeams(ctx, opt) if err != nil { - return nil, fmt.Errorf("error fetching team membership for authenticated user: %w", err) + return nil, fmt.Errorf("%s: %w", errorPrefix, err) } for _, team := range teamsResults { - org := team.GetOrganization().GetLogin() + teamInfo, err := buildAndValidateTeam(team) + if err != nil { + return nil, fmt.Errorf("%s: %w", errorPrefix, err) + } - if !isOrgAllowed(allowedOrganizations, org) { + if !isOrgAllowed(allowedOrganizations, teamInfo.Org) { continue } - teamInfos = append(teamInfos, TeamInfo{ - Name: team.GetName(), - Slug: team.GetSlug(), - Org: org, - }) + teamInfos = append(teamInfos, teamInfo) parent := team.GetParent() if parent != nil { - parentOrg := parent.GetOrganization().GetLogin() - if !isOrgAllowed(allowedOrganizations, parentOrg) { + teamInfo, err := buildAndValidateTeam(parent) + if err != nil { + return nil, fmt.Errorf("%s: %w", errorPrefix, err) + } + + if !isOrgAllowed(allowedOrganizations, teamInfo.Org) { continue } - teamInfos = append(teamInfos, TeamInfo{ - Name: parent.GetName(), - Slug: parent.GetSlug(), - Org: parentOrg, - }) + teamInfos = append(teamInfos, teamInfo) } } if response.NextPage == 0 { diff --git a/internal/githubclient/githubclient_test.go b/internal/githubclient/githubclient_test.go index 7e2c6987c..e9214e7c8 100644 --- a/internal/githubclient/githubclient_test.go +++ b/internal/githubclient/githubclient_test.go @@ -178,7 +178,7 @@ func TestGetUser(t *testing.T) { ), ), token: "does-this-token-work", - wantErr: `the "login" attribute is missing for authenticated user`, + wantErr: `error fetching authenticated user: the "login" attribute is missing`, }, { name: "handles missing ID", @@ -191,7 +191,7 @@ func TestGetUser(t *testing.T) { ), ), token: "does-this-token-work", - wantErr: `the "ID" attribute is missing for authenticated user`, + wantErr: `error fetching authenticated user: the "id" attribute is missing`, }, { name: "passes the context parameter into the API call", @@ -312,6 +312,21 @@ func TestGetOrgMembership(t *testing.T) { token: "does-this-token-work", wantOrgs: []string{"some-org-to-which-the-authenticated-user-belongs"}, }, + { + name: "errors when a Login field is empty", + httpClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetUserOrgs, + []github.Organization{ + {Login: github.String("page1-org1")}, + {Login: nil}, + {Login: github.String("page1-org3")}, + }, + ), + ), + token: "some-token", + wantErr: `error fetching organizations for authenticated user: one or more organizations is missing the "login" attribute`, + }, { name: "passes the context parameter into the API call", token: "some-token", @@ -338,7 +353,7 @@ func TestGetOrgMembership(t *testing.T) { ), ), token: "some-token", - wantErr: "error fetching organizations for authenticated user: GET {SERVER_URL}/user/orgs?per_page=10: 424 some random client error []", + wantErr: "error fetching organizations for authenticated user: GET {SERVER_URL}/user/orgs?per_page=100: 424 some random client error []", }, } for _, test := range tests { @@ -364,7 +379,8 @@ func TestGetOrgMembership(t *testing.T) { } require.NotNil(t, actual) - require.Equal(t, test.wantOrgs, actual) + require.Equal(t, len(actual), len(test.wantOrgs)) + require.True(t, actual.HasAll(test.wantOrgs...)) }) } } @@ -379,7 +395,7 @@ func TestGetTeamMembership(t *testing.T) { ctx context.Context allowedOrganizations []string wantErr string - wantTeams []TeamInfo + wantTeams []*TeamInfo }{ { name: "happy path", @@ -420,7 +436,7 @@ func TestGetTeamMembership(t *testing.T) { ), token: "some-token", allowedOrganizations: []string{"alpha", "beta"}, - wantTeams: []TeamInfo{ + wantTeams: []*TeamInfo{ { Name: "orgAlpha-team1-name", Slug: "orgAlpha-team1-slug", @@ -475,7 +491,7 @@ func TestGetTeamMembership(t *testing.T) { ), token: "some-token", allowedOrganizations: []string{"alpha", "gamma"}, - wantTeams: []TeamInfo{ + wantTeams: []*TeamInfo{ { Name: "team1-name", Slug: "team1-slug", @@ -526,7 +542,7 @@ func TestGetTeamMembership(t *testing.T) { ), ), token: "some-token", - wantTeams: []TeamInfo{ + wantTeams: []*TeamInfo{ { Name: "team1-name", Slug: "team1-slug", @@ -599,7 +615,7 @@ func TestGetTeamMembership(t *testing.T) { "parent-team-org-that-in-reality-can-never-be-different-than-child-team-org", "beta", }, - wantTeams: []TeamInfo{ + wantTeams: []*TeamInfo{ { Name: "team-name-with-parent", Slug: "team-slug-with-parent", @@ -649,7 +665,7 @@ func TestGetTeamMembership(t *testing.T) { ), token: "some-token", allowedOrganizations: []string{"page1-org-name", "page2-org-name"}, - wantTeams: []TeamInfo{ + wantTeams: []*TeamInfo{ { Name: "page1-team-name", Slug: "page1-team-slug", @@ -662,6 +678,164 @@ func TestGetTeamMembership(t *testing.T) { }, }, }, + { + name: "missing organization attribute returns an error", + httpClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetUserTeams, + []github.Team{ + { + Name: github.String("team-name"), + Slug: github.String("team-slug"), + }, + }, + ), + ), + wantErr: `error fetching team membership for authenticated user: missing the "organization" attribute for a team`, + }, + { + name: "missing organization's login attribute returns an error", + httpClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetUserTeams, + []github.Team{ + { + Name: github.String("team-name"), + Slug: github.String("team-slug"), + Organization: &github.Organization{}, + }, + }, + ), + ), + wantErr: `error fetching team membership for authenticated user: missing the organization's "login" attribute for a team`, + }, + { + name: "missing the name attribute for a team returns an error", + httpClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetUserTeams, + []github.Team{ + { + Slug: github.String("team-slug"), + Organization: &github.Organization{ + Login: github.String("some-org"), + }, + }, + }, + ), + ), + wantErr: `error fetching team membership for authenticated user: the "name" attribute is missing for a team`, + }, + { + name: "missing the slug attribute for a team returns an error", + httpClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetUserTeams, + []github.Team{ + { + Name: github.String("team-name"), + Organization: &github.Organization{ + Login: github.String("some-org"), + }, + }, + }, + ), + ), + wantErr: `error fetching team membership for authenticated user: the "slug" attribute is missing for a team`, + }, + { + name: "missing parent's organization attribute returns an error", + httpClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetUserTeams, + []github.Team{ + { + Name: github.String("team-name-with-parent"), + Slug: github.String("team-slug-with-parent"), + Parent: &github.Team{ + Name: github.String("parent-team-name"), + Slug: github.String("parent-team-slug"), + }, + Organization: &github.Organization{ + Login: github.String("some-org"), + }, + }, + }, + ), + ), + wantErr: `error fetching team membership for authenticated user: missing the "organization" attribute for a team`, + }, + { + name: "missing parent's organization's login attribute returns an error", + httpClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetUserTeams, + []github.Team{ + { + Name: github.String("team-name-with-parent"), + Slug: github.String("team-slug-with-parent"), + Parent: &github.Team{ + Name: github.String("parent-team-name"), + Slug: github.String("parent-team-slug"), + Organization: &github.Organization{}, + }, + Organization: &github.Organization{ + Login: github.String("some-org"), + }, + }, + }, + ), + ), + wantErr: `error fetching team membership for authenticated user: missing the organization's "login" attribute for a team`, + }, + { + name: "missing the name attribute for a parent team returns an error", + httpClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetUserTeams, + []github.Team{ + { + Name: github.String("team-name-with-parent"), + Slug: github.String("team-slug-with-parent"), + Parent: &github.Team{ + Slug: github.String("parent-team-slug"), + Organization: &github.Organization{ + Login: github.String("some-org"), + }, + }, + Organization: &github.Organization{ + Login: github.String("some-org"), + }, + }, + }, + ), + ), + wantErr: `error fetching team membership for authenticated user: the "name" attribute is missing for a team`, + }, + { + name: "missing the slug attribute for a parent team returns an error", + httpClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetUserTeams, + []github.Team{ + { + Name: github.String("team-name-with-parent"), + Slug: github.String("team-slug-with-parent"), + Parent: &github.Team{ + Name: github.String("parent-team-name"), + Organization: &github.Organization{ + Login: github.String("some-org"), + }, + }, + Organization: &github.Organization{ + Login: github.String("some-org"), + }, + }, + }, + ), + ), + wantErr: `error fetching team membership for authenticated user: the "slug" attribute is missing for a team`, + }, { name: "the token is added in the Authorization header", httpClient: mock.NewMockedHTTPClient( @@ -677,7 +851,7 @@ func TestGetTeamMembership(t *testing.T) { ), token: "does-this-token-work", allowedOrganizations: []string{"org-login"}, - wantTeams: []TeamInfo{ + wantTeams: []*TeamInfo{ { Name: "team1-name", Slug: "team1-slug", @@ -711,7 +885,7 @@ func TestGetTeamMembership(t *testing.T) { ), ), token: "some-token", - wantErr: "error fetching team membership for authenticated user: GET {SERVER_URL}/user/teams?per_page=10: 424 some random client error []", + wantErr: "error fetching team membership for authenticated user: GET {SERVER_URL}/user/teams?per_page=100: 424 some random client error []", }, } for _, test := range tests { From 8719c7a2db43d5bffdf74cc042de2e7ce1ccd65d Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Fri, 17 May 2024 15:22:50 -0500 Subject: [PATCH 07/25] Standardize error messages and url handling within NewGitHubClient Co-authored-by: Ryan Richard --- internal/githubclient/githubclient.go | 37 +++++++++++++--------- internal/githubclient/githubclient_test.go | 25 ++++++--------- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/internal/githubclient/githubclient.go b/internal/githubclient/githubclient.go index 04e978507..807fe5ae4 100644 --- a/internal/githubclient/githubclient.go +++ b/internal/githubclient/githubclient.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "net/http" + "net/url" + "strings" "github.com/google/go-github/v62/github" @@ -40,29 +42,34 @@ type githubClient struct { var _ GitHubInterface = (*githubClient)(nil) func NewGitHubClient(httpClient *http.Client, apiBaseURL, token string) (GitHubInterface, error) { + const errorPrefix = "unable to build new github client" + if httpClient == nil { - return nil, fmt.Errorf("httpClient cannot be nil") + return nil, fmt.Errorf("%s: httpClient cannot be nil", errorPrefix) + } + + parsedURL, err := url.Parse(apiBaseURL) + if err != nil { + return nil, fmt.Errorf("%s: %w", errorPrefix, err) + } + + if !strings.HasSuffix(parsedURL.Path, "/") { + parsedURL.Path += "/" + } + + if parsedURL.Scheme != "https" { + return nil, fmt.Errorf(`%s: apiBaseURL must use "https" protocol, found %q instead`, errorPrefix, parsedURL.Scheme) } if token == "" { - return nil, fmt.Errorf("token cannot be empty string") + return nil, fmt.Errorf("%s: token cannot be empty string", errorPrefix) } - if apiBaseURL == "https://github.com" { - apiBaseURL = "https://api.github.com/" - } - - client, err := github.NewClient(httpClient).WithEnterpriseURLs(apiBaseURL, "") - if err != nil { - return nil, fmt.Errorf("unable to create GitHub client using WithEnterpriseURLs: %w", err) - } - - if client.BaseURL.Scheme != "https" { - return nil, fmt.Errorf(`apiBaseURL must use "https" protocol, found "%s" instead`, client.BaseURL.Scheme) - } + client := github.NewClient(httpClient).WithAuthToken(token) + client.BaseURL = parsedURL return &githubClient{ - client: client.WithAuthToken(token), + client: client, }, nil } diff --git a/internal/githubclient/githubclient_test.go b/internal/githubclient/githubclient_test.go index e9214e7c8..36924fc8a 100644 --- a/internal/githubclient/githubclient_test.go +++ b/internal/githubclient/githubclient_test.go @@ -21,7 +21,7 @@ func TestNewGitHubClient(t *testing.T) { t.Run("rejects nil http client", func(t *testing.T) { _, err := NewGitHubClient(nil, "https://api.github.com/", "") - require.EqualError(t, err, "httpClient cannot be nil") + require.EqualError(t, err, "unable to build new github client: httpClient cannot be nil") }) tests := []struct { @@ -38,45 +38,39 @@ func TestNewGitHubClient(t *testing.T) { wantBaseURL: "https://api.github.com/", }, { - name: "happy path with https://api.github.com", + name: "adds trailing slash to path for https://api.github.com", apiBaseURL: "https://api.github.com", token: "other-token", wantBaseURL: "https://api.github.com/", }, { - name: "happy path with Enterprise URL https://fake.enterprise.tld", - apiBaseURL: "https://fake.enterprise.tld", + name: "adds trailing slash to path for Enterprise URL https://fake.enterprise.tld/api/v3", + apiBaseURL: "https://fake.enterprise.tld/api/v3", token: "some-enterprise-token", wantBaseURL: "https://fake.enterprise.tld/api/v3/", }, - { - name: "coerces https://github.com into https://api.github.com/", - apiBaseURL: "https://github.com", - token: "some-token", - wantBaseURL: "https://api.github.com/", - }, { name: "rejects apiBaseURL without https:// scheme", apiBaseURL: "scp://github.com", token: "some-token", - wantErr: `apiBaseURL must use "https" protocol, found "scp" instead`, + wantErr: `unable to build new github client: apiBaseURL must use "https" protocol, found "scp" instead`, }, { name: "rejects apiBaseURL with empty scheme", apiBaseURL: "github.com", token: "some-token", - wantErr: `apiBaseURL must use "https" protocol, found "" instead`, + wantErr: `unable to build new github client: apiBaseURL must use "https" protocol, found "" instead`, }, { name: "rejects empty token", apiBaseURL: "https://api.github.com/", - wantErr: "token cannot be empty string", + wantErr: "unable to build new github client: token cannot be empty string", }, { - name: "returns errors from WithEnterpriseURLs", + name: "returns errors from url.Parse", apiBaseURL: "https:// example.com", token: "some-token", - wantErr: `unable to create GitHub client using WithEnterpriseURLs: parse "https:// example.com": invalid character " " in host name`, + wantErr: `unable to build new github client: parse "https:// example.com": invalid character " " in host name`, }, } for _, test := range tests { @@ -111,7 +105,6 @@ func TestNewGitHubClient(t *testing.T) { require.True(t, ok) require.NotNil(t, actual.client.BaseURL) require.Equal(t, test.wantBaseURL, actual.client.BaseURL.String()) - //require.Equal(t, httpClient, actual.client.Client()) // Force the githubClient's httpClient roundTrippers to run and add the Authorization header _, err = actual.client.Client().Get(testServer.URL) From 938bea991024b773ea6c8ce35ecd0bd35659c914 Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Mon, 20 May 2024 09:46:57 -0500 Subject: [PATCH 08/25] upstreamgitub.go now uses githubclient to determine username and groups --- .../downstreamsubject/downstream_subject.go | 7 + .../downstream_subject_test.go | 38 +++ internal/mocks/mockgithubclient/generate.go | 6 + .../mockgithubclient/mockgithubclient.go | 91 ++++++ internal/upstreamgithub/upstreamgithub.go | 103 +++++-- .../upstreamgithub/upstreamgithub_test.go | 285 +++++++++++++++++- 6 files changed, 507 insertions(+), 23 deletions(-) create mode 100644 internal/mocks/mockgithubclient/generate.go create mode 100644 internal/mocks/mockgithubclient/mockgithubclient.go diff --git a/internal/federationdomain/downstreamsubject/downstream_subject.go b/internal/federationdomain/downstreamsubject/downstream_subject.go index 5c754d9aa..78d202918 100644 --- a/internal/federationdomain/downstreamsubject/downstream_subject.go +++ b/internal/federationdomain/downstreamsubject/downstream_subject.go @@ -24,3 +24,10 @@ func OIDC(upstreamIssuerAsString string, upstreamSubject string, idpDisplayName oidc.IDTokenClaimSubject, url.QueryEscape(upstreamSubject), ) } + +func GitHub(APIBaseURL, idpDisplayName, login, id string) string { + return fmt.Sprintf("%s?%s=%s&login=%s&id=%s", APIBaseURL, + oidc.IDTokenSubClaimIDPNameQueryParam, url.QueryEscape(idpDisplayName), + url.QueryEscape(login), url.QueryEscape(id), + ) +} diff --git a/internal/federationdomain/downstreamsubject/downstream_subject_test.go b/internal/federationdomain/downstreamsubject/downstream_subject_test.go index b96ec85ad..8add3c077 100644 --- a/internal/federationdomain/downstreamsubject/downstream_subject_test.go +++ b/internal/federationdomain/downstreamsubject/downstream_subject_test.go @@ -89,3 +89,41 @@ func TestOIDC(t *testing.T) { }) } } + +func TestGitHub(t *testing.T) { + tests := []struct { + name string + APIBaseURL string + idpDisplayName string + login string + id string + wantSubject string + }{ + { + name: "simple display name", + APIBaseURL: "https://github.com", + idpDisplayName: "simpleName", + login: "some login", + id: "some id", + wantSubject: "https://github.com?idpName=simpleName&login=some+login&id=some+id", + }, + { + name: "interesting display name", + APIBaseURL: "https://server.example.com:1234/path", + idpDisplayName: "this is a 👍 display name that 🦭 can handle", + login: "some other login", + id: "some other id", + wantSubject: "https://server.example.com:1234/path?idpName=this+is+a+%F0%9F%91%8D+display+name+that+%F0%9F%A6%AD+can+handle&login=some+other+login&id=some+other+id", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + actual := GitHub(test.APIBaseURL, test.idpDisplayName, test.login, test.id) + + require.Equal(t, test.wantSubject, actual) + }) + } +} diff --git a/internal/mocks/mockgithubclient/generate.go b/internal/mocks/mockgithubclient/generate.go new file mode 100644 index 000000000..8bca3dd91 --- /dev/null +++ b/internal/mocks/mockgithubclient/generate.go @@ -0,0 +1,6 @@ +// Copyright 2024 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package mockgithubclient + +//go:generate go run -v go.uber.org/mock/mockgen -destination=mockgithubclient.go -package=mockgithubclient -copyright_file=../../../hack/header.txt go.pinniped.dev/internal/githubclient GitHubInterface diff --git a/internal/mocks/mockgithubclient/mockgithubclient.go b/internal/mocks/mockgithubclient/mockgithubclient.go new file mode 100644 index 000000000..6a1901c14 --- /dev/null +++ b/internal/mocks/mockgithubclient/mockgithubclient.go @@ -0,0 +1,91 @@ +// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +// + +// Code generated by MockGen. DO NOT EDIT. +// Source: go.pinniped.dev/internal/githubclient (interfaces: GitHubInterface) +// +// Generated by this command: +// +// mockgen -destination=mockgithubclient.go -package=mockgithubclient -copyright_file=../../../hack/header.txt go.pinniped.dev/internal/githubclient GitHubInterface +// + +// Package mockgithubclient is a generated GoMock package. +package mockgithubclient + +import ( + context "context" + reflect "reflect" + + githubclient "go.pinniped.dev/internal/githubclient" + gomock "go.uber.org/mock/gomock" + sets "k8s.io/apimachinery/pkg/util/sets" +) + +// MockGitHubInterface is a mock of GitHubInterface interface. +type MockGitHubInterface struct { + ctrl *gomock.Controller + recorder *MockGitHubInterfaceMockRecorder +} + +// MockGitHubInterfaceMockRecorder is the mock recorder for MockGitHubInterface. +type MockGitHubInterfaceMockRecorder struct { + mock *MockGitHubInterface +} + +// NewMockGitHubInterface creates a new mock instance. +func NewMockGitHubInterface(ctrl *gomock.Controller) *MockGitHubInterface { + mock := &MockGitHubInterface{ctrl: ctrl} + mock.recorder = &MockGitHubInterfaceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockGitHubInterface) EXPECT() *MockGitHubInterfaceMockRecorder { + return m.recorder +} + +// GetOrgMembership mocks base method. +func (m *MockGitHubInterface) GetOrgMembership(arg0 context.Context) (sets.Set[string], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOrgMembership", arg0) + ret0, _ := ret[0].(sets.Set[string]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetOrgMembership indicates an expected call of GetOrgMembership. +func (mr *MockGitHubInterfaceMockRecorder) GetOrgMembership(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrgMembership", reflect.TypeOf((*MockGitHubInterface)(nil).GetOrgMembership), arg0) +} + +// GetTeamMembership mocks base method. +func (m *MockGitHubInterface) GetTeamMembership(arg0 context.Context, arg1 sets.Set[string]) ([]*githubclient.TeamInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTeamMembership", arg0, arg1) + ret0, _ := ret[0].([]*githubclient.TeamInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTeamMembership indicates an expected call of GetTeamMembership. +func (mr *MockGitHubInterfaceMockRecorder) GetTeamMembership(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeamMembership", reflect.TypeOf((*MockGitHubInterface)(nil).GetTeamMembership), arg0, arg1) +} + +// GetUserInfo mocks base method. +func (m *MockGitHubInterface) GetUserInfo(arg0 context.Context) (*githubclient.UserInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserInfo", arg0) + ret0, _ := ret[0].(*githubclient.UserInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserInfo indicates an expected call of GetUserInfo. +func (mr *MockGitHubInterfaceMockRecorder) GetUserInfo(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserInfo", reflect.TypeOf((*MockGitHubInterface)(nil).GetUserInfo), arg0) +} diff --git a/internal/upstreamgithub/upstreamgithub.go b/internal/upstreamgithub/upstreamgithub.go index 67f8b683d..6571c2fa7 100644 --- a/internal/upstreamgithub/upstreamgithub.go +++ b/internal/upstreamgithub/upstreamgithub.go @@ -6,14 +6,19 @@ package upstreamgithub import ( "context" + "errors" + "fmt" "net/http" coreosoidc "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/sets" - "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" + supervisoridpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" + "go.pinniped.dev/internal/federationdomain/downstreamsubject" "go.pinniped.dev/internal/federationdomain/upstreamprovider" + "go.pinniped.dev/internal/githubclient" ) // ProviderConfig holds the active configuration of an upstream GitHub provider. @@ -26,8 +31,8 @@ type ProviderConfig struct { // or https://HOSTNAME/api/v3/ for Enterprise Server. APIBaseURL string - UsernameAttribute v1alpha1.GitHubUsernameAttribute - GroupNameAttribute v1alpha1.GitHubGroupNameAttribute + UsernameAttribute supervisoridpv1alpha1.GitHubUsernameAttribute + GroupNameAttribute supervisoridpv1alpha1.GitHubGroupNameAttribute // AllowedOrganizations, when empty, means to allow users from all orgs. AllowedOrganizations []string @@ -46,7 +51,8 @@ type ProviderConfig struct { } type Provider struct { - c ProviderConfig + c ProviderConfig + buildGitHubClient func(httpClient *http.Client, apiBaseURL, token string) (githubclient.GitHubInterface, error) } var _ upstreamprovider.UpstreamGithubIdentityProviderI = &Provider{} @@ -54,7 +60,10 @@ var _ upstreamprovider.UpstreamGithubIdentityProviderI = &Provider{} // New creates a Provider. The config is not a pointer to ensure that a copy of the config is created, // making the resulting Provider use an effectively read-only configuration. func New(config ProviderConfig) *Provider { - return &Provider{c: config} + return &Provider{ + c: config, + buildGitHubClient: githubclient.NewGitHubClient, + } } func (p *Provider) GetName() string { @@ -73,11 +82,11 @@ func (p *Provider) GetScopes() []string { return p.c.OAuth2Config.Scopes } -func (p *Provider) GetUsernameAttribute() v1alpha1.GitHubUsernameAttribute { +func (p *Provider) GetUsernameAttribute() supervisoridpv1alpha1.GitHubUsernameAttribute { return p.c.UsernameAttribute } -func (p *Provider) GetGroupNameAttribute() v1alpha1.GitHubGroupNameAttribute { +func (p *Provider) GetGroupNameAttribute() supervisoridpv1alpha1.GitHubGroupNameAttribute { return p.c.GroupNameAttribute } @@ -104,19 +113,73 @@ func (p *Provider) ExchangeAuthcode(ctx context.Context, authcode string, redire return tok.AccessToken, nil } -func (p *Provider) GetUser(_ctx context.Context, _accessToken string) (*upstreamprovider.GitHubUser, error) { - // TODO Implement this to make several https calls to github to learn about the user, using a lower-level githubclient package. - // Pass the ctx, accessToken, p.c.HttpClient, and p.c.APIBaseURL to the lower-level package's functions. - // TODO: Reject the auth if the user does not belong to any of p.c.AllowedOrganizations (unless p.c.AllowedOrganizations is empty). - // TODO: Make use of p.c.UsernameAttribute and p.c.GroupNameAttribute when deciding the username and group names. - // TODO: Determine the downstream subject by first writing a helper in downstream_subject.go and then calling it here. - panic("implement me") - //nolint:govet // this code is intentionally unreachable until we resolve the todos - return &upstreamprovider.GitHubUser{ - Username: "TODO", - Groups: []string{"org/TODO"}, - DownstreamSubject: "TODO", - }, nil +// GetUser will use the provided configuration to make HTTPS calls to the GitHub API to find out who the logged-in user is, +// what organizations they belong to, and what teams they belong to. +// If the user's information meets the AllowedOrganization criteria specified on the GitHubIdentityProvider, they will be +// allowed to log in. +// Note that errors from the githubclient package already have helpful error prefixes, so there is no need for additional prefixes here. +// TODO: populate the IDP display name +// TODO: What should we do if the group or team name is outside of the enum? The controller would reject this. +// TODO: should we use the APIBaseURL or some other URL in the downstreamSubject +// +// Examples: +// +// "github.com" or "https://github.com" or "https://api.github.com"? +// "enterprise.tld" or "https://enterprise.tld" or "https://enterprise.tld/api/v3"? +func (p *Provider) GetUser(ctx context.Context, accessToken string) (*upstreamprovider.GitHubUser, error) { + githubClient, err := p.buildGitHubClient(p.c.HttpClient, p.c.APIBaseURL, accessToken) + if err != nil { + return nil, err + } + + githubUser := upstreamprovider.GitHubUser{} + + userInfo, err := githubClient.GetUserInfo(ctx) + if err != nil { + return nil, err + } + + githubUser.DownstreamSubject = downstreamsubject.GitHub(p.c.APIBaseURL, "TODO_IDP_DISPLAY_NAME", userInfo.Login, userInfo.ID) + + switch p.c.UsernameAttribute { + case supervisoridpv1alpha1.GitHubUsernameLoginAndID: + githubUser.Username = fmt.Sprintf("%s:%s", userInfo.Login, userInfo.ID) + case supervisoridpv1alpha1.GitHubUsernameLogin: + githubUser.Username = userInfo.Login + case supervisoridpv1alpha1.GitHubUsernameID: + githubUser.Username = userInfo.ID + } + + orgMembership, err := githubClient.GetOrgMembership(ctx) + if err != nil { + return nil, err + } + + allowedOrgs := sets.New[string](p.c.AllowedOrganizations...) + + if allowedOrgs.Len() > 0 && allowedOrgs.Intersection(orgMembership).Len() < 1 { + return nil, errors.New("user is not allowed to log in due to organization membership policy") + } + + teamMembership, err := githubClient.GetTeamMembership(ctx, allowedOrgs) + if err != nil { + return nil, err + } + + for _, team := range teamMembership { + downstreamGroup := "" + + switch p.c.GroupNameAttribute { + case supervisoridpv1alpha1.GitHubUseTeamNameForGroupName: + downstreamGroup = fmt.Sprintf("%s/%s", team.Org, team.Name) + case supervisoridpv1alpha1.GitHubUseTeamSlugForGroupName: + downstreamGroup = fmt.Sprintf("%s/%s", team.Org, team.Slug) + } + + githubUser.Groups = append(githubUser.Groups, downstreamGroup) + } + + return &githubUser, nil } // GetConfig returns the config. This is not part of the UpstreamGithubIdentityProviderI interface and is just for testing. diff --git a/internal/upstreamgithub/upstreamgithub_test.go b/internal/upstreamgithub/upstreamgithub_test.go index 9726717a1..428872af6 100644 --- a/internal/upstreamgithub/upstreamgithub_test.go +++ b/internal/upstreamgithub/upstreamgithub_test.go @@ -4,14 +4,22 @@ package upstreamgithub import ( + "context" + "errors" "net/http" "testing" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "golang.org/x/oauth2" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/rand" + "k8s.io/apimachinery/pkg/util/sets" - "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" + supervisoridpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" + "go.pinniped.dev/internal/federationdomain/upstreamprovider" + "go.pinniped.dev/internal/githubclient" + "go.pinniped.dev/internal/mocks/mockgithubclient" ) func TestGitHubProvider(t *testing.T) { @@ -65,11 +73,282 @@ func TestGitHubProvider(t *testing.T) { require.Equal(t, types.UID("resource-uid-12345"), subject.GetResourceUID()) require.Equal(t, "fake-client-id", subject.GetClientID()) require.Equal(t, "fake-client-id", subject.GetClientID()) - require.Equal(t, v1alpha1.GitHubUsernameAttribute("fake-username-attribute"), subject.GetUsernameAttribute()) - require.Equal(t, v1alpha1.GitHubGroupNameAttribute("fake-group-name-attribute"), subject.GetGroupNameAttribute()) + require.Equal(t, supervisoridpv1alpha1.GitHubUsernameAttribute("fake-username-attribute"), subject.GetUsernameAttribute()) + require.Equal(t, supervisoridpv1alpha1.GitHubGroupNameAttribute("fake-group-name-attribute"), subject.GetGroupNameAttribute()) require.Equal(t, []string{"fake-org", "fake-org2"}, subject.GetAllowedOrganizations()) require.Equal(t, "https://fake-authorization-url", subject.GetAuthorizationURL()) require.Equal(t, &http.Client{ Timeout: 1234509, }, subject.GetConfig().HttpClient) } + +func TestGetUser(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + someContext := context.Background() + + someHttpClient := &http.Client{ + Timeout: 1234509, + } + + tests := []struct { + name string + providerConfig ProviderConfig + buildGitHubClientError error + buildMockResponses func(hubInterface *mockgithubclient.MockGitHubInterface) + wantUser *upstreamprovider.GitHubUser + wantErr string + }{ + { + name: "happy path with username=login:id", + providerConfig: ProviderConfig{ + APIBaseURL: "https://some-url", + HttpClient: someHttpClient, + UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameLoginAndID, + }, + buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) { + mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{ + Login: "some-github-login", + ID: "some-github-id", + }, nil) + mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(nil, nil) + mockGitHubInterface.EXPECT().GetTeamMembership(someContext, sets.New[string]()).Return(nil, nil) + }, + wantUser: &upstreamprovider.GitHubUser{ + Username: "some-github-login:some-github-id", + DownstreamSubject: "https://some-url?idpName=TODO_IDP_DISPLAY_NAME&login=some-github-login&id=some-github-id", + }, + }, + { + name: "happy path with username=login", + providerConfig: ProviderConfig{ + APIBaseURL: "https://some-url", + HttpClient: someHttpClient, + UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameLogin, + }, + buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) { + mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{ + Login: "some-github-login", + ID: "some-github-id", + }, nil) + mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(nil, nil) + mockGitHubInterface.EXPECT().GetTeamMembership(someContext, sets.New[string]()).Return(nil, nil) + }, + wantUser: &upstreamprovider.GitHubUser{ + Username: "some-github-login", + DownstreamSubject: "https://some-url?idpName=TODO_IDP_DISPLAY_NAME&login=some-github-login&id=some-github-id", + }, + }, + { + name: "happy path with username=id", + providerConfig: ProviderConfig{ + APIBaseURL: "https://some-url", + HttpClient: someHttpClient, + UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameID, + }, + buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) { + mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{ + Login: "some-github-login", + ID: "some-github-id", + }, nil) + mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(nil, nil) + mockGitHubInterface.EXPECT().GetTeamMembership(someContext, sets.New[string]()).Return(nil, nil) + }, + wantUser: &upstreamprovider.GitHubUser{ + Username: "some-github-id", + DownstreamSubject: "https://some-url?idpName=TODO_IDP_DISPLAY_NAME&login=some-github-login&id=some-github-id", + }, + }, + { + name: "happy path with user in allowed organizations", + providerConfig: ProviderConfig{ + APIBaseURL: "https://some-url", + HttpClient: someHttpClient, + UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameLoginAndID, + AllowedOrganizations: []string{"allowed-org1", "allowed-org2"}, + }, + buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) { + mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{ + Login: "some-github-login", + ID: "some-github-id", + }, nil) + mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(sets.New[string]("allowed-org2"), nil) + mockGitHubInterface.EXPECT().GetTeamMembership(someContext, sets.New[string]("allowed-org1", "allowed-org2")).Return(nil, nil) + }, + wantUser: &upstreamprovider.GitHubUser{ + Username: "some-github-login:some-github-id", + DownstreamSubject: "https://some-url?idpName=TODO_IDP_DISPLAY_NAME&login=some-github-login&id=some-github-id", + }, + }, + { + name: "returns error when the user does not belong to the allowed organizations", + providerConfig: ProviderConfig{ + APIBaseURL: "https://some-url", + HttpClient: someHttpClient, + UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameID, + AllowedOrganizations: []string{"allowed-org"}, + }, + buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) { + mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{ + Login: "some-github-login", + ID: "some-github-id", + }, nil) + mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(sets.New[string]("disallowed-org"), nil) + }, + wantErr: "user is not allowed to log in due to organization membership policy", + }, + { + name: "happy path with groups=name", + providerConfig: ProviderConfig{ + APIBaseURL: "https://some-url", + HttpClient: someHttpClient, + UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameLoginAndID, + AllowedOrganizations: []string{"allowed-org1", "allowed-org2"}, + GroupNameAttribute: supervisoridpv1alpha1.GitHubUseTeamNameForGroupName, + }, + buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) { + mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{ + Login: "some-github-login", + ID: "some-github-id", + }, nil) + mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(sets.New[string]("allowed-org2"), nil) + mockGitHubInterface.EXPECT().GetTeamMembership(someContext, sets.New[string]("allowed-org1", "allowed-org2")).Return([]*githubclient.TeamInfo{ + { + Name: "org1-team1-name", + Slug: "org1-team1-slug", + Org: "org1-name", + }, + { + Name: "org1-team2-name", + Slug: "org1-team2-slug", + Org: "org1-name", + }, + { + Name: "org2-team1-name", + Slug: "org2-team1-slug", + Org: "org2-name", + }, + }, nil) + }, + wantUser: &upstreamprovider.GitHubUser{ + Username: "some-github-login:some-github-id", + Groups: []string{"org1-name/org1-team1-name", "org1-name/org1-team2-name", "org2-name/org2-team1-name"}, + DownstreamSubject: "https://some-url?idpName=TODO_IDP_DISPLAY_NAME&login=some-github-login&id=some-github-id", + }, + }, + { + name: "happy path with groups=slug", + providerConfig: ProviderConfig{ + APIBaseURL: "https://some-url", + HttpClient: someHttpClient, + UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameLoginAndID, + AllowedOrganizations: []string{"allowed-org1", "allowed-org2"}, + GroupNameAttribute: supervisoridpv1alpha1.GitHubUseTeamSlugForGroupName, + }, + buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) { + mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{ + Login: "some-github-login", + ID: "some-github-id", + }, nil) + mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(sets.New[string]("allowed-org2"), nil) + mockGitHubInterface.EXPECT().GetTeamMembership(someContext, sets.New[string]("allowed-org1", "allowed-org2")).Return([]*githubclient.TeamInfo{ + { + Name: "org1-team1-name", + Slug: "org1-team1-slug", + Org: "org1-name", + }, + { + Name: "org1-team2-name", + Slug: "org1-team2-slug", + Org: "org1-name", + }, + { + Name: "org2-team1-name", + Slug: "org2-team1-slug", + Org: "org2-name", + }, + }, nil) + }, + wantUser: &upstreamprovider.GitHubUser{ + Username: "some-github-login:some-github-id", + Groups: []string{"org1-name/org1-team1-slug", "org1-name/org1-team2-slug", "org2-name/org2-team1-slug"}, + DownstreamSubject: "https://some-url?idpName=TODO_IDP_DISPLAY_NAME&login=some-github-login&id=some-github-id", + }, + }, + { + name: "returns errors from buildGitHubClient()", + providerConfig: ProviderConfig{ + APIBaseURL: "https://some-url", + HttpClient: someHttpClient, + }, + buildGitHubClientError: errors.New("error from building a github client"), + wantErr: "error from building a github client", + }, + { + name: "returns errors from githubClient.GetUserInfo()", + providerConfig: ProviderConfig{ + APIBaseURL: "https://some-url", + HttpClient: someHttpClient, + }, + buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) { + mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(nil, errors.New("error from githubClient.GetUserInfo")) + }, + wantErr: "error from githubClient.GetUserInfo", + }, + { + name: "returns errors from githubClient.GetOrgMembership()", + providerConfig: ProviderConfig{ + APIBaseURL: "https://some-url", + HttpClient: someHttpClient, + }, + buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) { + mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{}, nil) + mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(nil, errors.New("error from githubClient.GetOrgMembership")) + }, + wantErr: "error from githubClient.GetOrgMembership", + }, + { + name: "returns errors from githubClient.GetTeamMembership()", + providerConfig: ProviderConfig{ + APIBaseURL: "https://some-url", + HttpClient: someHttpClient, + }, + buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) { + mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{}, nil) + mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(nil, nil) + mockGitHubInterface.EXPECT().GetTeamMembership(someContext, gomock.Any()).Return(nil, errors.New("error from githubClient.GetTeamMembership")) + }, + wantErr: "error from githubClient.GetTeamMembership", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + accessToken := "some-opaque-github-access-token" + rand.String(8) + mockGitHubInterface := mockgithubclient.NewMockGitHubInterface(ctrl) + if test.buildMockResponses != nil { + test.buildMockResponses(mockGitHubInterface) + } + + p := New(test.providerConfig) + p.buildGitHubClient = func(httpClient *http.Client, apiBaseURL, token string) (githubclient.GitHubInterface, error) { + require.Equal(t, test.providerConfig.HttpClient, httpClient) + require.Equal(t, test.providerConfig.APIBaseURL, apiBaseURL) + require.Equal(t, accessToken, token) + + return mockGitHubInterface, test.buildGitHubClientError + } + + actualUser, actualErr := p.GetUser(context.Background(), accessToken) + if test.wantErr != "" { + require.EqualError(t, actualErr, test.wantErr) + require.Nil(t, actualUser) + return + } + require.NoError(t, actualErr) + require.Equal(t, test.wantUser, actualUser) + }) + } +} From ba2d122308155878e88479077a71dba3374e962b Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Mon, 20 May 2024 11:34:52 -0500 Subject: [PATCH 09/25] fix lint --- .../downstreamsubject/downstream_subject.go | 4 ++-- .../downstreamsubject/downstream_subject_test.go | 8 ++++---- internal/githubclient/githubclient.go | 3 +++ internal/githubclient/githubclient_test.go | 9 ++++++++- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/internal/federationdomain/downstreamsubject/downstream_subject.go b/internal/federationdomain/downstreamsubject/downstream_subject.go index 78d202918..d4c206ce7 100644 --- a/internal/federationdomain/downstreamsubject/downstream_subject.go +++ b/internal/federationdomain/downstreamsubject/downstream_subject.go @@ -25,8 +25,8 @@ func OIDC(upstreamIssuerAsString string, upstreamSubject string, idpDisplayName ) } -func GitHub(APIBaseURL, idpDisplayName, login, id string) string { - return fmt.Sprintf("%s?%s=%s&login=%s&id=%s", APIBaseURL, +func GitHub(apiBaseURL, idpDisplayName, login, id string) string { + return fmt.Sprintf("%s?%s=%s&login=%s&id=%s", apiBaseURL, oidc.IDTokenSubClaimIDPNameQueryParam, url.QueryEscape(idpDisplayName), url.QueryEscape(login), url.QueryEscape(id), ) diff --git a/internal/federationdomain/downstreamsubject/downstream_subject_test.go b/internal/federationdomain/downstreamsubject/downstream_subject_test.go index 8add3c077..3e57707ef 100644 --- a/internal/federationdomain/downstreamsubject/downstream_subject_test.go +++ b/internal/federationdomain/downstreamsubject/downstream_subject_test.go @@ -93,7 +93,7 @@ func TestOIDC(t *testing.T) { func TestGitHub(t *testing.T) { tests := []struct { name string - APIBaseURL string + apiBaseURL string idpDisplayName string login string id string @@ -101,7 +101,7 @@ func TestGitHub(t *testing.T) { }{ { name: "simple display name", - APIBaseURL: "https://github.com", + apiBaseURL: "https://github.com", idpDisplayName: "simpleName", login: "some login", id: "some id", @@ -109,7 +109,7 @@ func TestGitHub(t *testing.T) { }, { name: "interesting display name", - APIBaseURL: "https://server.example.com:1234/path", + apiBaseURL: "https://server.example.com:1234/path", idpDisplayName: "this is a 👍 display name that 🦭 can handle", login: "some other login", id: "some other id", @@ -121,7 +121,7 @@ func TestGitHub(t *testing.T) { t.Run(test.name, func(t *testing.T) { t.Parallel() - actual := GitHub(test.APIBaseURL, test.idpDisplayName, test.login, test.id) + actual := GitHub(test.apiBaseURL, test.idpDisplayName, test.login, test.id) require.Equal(t, test.wantSubject, actual) }) diff --git a/internal/githubclient/githubclient.go b/internal/githubclient/githubclient.go index 807fe5ae4..8fdaea6fc 100644 --- a/internal/githubclient/githubclient.go +++ b/internal/githubclient/githubclient.go @@ -1,3 +1,6 @@ +// Copyright 2024 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + package githubclient import ( diff --git a/internal/githubclient/githubclient_test.go b/internal/githubclient/githubclient_test.go index 36924fc8a..35ffc4e46 100644 --- a/internal/githubclient/githubclient_test.go +++ b/internal/githubclient/githubclient_test.go @@ -1,3 +1,6 @@ +// Copyright 2024 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + package githubclient import ( @@ -107,7 +110,11 @@ func TestNewGitHubClient(t *testing.T) { require.Equal(t, test.wantBaseURL, actual.client.BaseURL.String()) // Force the githubClient's httpClient roundTrippers to run and add the Authorization header - _, err = actual.client.Client().Get(testServer.URL) + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, testServer.URL, nil) + require.NoError(t, err) + + _, err = actual.client.Client().Do(req) //nolint:bodyclose require.NoError(t, err) }) } From 8923704f3c00396929ed422b266e09ff3ebb2676 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Mon, 20 May 2024 16:36:31 -0700 Subject: [PATCH 10/25] Finish initial github login flow Also: - fix github teams query: fix bug and sort/unique the results - add IDP display name to github downstream subject - fix error types returned by LoginFromCallback - add trace logs to github API results - update e2e test - implement placeholder version of refresh for github --- .../downstreamsubject/downstream_subject.go | 3 +- .../resolved_github_provider.go | 27 +- .../resolved_github_provider_test.go | 37 +-- .../upstreamprovider/upsteam_provider.go | 3 +- internal/githubclient/githubclient.go | 49 +++- internal/githubclient/githubclient_test.go | 248 ++++++------------ .../mockgithubclient/mockgithubclient.go | 4 +- .../oidctestutil/testgithubprovider.go | 12 +- internal/upstreamgithub/upstreamgithub.go | 29 +- .../upstreamgithub/upstreamgithub_test.go | 177 ++++++++++++- test/integration/e2e_test.go | 44 ++-- test/testlib/browsertest/browsertest.go | 55 +++- test/testlib/env.go | 30 ++- test/testlib/skip.go | 10 +- 14 files changed, 453 insertions(+), 275 deletions(-) diff --git a/internal/federationdomain/downstreamsubject/downstream_subject.go b/internal/federationdomain/downstreamsubject/downstream_subject.go index d4c206ce7..345ec737e 100644 --- a/internal/federationdomain/downstreamsubject/downstream_subject.go +++ b/internal/federationdomain/downstreamsubject/downstream_subject.go @@ -28,6 +28,7 @@ func OIDC(upstreamIssuerAsString string, upstreamSubject string, idpDisplayName func GitHub(apiBaseURL, idpDisplayName, login, id string) string { return fmt.Sprintf("%s?%s=%s&login=%s&id=%s", apiBaseURL, oidc.IDTokenSubClaimIDPNameQueryParam, url.QueryEscape(idpDisplayName), - url.QueryEscape(login), url.QueryEscape(id), + url.QueryEscape(login), + url.QueryEscape(id), ) } diff --git a/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider.go b/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider.go index c9de9036e..e0bec1ed1 100644 --- a/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider.go +++ b/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider.go @@ -7,13 +7,16 @@ import ( "context" "errors" "fmt" + "net/http" "golang.org/x/oauth2" "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1" "go.pinniped.dev/internal/federationdomain/resolvedprovider" "go.pinniped.dev/internal/federationdomain/upstreamprovider" + "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/idtransform" + "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/psession" "go.pinniped.dev/pkg/oidcclient/nonce" "go.pinniped.dev/pkg/oidcclient/pkce" @@ -99,12 +102,19 @@ func (p *FederationDomainResolvedGitHubIdentityProvider) LoginFromCallback( ) (*resolvedprovider.Identity, *resolvedprovider.IdentityLoginExtras, error) { accessToken, err := p.Provider.ExchangeAuthcode(ctx, authCode, redirectURI) if err != nil { - return nil, nil, fmt.Errorf("failed to exchange auth code using GitHub API: %w", err) + plog.WarningErr("error exchanging GitHub authcode", err, "upstreamName", p.Provider.GetName()) + return nil, nil, httperr.Wrap(http.StatusBadGateway, + fmt.Sprintf("failed to exchange authcode using GitHub API: %s", err.Error()), + err, + ) } - user, err := p.Provider.GetUser(ctx, accessToken) + user, err := p.Provider.GetUser(ctx, accessToken, p.GetDisplayName()) if err != nil { - return nil, nil, fmt.Errorf("failed to get user info from GitHub API: %w", err) + return nil, nil, httperr.Wrap(http.StatusUnprocessableEntity, + fmt.Sprintf("failed to get user info from GitHub API: %s", err.Error()), + err, + ) } return &resolvedprovider.Identity{ @@ -124,7 +134,12 @@ func (p *FederationDomainResolvedGitHubIdentityProvider) LoginFromCallback( func (p *FederationDomainResolvedGitHubIdentityProvider) UpstreamRefresh( _ context.Context, - _ *resolvedprovider.Identity, -) (refreshedIdentity *resolvedprovider.RefreshedIdentity, err error) { - return nil, errors.New("function UpstreamRefresh not yet implemented for GitHub IDP") + identity *resolvedprovider.Identity, +) (*resolvedprovider.RefreshedIdentity, error) { + // TODO: actually implement refresh. this is just a placeholder that will make refresh always succeed. + return &resolvedprovider.RefreshedIdentity{ + UpstreamUsername: identity.UpstreamUsername, + UpstreamGroups: identity.UpstreamGroups, + IDPSpecificSessionData: nil, // nil means that no update to the GitHub-specific portion of the session data is required + }, nil } diff --git a/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider_test.go b/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider_test.go index 3f957ce37..7941c104f 100644 --- a/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider_test.go +++ b/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider_test.go @@ -15,6 +15,7 @@ import ( idpdiscoveryv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1" "go.pinniped.dev/internal/federationdomain/resolvedprovider" "go.pinniped.dev/internal/federationdomain/upstreamprovider" + "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/testutil/oidctestutil" "go.pinniped.dev/internal/testutil/transformtestutil" @@ -109,10 +110,11 @@ func TestLoginFromCallback(t *testing.T) { uniqueCtx := context.WithValue(context.Background(), "some-unique-key", "some-value") //nolint:staticcheck // okay to use string key for test tests := []struct { - name string - provider *oidctestutil.TestUpstreamGitHubIdentityProvider - authcode string - redirectURI string + name string + provider *oidctestutil.TestUpstreamGitHubIdentityProvider + idpDisplayName string + authcode string + redirectURI string wantExchangeAuthcodeCall bool wantExchangeAuthcodeArgs *oidctestutil.ExchangeAuthcodeArgs @@ -132,6 +134,7 @@ func TestLoginFromCallback(t *testing.T) { DownstreamSubject: "https://fake-downstream-subject", }). Build(), + idpDisplayName: "fake-display-name", authcode: "fake-authcode", redirectURI: "https://fake-redirect-uri", wantExchangeAuthcodeCall: true, @@ -142,8 +145,9 @@ func TestLoginFromCallback(t *testing.T) { }, wantGetUserCall: true, wantGetUserArgs: &oidctestutil.GetUserArgs{ - Ctx: uniqueCtx, - AccessToken: "fake-access-token", + Ctx: uniqueCtx, + AccessToken: "fake-access-token", + IDPDisplayName: "fake-display-name", }, wantIdentity: &resolvedprovider.Identity{ UpstreamUsername: "fake-username", @@ -160,6 +164,7 @@ func TestLoginFromCallback(t *testing.T) { provider: oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder(). WithAuthcodeExchangeError(errors.New("fake authcode exchange error")). Build(), + idpDisplayName: "fake-display-name", authcode: "fake-authcode", redirectURI: "https://fake-redirect-uri", wantExchangeAuthcodeCall: true, @@ -171,7 +176,7 @@ func TestLoginFromCallback(t *testing.T) { wantGetUserCall: false, wantIdentity: nil, wantExtras: nil, - wantErr: "failed to exchange auth code using GitHub API: fake authcode exchange error", + wantErr: "failed to exchange authcode using GitHub API: fake authcode exchange error: fake authcode exchange error", }, { name: "error while getting user info", @@ -179,6 +184,7 @@ func TestLoginFromCallback(t *testing.T) { WithAccessToken("fake-access-token"). WithGetUserError(errors.New("fake user info error")). Build(), + idpDisplayName: "fake-display-name", authcode: "fake-authcode", redirectURI: "https://fake-redirect-uri", wantExchangeAuthcodeCall: true, @@ -189,24 +195,23 @@ func TestLoginFromCallback(t *testing.T) { }, wantGetUserCall: true, wantGetUserArgs: &oidctestutil.GetUserArgs{ - Ctx: uniqueCtx, - AccessToken: "fake-access-token", + Ctx: uniqueCtx, + AccessToken: "fake-access-token", + IDPDisplayName: "fake-display-name", }, wantIdentity: nil, wantExtras: nil, - wantErr: "failed to get user info from GitHub API: fake user info error", + wantErr: "failed to get user info from GitHub API: fake user info error: fake user info error", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - transforms := transformtestutil.NewRejectAllAuthPipeline(t) - subject := FederationDomainResolvedGitHubIdentityProvider{ - DisplayName: "fake-display-name", + DisplayName: test.idpDisplayName, Provider: test.provider, SessionProviderType: psession.ProviderTypeGitHub, - Transforms: transforms, + Transforms: transformtestutil.NewRejectAllAuthPipeline(t), } identity, loginExtras, err := subject.LoginFromCallback(uniqueCtx, @@ -233,7 +238,9 @@ func TestLoginFromCallback(t *testing.T) { if test.wantErr == "" { require.NoError(t, err) } else { - require.EqualError(t, err, test.wantErr) + errAsResponder, ok := err.(httperr.Responder) + require.True(t, ok) + require.EqualError(t, errAsResponder, test.wantErr) } require.Equal(t, test.wantExtras, loginExtras) require.Equal(t, test.wantIdentity, identity) diff --git a/internal/federationdomain/upstreamprovider/upsteam_provider.go b/internal/federationdomain/upstreamprovider/upsteam_provider.go index cb52dcedf..c05785417 100644 --- a/internal/federationdomain/upstreamprovider/upsteam_provider.go +++ b/internal/federationdomain/upstreamprovider/upsteam_provider.go @@ -171,5 +171,6 @@ type UpstreamGithubIdentityProviderI interface { // GetUser calls the user, orgs, and teams APIs of GitHub using the accessToken. // It validates any required org memberships. It returns a User or an error. - GetUser(ctx context.Context, accessToken string) (*GitHubUser, error) + // The IDP display name is passed to aid in building a suitable downstream subject string. + GetUser(ctx context.Context, accessToken string, idpDisplayName string) (*GitHubUser, error) } diff --git a/internal/githubclient/githubclient.go b/internal/githubclient/githubclient.go index 8fdaea6fc..c1889dcaa 100644 --- a/internal/githubclient/githubclient.go +++ b/internal/githubclient/githubclient.go @@ -9,11 +9,13 @@ import ( "fmt" "net/http" "net/url" + "slices" "strings" "github.com/google/go-github/v62/github" - "k8s.io/apimachinery/pkg/util/sets" + + "go.pinniped.dev/internal/plog" ) const ( @@ -35,7 +37,7 @@ type TeamInfo struct { type GitHubInterface interface { GetUserInfo(ctx context.Context) (*UserInfo, error) GetOrgMembership(ctx context.Context) (sets.Set[string], error) - GetTeamMembership(ctx context.Context, allowedOrganizations sets.Set[string]) ([]*TeamInfo, error) + GetTeamMembership(ctx context.Context, allowedOrganizations sets.Set[string]) ([]TeamInfo, error) } type githubClient struct { @@ -87,6 +89,7 @@ func (g *githubClient) GetUserInfo(ctx context.Context) (*UserInfo, error) { if user == nil { // untested return nil, fmt.Errorf("%s: user is nil", errorPrefix) } + plog.Trace("got raw GitHub API user results", "user", user) userInfo := &UserInfo{ Login: user.GetLogin(), @@ -98,6 +101,8 @@ func (g *githubClient) GetUserInfo(ctx context.Context) (*UserInfo, error) { if userInfo.Login == "" { return nil, fmt.Errorf(`%s: the "login" attribute is missing`, errorPrefix) } + + plog.Trace("calculated response from GitHub user endpoint", "user", userInfo) return userInfo, nil } @@ -114,6 +119,7 @@ func (g *githubClient) GetOrgMembership(ctx context.Context) (sets.Set[string], if err != nil { return nil, fmt.Errorf("%s: %w", errorPrefix, err) } + plog.Trace("got raw GitHub API org results", "orgs", organizationResults, "hasNextPage", response.NextPage) for _, organization := range organizationResults { organizationLogins.Insert(organization.GetLogin()) @@ -128,6 +134,7 @@ func (g *githubClient) GetOrgMembership(ctx context.Context) (sets.Set[string], return nil, fmt.Errorf(`%s: one or more organizations is missing the "login" attribute`, errorPrefix) } + plog.Trace("calculated response from GitHub org membership endpoint", "orgs", organizationLogins.UnsortedList()) return organizationLogins, nil } @@ -135,6 +142,10 @@ func isOrgAllowed(allowedOrganizations sets.Set[string], login string) bool { return len(allowedOrganizations) == 0 || allowedOrganizations.Has(login) } +func buildAndValidateParentTeam(githubTeam *github.Team, organizationLogin string) (*TeamInfo, error) { + return buildTeam(githubTeam, organizationLogin) +} + func buildAndValidateTeam(githubTeam *github.Team) (*TeamInfo, error) { if githubTeam.GetOrganization() == nil { return nil, errors.New(`missing the "organization" attribute for a team`) @@ -144,6 +155,10 @@ func buildAndValidateTeam(githubTeam *github.Team) (*TeamInfo, error) { return nil, errors.New(`missing the organization's "login" attribute for a team`) } + return buildTeam(githubTeam, organizationLogin) +} + +func buildTeam(githubTeam *github.Team, organizationLogin string) (*TeamInfo, error) { teamInfo := &TeamInfo{ Name: githubTeam.GetName(), Slug: githubTeam.GetSlug(), @@ -161,9 +176,9 @@ func buildAndValidateTeam(githubTeam *github.Team) (*TeamInfo, error) { // GetTeamMembership returns a description of each team to which the authenticated user belongs. // If allowedOrganizations is not empty, will filter the results to only those teams which belong to the allowed organizations. // Parent teams will also be returned. -func (g *githubClient) GetTeamMembership(ctx context.Context, allowedOrganizations sets.Set[string]) ([]*TeamInfo, error) { +func (g *githubClient) GetTeamMembership(ctx context.Context, allowedOrganizations sets.Set[string]) ([]TeamInfo, error) { const errorPrefix = "error fetching team membership for authenticated user" - teamInfos := make([]*TeamInfo, 0) + teamInfos := sets.New[TeamInfo]() opt := &github.ListOptions{PerPage: pageSize} // get all pages of results @@ -172,6 +187,7 @@ func (g *githubClient) GetTeamMembership(ctx context.Context, allowedOrganizatio if err != nil { return nil, fmt.Errorf("%s: %w", errorPrefix, err) } + plog.Trace("got raw GitHub API team results", "teams", teamsResults, "hasNextPage", response.NextPage) for _, team := range teamsResults { teamInfo, err := buildAndValidateTeam(team) @@ -183,20 +199,18 @@ func (g *githubClient) GetTeamMembership(ctx context.Context, allowedOrganizatio continue } - teamInfos = append(teamInfos, teamInfo) + teamInfos.Insert(*teamInfo) parent := team.GetParent() if parent != nil { - teamInfo, err := buildAndValidateTeam(parent) + // The GitHub API does not return the Organization for the Parent of the team. + // Use the org of the child as the org of the parent, since they must come from the same org. + parentTeamInfo, err := buildAndValidateParentTeam(parent, teamInfo.Org) if err != nil { return nil, fmt.Errorf("%s: %w", errorPrefix, err) } - if !isOrgAllowed(allowedOrganizations, teamInfo.Org) { - continue - } - - teamInfos = append(teamInfos, teamInfo) + teamInfos.Insert(*parentTeamInfo) } } if response.NextPage == 0 { @@ -205,5 +219,16 @@ func (g *githubClient) GetTeamMembership(ctx context.Context, allowedOrganizatio opt.Page = response.NextPage } - return teamInfos, nil + // Sort by org and then by name, just so we always return teams in the same order. + sortedTeams := teamInfos.UnsortedList() + slices.SortStableFunc(sortedTeams, func(a, b TeamInfo) int { + orgsCompared := strings.Compare(a.Org, b.Org) + if orgsCompared == 0 { + return strings.Compare(a.Slug, b.Slug) + } + return orgsCompared + }) + + plog.Trace("calculated response from GitHub teams endpoint", "teams", sortedTeams) + return sortedTeams, nil } diff --git a/internal/githubclient/githubclient_test.go b/internal/githubclient/githubclient_test.go index 35ffc4e46..0ea8e7523 100644 --- a/internal/githubclient/githubclient_test.go +++ b/internal/githubclient/githubclient_test.go @@ -100,22 +100,23 @@ func TestNewGitHubClient(t *testing.T) { if test.wantErr != "" { require.EqualError(t, err, test.wantErr) - return + } else { + require.NoError(t, err) + + require.NotNil(t, actualI) + actual, ok := actualI.(*githubClient) + require.True(t, ok) + require.NotNil(t, actual.client.BaseURL) + require.Equal(t, test.wantBaseURL, actual.client.BaseURL.String()) + + // Force the githubClient's httpClient roundTrippers to run and add the Authorization header + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, testServer.URL, nil) + require.NoError(t, err) + + _, err = actual.client.Client().Do(req) //nolint:bodyclose + require.NoError(t, err) } - - require.NotNil(t, actualI) - actual, ok := actualI.(*githubClient) - require.True(t, ok) - require.NotNil(t, actual.client.BaseURL) - require.Equal(t, test.wantBaseURL, actual.client.BaseURL.String()) - - // Force the githubClient's httpClient roundTrippers to run and add the Authorization header - - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, testServer.URL, nil) - require.NoError(t, err) - - _, err = actual.client.Client().Do(req) //nolint:bodyclose - require.NoError(t, err) }) } } @@ -241,11 +242,11 @@ func TestGetUser(t *testing.T) { require.True(t, ok) test.wantErr = strings.ReplaceAll(test.wantErr, "{SERVER_URL}", rt.Host) require.EqualError(t, err, test.wantErr) - return + } else { + require.NoError(t, err) + require.NotNil(t, actual) + require.Equal(t, test.wantUserInfo, *actual) } - - require.NotNil(t, actual) - require.Equal(t, test.wantUserInfo, *actual) }) } } @@ -395,7 +396,7 @@ func TestGetTeamMembership(t *testing.T) { ctx context.Context allowedOrganizations []string wantErr string - wantTeams []*TeamInfo + wantTeams []TeamInfo }{ { name: "happy path", @@ -436,7 +437,7 @@ func TestGetTeamMembership(t *testing.T) { ), token: "some-token", allowedOrganizations: []string{"alpha", "beta"}, - wantTeams: []*TeamInfo{ + wantTeams: []TeamInfo{ { Name: "orgAlpha-team1-name", Slug: "orgAlpha-team1-slug", @@ -491,7 +492,7 @@ func TestGetTeamMembership(t *testing.T) { ), token: "some-token", allowedOrganizations: []string{"alpha", "gamma"}, - wantTeams: []*TeamInfo{ + wantTeams: []TeamInfo{ { Name: "team1-name", Slug: "team1-slug", @@ -528,11 +529,9 @@ func TestGetTeamMembership(t *testing.T) { Name: github.String("team3-name"), Slug: github.String("team3-slug"), Parent: &github.Team{ - Name: github.String("delta-team-name"), - Slug: github.String("delta-team-slug"), - Organization: &github.Organization{ - Login: github.String("delta"), - }, + Name: github.String("delta-team-name"), + Slug: github.String("delta-team-slug"), + Organization: nil, // the real GitHub API does not return Org on "Parent" team. }, Organization: &github.Organization{ Login: github.String("gamma"), @@ -542,7 +541,7 @@ func TestGetTeamMembership(t *testing.T) { ), ), token: "some-token", - wantTeams: []*TeamInfo{ + wantTeams: []TeamInfo{ { Name: "team1-name", Slug: "team1-slug", @@ -553,20 +552,20 @@ func TestGetTeamMembership(t *testing.T) { Slug: "team2-slug", Org: "beta", }, + { + Name: "delta-team-name", + Slug: "delta-team-slug", + Org: "gamma", + }, { Name: "team3-name", Slug: "team3-slug", Org: "gamma", }, - { - Name: "delta-team-name", - Slug: "delta-team-slug", - Org: "delta", - }, }, }, { - name: "includes parent team if present", + name: "includes parent team in allowed orgs if present", httpClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.GetUserTeams, @@ -575,33 +574,48 @@ func TestGetTeamMembership(t *testing.T) { Name: github.String("team-name-with-parent"), Slug: github.String("team-slug-with-parent"), Parent: &github.Team{ - Name: github.String("parent-team-name"), - Slug: github.String("parent-team-slug"), - Organization: &github.Organization{ - Login: github.String("parent-team-org-that-in-reality-can-never-be-different-than-child-team-org"), - }, + Name: github.String("parent-team-name"), + Slug: github.String("parent-team-slug"), + Organization: nil, // the real GitHub API does not return Org on "Parent" team. }, Organization: &github.Organization{ Login: github.String("org-with-nested-teams"), }, }, { - Name: github.String("team-name-without-parent"), - Slug: github.String("team-slug-without-parent"), + Name: github.String("team-name-with-same-parent-again"), + Slug: github.String("team-slug-with-same-parent-again"), + Parent: &github.Team{ + Name: github.String("parent-team-name"), + Slug: github.String("parent-team-slug"), + Organization: nil, // the real GitHub API does not return Org on "Parent" team. + }, Organization: &github.Organization{ - Login: github.String("beta"), + Login: github.String("org-with-nested-teams"), }, }, { - Name: github.String("team-name-with-parent-in-disallowed-org"), - Slug: github.String("team-slug-with-parent-in-disallowed-org"), - Parent: &github.Team{ - Name: github.String("disallowed-parent-team-name"), - Slug: github.String("disallowed-parent-team-slug"), - Organization: &github.Organization{ - Login: github.String("disallowed-org"), - }, + Name: github.String("parent-team-name"), + Slug: github.String("parent-team-slug"), + Organization: &github.Organization{ + Login: github.String("org-with-nested-teams"), }, + }, + { + Name: github.String("team-name-with-parent-from-disallowed-org"), + Slug: github.String("team-slug-with-parent-from-disallowed-org"), + Parent: &github.Team{ + Name: github.String("parent-team-name-from-disallowed-org"), + Slug: github.String("parent-team-slug-from-disallowed-org"), + Organization: nil, // the real GitHub API does not return Org on "Parent" team. + }, + Organization: &github.Organization{ + Login: github.String("disallowed-org"), + }, + }, + { + Name: github.String("team-name-without-parent"), + Slug: github.String("team-slug-without-parent"), Organization: &github.Organization{ Login: github.String("beta"), }, @@ -612,29 +626,28 @@ func TestGetTeamMembership(t *testing.T) { token: "some-token", allowedOrganizations: []string{ "org-with-nested-teams", - "parent-team-org-that-in-reality-can-never-be-different-than-child-team-org", "beta", }, - wantTeams: []*TeamInfo{ - { - Name: "team-name-with-parent", - Slug: "team-slug-with-parent", - Org: "org-with-nested-teams", - }, - { - Name: "parent-team-name", - Slug: "parent-team-slug", - Org: "parent-team-org-that-in-reality-can-never-be-different-than-child-team-org", - }, + wantTeams: []TeamInfo{ { Name: "team-name-without-parent", Slug: "team-slug-without-parent", Org: "beta", }, { - Name: "team-name-with-parent-in-disallowed-org", - Slug: "team-slug-with-parent-in-disallowed-org", - Org: "beta", + Name: "parent-team-name", + Slug: "parent-team-slug", + Org: "org-with-nested-teams", + }, + { + Name: "team-name-with-parent", + Slug: "team-slug-with-parent", + Org: "org-with-nested-teams", + }, + { + Name: "team-name-with-same-parent-again", + Slug: "team-slug-with-same-parent-again", + Org: "org-with-nested-teams", }, }, }, @@ -665,7 +678,7 @@ func TestGetTeamMembership(t *testing.T) { ), token: "some-token", allowedOrganizations: []string{"page1-org-name", "page2-org-name"}, - wantTeams: []*TeamInfo{ + wantTeams: []TeamInfo{ { Name: "page1-team-name", Slug: "page1-team-slug", @@ -743,99 +756,6 @@ func TestGetTeamMembership(t *testing.T) { ), wantErr: `error fetching team membership for authenticated user: the "slug" attribute is missing for a team`, }, - { - name: "missing parent's organization attribute returns an error", - httpClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetUserTeams, - []github.Team{ - { - Name: github.String("team-name-with-parent"), - Slug: github.String("team-slug-with-parent"), - Parent: &github.Team{ - Name: github.String("parent-team-name"), - Slug: github.String("parent-team-slug"), - }, - Organization: &github.Organization{ - Login: github.String("some-org"), - }, - }, - }, - ), - ), - wantErr: `error fetching team membership for authenticated user: missing the "organization" attribute for a team`, - }, - { - name: "missing parent's organization's login attribute returns an error", - httpClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetUserTeams, - []github.Team{ - { - Name: github.String("team-name-with-parent"), - Slug: github.String("team-slug-with-parent"), - Parent: &github.Team{ - Name: github.String("parent-team-name"), - Slug: github.String("parent-team-slug"), - Organization: &github.Organization{}, - }, - Organization: &github.Organization{ - Login: github.String("some-org"), - }, - }, - }, - ), - ), - wantErr: `error fetching team membership for authenticated user: missing the organization's "login" attribute for a team`, - }, - { - name: "missing the name attribute for a parent team returns an error", - httpClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetUserTeams, - []github.Team{ - { - Name: github.String("team-name-with-parent"), - Slug: github.String("team-slug-with-parent"), - Parent: &github.Team{ - Slug: github.String("parent-team-slug"), - Organization: &github.Organization{ - Login: github.String("some-org"), - }, - }, - Organization: &github.Organization{ - Login: github.String("some-org"), - }, - }, - }, - ), - ), - wantErr: `error fetching team membership for authenticated user: the "name" attribute is missing for a team`, - }, - { - name: "missing the slug attribute for a parent team returns an error", - httpClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetUserTeams, - []github.Team{ - { - Name: github.String("team-name-with-parent"), - Slug: github.String("team-slug-with-parent"), - Parent: &github.Team{ - Name: github.String("parent-team-name"), - Organization: &github.Organization{ - Login: github.String("some-org"), - }, - }, - Organization: &github.Organization{ - Login: github.String("some-org"), - }, - }, - }, - ), - ), - wantErr: `error fetching team membership for authenticated user: the "slug" attribute is missing for a team`, - }, { name: "the token is added in the Authorization header", httpClient: mock.NewMockedHTTPClient( @@ -851,7 +771,7 @@ func TestGetTeamMembership(t *testing.T) { ), token: "does-this-token-work", allowedOrganizations: []string{"org-login"}, - wantTeams: []*TeamInfo{ + wantTeams: []TeamInfo{ { Name: "team1-name", Slug: "team1-slug", @@ -907,11 +827,11 @@ func TestGetTeamMembership(t *testing.T) { require.True(t, ok) test.wantErr = strings.ReplaceAll(test.wantErr, "{SERVER_URL}", rt.Host) require.EqualError(t, err, test.wantErr) - return + } else { + require.NoError(t, err) + require.NotNil(t, actual) + require.Equal(t, test.wantTeams, actual) } - - require.NotNil(t, actual) - require.Equal(t, test.wantTeams, actual) }) } } diff --git a/internal/mocks/mockgithubclient/mockgithubclient.go b/internal/mocks/mockgithubclient/mockgithubclient.go index 6a1901c14..988031522 100644 --- a/internal/mocks/mockgithubclient/mockgithubclient.go +++ b/internal/mocks/mockgithubclient/mockgithubclient.go @@ -61,10 +61,10 @@ func (mr *MockGitHubInterfaceMockRecorder) GetOrgMembership(arg0 any) *gomock.Ca } // GetTeamMembership mocks base method. -func (m *MockGitHubInterface) GetTeamMembership(arg0 context.Context, arg1 sets.Set[string]) ([]*githubclient.TeamInfo, error) { +func (m *MockGitHubInterface) GetTeamMembership(arg0 context.Context, arg1 sets.Set[string]) ([]githubclient.TeamInfo, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetTeamMembership", arg0, arg1) - ret0, _ := ret[0].([]*githubclient.TeamInfo) + ret0, _ := ret[0].([]githubclient.TeamInfo) ret1, _ := ret[1].(error) return ret0, ret1 } diff --git a/internal/testutil/oidctestutil/testgithubprovider.go b/internal/testutil/oidctestutil/testgithubprovider.go index 1f793aeac..1eea5896c 100644 --- a/internal/testutil/oidctestutil/testgithubprovider.go +++ b/internal/testutil/oidctestutil/testgithubprovider.go @@ -24,8 +24,9 @@ type ExchangeAuthcodeArgs struct { // GetUserArgs is used to spy on calls to // TestUpstreamGitHubIdentityProvider.GetUserFunc(). type GetUserArgs struct { - Ctx context.Context - AccessToken string + Ctx context.Context + AccessToken string + IDPDisplayName string } type TestUpstreamGitHubIdentityProviderBuilder struct { @@ -232,14 +233,15 @@ func (u *TestUpstreamGitHubIdentityProvider) ExchangeAuthcodeArgs(call int) *Exc return u.exchangeAuthcodeArgs[call] } -func (u *TestUpstreamGitHubIdentityProvider) GetUser(ctx context.Context, accessToken string) (*upstreamprovider.GitHubUser, error) { +func (u *TestUpstreamGitHubIdentityProvider) GetUser(ctx context.Context, accessToken string, idpDisplayName string) (*upstreamprovider.GitHubUser, error) { if u.getUserArgs == nil { u.getUserArgs = make([]*GetUserArgs, 0) } u.getUserCallCount++ u.getUserArgs = append(u.getUserArgs, &GetUserArgs{ - Ctx: ctx, - AccessToken: accessToken, + Ctx: ctx, + AccessToken: accessToken, + IDPDisplayName: idpDisplayName, }) return u.GetUserFunc(ctx, accessToken) } diff --git a/internal/upstreamgithub/upstreamgithub.go b/internal/upstreamgithub/upstreamgithub.go index 6571c2fa7..7321ac35e 100644 --- a/internal/upstreamgithub/upstreamgithub.go +++ b/internal/upstreamgithub/upstreamgithub.go @@ -99,34 +99,23 @@ func (p *Provider) GetAuthorizationURL() string { } func (p *Provider) ExchangeAuthcode(ctx context.Context, authcode string, redirectURI string) (string, error) { - // TODO: write tests for this - panic("write some tests for this sketch of the implementation, maybe by running a test server in the unit tests") - //nolint:govet // this code is intentionally unreachable until we resolve the todos tok, err := p.c.OAuth2Config.Exchange( coreosoidc.ClientContext(ctx, p.c.HttpClient), authcode, oauth2.SetAuthURLParam("redirect_uri", redirectURI), ) if err != nil { - return "", err + return "", fmt.Errorf("error exchanging authorization code using GitHub API: %w", err) } return tok.AccessToken, nil } -// GetUser will use the provided configuration to make HTTPS calls to the GitHub API to find out who the logged-in user is, -// what organizations they belong to, and what teams they belong to. -// If the user's information meets the AllowedOrganization criteria specified on the GitHubIdentityProvider, they will be -// allowed to log in. +// GetUser will use the provided configuration to make HTTPS calls to the GitHub API to get the identity of the +// authenticated user and to discover their org and team memberships. +// If the user's information meets the AllowedOrganization criteria specified on the GitHubIdentityProvider, +// they will be allowed to log in. // Note that errors from the githubclient package already have helpful error prefixes, so there is no need for additional prefixes here. -// TODO: populate the IDP display name -// TODO: What should we do if the group or team name is outside of the enum? The controller would reject this. -// TODO: should we use the APIBaseURL or some other URL in the downstreamSubject -// -// Examples: -// -// "github.com" or "https://github.com" or "https://api.github.com"? -// "enterprise.tld" or "https://enterprise.tld" or "https://enterprise.tld/api/v3"? -func (p *Provider) GetUser(ctx context.Context, accessToken string) (*upstreamprovider.GitHubUser, error) { +func (p *Provider) GetUser(ctx context.Context, accessToken string, idpDisplayName string) (*upstreamprovider.GitHubUser, error) { githubClient, err := p.buildGitHubClient(p.c.HttpClient, p.c.APIBaseURL, accessToken) if err != nil { return nil, err @@ -139,7 +128,7 @@ func (p *Provider) GetUser(ctx context.Context, accessToken string) (*upstreampr return nil, err } - githubUser.DownstreamSubject = downstreamsubject.GitHub(p.c.APIBaseURL, "TODO_IDP_DISPLAY_NAME", userInfo.Login, userInfo.ID) + githubUser.DownstreamSubject = downstreamsubject.GitHub(p.c.APIBaseURL, idpDisplayName, userInfo.Login, userInfo.ID) switch p.c.UsernameAttribute { case supervisoridpv1alpha1.GitHubUsernameLoginAndID: @@ -148,6 +137,8 @@ func (p *Provider) GetUser(ctx context.Context, accessToken string) (*upstreampr githubUser.Username = userInfo.Login case supervisoridpv1alpha1.GitHubUsernameID: githubUser.Username = userInfo.ID + default: + return nil, fmt.Errorf("bad configuration: unknown GitHub username attribute: %s", p.c.UsernameAttribute) } orgMembership, err := githubClient.GetOrgMembership(ctx) @@ -174,6 +165,8 @@ func (p *Provider) GetUser(ctx context.Context, accessToken string) (*upstreampr downstreamGroup = fmt.Sprintf("%s/%s", team.Org, team.Name) case supervisoridpv1alpha1.GitHubUseTeamSlugForGroupName: downstreamGroup = fmt.Sprintf("%s/%s", team.Org, team.Slug) + default: + return nil, fmt.Errorf("bad configuration: unknown GitHub group name attribute: %s", p.c.GroupNameAttribute) } githubUser.Groups = append(githubUser.Groups, downstreamGroup) diff --git a/internal/upstreamgithub/upstreamgithub_test.go b/internal/upstreamgithub/upstreamgithub_test.go index 428872af6..5b8ae3c6d 100644 --- a/internal/upstreamgithub/upstreamgithub_test.go +++ b/internal/upstreamgithub/upstreamgithub_test.go @@ -5,9 +5,12 @@ package upstreamgithub import ( "context" + "crypto/tls" "errors" + "fmt" "net/http" "testing" + "time" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" @@ -15,11 +18,13 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/rand" "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/client-go/util/cert" supervisoridpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" "go.pinniped.dev/internal/federationdomain/upstreamprovider" "go.pinniped.dev/internal/githubclient" "go.pinniped.dev/internal/mocks/mockgithubclient" + "go.pinniped.dev/internal/testutil/tlsserver" ) func TestGitHubProvider(t *testing.T) { @@ -82,7 +87,112 @@ func TestGitHubProvider(t *testing.T) { }, subject.GetConfig().HttpClient) } +func TestExchangeAuthcode(t *testing.T) { + const fakeGitHubAccessToken = "gho_16C7e42F292c6912E7710c838347Ae178B4a" //nolint:gosec // this is not a credential + + tests := []struct { + name string + tokenEndpointPath string + wantErr string + }{ + { + name: "happy path", + tokenEndpointPath: "/token", + }, + { + name: "when the GitHub token endpoint returns an error", + tokenEndpointPath: "/token-error", + wantErr: "error exchanging authorization code using GitHub API: oauth2: cannot fetch token: 401 Unauthorized\nResponse: some github error", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + testServer, testServerCA := tlsserver.TestServerIPv4(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // See documentation at https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps + // GitHub docs say to use a POST. + require.Equal(t, http.MethodPost, r.Method) + + // The OAuth client library happens to choose to send these headers. Asserting here for our own understanding. + require.Len(t, r.Header, 4) + require.Equal(t, "application/x-www-form-urlencoded", r.Header.Get("Content-Type")) + require.Equal(t, "gzip", r.Header.Get("Accept-Encoding")) + require.NotEmpty(t, r.Header.Get("User-Agent")) + require.NotEmpty(t, r.Header.Get("Content-Length")) + + // Get the params. + err := r.ParseForm() + require.NoError(t, err) + params := r.PostForm + require.Len(t, params, 5) + // These four params are documented by GitHub. + require.Equal(t, "fake-client-id", params.Get("client_id")) + require.Equal(t, "fake-client-secret", params.Get("client_secret")) + require.Equal(t, "https://fake-redirect-url", params.Get("redirect_uri")) + require.Equal(t, "fake-authcode", params.Get("code")) + // This param is not documented by GitHub, but is standard OAuth2. GitHub should respect or ignore it. + require.Equal(t, "authorization_code", params.Get("grant_type")) + + // The GitHub docs say that it will return a URL encoded form by default, so I assume it would set this header. + w.Header().Set("content-type", "application/x-www-form-urlencoded") + + switch r.URL.Path { + case "/token": + // Example response from GitHub docs. + responseBody := "access_token=" + fakeGitHubAccessToken + "&scope=repo%2Cgist&token_type=bearer" + w.WriteHeader(http.StatusOK) + _, err = w.Write([]byte(responseBody)) + require.NoError(t, err) + case "/token-error": + responseBody := "some github error" + w.WriteHeader(http.StatusUnauthorized) + _, err = w.Write([]byte(responseBody)) + require.NoError(t, err) + default: + t.Fatalf("tried to call provider at unexpected endpoint: %s", r.URL.Path) + } + }), nil) + testServerPool, err := cert.NewPoolFromBytes(testServerCA) + require.NoError(t, err) + + subject := New(ProviderConfig{ + OAuth2Config: &oauth2.Config{ + ClientID: "fake-client-id", + ClientSecret: "fake-client-secret", + Scopes: []string{"scope1", "scope2"}, + Endpoint: oauth2.Endpoint{ + AuthURL: "https://fake-auth-url", + TokenURL: testServer.URL + test.tokenEndpointPath, + AuthStyle: oauth2.AuthStyleInParams, + }, + }, + HttpClient: &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + RootCAs: testServerPool, + }}, + }, + }) + + accessToken, err := subject.ExchangeAuthcode(context.Background(), "fake-authcode", "https://fake-redirect-url") + if test.wantErr != "" { + require.EqualError(t, err, test.wantErr) + require.Empty(t, accessToken) + } else { + require.NoError(t, err) + require.Equal(t, fakeGitHubAccessToken, accessToken) + } + }) + } +} + func TestGetUser(t *testing.T) { + const idpDisplayName = "idp display name 😀" + const encodedIDPDisplayName = "idp+display+name+%F0%9F%98%80" + ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) @@ -117,7 +227,7 @@ func TestGetUser(t *testing.T) { }, wantUser: &upstreamprovider.GitHubUser{ Username: "some-github-login:some-github-id", - DownstreamSubject: "https://some-url?idpName=TODO_IDP_DISPLAY_NAME&login=some-github-login&id=some-github-id", + DownstreamSubject: fmt.Sprintf("https://some-url?idpName=%s&login=some-github-login&id=some-github-id", encodedIDPDisplayName), }, }, { @@ -137,7 +247,7 @@ func TestGetUser(t *testing.T) { }, wantUser: &upstreamprovider.GitHubUser{ Username: "some-github-login", - DownstreamSubject: "https://some-url?idpName=TODO_IDP_DISPLAY_NAME&login=some-github-login&id=some-github-id", + DownstreamSubject: fmt.Sprintf("https://some-url?idpName=%s&login=some-github-login&id=some-github-id", encodedIDPDisplayName), }, }, { @@ -157,7 +267,7 @@ func TestGetUser(t *testing.T) { }, wantUser: &upstreamprovider.GitHubUser{ Username: "some-github-id", - DownstreamSubject: "https://some-url?idpName=TODO_IDP_DISPLAY_NAME&login=some-github-login&id=some-github-id", + DownstreamSubject: fmt.Sprintf("https://some-url?idpName=%s&login=some-github-login&id=some-github-id", encodedIDPDisplayName), }, }, { @@ -178,7 +288,7 @@ func TestGetUser(t *testing.T) { }, wantUser: &upstreamprovider.GitHubUser{ Username: "some-github-login:some-github-id", - DownstreamSubject: "https://some-url?idpName=TODO_IDP_DISPLAY_NAME&login=some-github-login&id=some-github-id", + DownstreamSubject: fmt.Sprintf("https://some-url?idpName=%s&login=some-github-login&id=some-github-id", encodedIDPDisplayName), }, }, { @@ -213,7 +323,7 @@ func TestGetUser(t *testing.T) { ID: "some-github-id", }, nil) mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(sets.New[string]("allowed-org2"), nil) - mockGitHubInterface.EXPECT().GetTeamMembership(someContext, sets.New[string]("allowed-org1", "allowed-org2")).Return([]*githubclient.TeamInfo{ + mockGitHubInterface.EXPECT().GetTeamMembership(someContext, sets.New[string]("allowed-org1", "allowed-org2")).Return([]githubclient.TeamInfo{ { Name: "org1-team1-name", Slug: "org1-team1-slug", @@ -234,7 +344,7 @@ func TestGetUser(t *testing.T) { wantUser: &upstreamprovider.GitHubUser{ Username: "some-github-login:some-github-id", Groups: []string{"org1-name/org1-team1-name", "org1-name/org1-team2-name", "org2-name/org2-team1-name"}, - DownstreamSubject: "https://some-url?idpName=TODO_IDP_DISPLAY_NAME&login=some-github-login&id=some-github-id", + DownstreamSubject: fmt.Sprintf("https://some-url?idpName=%s&login=some-github-login&id=some-github-id", encodedIDPDisplayName), }, }, { @@ -252,7 +362,7 @@ func TestGetUser(t *testing.T) { ID: "some-github-id", }, nil) mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(sets.New[string]("allowed-org2"), nil) - mockGitHubInterface.EXPECT().GetTeamMembership(someContext, sets.New[string]("allowed-org1", "allowed-org2")).Return([]*githubclient.TeamInfo{ + mockGitHubInterface.EXPECT().GetTeamMembership(someContext, sets.New[string]("allowed-org1", "allowed-org2")).Return([]githubclient.TeamInfo{ { Name: "org1-team1-name", Slug: "org1-team1-slug", @@ -273,7 +383,7 @@ func TestGetUser(t *testing.T) { wantUser: &upstreamprovider.GitHubUser{ Username: "some-github-login:some-github-id", Groups: []string{"org1-name/org1-team1-slug", "org1-name/org1-team2-slug", "org2-name/org2-team1-slug"}, - DownstreamSubject: "https://some-url?idpName=TODO_IDP_DISPLAY_NAME&login=some-github-login&id=some-github-id", + DownstreamSubject: fmt.Sprintf("https://some-url?idpName=%s&login=some-github-login&id=some-github-id", encodedIDPDisplayName), }, }, { @@ -299,8 +409,9 @@ func TestGetUser(t *testing.T) { { name: "returns errors from githubClient.GetOrgMembership()", providerConfig: ProviderConfig{ - APIBaseURL: "https://some-url", - HttpClient: someHttpClient, + APIBaseURL: "https://some-url", + HttpClient: someHttpClient, + UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameLoginAndID, }, buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) { mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{}, nil) @@ -311,8 +422,9 @@ func TestGetUser(t *testing.T) { { name: "returns errors from githubClient.GetTeamMembership()", providerConfig: ProviderConfig{ - APIBaseURL: "https://some-url", - HttpClient: someHttpClient, + APIBaseURL: "https://some-url", + HttpClient: someHttpClient, + UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameLoginAndID, }, buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) { mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{}, nil) @@ -321,6 +433,45 @@ func TestGetUser(t *testing.T) { }, wantErr: "error from githubClient.GetTeamMembership", }, + { + name: "bad configuration: UsernameAttribute", + providerConfig: ProviderConfig{ + APIBaseURL: "https://some-url", + HttpClient: someHttpClient, + UsernameAttribute: "this-is-not-legal-value-from-the-enum", + }, + buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) { + mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{ + Login: "some-github-login", + ID: "some-github-id", + }, nil) + }, + wantErr: "bad configuration: unknown GitHub username attribute: this-is-not-legal-value-from-the-enum", + }, + { + name: "bad configuration: GroupNameAttribute", + providerConfig: ProviderConfig{ + APIBaseURL: "https://some-url", + HttpClient: someHttpClient, + UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameLoginAndID, + GroupNameAttribute: "this-is-not-legal-value-from-the-enum", + }, + buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) { + mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{ + Login: "some-github-login", + ID: "some-github-id", + }, nil) + mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(nil, nil) + mockGitHubInterface.EXPECT().GetTeamMembership(someContext, sets.New[string]()).Return([]githubclient.TeamInfo{ + { + Name: "org1-team1-name", + Slug: "org1-team1-slug", + Org: "org1-name", + }, + }, nil) + }, + wantErr: "bad configuration: unknown GitHub group name attribute: this-is-not-legal-value-from-the-enum", + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -341,7 +492,7 @@ func TestGetUser(t *testing.T) { return mockGitHubInterface, test.buildGitHubClientError } - actualUser, actualErr := p.GetUser(context.Background(), accessToken) + actualUser, actualErr := p.GetUser(context.Background(), accessToken, idpDisplayName) if test.wantErr != "" { require.EqualError(t, actualErr, test.wantErr) require.Nil(t, actualUser) diff --git a/test/integration/e2e_test.go b/test/integration/e2e_test.go index 28fcc5f91..34e652a74 100644 --- a/test/integration/e2e_test.go +++ b/test/integration/e2e_test.go @@ -1210,8 +1210,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { }) t.Run("with Supervisor GitHub upstream IDP and browser flow with with form_post automatic authcode delivery to CLI", func(t *testing.T) { - // TODO only skip this test when the GitHub test env vars are not set - t.Skip("always skipping for now, this test is still a work in progress and it always fails at the moment") + testlib.SkipTestWhenGitHubIsUnavailable(t) testCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) t.Cleanup(cancel) @@ -1221,16 +1220,32 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // Start a fresh browser driver because we don't want to share cookies between the various tests in this file. browser := browsertest.OpenBrowser(t) - // TODO create clusterrolebinding for expected user and WaitForUserToHaveAccess. doesn't matter until login fully works. + 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. - // TODO use return value when calling requireUserCanUseKubectlWithoutAuthenticatingAgain below - _ = testlib.CreateTestGitHubIdentityProvider(t, idpv1alpha1.GitHubIdentityProviderSpec{ + 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, @@ -1262,8 +1277,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { 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. - // TODO use return value when calling requireKubectlGetNamespaceOutput below - _ = startKubectlAndOpenAuthorizationURLInBrowser(testCtx, t, kubectlCmd, 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) @@ -1272,16 +1286,14 @@ func TestE2EFullIntegration_Browser(t *testing.T) { t.Logf("waiting for response page %s", federationDomain.Spec.Issuer) browser.WaitForURL(t, regexp.MustCompile(regexp.QuoteMeta(federationDomain.Spec.Issuer))) - // TODO When you turn off headless and watch this test run, - // the browser is indeed redirected back to the Supervisor at this point with a code, - // but the Supervisor's callback endpoint does not yet work for github IDPs so it returns an error page, - // and the Supervisor's form_post page is not loaded, so it does not automatically post the callback to the CLI's callback listener. - // The test eventually times out and fails at this point. + // 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) - // TODO - // formpostExpectSuccessState - // requireKubectlGetNamespaceOutput - // requireUserCanUseKubectlWithoutAuthenticatingAgain + 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) { diff --git a/test/testlib/browsertest/browsertest.go b/test/testlib/browsertest/browsertest.go index 4cf1c3de8..2cf06a991 100644 --- a/test/testlib/browsertest/browsertest.go +++ b/test/testlib/browsertest/browsertest.go @@ -398,14 +398,27 @@ func LoginToUpstreamGitHub(t *testing.T, b *Browser, upstream testlib.TestGithub t.Logf("entering GitHub OTP code") b.SendKeysToFirstMatch(t, otpSelector, code) + // 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) { + continue + } +} + +// 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) 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) - // 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. - if strings.HasPrefix(pageTitle, "Authorize ") { // the title is "Authorize " + switch { + case strings.HasPrefix(lowercaseTitle, "authorize "): // the title is "Authorize " + // 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: @@ -415,16 +428,23 @@ func LoginToUpstreamGitHub(t *testing.T, b *Browser, upstream testlib.TestGithub b.WaitForVisibleElements(t, submitAuthorizeAppButtonSelector) t.Logf("clicking authorize button") b.ClickFirstMatch(t, submitAuthorizeAppButtonSelector) + return true - t.Log("sleeping for 2 seconds before looking at page title again") - time.Sleep(2 * time.Second) - pageTitle = b.Title(t) - t.Logf("saw page title %q", pageTitle) - } + 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: + // + submitConfirmButtonSelector := "button.btn-primary" + b.WaitForVisibleElements(t, submitConfirmButtonSelector) + t.Logf("clicking confirm button") + b.ClickFirstMatch(t, submitConfirmButtonSelector) + return true - // TODO I only saw this happen once, so I did not get a chance to finish this code. Not sure if it will happen again? - // Next GitHub might ask if we want to configure a passkey for auth. - if strings.HasPrefix(pageTitle, "Passkey TODO GET THIS PAGE TITLE") { + 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: // dontAskAgainLinkSelector := `input[value="Don't ask again for this browser"]` @@ -434,6 +454,19 @@ func LoginToUpstreamGitHub(t *testing.T, b *Browser, upstream testlib.TestGithub // 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, "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 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 } } diff --git a/test/testlib/env.go b/test/testlib/env.go index a14bdca35..6b3615b81 100644 --- a/test/testlib/env.go +++ b/test/testlib/env.go @@ -112,11 +112,15 @@ type TestLDAPUpstream struct { } type TestGithubUpstream struct { - GithubAppClientID string `json:"githubAppClientId"` - GithubAppClientSecret string `json:"githubAppClientSecret"` - TestUserUsername string `json:"testUserUsername"` - TestUserPassword string `json:"testUserPassword"` - TestUserOTPSecret string `json:"testUserOTPSecret"` + GithubAppClientID string `json:"githubAppClientId"` + GithubAppClientSecret string `json:"githubAppClientSecret"` + 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. @@ -329,11 +333,15 @@ func loadEnvVars(t *testing.T, result *TestEnv) { } result.SupervisorUpstreamGithub = TestGithubUpstream{ - GithubAppClientID: wantEnv("PINNIPED_TEST_GITHUB_APP_CLIENT_ID", ""), - GithubAppClientSecret: wantEnv("PINNIPED_TEST_GITHUB_APP_CLIENT_SECRET", ""), - TestUserUsername: wantEnv("PINNIPED_TEST_GITHUB_USER_USERNAME", ""), - TestUserPassword: wantEnv("PINNIPED_TEST_GITHUB_USER_PASSWORD", ""), - TestUserOTPSecret: wantEnv("PINNIPED_TEST_GITHUB_USER_OTP_SECRET", ""), + GithubAppClientID: wantEnv("PINNIPED_TEST_GITHUB_APP_CLIENT_ID", ""), + GithubAppClientSecret: wantEnv("PINNIPED_TEST_GITHUB_APP_CLIENT_SECRET", ""), + 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) @@ -341,6 +349,8 @@ func loadEnvVars(t *testing.T, result *TestEnv) { 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 { diff --git a/test/testlib/skip.go b/test/testlib/skip.go index 6fbc621f0..3ad283faf 100644 --- a/test/testlib/skip.go +++ b/test/testlib/skip.go @@ -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,11 @@ 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") + } +} From 8f8db3f54278bf54d6bccfc6ed54fde0f2797a84 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Tue, 21 May 2024 11:57:55 -0700 Subject: [PATCH 11/25] Make github org comparison case-insensitive, but return original case Co-authored-by: Joshua Casey --- .../github_upstream_watcher.go | 3 +- .../github_upstream_watcher_test.go | 16 ++-- .../resolved_github_provider_test.go | 3 +- .../upstreamprovider/upsteam_provider.go | 3 +- internal/githubclient/githubclient.go | 15 ++-- internal/githubclient/githubclient_test.go | 36 ++++----- .../mockgithubclient/mockgithubclient.go | 8 +- internal/setutil/setutil.go | 43 +++++++++++ internal/setutil/setutil_test.go | 34 +++++++++ internal/sliceutil/sliceutil.go | 13 ++++ internal/sliceutil/sliceutil_test.go | 74 +++++++++++++++++++ .../oidctestutil/testgithubprovider.go | 9 ++- internal/upstreamgithub/upstreamgithub.go | 12 ++- .../upstreamgithub/upstreamgithub_test.go | 44 +++++------ 14 files changed, 240 insertions(+), 73 deletions(-) create mode 100644 internal/setutil/setutil.go create mode 100644 internal/setutil/setutil_test.go create mode 100644 internal/sliceutil/sliceutil.go create mode 100644 internal/sliceutil/sliceutil_test.go diff --git a/internal/controller/supervisorconfig/githubupstreamwatcher/github_upstream_watcher.go b/internal/controller/supervisorconfig/githubupstreamwatcher/github_upstream_watcher.go index c6f65ff98..fc0c98d83 100644 --- a/internal/controller/supervisorconfig/githubupstreamwatcher/github_upstream_watcher.go +++ b/internal/controller/supervisorconfig/githubupstreamwatcher/github_upstream_watcher.go @@ -38,6 +38,7 @@ import ( "go.pinniped.dev/internal/federationdomain/upstreamprovider" "go.pinniped.dev/internal/net/phttp" "go.pinniped.dev/internal/plog" + "go.pinniped.dev/internal/setutil" "go.pinniped.dev/internal/upstreamgithub" ) @@ -317,7 +318,7 @@ func (c *gitHubWatcherController) validateUpstreamAndUpdateConditions(ctx contro RedirectURL: "", // this will be different for each FederationDomain, so we do not set it here Scopes: []string{"read:user", "read:org"}, }, - AllowedOrganizations: upstream.Spec.AllowAuthentication.Organizations.Allowed, + AllowedOrganizations: setutil.NewCaseInsensitiveSet(upstream.Spec.AllowAuthentication.Organizations.Allowed...), HttpClient: httpClient, }, ) diff --git a/internal/controller/supervisorconfig/githubupstreamwatcher/github_upstream_watcher_test.go b/internal/controller/supervisorconfig/githubupstreamwatcher/github_upstream_watcher_test.go index 162b0fd7d..7be242074 100644 --- a/internal/controller/supervisorconfig/githubupstreamwatcher/github_upstream_watcher_test.go +++ b/internal/controller/supervisorconfig/githubupstreamwatcher/github_upstream_watcher_test.go @@ -41,6 +41,7 @@ import ( "go.pinniped.dev/internal/federationdomain/upstreamprovider" "go.pinniped.dev/internal/net/phttp" "go.pinniped.dev/internal/plog" + "go.pinniped.dev/internal/setutil" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil/tlsserver" "go.pinniped.dev/internal/upstreamgithub" @@ -406,7 +407,7 @@ func TestController(t *testing.T) { RedirectURL: "", // not used Scopes: []string{"read:user", "read:org"}, }, - AllowedOrganizations: []string{"organization1", "org2"}, + AllowedOrganizations: setutil.NewCaseInsensitiveSet("organization1", "org2"), HttpClient: nil, // let the test runner populate this for us }, }, @@ -462,7 +463,8 @@ func TestController(t *testing.T) { RedirectURL: "", // not used Scopes: []string{"read:user", "read:org"}, }, - HttpClient: nil, // let the test runner populate this for us + AllowedOrganizations: setutil.NewCaseInsensitiveSet(), + HttpClient: nil, // let the test runner populate this for us }, }, wantResultingUpstreams: []v1alpha1.GitHubIdentityProvider{ @@ -531,7 +533,8 @@ func TestController(t *testing.T) { RedirectURL: "", // not used Scopes: []string{"read:user", "read:org"}, }, - HttpClient: nil, // let the test runner populate this for us + AllowedOrganizations: setutil.NewCaseInsensitiveSet(), + HttpClient: nil, // let the test runner populate this for us }, }, wantResultingUpstreams: []v1alpha1.GitHubIdentityProvider{ @@ -598,7 +601,8 @@ func TestController(t *testing.T) { RedirectURL: "", // not used Scopes: []string{"read:user", "read:org"}, }, - HttpClient: nil, // let the test runner populate this for us + AllowedOrganizations: setutil.NewCaseInsensitiveSet(), + HttpClient: nil, // let the test runner populate this for us }, }, wantResultingUpstreams: []v1alpha1.GitHubIdentityProvider{ @@ -685,7 +689,7 @@ func TestController(t *testing.T) { RedirectURL: "", // not used Scopes: []string{"read:user", "read:org"}, }, - AllowedOrganizations: []string{"organization1", "org2"}, + AllowedOrganizations: setutil.NewCaseInsensitiveSet("organization1", "org2"), HttpClient: nil, // let the test runner populate this for us }, { @@ -706,7 +710,7 @@ func TestController(t *testing.T) { RedirectURL: "", // not used Scopes: []string{"read:user", "read:org"}, }, - AllowedOrganizations: []string{"organization1", "org2"}, + AllowedOrganizations: setutil.NewCaseInsensitiveSet("organization1", "org2"), HttpClient: nil, // let the test runner populate this for us }, }, diff --git a/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider_test.go b/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider_test.go index 7941c104f..5a2b63b6e 100644 --- a/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider_test.go +++ b/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider_test.go @@ -17,6 +17,7 @@ import ( "go.pinniped.dev/internal/federationdomain/upstreamprovider" "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/psession" + "go.pinniped.dev/internal/setutil" "go.pinniped.dev/internal/testutil/oidctestutil" "go.pinniped.dev/internal/testutil/transformtestutil" "go.pinniped.dev/internal/upstreamgithub" @@ -31,7 +32,7 @@ func TestFederationDomainResolvedGitHubIdentityProvider(t *testing.T) { APIBaseURL: "https://fake-api-host.com", UsernameAttribute: idpv1alpha1.GitHubUsernameID, GroupNameAttribute: idpv1alpha1.GitHubUseTeamSlugForGroupName, - AllowedOrganizations: []string{"org1", "org2"}, + AllowedOrganizations: setutil.NewCaseInsensitiveSet("org1", "org2"), HttpClient: nil, // not needed yet for this test OAuth2Config: &oauth2.Config{ ClientID: "fake-client-id", diff --git a/internal/federationdomain/upstreamprovider/upsteam_provider.go b/internal/federationdomain/upstreamprovider/upsteam_provider.go index c05785417..faf6af015 100644 --- a/internal/federationdomain/upstreamprovider/upsteam_provider.go +++ b/internal/federationdomain/upstreamprovider/upsteam_provider.go @@ -12,6 +12,7 @@ import ( "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" "go.pinniped.dev/internal/authenticators" + "go.pinniped.dev/internal/setutil" "go.pinniped.dev/pkg/oidcclient/nonce" "go.pinniped.dev/pkg/oidcclient/oidctypes" "go.pinniped.dev/pkg/oidcclient/pkce" @@ -157,7 +158,7 @@ type UpstreamGithubIdentityProviderI interface { // and only teams from the listed organizations should be represented as groups for the downstream token. // If this list is empty, then any user can log in regardless of org membership, and any observable // teams memberships should be represented as groups for the downstream token. - GetAllowedOrganizations() []string + GetAllowedOrganizations() *setutil.CaseInsensitiveSet // GetAuthorizationURL returns the authorization URL for the configured GitHub. This will look like: // https:///login/oauth/authorize diff --git a/internal/githubclient/githubclient.go b/internal/githubclient/githubclient.go index c1889dcaa..36a7099e0 100644 --- a/internal/githubclient/githubclient.go +++ b/internal/githubclient/githubclient.go @@ -16,6 +16,7 @@ import ( "k8s.io/apimachinery/pkg/util/sets" "go.pinniped.dev/internal/plog" + "go.pinniped.dev/internal/setutil" ) const ( @@ -36,8 +37,8 @@ type TeamInfo struct { type GitHubInterface interface { GetUserInfo(ctx context.Context) (*UserInfo, error) - GetOrgMembership(ctx context.Context) (sets.Set[string], error) - GetTeamMembership(ctx context.Context, allowedOrganizations sets.Set[string]) ([]TeamInfo, error) + GetOrgMembership(ctx context.Context) ([]string, error) + GetTeamMembership(ctx context.Context, allowedOrganizations *setutil.CaseInsensitiveSet) ([]TeamInfo, error) } type githubClient struct { @@ -107,7 +108,7 @@ func (g *githubClient) GetUserInfo(ctx context.Context) (*UserInfo, error) { } // GetOrgMembership returns an array of the "Login" attributes for all organizations to which the authenticated user belongs. -func (g *githubClient) GetOrgMembership(ctx context.Context) (sets.Set[string], error) { +func (g *githubClient) GetOrgMembership(ctx context.Context) ([]string, error) { const errorPrefix = "error fetching organizations for authenticated user" organizationLogins := sets.New[string]() @@ -135,11 +136,11 @@ func (g *githubClient) GetOrgMembership(ctx context.Context) (sets.Set[string], } plog.Trace("calculated response from GitHub org membership endpoint", "orgs", organizationLogins.UnsortedList()) - return organizationLogins, nil + return organizationLogins.UnsortedList(), nil } -func isOrgAllowed(allowedOrganizations sets.Set[string], login string) bool { - return len(allowedOrganizations) == 0 || allowedOrganizations.Has(login) +func isOrgAllowed(allowedOrganizations *setutil.CaseInsensitiveSet, login string) bool { + return allowedOrganizations.Empty() || allowedOrganizations.ContainsIgnoringCase(login) } func buildAndValidateParentTeam(githubTeam *github.Team, organizationLogin string) (*TeamInfo, error) { @@ -176,7 +177,7 @@ func buildTeam(githubTeam *github.Team, organizationLogin string) (*TeamInfo, er // GetTeamMembership returns a description of each team to which the authenticated user belongs. // If allowedOrganizations is not empty, will filter the results to only those teams which belong to the allowed organizations. // Parent teams will also be returned. -func (g *githubClient) GetTeamMembership(ctx context.Context, allowedOrganizations sets.Set[string]) ([]TeamInfo, error) { +func (g *githubClient) GetTeamMembership(ctx context.Context, allowedOrganizations *setutil.CaseInsensitiveSet) ([]TeamInfo, error) { const errorPrefix = "error fetching team membership for authenticated user" teamInfos := sets.New[TeamInfo]() diff --git a/internal/githubclient/githubclient_test.go b/internal/githubclient/githubclient_test.go index 0ea8e7523..ae3b00eba 100644 --- a/internal/githubclient/githubclient_test.go +++ b/internal/githubclient/githubclient_test.go @@ -12,10 +12,10 @@ import ( "github.com/google/go-github/v62/github" "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/stretchr/testify/require" - "k8s.io/apimachinery/pkg/util/sets" "k8s.io/client-go/util/cert" "go.pinniped.dev/internal/net/phttp" + "go.pinniped.dev/internal/setutil" "go.pinniped.dev/internal/testutil/tlsserver" ) @@ -380,8 +380,7 @@ func TestGetOrgMembership(t *testing.T) { } require.NotNil(t, actual) - require.Equal(t, len(actual), len(test.wantOrgs)) - require.True(t, actual.HasAll(test.wantOrgs...)) + require.ElementsMatch(t, test.wantOrgs, actual) }) } } @@ -394,7 +393,7 @@ func TestGetTeamMembership(t *testing.T) { httpClient *http.Client token string ctx context.Context - allowedOrganizations []string + allowedOrganizations *setutil.CaseInsensitiveSet wantErr string wantTeams []TeamInfo }{ @@ -436,7 +435,7 @@ func TestGetTeamMembership(t *testing.T) { ), ), token: "some-token", - allowedOrganizations: []string{"alpha", "beta"}, + allowedOrganizations: setutil.NewCaseInsensitiveSet("alpha", "beta"), wantTeams: []TeamInfo{ { Name: "orgAlpha-team1-name", @@ -461,7 +460,7 @@ func TestGetTeamMembership(t *testing.T) { }, }, { - name: "filters by allowedOrganizations", + name: "filters by allowedOrganizations in a case-insensitive way, but preserves case as returned by GitHub API in the result", httpClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.GetUserTeams, @@ -470,38 +469,38 @@ func TestGetTeamMembership(t *testing.T) { Name: github.String("team1-name"), Slug: github.String("team1-slug"), Organization: &github.Organization{ - Login: github.String("alpha"), + Login: github.String("alPhA"), }, }, { Name: github.String("team2-name"), Slug: github.String("team2-slug"), Organization: &github.Organization{ - Login: github.String("beta"), + Login: github.String("bEtA"), }, }, { Name: github.String("team3-name"), Slug: github.String("team3-slug"), Organization: &github.Organization{ - Login: github.String("gamma"), + Login: github.String("gAmmA"), }, }, }, ), ), token: "some-token", - allowedOrganizations: []string{"alpha", "gamma"}, + allowedOrganizations: setutil.NewCaseInsensitiveSet("ALPHA", "gamma"), wantTeams: []TeamInfo{ { Name: "team1-name", Slug: "team1-slug", - Org: "alpha", + Org: "alPhA", }, { Name: "team3-name", Slug: "team3-slug", - Org: "gamma", + Org: "gAmmA", }, }, }, @@ -623,11 +622,8 @@ func TestGetTeamMembership(t *testing.T) { }, ), ), - token: "some-token", - allowedOrganizations: []string{ - "org-with-nested-teams", - "beta", - }, + token: "some-token", + allowedOrganizations: setutil.NewCaseInsensitiveSet("org-with-nested-teams", "beta"), wantTeams: []TeamInfo{ { Name: "team-name-without-parent", @@ -677,7 +673,7 @@ func TestGetTeamMembership(t *testing.T) { ), ), token: "some-token", - allowedOrganizations: []string{"page1-org-name", "page2-org-name"}, + allowedOrganizations: setutil.NewCaseInsensitiveSet("page1-org-name", "page2-org-name"), wantTeams: []TeamInfo{ { Name: "page1-team-name", @@ -770,7 +766,7 @@ func TestGetTeamMembership(t *testing.T) { ), ), token: "does-this-token-work", - allowedOrganizations: []string{"org-login"}, + allowedOrganizations: setutil.NewCaseInsensitiveSet("org-login"), wantTeams: []TeamInfo{ { Name: "team1-name", @@ -821,7 +817,7 @@ func TestGetTeamMembership(t *testing.T) { ctx = test.ctx } - actual, err := githubClient.GetTeamMembership(ctx, sets.New[string](test.allowedOrganizations...)) + actual, err := githubClient.GetTeamMembership(ctx, test.allowedOrganizations) if test.wantErr != "" { rt, ok := test.httpClient.Transport.(*mock.EnforceHostRoundTripper) require.True(t, ok) diff --git a/internal/mocks/mockgithubclient/mockgithubclient.go b/internal/mocks/mockgithubclient/mockgithubclient.go index 988031522..d259daadf 100644 --- a/internal/mocks/mockgithubclient/mockgithubclient.go +++ b/internal/mocks/mockgithubclient/mockgithubclient.go @@ -18,8 +18,8 @@ import ( reflect "reflect" githubclient "go.pinniped.dev/internal/githubclient" + setutil "go.pinniped.dev/internal/setutil" gomock "go.uber.org/mock/gomock" - sets "k8s.io/apimachinery/pkg/util/sets" ) // MockGitHubInterface is a mock of GitHubInterface interface. @@ -46,10 +46,10 @@ func (m *MockGitHubInterface) EXPECT() *MockGitHubInterfaceMockRecorder { } // GetOrgMembership mocks base method. -func (m *MockGitHubInterface) GetOrgMembership(arg0 context.Context) (sets.Set[string], error) { +func (m *MockGitHubInterface) GetOrgMembership(arg0 context.Context) ([]string, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetOrgMembership", arg0) - ret0, _ := ret[0].(sets.Set[string]) + ret0, _ := ret[0].([]string) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -61,7 +61,7 @@ func (mr *MockGitHubInterfaceMockRecorder) GetOrgMembership(arg0 any) *gomock.Ca } // GetTeamMembership mocks base method. -func (m *MockGitHubInterface) GetTeamMembership(arg0 context.Context, arg1 sets.Set[string]) ([]githubclient.TeamInfo, error) { +func (m *MockGitHubInterface) GetTeamMembership(arg0 context.Context, arg1 *setutil.CaseInsensitiveSet) ([]githubclient.TeamInfo, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetTeamMembership", arg0, arg1) ret0, _ := ret[0].([]githubclient.TeamInfo) diff --git a/internal/setutil/setutil.go b/internal/setutil/setutil.go new file mode 100644 index 000000000..714686c8d --- /dev/null +++ b/internal/setutil/setutil.go @@ -0,0 +1,43 @@ +// Copyright 2024 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package setutil + +import ( + "strings" + + "k8s.io/apimachinery/pkg/util/sets" + + "go.pinniped.dev/internal/sliceutil" +) + +type CaseInsensitiveSet struct { + lowercasedContents sets.Set[string] +} + +func NewCaseInsensitiveSet(items ...string) *CaseInsensitiveSet { + return &CaseInsensitiveSet{ + lowercasedContents: sets.New(sliceutil.Map(items, strings.ToLower)...), + } +} + +func (s *CaseInsensitiveSet) HasAnyIgnoringCase(items []string) bool { + if s == nil { + return false + } + return s.lowercasedContents.HasAny(sliceutil.Map(items, strings.ToLower)...) +} + +func (s *CaseInsensitiveSet) ContainsIgnoringCase(item string) bool { + if s == nil { + return false + } + return s.lowercasedContents.Has(strings.ToLower(item)) +} + +func (s *CaseInsensitiveSet) Empty() bool { + if s == nil { + return true + } + return s.lowercasedContents.Len() == 0 +} diff --git a/internal/setutil/setutil_test.go b/internal/setutil/setutil_test.go new file mode 100644 index 000000000..0fb61ede8 --- /dev/null +++ b/internal/setutil/setutil_test.go @@ -0,0 +1,34 @@ +// Copyright 2024 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package setutil + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCaseInsensitiveSet(t *testing.T) { + var nilSet *CaseInsensitiveSet + require.True(t, nilSet.Empty()) + require.False(t, nilSet.HasAnyIgnoringCase([]string{"a", "b"})) + require.False(t, nilSet.HasAnyIgnoringCase(nil)) + require.False(t, nilSet.ContainsIgnoringCase("a")) + require.False(t, nilSet.ContainsIgnoringCase("a")) + + emptySet := NewCaseInsensitiveSet() + require.True(t, emptySet.Empty()) + require.False(t, emptySet.HasAnyIgnoringCase([]string{"a", "b"})) + require.False(t, emptySet.HasAnyIgnoringCase(nil)) + require.False(t, emptySet.ContainsIgnoringCase("a")) + require.False(t, emptySet.ContainsIgnoringCase("a")) + + set := NewCaseInsensitiveSet("A", "B", "c") + require.False(t, set.Empty()) + require.False(t, set.HasAnyIgnoringCase([]string{"x", "y"})) + require.True(t, set.HasAnyIgnoringCase([]string{"a", "x"})) + require.False(t, set.HasAnyIgnoringCase(nil)) + require.False(t, set.ContainsIgnoringCase("x")) + require.True(t, set.ContainsIgnoringCase("a")) +} diff --git a/internal/sliceutil/sliceutil.go b/internal/sliceutil/sliceutil.go new file mode 100644 index 000000000..af243ab4a --- /dev/null +++ b/internal/sliceutil/sliceutil.go @@ -0,0 +1,13 @@ +// Copyright 2024 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package sliceutil + +// Map transforms a slice from an input type I to an output type O using a transform func. +func Map[I, O any](in []I, transform func(I) O) []O { + out := make([]O, len(in)) + for i := range in { + out[i] = transform(in[i]) + } + return out +} diff --git a/internal/sliceutil/sliceutil_test.go b/internal/sliceutil/sliceutil_test.go new file mode 100644 index 000000000..389603a12 --- /dev/null +++ b/internal/sliceutil/sliceutil_test.go @@ -0,0 +1,74 @@ +// Copyright 2024 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package sliceutil + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMap(t *testing.T) { + type testCase[I any, O any] struct { + name string + in []I + transformFunc func(I) O + want []O + } + + stringStringTests := []testCase[string, string]{ + { + name: "downcase func", + in: []string{"Aa", "bB", "CC"}, + transformFunc: strings.ToLower, + want: []string{"aa", "bb", "cc"}, + }, + { + name: "upcase func", + in: []string{"Aa", "bB", "CC"}, + transformFunc: strings.ToUpper, + want: []string{"AA", "BB", "CC"}, + }, + { + name: "when in is nil, then out is an empty slice", + in: nil, + transformFunc: strings.ToUpper, + want: []string{}, + }, + } + for _, tt := range stringStringTests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + actual := Map(tt.in, tt.transformFunc) + require.Equal(t, tt.want, actual) + }) + } + + stringIntTests := []testCase[string, int]{ + { + name: "len func", + in: []string{"Aa", "bBb", "CCcC"}, + transformFunc: func(s string) int { + return len(s) + }, + want: []int{2, 3, 4}, + }, + { + name: "index func", + in: []string{"Aab", "bB", "CC"}, + transformFunc: func(s string) int { + return strings.Index(s, "b") + }, + want: []int{2, 0, -1}, + }, + } + for _, tt := range stringIntTests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + actual := Map(tt.in, tt.transformFunc) + require.Equal(t, tt.want, actual) + }) + } +} diff --git a/internal/testutil/oidctestutil/testgithubprovider.go b/internal/testutil/oidctestutil/testgithubprovider.go index 1eea5896c..aa210e448 100644 --- a/internal/testutil/oidctestutil/testgithubprovider.go +++ b/internal/testutil/oidctestutil/testgithubprovider.go @@ -11,6 +11,7 @@ import ( "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" "go.pinniped.dev/internal/federationdomain/upstreamprovider" "go.pinniped.dev/internal/idtransform" + "go.pinniped.dev/internal/setutil" ) // ExchangeAuthcodeArgs is used to spy on calls to @@ -38,7 +39,7 @@ type TestUpstreamGitHubIdentityProviderBuilder struct { transformsForFederationDomain *idtransform.TransformationPipeline usernameAttribute v1alpha1.GitHubUsernameAttribute groupNameAttribute v1alpha1.GitHubGroupNameAttribute - allowedOrganizations []string + allowedOrganizations *setutil.CaseInsensitiveSet authorizationURL string authcodeExchangeErr error accessToken string @@ -81,7 +82,7 @@ func (u *TestUpstreamGitHubIdentityProviderBuilder) WithGroupNameAttribute(value return u } -func (u *TestUpstreamGitHubIdentityProviderBuilder) WithAllowedOrganizations(value []string) *TestUpstreamGitHubIdentityProviderBuilder { +func (u *TestUpstreamGitHubIdentityProviderBuilder) WithAllowedOrganizations(value *setutil.CaseInsensitiveSet) *TestUpstreamGitHubIdentityProviderBuilder { u.allowedOrganizations = value return u } @@ -159,7 +160,7 @@ type TestUpstreamGitHubIdentityProvider struct { TransformsForFederationDomain *idtransform.TransformationPipeline UsernameAttribute v1alpha1.GitHubUsernameAttribute GroupNameAttribute v1alpha1.GitHubGroupNameAttribute - AllowedOrganizations []string + AllowedOrganizations *setutil.CaseInsensitiveSet AuthorizationURL string GetUserFunc func(ctx context.Context, accessToken string) (*upstreamprovider.GitHubUser, error) ExchangeAuthcodeFunc func(ctx context.Context, authcode string) (string, error) @@ -197,7 +198,7 @@ func (u *TestUpstreamGitHubIdentityProvider) GetGroupNameAttribute() v1alpha1.Gi return u.GroupNameAttribute } -func (u *TestUpstreamGitHubIdentityProvider) GetAllowedOrganizations() []string { +func (u *TestUpstreamGitHubIdentityProvider) GetAllowedOrganizations() *setutil.CaseInsensitiveSet { return u.AllowedOrganizations } diff --git a/internal/upstreamgithub/upstreamgithub.go b/internal/upstreamgithub/upstreamgithub.go index 7321ac35e..d1085e466 100644 --- a/internal/upstreamgithub/upstreamgithub.go +++ b/internal/upstreamgithub/upstreamgithub.go @@ -13,12 +13,12 @@ import ( coreosoidc "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/sets" supervisoridpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" "go.pinniped.dev/internal/federationdomain/downstreamsubject" "go.pinniped.dev/internal/federationdomain/upstreamprovider" "go.pinniped.dev/internal/githubclient" + "go.pinniped.dev/internal/setutil" ) // ProviderConfig holds the active configuration of an upstream GitHub provider. @@ -35,7 +35,7 @@ type ProviderConfig struct { GroupNameAttribute supervisoridpv1alpha1.GitHubGroupNameAttribute // AllowedOrganizations, when empty, means to allow users from all orgs. - AllowedOrganizations []string + AllowedOrganizations *setutil.CaseInsensitiveSet // HttpClient is a client that can be used to call the GitHub APIs and token endpoint. // This client should be configured with the user-provided CA bundle and a timeout. @@ -90,7 +90,7 @@ func (p *Provider) GetGroupNameAttribute() supervisoridpv1alpha1.GitHubGroupName return p.c.GroupNameAttribute } -func (p *Provider) GetAllowedOrganizations() []string { +func (p *Provider) GetAllowedOrganizations() *setutil.CaseInsensitiveSet { return p.c.AllowedOrganizations } @@ -146,13 +146,11 @@ func (p *Provider) GetUser(ctx context.Context, accessToken string, idpDisplayNa return nil, err } - allowedOrgs := sets.New[string](p.c.AllowedOrganizations...) - - if allowedOrgs.Len() > 0 && allowedOrgs.Intersection(orgMembership).Len() < 1 { + if !p.c.AllowedOrganizations.Empty() && !p.c.AllowedOrganizations.HasAnyIgnoringCase(orgMembership) { return nil, errors.New("user is not allowed to log in due to organization membership policy") } - teamMembership, err := githubClient.GetTeamMembership(ctx, allowedOrgs) + teamMembership, err := githubClient.GetTeamMembership(ctx, p.c.AllowedOrganizations) if err != nil { return nil, err } diff --git a/internal/upstreamgithub/upstreamgithub_test.go b/internal/upstreamgithub/upstreamgithub_test.go index 5b8ae3c6d..f9de917d3 100644 --- a/internal/upstreamgithub/upstreamgithub_test.go +++ b/internal/upstreamgithub/upstreamgithub_test.go @@ -17,13 +17,13 @@ import ( "golang.org/x/oauth2" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/rand" - "k8s.io/apimachinery/pkg/util/sets" "k8s.io/client-go/util/cert" supervisoridpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" "go.pinniped.dev/internal/federationdomain/upstreamprovider" "go.pinniped.dev/internal/githubclient" "go.pinniped.dev/internal/mocks/mockgithubclient" + "go.pinniped.dev/internal/setutil" "go.pinniped.dev/internal/testutil/tlsserver" ) @@ -45,7 +45,7 @@ func TestGitHubProvider(t *testing.T) { AuthStyle: oauth2.AuthStyleInParams, }, }, - AllowedOrganizations: []string{"fake-org", "fake-org2"}, + AllowedOrganizations: setutil.NewCaseInsensitiveSet("fake-org", "fake-org2"), HttpClient: &http.Client{ Timeout: 1234509, }, @@ -68,7 +68,7 @@ func TestGitHubProvider(t *testing.T) { AuthStyle: oauth2.AuthStyleInParams, }, }, - AllowedOrganizations: []string{"fake-org", "fake-org2"}, + AllowedOrganizations: setutil.NewCaseInsensitiveSet("fake-org", "fake-org2"), HttpClient: &http.Client{ Timeout: 1234509, }, @@ -80,7 +80,7 @@ func TestGitHubProvider(t *testing.T) { require.Equal(t, "fake-client-id", subject.GetClientID()) require.Equal(t, supervisoridpv1alpha1.GitHubUsernameAttribute("fake-username-attribute"), subject.GetUsernameAttribute()) require.Equal(t, supervisoridpv1alpha1.GitHubGroupNameAttribute("fake-group-name-attribute"), subject.GetGroupNameAttribute()) - require.Equal(t, []string{"fake-org", "fake-org2"}, subject.GetAllowedOrganizations()) + require.Equal(t, setutil.NewCaseInsensitiveSet("fake-org", "fake-org2"), subject.GetAllowedOrganizations()) require.Equal(t, "https://fake-authorization-url", subject.GetAuthorizationURL()) require.Equal(t, &http.Client{ Timeout: 1234509, @@ -193,9 +193,6 @@ func TestGetUser(t *testing.T) { const idpDisplayName = "idp display name 😀" const encodedIDPDisplayName = "idp+display+name+%F0%9F%98%80" - ctrl := gomock.NewController(t) - t.Cleanup(ctrl.Finish) - someContext := context.Background() someHttpClient := &http.Client{ @@ -223,7 +220,7 @@ func TestGetUser(t *testing.T) { ID: "some-github-id", }, nil) mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(nil, nil) - mockGitHubInterface.EXPECT().GetTeamMembership(someContext, sets.New[string]()).Return(nil, nil) + mockGitHubInterface.EXPECT().GetTeamMembership(someContext, gomock.Any()).Return(nil, nil) }, wantUser: &upstreamprovider.GitHubUser{ Username: "some-github-login:some-github-id", @@ -243,7 +240,7 @@ func TestGetUser(t *testing.T) { ID: "some-github-id", }, nil) mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(nil, nil) - mockGitHubInterface.EXPECT().GetTeamMembership(someContext, sets.New[string]()).Return(nil, nil) + mockGitHubInterface.EXPECT().GetTeamMembership(someContext, nil).Return(nil, nil) }, wantUser: &upstreamprovider.GitHubUser{ Username: "some-github-login", @@ -263,7 +260,7 @@ func TestGetUser(t *testing.T) { ID: "some-github-id", }, nil) mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(nil, nil) - mockGitHubInterface.EXPECT().GetTeamMembership(someContext, sets.New[string]()).Return(nil, nil) + mockGitHubInterface.EXPECT().GetTeamMembership(someContext, nil).Return(nil, nil) }, wantUser: &upstreamprovider.GitHubUser{ Username: "some-github-id", @@ -276,15 +273,15 @@ func TestGetUser(t *testing.T) { APIBaseURL: "https://some-url", HttpClient: someHttpClient, UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameLoginAndID, - AllowedOrganizations: []string{"allowed-org1", "allowed-org2"}, + AllowedOrganizations: setutil.NewCaseInsensitiveSet("ALLOWED-ORG1", "ALLOWED-ORG2"), }, buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) { mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{ Login: "some-github-login", ID: "some-github-id", }, nil) - mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(sets.New[string]("allowed-org2"), nil) - mockGitHubInterface.EXPECT().GetTeamMembership(someContext, sets.New[string]("allowed-org1", "allowed-org2")).Return(nil, nil) + mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return([]string{"allowed-org2"}, nil) + mockGitHubInterface.EXPECT().GetTeamMembership(someContext, setutil.NewCaseInsensitiveSet("ALLOWED-ORG1", "ALLOWED-ORG2")).Return(nil, nil) }, wantUser: &upstreamprovider.GitHubUser{ Username: "some-github-login:some-github-id", @@ -297,14 +294,14 @@ func TestGetUser(t *testing.T) { APIBaseURL: "https://some-url", HttpClient: someHttpClient, UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameID, - AllowedOrganizations: []string{"allowed-org"}, + AllowedOrganizations: setutil.NewCaseInsensitiveSet("allowed-org"), }, buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) { mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{ Login: "some-github-login", ID: "some-github-id", }, nil) - mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(sets.New[string]("disallowed-org"), nil) + mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return([]string{"disallowed-org"}, nil) }, wantErr: "user is not allowed to log in due to organization membership policy", }, @@ -314,7 +311,7 @@ func TestGetUser(t *testing.T) { APIBaseURL: "https://some-url", HttpClient: someHttpClient, UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameLoginAndID, - AllowedOrganizations: []string{"allowed-org1", "allowed-org2"}, + AllowedOrganizations: setutil.NewCaseInsensitiveSet("allowed-org1", "allowed-org2"), GroupNameAttribute: supervisoridpv1alpha1.GitHubUseTeamNameForGroupName, }, buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) { @@ -322,8 +319,8 @@ func TestGetUser(t *testing.T) { Login: "some-github-login", ID: "some-github-id", }, nil) - mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(sets.New[string]("allowed-org2"), nil) - mockGitHubInterface.EXPECT().GetTeamMembership(someContext, sets.New[string]("allowed-org1", "allowed-org2")).Return([]githubclient.TeamInfo{ + mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return([]string{"allowed-org2"}, nil) + mockGitHubInterface.EXPECT().GetTeamMembership(someContext, setutil.NewCaseInsensitiveSet("allowed-org1", "allowed-org2")).Return([]githubclient.TeamInfo{ { Name: "org1-team1-name", Slug: "org1-team1-slug", @@ -353,7 +350,7 @@ func TestGetUser(t *testing.T) { APIBaseURL: "https://some-url", HttpClient: someHttpClient, UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameLoginAndID, - AllowedOrganizations: []string{"allowed-org1", "allowed-org2"}, + AllowedOrganizations: setutil.NewCaseInsensitiveSet("allowed-org1", "allowed-org2"), GroupNameAttribute: supervisoridpv1alpha1.GitHubUseTeamSlugForGroupName, }, buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) { @@ -361,8 +358,8 @@ func TestGetUser(t *testing.T) { Login: "some-github-login", ID: "some-github-id", }, nil) - mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(sets.New[string]("allowed-org2"), nil) - mockGitHubInterface.EXPECT().GetTeamMembership(someContext, sets.New[string]("allowed-org1", "allowed-org2")).Return([]githubclient.TeamInfo{ + mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return([]string{"allowed-org2"}, nil) + mockGitHubInterface.EXPECT().GetTeamMembership(someContext, setutil.NewCaseInsensitiveSet("allowed-org1", "allowed-org2")).Return([]githubclient.TeamInfo{ { Name: "org1-team1-name", Slug: "org1-team1-slug", @@ -462,7 +459,7 @@ func TestGetUser(t *testing.T) { ID: "some-github-id", }, nil) mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(nil, nil) - mockGitHubInterface.EXPECT().GetTeamMembership(someContext, sets.New[string]()).Return([]githubclient.TeamInfo{ + mockGitHubInterface.EXPECT().GetTeamMembership(someContext, nil).Return([]githubclient.TeamInfo{ { Name: "org1-team1-name", Slug: "org1-team1-slug", @@ -477,6 +474,9 @@ func TestGetUser(t *testing.T) { t.Run(test.name, func(t *testing.T) { t.Parallel() + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + accessToken := "some-opaque-github-access-token" + rand.String(8) mockGitHubInterface := mockgithubclient.NewMockGitHubInterface(ctrl) if test.buildMockResponses != nil { From e69eb46911a8a95999dd0c46e6d885c0ae74d611 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Tue, 21 May 2024 13:57:46 -0700 Subject: [PATCH 12/25] Add github integration tests to supervisor_login_test.go Co-authored-by: Joshua Casey --- test/integration/supervisor_login_test.go | 145 +++++++++++++++++++++- test/testlib/browsertest/browsertest.go | 26 ++-- 2 files changed, 161 insertions(+), 10 deletions(-) diff --git a/test/integration/supervisor_login_test.go b/test/integration/supervisor_login_test.go index 6947568d5..5ab67d5af 100644 --- a/test/integration/supervisor_login_test.go +++ b/test/integration/supervisor_login_test.go @@ -56,6 +56,11 @@ func TestSupervisorLogin_Browser(t *testing.T) { testlib.SkipTestWhenLDAPIsUnavailable(t, env) } + skipGitHubTests := func(t *testing.T) { + t.Helper() + testlib.SkipTestWhenGitHubIsUnavailable(t) + } + skipActiveDirectoryTests := func(t *testing.T) { t.Helper() testlib.SkipTestWhenActiveDirectoryIsUnavailable(t, env) @@ -73,6 +78,19 @@ func TestSupervisorLogin_Browser(t *testing.T) { } } + basicGitHubIdentityProviderSpec := func() idpv1alpha1.GitHubIdentityProviderSpec { + return idpv1alpha1.GitHubIdentityProviderSpec{ + AllowAuthentication: idpv1alpha1.GitHubAllowAuthenticationSpec{ + Organizations: idpv1alpha1.GitHubOrganizationsSpec{ + Policy: ptr.To(idpv1alpha1.GitHubAllowedAuthOrganizationsPolicyAllGitHubUsers), + }, + }, + Client: idpv1alpha1.GitHubClientSpec{ + SecretName: testlib.CreateGitHubClientCredentialsSecret(t, env.SupervisorUpstreamGithub.GithubAppClientID, env.SupervisorUpstreamGithub.GithubAppClientSecret).Name, + }, + } + } + createActiveDirectoryIdentityProvider := func(t *testing.T, edit func(spec *idpv1alpha1.ActiveDirectoryIdentityProviderSpec)) (*idpv1alpha1.ActiveDirectoryIdentityProvider, *corev1.Secret) { t.Helper() @@ -169,6 +187,14 @@ func TestSupervisorLogin_Browser(t *testing.T) { regexp.QuoteMeta("&sub=") + ".+" + "$" + // 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) + + "$" + // The downstream ID token Subject should be the Host URL, plus the user search base, plus the IDP display name, // plus value pulled from the requested UserSearch.Attributes.UID attribute. expectedIDTokenSubjectRegexForUpstreamLDAP := "^" + @@ -484,6 +510,98 @@ func TestSupervisorLogin_Browser(t *testing.T) { "upstream_username": env.SupervisorUpstreamOIDC.Username, }, "upstream_groups", env.SupervisorUpstreamOIDC.ExpectedGroups), }, + { + name: "github with all orgs allowed and default claim settings", + maybeSkip: skipGitHubTests, + createIDP: func(t *testing.T) string { + return testlib.CreateTestGitHubIdentityProvider(t, basicGitHubIdentityProviderSpec(), idpv1alpha1.GitHubPhaseReady).Name + }, + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowGitHub, + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamGitHub, + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { + return "^" + regexp.QuoteMeta(env.SupervisorUpstreamGithub.TestUserUsername+":"+env.SupervisorUpstreamGithub.TestUserID) + "$" + }, + wantDownstreamIDTokenGroups: env.SupervisorUpstreamGithub.TestUserExpectedTeamSlugs, + }, + { + name: "github with list of allowed orgs, username as login, and groups as names", + maybeSkip: skipGitHubTests, + createIDP: func(t *testing.T) string { + spec := basicGitHubIdentityProviderSpec() + 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: "github with list of allowed orgs differently cased, username as id, and groups as names", + maybeSkip: skipGitHubTests, + createIDP: func(t *testing.T) string { + spec := basicGitHubIdentityProviderSpec() + 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: "github when user does not belong to any of the allowed orgs, should fail to exchange downstream authcode", + maybeSkip: skipGitHubTests, + createIDP: func(t *testing.T) string { + spec := basicGitHubIdentityProviderSpec() + 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 + }, + 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, + "Unprocessable Entity: failed to get user info from GitHub API: "+ + "user is not allowed to log in due to organization membership policy\n", + textOfPreTag) + }, + wantLocalhostCallbackToNeverHappen: true, + }, { name: "ldap with email as username and groups names as DNs and using an LDAP provider which supports TLS", maybeSkip: skipLDAPTests, @@ -2427,7 +2545,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, configv1alpha1.FederationDomainSpec{ Issuer: issuerURL.String(), TLS: &configv1alpha1.FederationDomainTLSSpec{SecretName: certSecret.Name}, @@ -2476,7 +2594,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 +2645,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) @@ -2884,6 +3002,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 +3023,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() diff --git a/test/testlib/browsertest/browsertest.go b/test/testlib/browsertest/browsertest.go index 2cf06a991..33b0415c1 100644 --- a/test/testlib/browsertest/browsertest.go +++ b/test/testlib/browsertest/browsertest.go @@ -380,6 +380,15 @@ func LoginToUpstreamGitHub(t *testing.T, b *Browser, upstream testlib.TestGithub 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" @@ -397,16 +406,11 @@ func LoginToUpstreamGitHub(t *testing.T, b *Browser, upstream testlib.TestGithub // 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) - - // 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) { - continue - } } // 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) bool { +func handleOccasionalGithubLoginPage(t *testing.T, b *Browser, upstream testlib.TestGithubUpstream) bool { t.Helper() t.Log("sleeping for 2 seconds before looking at page title") @@ -456,11 +460,19 @@ func handleOccasionalGithubLoginPage(t *testing.T, b *Browser) bool { 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. + t.Log("sleeping before trying to generate and use a new GitHub OTP code") + time.Sleep(5 * time.Second) // 5 seconds may not be enough time, but if we get the error again then we can try again + 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 error page during login flow. This is not expected, but is unfortunately unrecoverable.") + 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: From 0a15d488c88aa62b0ab64a2099574b1619718fb7 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Tue, 21 May 2024 14:50:35 -0700 Subject: [PATCH 13/25] Merge callback_handler_github_test.go into callback_handler_test.go Co-authored-by: Joshua Casey --- hack/prepare-supervisor-on-kind.sh | 7 +- .../callback/callback_handler_github_test.go | 272 ------------------ .../callback/callback_handler_test.go | 131 ++++++++- 3 files changed, 132 insertions(+), 278 deletions(-) delete mode 100644 internal/federationdomain/endpoints/callback/callback_handler_github_test.go diff --git a/hack/prepare-supervisor-on-kind.sh b/hack/prepare-supervisor-on-kind.sh index 0a8a971a3..0bd24c43f 100755 --- a/hack/prepare-supervisor-on-kind.sh +++ b/hack/prepare-supervisor-on-kind.sh @@ -323,10 +323,9 @@ stringData: EOF # Grant the test user some RBAC permissions so we can play with kubectl as that user. - # TODO -# kubectl create clusterrolebinding github-test-user-can-view --clusterrole view \ -# --user "$PINNIPED_TEST_GITHUB_TODO_WE_DONT_HAVE_THIS_VARIABLE_YET" \ -# --dry-run=client --output yaml | kubectl apply -f - + kubectl create clusterrolebinding github-test-user-can-view --clusterrole view \ + --user "$PINNIPED_TEST_GITHUB_USER_USERNAME:$PINNIPED_TEST_GITHUB_USERID" \ + --dry-run=client --output yaml | kubectl apply -f - fi # Create a CA and TLS serving certificates for the Supervisor's FederationDomain. diff --git a/internal/federationdomain/endpoints/callback/callback_handler_github_test.go b/internal/federationdomain/endpoints/callback/callback_handler_github_test.go deleted file mode 100644 index 1d506436d..000000000 --- a/internal/federationdomain/endpoints/callback/callback_handler_github_test.go +++ /dev/null @@ -1,272 +0,0 @@ -// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package callback - -import ( - "context" - "fmt" - "net/http" - "net/http/httptest" - "regexp" - "strings" - "testing" - - "github.com/gorilla/securecookie" - "github.com/stretchr/testify/require" - "golang.org/x/crypto/bcrypt" - "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/kubernetes/fake" - - idpdiscoveryv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1" - supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake" - "go.pinniped.dev/internal/federationdomain/endpoints/jwks" - "go.pinniped.dev/internal/federationdomain/oidc" - "go.pinniped.dev/internal/federationdomain/oidcclientvalidator" - "go.pinniped.dev/internal/federationdomain/storage" - "go.pinniped.dev/internal/federationdomain/upstreamprovider" - "go.pinniped.dev/internal/psession" - "go.pinniped.dev/internal/testutil" - "go.pinniped.dev/internal/testutil/oidctestutil" - "go.pinniped.dev/internal/testutil/testidplister" -) - -var ( - githubIDPName = "upstream-github-idp-name" - githubIDPResourceUID = types.UID("upstream-github-idp-resource-uid") - githubUpstreamUsername = "some-github-login" - githubUpstreamGroups = []string{"org1/team1", "org2/team2"} - githubDownstreamSubject = fmt.Sprintf("https://github.com?idpName=%s&sub=%s", githubIDPName, githubUpstreamUsername) - githubUpstreamAccessToken = "some-opaque-access-token-from-github" //nolint:gosec // this is not a credential - - happyDownstreamGitHubCustomSessionData = &psession.CustomSessionData{ - Username: githubUpstreamUsername, - UpstreamUsername: githubUpstreamUsername, - UpstreamGroups: githubUpstreamGroups, - ProviderUID: githubIDPResourceUID, - ProviderName: githubIDPName, - ProviderType: psession.ProviderTypeGitHub, - GitHub: &psession.GitHubSessionData{ - UpstreamAccessToken: githubUpstreamAccessToken, - }, - } -) - -func TestCallbackEndpointWithGitHubIdentityProviders(t *testing.T) { - require.Len(t, happyDownstreamState, 8, "we expect fosite to allow 8 byte state params, so we want to test that boundary case") - - var stateEncoderHashKey = []byte("fake-hash-secret") - var stateEncoderBlockKey = []byte("0123456789ABCDEF") // block encryption requires 16/24/32 bytes for AES - var cookieEncoderHashKey = []byte("fake-hash-secret2") - var cookieEncoderBlockKey = []byte("0123456789ABCDE2") // block encryption requires 16/24/32 bytes for AES - require.NotEqual(t, stateEncoderHashKey, cookieEncoderHashKey) - require.NotEqual(t, stateEncoderBlockKey, cookieEncoderBlockKey) - - var happyStateCodec = securecookie.New(stateEncoderHashKey, stateEncoderBlockKey) - happyStateCodec.SetSerializer(securecookie.JSONEncoder{}) - var happyCookieCodec = securecookie.New(cookieEncoderHashKey, cookieEncoderBlockKey) - happyCookieCodec.SetSerializer(securecookie.JSONEncoder{}) - - encodedIncomingCookieCSRFValue, err := happyCookieCodec.Encode("csrf", happyDownstreamCSRF) - require.NoError(t, err) - happyCSRFCookie := "__Host-pinniped-csrf=" + encodedIncomingCookieCSRFValue - - happyExchangeAndValidateTokensArgs := &oidctestutil.ExchangeAuthcodeArgs{ - Authcode: happyUpstreamAuthcode, - RedirectURI: happyUpstreamRedirectURI, - } - - // TODO: when we merge this file back into callback_handler_test.go, we do not need to copy this function - // because it is already in callback_handler_test.go - addFullyCapableDynamicClientAndSecretToKubeResources := func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) { - oidcClient, secret := testutil.FullyCapableOIDCClientAndStorageSecret(t, - "some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID, downstreamRedirectURI, nil, - []string{testutil.HashedPassword1AtGoMinCost}, oidcclientvalidator.Validate) - require.NoError(t, supervisorClient.Tracker().Add(oidcClient)) - require.NoError(t, kubeClient.Tracker().Add(secret)) - } - - tests := []struct { - name string - - idps *testidplister.UpstreamIDPListerBuilder - kubeResources func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) - method string - path string - csrfCookie string - - wantRedirectLocationRegexp string - wantDownstreamGrantedScopes []string - wantDownstreamIDTokenSubject string - wantDownstreamIDTokenUsername string - wantDownstreamIDTokenGroups []string - wantDownstreamRequestedScopes []string - wantDownstreamNonce string - wantDownstreamClientID string - wantDownstreamPKCEChallenge string - wantDownstreamPKCEChallengeMethod string - wantDownstreamCustomSessionData *psession.CustomSessionData - wantDownstreamAdditionalClaims map[string]interface{} - wantGitHubAuthcodeExchangeCall *expectedGitHubAuthcodeExchange - }{ - { - name: "GitHub IDP: GET with good state and cookie and successful upstream token exchange returns 303 to downstream client callback", - idps: testidplister.NewUpstreamIDPListerBuilder().WithGitHub( - happyGitHubUpstream(). - WithAccessToken(githubUpstreamAccessToken). - WithUser(&upstreamprovider.GitHubUser{ - Username: githubUpstreamUsername, - Groups: githubUpstreamGroups, - DownstreamSubject: githubDownstreamSubject, - }). - Build()), - method: http.MethodGet, - path: newRequestPath().WithState( - happyUpstreamStateParam(). - WithUpstreamIDPName(githubIDPName). - WithUpstreamIDPType(idpdiscoveryv1alpha1.IDPTypeGitHub). - WithAuthorizeRequestParams( - happyDownstreamRequestParamsQuery.Encode(), - ).Build(t, happyStateCodec), - ).String(), - csrfCookie: happyCSRFCookie, - wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=` + regexp.QuoteMeta(strings.Join(happyDownstreamScopesGranted, "+")) + `&state=` + happyDownstreamState, - wantDownstreamIDTokenSubject: githubDownstreamSubject, - wantDownstreamIDTokenUsername: githubUpstreamUsername, - wantDownstreamIDTokenGroups: githubUpstreamGroups, - wantDownstreamRequestedScopes: happyDownstreamScopesRequested, - wantDownstreamGrantedScopes: happyDownstreamScopesGranted, - wantDownstreamNonce: downstreamNonce, - wantDownstreamClientID: downstreamPinnipedClientID, - wantDownstreamPKCEChallenge: downstreamPKCEChallenge, - wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: happyDownstreamGitHubCustomSessionData, - wantGitHubAuthcodeExchangeCall: &expectedGitHubAuthcodeExchange{ - performedByUpstreamName: githubIDPName, - args: happyExchangeAndValidateTokensArgs, - }, - }, - { - name: "GitHub IDP: GET with good state and cookie and successful upstream token exchange with dynamic client returns 303 to downstream client callback, with dynamic client", - idps: testidplister.NewUpstreamIDPListerBuilder().WithGitHub( - happyGitHubUpstream(). - WithAccessToken(githubUpstreamAccessToken). - WithUser(&upstreamprovider.GitHubUser{ - Username: githubUpstreamUsername, - Groups: githubUpstreamGroups, - DownstreamSubject: githubDownstreamSubject, - }). - Build()), - method: http.MethodGet, - kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, - path: newRequestPath().WithState( - happyUpstreamStateParam(). - WithUpstreamIDPName(githubIDPName). - WithUpstreamIDPType(idpdiscoveryv1alpha1.IDPTypeGitHub). - WithAuthorizeRequestParams( - shallowCopyAndModifyQuery( - happyDownstreamRequestParamsQuery, - map[string]string{ - "client_id": downstreamDynamicClientID, - }, - ).Encode(), - ).Build(t, happyStateCodec), - ).String(), - csrfCookie: happyCSRFCookie, - wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=` + regexp.QuoteMeta(strings.Join(happyDownstreamScopesGranted, "+")) + `&state=` + happyDownstreamState, - wantDownstreamIDTokenSubject: githubDownstreamSubject, - wantDownstreamIDTokenUsername: githubUpstreamUsername, - wantDownstreamIDTokenGroups: githubUpstreamGroups, - wantDownstreamRequestedScopes: happyDownstreamScopesRequested, - wantDownstreamGrantedScopes: happyDownstreamScopesGranted, - wantDownstreamNonce: downstreamNonce, - wantDownstreamClientID: downstreamDynamicClientID, - wantDownstreamPKCEChallenge: downstreamPKCEChallenge, - wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: happyDownstreamGitHubCustomSessionData, - wantGitHubAuthcodeExchangeCall: &expectedGitHubAuthcodeExchange{ - performedByUpstreamName: githubIDPName, - args: happyExchangeAndValidateTokensArgs, - }, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - kubeClient := fake.NewSimpleClientset() - supervisorClient := supervisorfake.NewSimpleClientset() - secrets := kubeClient.CoreV1().Secrets("some-namespace") - oidcClientsClient := supervisorClient.ConfigV1alpha1().OIDCClients("some-namespace") - - if test.kubeResources != nil { - test.kubeResources(t, supervisorClient, kubeClient) - } - - // Configure fosite the same way that the production code would. - // Inject this into our test subject at the last second, so we get a fresh storage for every test. - timeoutsConfiguration := oidc.DefaultOIDCTimeoutsConfiguration() - // Use lower minimum required bcrypt cost than we would use in production to keep unit the tests fast. - oauthStore := storage.NewKubeStorage(secrets, oidcClientsClient, timeoutsConfiguration, bcrypt.MinCost) - hmacSecretFunc := func() []byte { return []byte("some secret - must have at least 32 bytes") } - require.GreaterOrEqual(t, len(hmacSecretFunc()), 32, "fosite requires that hmac secrets have at least 32 bytes") - jwksProviderIsUnused := jwks.NewDynamicJWKSProvider() - oauthHelper := oidc.FositeOauth2Helper(oauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration) - - subject := NewHandler(test.idps.BuildFederationDomainIdentityProvidersListerFinder(), oauthHelper, happyStateCodec, happyCookieCodec, happyUpstreamRedirectURI) - reqContext := context.WithValue(context.Background(), struct{ name string }{name: "test"}, "request-context") - req := httptest.NewRequest(test.method, test.path, nil).WithContext(reqContext) - if test.csrfCookie != "" { - req.Header.Set("Cookie", test.csrfCookie) - } - rsp := httptest.NewRecorder() - subject.ServeHTTP(rsp, req) - t.Logf("response: %#v", rsp) - t.Logf("response body: %q", rsp.Body.String()) - - testutil.RequireSecurityHeadersWithFormPostPageCSPs(t, rsp) - - require.NotNil(t, test.wantGitHubAuthcodeExchangeCall, "wantOIDCAuthcodeExchangeCall is required for testing purposes") - - test.wantGitHubAuthcodeExchangeCall.args.Ctx = reqContext - test.idps.RequireExactlyOneGitHubAuthcodeExchange(t, - test.wantGitHubAuthcodeExchangeCall.performedByUpstreamName, - test.wantGitHubAuthcodeExchangeCall.args, - ) - - require.Equal(t, http.StatusSeeOther, rsp.Code) - testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), "") - require.Empty(t, rsp.Body.String()) - - require.Len(t, rsp.Header().Values("Location"), 1) - require.NotEmpty(t, test.wantRedirectLocationRegexp, "wantRedirectLocationRegexp is required for testing purposes") - oidctestutil.RequireAuthCodeRegexpMatch( - t, - rsp.Header().Get("Location"), - test.wantRedirectLocationRegexp, - kubeClient, - secrets, - oauthStore, - test.wantDownstreamGrantedScopes, - test.wantDownstreamIDTokenSubject, - test.wantDownstreamIDTokenUsername, - test.wantDownstreamIDTokenGroups, - test.wantDownstreamRequestedScopes, - test.wantDownstreamPKCEChallenge, - test.wantDownstreamPKCEChallengeMethod, - test.wantDownstreamNonce, - test.wantDownstreamClientID, - downstreamRedirectURI, - test.wantDownstreamCustomSessionData, - test.wantDownstreamAdditionalClaims, - ) - }) - } -} - -func happyGitHubUpstream() *oidctestutil.TestUpstreamGitHubIdentityProviderBuilder { - return oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder(). - WithName(githubIDPName). - WithResourceUID(githubIDPResourceUID). - WithClientID("some-client-id"). - WithScopes([]string{"these", "scopes", "appear", "unused"}) -} diff --git a/internal/federationdomain/endpoints/callback/callback_handler_test.go b/internal/federationdomain/endpoints/callback/callback_handler_test.go index 7818e7f0a..4968f41bd 100644 --- a/internal/federationdomain/endpoints/callback/callback_handler_test.go +++ b/internal/federationdomain/endpoints/callback/callback_handler_test.go @@ -6,6 +6,7 @@ package callback import ( "context" "errors" + "fmt" "net/http" "net/http/httptest" "net/url" @@ -17,14 +18,17 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/crypto/bcrypt" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/kubernetes/fake" configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" + idpdiscoveryv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1" supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake" "go.pinniped.dev/internal/federationdomain/endpoints/jwks" "go.pinniped.dev/internal/federationdomain/oidc" "go.pinniped.dev/internal/federationdomain/oidcclientvalidator" "go.pinniped.dev/internal/federationdomain/storage" + "go.pinniped.dev/internal/federationdomain/upstreamprovider" "go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil/oidctestutil" @@ -73,6 +77,13 @@ const ( ) var ( + githubIDPName = "upstream-github-idp-name" + githubIDPResourceUID = types.UID("upstream-github-idp-resource-uid") + githubUpstreamUsername = "some-github-login" + githubUpstreamGroups = []string{"org1/team1", "org2/team2"} + githubDownstreamSubject = fmt.Sprintf("https://github.com?idpName=%s&sub=%s", githubIDPName, githubUpstreamUsername) + githubUpstreamAccessToken = "some-opaque-access-token-from-github" //nolint:gosec // this is not a credential + oidcUpstreamGroupMembership = []string{"test-pinniped-group-0", "test-pinniped-group-1"} happyDownstreamScopesRequested = []string{"openid", "username", "groups"} happyDownstreamScopesGranted = []string{"openid", "username", "groups"} @@ -129,8 +140,27 @@ var ( UpstreamSubject: oidcUpstreamSubject, }, } + happyDownstreamGitHubCustomSessionData = &psession.CustomSessionData{ + Username: githubUpstreamUsername, + UpstreamUsername: githubUpstreamUsername, + UpstreamGroups: githubUpstreamGroups, + ProviderUID: githubIDPResourceUID, + ProviderName: githubIDPName, + ProviderType: psession.ProviderTypeGitHub, + GitHub: &psession.GitHubSessionData{ + UpstreamAccessToken: githubUpstreamAccessToken, + }, + } ) +func happyGitHubUpstream() *oidctestutil.TestUpstreamGitHubIdentityProviderBuilder { + return oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder(). + WithName(githubIDPName). + WithResourceUID(githubIDPResourceUID). + WithClientID("some-client-id"). + WithScopes([]string{"these", "scopes", "appear", "unused"}) +} + func TestCallbackEndpoint(t *testing.T) { require.Len(t, happyDownstreamState, 8, "we expect fosite to allow 8 byte state params, so we want to test that boundary case") @@ -166,6 +196,11 @@ func TestCallbackEndpoint(t *testing.T) { RedirectURI: happyUpstreamRedirectURI, } + happyGitHubExchangeAuthcodeArgs := &oidctestutil.ExchangeAuthcodeArgs{ + Authcode: happyUpstreamAuthcode, + RedirectURI: happyUpstreamRedirectURI, + } + // Note that fosite puts the granted scopes as a param in the redirect URI even though the spec doesn't seem to require it happyDownstreamRedirectLocationRegexp := downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+username\+groups&state=` + happyDownstreamState @@ -206,6 +241,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamCustomSessionData *psession.CustomSessionData wantDownstreamAdditionalClaims map[string]interface{} wantOIDCAuthcodeExchangeCall *expectedOIDCAuthcodeExchange + wantGitHubAuthcodeExchangeCall *expectedGitHubAuthcodeExchange }{ { name: "GET with good state and cookie and successful upstream token exchange with response_mode=form_post returns 200 with HTML+JS form", @@ -1231,6 +1267,90 @@ func TestCallbackEndpoint(t *testing.T) { args: happyExchangeAndValidateTokensArgs, }, }, + { + name: "GitHub IDP: GET with good state and cookie and successful upstream token exchange returns 303 to downstream client callback", + idps: testidplister.NewUpstreamIDPListerBuilder().WithGitHub( + happyGitHubUpstream(). + WithAccessToken(githubUpstreamAccessToken). + WithUser(&upstreamprovider.GitHubUser{ + Username: githubUpstreamUsername, + Groups: githubUpstreamGroups, + DownstreamSubject: githubDownstreamSubject, + }). + Build()), + method: http.MethodGet, + path: newRequestPath().WithState( + happyUpstreamStateParam(). + WithUpstreamIDPName(githubIDPName). + WithUpstreamIDPType(idpdiscoveryv1alpha1.IDPTypeGitHub). + WithAuthorizeRequestParams( + happyDownstreamRequestParamsQuery.Encode(), + ).Build(t, happyStateCodec), + ).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusSeeOther, + wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, + wantBody: "", + wantDownstreamIDTokenSubject: githubDownstreamSubject, + wantDownstreamIDTokenUsername: githubUpstreamUsername, + wantDownstreamIDTokenGroups: githubUpstreamGroups, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamClientID: downstreamPinnipedClientID, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: happyDownstreamGitHubCustomSessionData, + wantGitHubAuthcodeExchangeCall: &expectedGitHubAuthcodeExchange{ + performedByUpstreamName: githubIDPName, + args: happyGitHubExchangeAuthcodeArgs, + }, + }, + { + name: "GitHub IDP: GET with good state and cookie and successful upstream token exchange with dynamic client returns 303 to downstream client callback, with dynamic client", + idps: testidplister.NewUpstreamIDPListerBuilder().WithGitHub( + happyGitHubUpstream(). + WithAccessToken(githubUpstreamAccessToken). + WithUser(&upstreamprovider.GitHubUser{ + Username: githubUpstreamUsername, + Groups: githubUpstreamGroups, + DownstreamSubject: githubDownstreamSubject, + }). + Build()), + method: http.MethodGet, + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + path: newRequestPath().WithState( + happyUpstreamStateParam(). + WithUpstreamIDPName(githubIDPName). + WithUpstreamIDPType(idpdiscoveryv1alpha1.IDPTypeGitHub). + WithAuthorizeRequestParams( + shallowCopyAndModifyQuery( + happyDownstreamRequestParamsQuery, + map[string]string{ + "client_id": downstreamDynamicClientID, + }, + ).Encode(), + ).Build(t, happyStateCodec), + ).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusSeeOther, + wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, + wantBody: "", + wantDownstreamIDTokenSubject: githubDownstreamSubject, + wantDownstreamIDTokenUsername: githubUpstreamUsername, + wantDownstreamIDTokenGroups: githubUpstreamGroups, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamClientID: downstreamDynamicClientID, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: happyDownstreamGitHubCustomSessionData, + wantGitHubAuthcodeExchangeCall: &expectedGitHubAuthcodeExchange{ + performedByUpstreamName: githubIDPName, + args: happyGitHubExchangeAuthcodeArgs, + }, + }, { name: "the OIDCIdentityProvider resource has been deleted", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(otherUpstreamOIDCIdentityProvider), @@ -1561,13 +1681,20 @@ func TestCallbackEndpoint(t *testing.T) { testutil.RequireSecurityHeadersWithFormPostPageCSPs(t, rsp) - if test.wantOIDCAuthcodeExchangeCall != nil { + switch { + case test.wantOIDCAuthcodeExchangeCall != nil: test.wantOIDCAuthcodeExchangeCall.args.Ctx = reqContext test.idps.RequireExactlyOneOIDCAuthcodeExchange(t, test.wantOIDCAuthcodeExchangeCall.performedByUpstreamName, test.wantOIDCAuthcodeExchangeCall.args, ) - } else { + case test.wantGitHubAuthcodeExchangeCall != nil: + test.wantGitHubAuthcodeExchangeCall.args.Ctx = reqContext + test.idps.RequireExactlyOneGitHubAuthcodeExchange(t, + test.wantGitHubAuthcodeExchangeCall.performedByUpstreamName, + test.wantGitHubAuthcodeExchangeCall.args, + ) + default: test.idps.RequireExactlyZeroAuthcodeExchanges(t) } From fef494949f77e68ea5ea5f9290f47a57aee57cb5 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Wed, 22 May 2024 10:58:38 -0700 Subject: [PATCH 14/25] implement upstream refresh for github --- .../resolved_github_provider.go | 37 +++- .../resolved_github_provider_test.go | 163 ++++++++++++++++++ test/integration/supervisor_login_test.go | 22 ++- 3 files changed, 216 insertions(+), 6 deletions(-) diff --git a/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider.go b/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider.go index e0bec1ed1..3f53538a9 100644 --- a/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider.go +++ b/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider.go @@ -9,6 +9,7 @@ import ( "fmt" "net/http" + "github.com/ory/fosite" "golang.org/x/oauth2" "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1" @@ -133,13 +134,41 @@ func (p *FederationDomainResolvedGitHubIdentityProvider) LoginFromCallback( } func (p *FederationDomainResolvedGitHubIdentityProvider) UpstreamRefresh( - _ context.Context, + ctx context.Context, identity *resolvedprovider.Identity, ) (*resolvedprovider.RefreshedIdentity, error) { - // TODO: actually implement refresh. this is just a placeholder that will make refresh always succeed. + githubSessionData, ok := identity.IDPSpecificSessionData.(*psession.GitHubSessionData) + if !ok { + // This should not really happen. + return nil, p.refreshErr(errors.New("wrong data type found for IDPSpecificSessionData")) + } + if len(githubSessionData.UpstreamAccessToken) == 0 { + // This should not really happen. + return nil, p.refreshErr(errors.New("session is missing GitHub access token")) + } + + // Get the user's GitHub identity and groups again using the cached access token. + refreshedUserInfo, err := p.Provider.GetUser(ctx, githubSessionData.UpstreamAccessToken, p.GetDisplayName()) + if err != nil { + return nil, p.refreshErr(fmt.Errorf("failed to get user info from GitHub API: %w", err)) + } + + if refreshedUserInfo.DownstreamSubject != identity.DownstreamSubject { + // The user's upstream identity changed since the initial login in a surprising way. + return nil, p.refreshErr(fmt.Errorf("user's calculated downstream subject at initial login was %q but now is %q", + identity.DownstreamSubject, refreshedUserInfo.DownstreamSubject)) + } + return &resolvedprovider.RefreshedIdentity{ - UpstreamUsername: identity.UpstreamUsername, - UpstreamGroups: identity.UpstreamGroups, + UpstreamUsername: refreshedUserInfo.Username, + UpstreamGroups: refreshedUserInfo.Groups, IDPSpecificSessionData: nil, // nil means that no update to the GitHub-specific portion of the session data is required }, nil } + +func (p *FederationDomainResolvedGitHubIdentityProvider) refreshErr(err error) *fosite.RFC6749Error { + return resolvedprovider.ErrUpstreamRefreshError(). + WithHint("Upstream refresh failed."). + WithTrace(err). + WithDebugf("provider name: %q, provider type: %q", p.Provider.GetName(), p.GetSessionProviderType()) +} diff --git a/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider_test.go b/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider_test.go index 5a2b63b6e..824b2bead 100644 --- a/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider_test.go +++ b/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider_test.go @@ -6,8 +6,10 @@ package resolvedgithub import ( "context" "errors" + "net/http" "testing" + "github.com/ory/fosite" "github.com/stretchr/testify/require" "golang.org/x/oauth2" @@ -248,3 +250,164 @@ func TestLoginFromCallback(t *testing.T) { }) } } + +func TestUpstreamRefresh(t *testing.T) { + uniqueCtx := context.WithValue(context.Background(), "some-unique-key", "some-value") //nolint:staticcheck // okay to use string key for test + + tests := []struct { + name string + provider *oidctestutil.TestUpstreamGitHubIdentityProvider + idpDisplayName string + identity *resolvedprovider.Identity + + wantGetUserCall bool + wantGetUserArgs *oidctestutil.GetUserArgs + wantRefreshedIdentity *resolvedprovider.RefreshedIdentity + wantWrappedErr string + }{ + { + name: "happy path", + provider: oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder(). + WithUser(&upstreamprovider.GitHubUser{ + Username: "refreshed-username", + Groups: []string{"refreshed-group1", "refreshed-group2"}, + DownstreamSubject: "https://fake-downstream-subject", + }). + Build(), + identity: &resolvedprovider.Identity{ + UpstreamUsername: "initial-username", + UpstreamGroups: []string{"initial-group1", "initial-group2"}, + DownstreamSubject: "https://fake-downstream-subject", + IDPSpecificSessionData: &psession.GitHubSessionData{UpstreamAccessToken: "fake-access-token"}, + }, + idpDisplayName: "fake-display-name", + wantGetUserCall: true, + wantGetUserArgs: &oidctestutil.GetUserArgs{ + Ctx: uniqueCtx, + AccessToken: "fake-access-token", + IDPDisplayName: "fake-display-name", + }, + wantRefreshedIdentity: &resolvedprovider.RefreshedIdentity{ + UpstreamUsername: "refreshed-username", + UpstreamGroups: []string{"refreshed-group1", "refreshed-group2"}, + IDPSpecificSessionData: nil, + }, + }, + { + name: "error while getting user info", + provider: oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder(). + WithName("fake-provider-name"). + WithGetUserError(errors.New("fake user info error")). + Build(), + identity: &resolvedprovider.Identity{ + UpstreamUsername: "initial-username", + UpstreamGroups: []string{"initial-group1", "initial-group2"}, + DownstreamSubject: "https://fake-downstream-subject", + IDPSpecificSessionData: &psession.GitHubSessionData{UpstreamAccessToken: "fake-access-token"}, + }, + idpDisplayName: "fake-display-name", + wantGetUserCall: true, + wantGetUserArgs: &oidctestutil.GetUserArgs{ + Ctx: uniqueCtx, + AccessToken: "fake-access-token", + IDPDisplayName: "fake-display-name", + }, + wantRefreshedIdentity: nil, + wantWrappedErr: "failed to get user info from GitHub API: fake user info error", + }, + { + name: "wrong session data type, which should not really happen", + provider: oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder(). + WithName("fake-provider-name"). + Build(), + identity: &resolvedprovider.Identity{ + UpstreamUsername: "initial-username", + UpstreamGroups: []string{"initial-group1", "initial-group2"}, + DownstreamSubject: "https://fake-downstream-subject", + IDPSpecificSessionData: &psession.LDAPSessionData{}, // wrong type + }, + idpDisplayName: "fake-display-name", + wantGetUserCall: false, + wantRefreshedIdentity: nil, + wantWrappedErr: "wrong data type found for IDPSpecificSessionData", + }, + { + name: "session is missing github access token, which should not really happen", + provider: oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder(). + WithName("fake-provider-name"). + Build(), + identity: &resolvedprovider.Identity{ + UpstreamUsername: "initial-username", + UpstreamGroups: []string{"initial-group1", "initial-group2"}, + DownstreamSubject: "https://fake-downstream-subject", + IDPSpecificSessionData: &psession.GitHubSessionData{UpstreamAccessToken: ""}, // missing token + }, + idpDisplayName: "fake-display-name", + wantGetUserCall: false, + wantRefreshedIdentity: nil, + wantWrappedErr: "session is missing GitHub access token", + }, + { + name: "users downstream subject changes based on an unexpected change in the upstream identity", + provider: oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder(). + WithName("fake-provider-name"). + WithUser(&upstreamprovider.GitHubUser{ + Username: "refreshed-username", + Groups: []string{"refreshed-group1", "refreshed-group2"}, + DownstreamSubject: "https://unexpected-different-downstream-subject", // unexpected change in calculated subject during refresh + }). + Build(), + identity: &resolvedprovider.Identity{ + UpstreamUsername: "initial-username", + UpstreamGroups: []string{"initial-group1", "initial-group2"}, + DownstreamSubject: "https://fake-downstream-subject", + IDPSpecificSessionData: &psession.GitHubSessionData{UpstreamAccessToken: "fake-access-token"}, + }, + idpDisplayName: "fake-display-name", + wantGetUserCall: true, + wantGetUserArgs: &oidctestutil.GetUserArgs{ + Ctx: uniqueCtx, + AccessToken: "fake-access-token", + IDPDisplayName: "fake-display-name", + }, + wantRefreshedIdentity: nil, + wantWrappedErr: `user's calculated downstream subject at initial login was "https://fake-downstream-subject" ` + + `but now is "https://unexpected-different-downstream-subject"`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + subject := FederationDomainResolvedGitHubIdentityProvider{ + DisplayName: test.idpDisplayName, + Provider: test.provider, + SessionProviderType: psession.ProviderTypeGitHub, + Transforms: transformtestutil.NewRejectAllAuthPipeline(t), + } + + refreshedIdentity, err := subject.UpstreamRefresh(uniqueCtx, test.identity) + + if test.wantGetUserCall { + require.Equal(t, 1, test.provider.GetUserCallCount()) + require.Equal(t, test.wantGetUserArgs, test.provider.GetUserArgs(0)) + } else { + require.Zero(t, test.provider.GetUserCallCount()) + } + + if test.wantWrappedErr == "" { + require.NoError(t, err) + } else { + require.NotNil(t, err, "expected to get an error but did not get one") + errAsFositeErr, ok := err.(*fosite.RFC6749Error) + require.True(t, ok) + require.EqualError(t, errAsFositeErr.Unwrap(), test.wantWrappedErr) + require.Equal(t, "error", errAsFositeErr.ErrorField) + require.Equal(t, "Error during upstream refresh.", errAsFositeErr.DescriptionField) + require.Equal(t, http.StatusUnauthorized, errAsFositeErr.CodeField) + require.Equal(t, `provider name: "fake-provider-name", provider type: "github"`, errAsFositeErr.DebugField) + } + + require.Equal(t, test.wantRefreshedIdentity, refreshedIdentity) + }) + } +} diff --git a/test/integration/supervisor_login_test.go b/test/integration/supervisor_login_test.go index 5ab67d5af..d17c3ee49 100644 --- a/test/integration/supervisor_login_test.go +++ b/test/integration/supervisor_login_test.go @@ -516,7 +516,24 @@ func TestSupervisorLogin_Browser(t *testing.T) { createIDP: func(t *testing.T) string { return testlib.CreateTestGitHubIdentityProvider(t, basicGitHubIdentityProviderSpec(), idpv1alpha1.GitHubPhaseReady).Name }, - requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowGitHub, + 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) + "$" @@ -572,7 +589,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { wantDownstreamIDTokenGroups: env.SupervisorUpstreamGithub.TestUserExpectedTeamNames, }, { - name: "github when user does not belong to any of the allowed orgs, should fail to exchange downstream authcode", + name: "github when user does not belong to any of the allowed orgs, should fail at the Supervisor callback endpoint", maybeSkip: skipGitHubTests, createIDP: func(t *testing.T) string { spec := basicGitHubIdentityProviderSpec() @@ -2836,6 +2853,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") } } From bb1737daec5e0bf0960caee946cc702c6b6b19e5 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Wed, 22 May 2024 10:59:14 -0700 Subject: [PATCH 15/25] slow down github integration tests to avoid OTP reuse errors from github --- test/testlib/browsertest/browsertest.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/test/testlib/browsertest/browsertest.go b/test/testlib/browsertest/browsertest.go index 33b0415c1..9ace9fc4b 100644 --- a/test/testlib/browsertest/browsertest.go +++ b/test/testlib/browsertest/browsertest.go @@ -396,6 +396,12 @@ func handleGithubOTPLoginPage(t *testing.T, b *Browser, upstream testlib.TestGit 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. + otpSleepSeconds := 25 + 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") @@ -463,8 +469,11 @@ func handleOccasionalGithubLoginPage(t *testing.T, b *Browser, upstream testlib. 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. - t.Log("sleeping before trying to generate and use a new GitHub OTP code") - time.Sleep(5 * time.Second) // 5 seconds may not be enough time, but if we get the error again then we can try 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 From 65682aa60d9ba5f06b5f0ac71122173bf8e08fc3 Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Wed, 22 May 2024 23:04:15 -0500 Subject: [PATCH 16/25] Add sample unit test for GitHub in token_handler_test.go --- .../endpoints/token/token_handler_test.go | 72 ++++++++++++++++++- .../testutil/testidplister/testidplister.go | 11 ++- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/internal/federationdomain/endpoints/token/token_handler_test.go b/internal/federationdomain/endpoints/token/token_handler_test.go index 799990fdd..2992ccc57 100644 --- a/internal/federationdomain/endpoints/token/token_handler_test.go +++ b/internal/federationdomain/endpoints/token/token_handler_test.go @@ -51,6 +51,7 @@ import ( "go.pinniped.dev/internal/federationdomain/oidc" "go.pinniped.dev/internal/federationdomain/oidcclientvalidator" "go.pinniped.dev/internal/federationdomain/storage" + "go.pinniped.dev/internal/federationdomain/upstreamprovider" "go.pinniped.dev/internal/fositestorage/accesstoken" "go.pinniped.dev/internal/fositestorage/authorizationcode" "go.pinniped.dev/internal/fositestorage/openidconnect" @@ -1828,6 +1829,11 @@ func TestRefreshGrant(t *testing.T) { activeDirectoryUpstreamType = "activedirectory" activeDirectoryUpstreamDN = "some-ad-user-dn" + githubUpstreamName = "some-github-idp" + githubUpstreamResourceUID = "github-resource-uid" + githubUpstreamType = "github" + githubUpstreamAccessToken = "some-opaque-access-token-from-github" + transformationUsernamePrefix = "username_prefix:" transformationGroupsPrefix = "groups_prefix:" ) @@ -1843,6 +1849,18 @@ func TestRefreshGrant(t *testing.T) { WithResourceUID(oidcUpstreamResourceUID) } + upstreamGitHubIdentityProviderBuilder := func() *oidctestutil.TestUpstreamGitHubIdentityProviderBuilder { + goodGitHubUser := &upstreamprovider.GitHubUser{ + Username: goodUsername, + Groups: goodGroups, + DownstreamSubject: goodSubject, + } + return oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder(). + WithName(githubUpstreamName). + WithResourceUID(githubUpstreamResourceUID). + WithUser(goodGitHubUser) + } + initialUpstreamOIDCRefreshTokenCustomSessionData := func() *psession.CustomSessionData { return &psession.CustomSessionData{ Username: goodUsername, @@ -1859,6 +1877,20 @@ func TestRefreshGrant(t *testing.T) { } } + initialUpstreamGitHubCustomSessionData := func() *psession.CustomSessionData { + return &psession.CustomSessionData{ + Username: goodUsername, + UpstreamUsername: goodUsername, + UpstreamGroups: goodGroups, + ProviderName: githubUpstreamName, + ProviderUID: githubUpstreamResourceUID, + ProviderType: githubUpstreamType, + GitHub: &psession.GitHubSessionData{ + UpstreamAccessToken: githubUpstreamAccessToken, + }, + } + } + initialUpstreamOIDCRefreshTokenCustomSessionDataWithUsername := func(downstreamUsername string) *psession.CustomSessionData { customSessionData := initialUpstreamOIDCRefreshTokenCustomSessionData() customSessionData.Username = downstreamUsername @@ -1903,6 +1935,12 @@ func TestRefreshGrant(t *testing.T) { } } + happyGitHubUpstreamRefreshCall := func() *expectedUpstreamRefresh { + return &expectedUpstreamRefresh{ + performedByUpstreamName: githubUpstreamName, + } + } + happyLDAPUpstreamRefreshCall := func() *expectedUpstreamRefresh { return &expectedUpstreamRefresh{ performedByUpstreamName: ldapUpstreamName, @@ -1995,6 +2033,15 @@ func TestRefreshGrant(t *testing.T) { return want } + happyRefreshTokenResponseForGitHubAndOfflineAccessWithUsernameAndGroups := func(wantCustomSessionDataStored *psession.CustomSessionData, wantDownstreamUsername string, wantDownstreamGroups []string) tokenEndpointResponseExpectedValues { + // Should always have some custom session data stored. The other expectations happens to be the + // same as the same values as the authcode exchange case. + want := happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccessWithUsernameAndGroups(wantCustomSessionDataStored, wantDownstreamUsername, wantDownstreamGroups) + // Should always try to perform an upstream refresh. + want.wantUpstreamRefreshCall = happyGitHubUpstreamRefreshCall() + return want + } + happyRefreshTokenResponseForOpenIDAndOfflineAccessWithAdditionalClaims := func(wantCustomSessionDataStored *psession.CustomSessionData, expectToValidateToken *oauth2.Token, wantAdditionalClaims map[string]interface{}) tokenEndpointResponseExpectedValues { want := happyRefreshTokenResponseForOpenIDAndOfflineAccess(wantCustomSessionDataStored, expectToValidateToken) want.wantAdditionalClaims = wantAdditionalClaims @@ -2151,6 +2198,27 @@ func TestRefreshGrant(t *testing.T) { ), }, }, + { + name: "happy path refresh grant with GitHub upstream", + idps: testidplister.NewUpstreamIDPListerBuilder().WithGitHub( + upstreamGitHubIdentityProviderBuilder().Build()), + authcodeExchange: authcodeExchangeInputs{ + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access username groups") }, + customSessionData: initialUpstreamGitHubCustomSessionData(), + want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccessWithUsernameAndGroups( + initialUpstreamGitHubCustomSessionData(), + goodUsername, + goodGroups, + ), + }, + refreshRequest: refreshRequestInputs{ + want: happyRefreshTokenResponseForGitHubAndOfflineAccessWithUsernameAndGroups( + initialUpstreamGitHubCustomSessionData(), + goodUsername, + goodGroups, + ), + }, + }, { name: "happy path refresh grant with OIDC upstream with identity transformations which modify the username and group names when the upstream refresh does not return new username or groups then it reruns the transformations on the old upstream username and groups", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC( @@ -4568,7 +4636,9 @@ func TestRefreshGrant(t *testing.T) { // Test that we did or did not make a call to the upstream OIDC provider interface to perform a token refresh. if test.refreshRequest.want.wantUpstreamRefreshCall != nil { - test.refreshRequest.want.wantUpstreamRefreshCall.args.Ctx = reqContext + if test.authcodeExchange.customSessionData.ProviderType != "github" { + test.refreshRequest.want.wantUpstreamRefreshCall.args.Ctx = reqContext + } test.idps.RequireExactlyOneCallToPerformRefresh(t, test.refreshRequest.want.wantUpstreamRefreshCall.performedByUpstreamName, test.refreshRequest.want.wantUpstreamRefreshCall.args, diff --git a/internal/testutil/testidplister/testidplister.go b/internal/testutil/testidplister/testidplister.go index d7fed23eb..2dded4809 100644 --- a/internal/testutil/testidplister/testidplister.go +++ b/internal/testutil/testidplister/testidplister.go @@ -363,7 +363,16 @@ func (b *UpstreamIDPListerBuilder) RequireExactlyOneCallToPerformRefresh( actualArgs = upstreamAD.PerformRefreshArgs(0) } } - // TODO: probably add GitHub loop once we flesh out the structs + for _, upstream := range b.upstreamGitHubIdentityProviders { + // Remember that GitHub does not have a traditional PerformRefresh function. + // GitHub calls GetUser during both the original authcode exchange and the refresh. + callCountOnThisUpstream := upstream.GetUserCallCount() + actualCallCountAcrossAllUpstreams += callCountOnThisUpstream + if callCountOnThisUpstream == 1 { + actualNameOfUpstreamWhichMadeCall = upstream.Name + actualArgs = nil + } + } require.Equal(t, 1, actualCallCountAcrossAllUpstreams, "should have been exactly one call to PerformRefresh() by all upstreams", ) From 02ffff01d5eb2ad1b1d42db127b896bf3ba22e3f Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Thu, 23 May 2024 12:32:18 -0500 Subject: [PATCH 17/25] fix lint --- internal/federationdomain/endpoints/token/token_handler_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/federationdomain/endpoints/token/token_handler_test.go b/internal/federationdomain/endpoints/token/token_handler_test.go index 2992ccc57..5d11690c1 100644 --- a/internal/federationdomain/endpoints/token/token_handler_test.go +++ b/internal/federationdomain/endpoints/token/token_handler_test.go @@ -1832,7 +1832,7 @@ func TestRefreshGrant(t *testing.T) { githubUpstreamName = "some-github-idp" githubUpstreamResourceUID = "github-resource-uid" githubUpstreamType = "github" - githubUpstreamAccessToken = "some-opaque-access-token-from-github" + githubUpstreamAccessToken = "some-opaque-access-token-from-github" //nolint:gosec // this is not a credential transformationUsernamePrefix = "username_prefix:" transformationGroupsPrefix = "groups_prefix:" From f323690049b7ac11b40c4f7441c176ce7257422c Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 23 May 2024 13:35:31 -0700 Subject: [PATCH 18/25] refactor upstream refresh test helpers to be more specific to IDP type --- .../active_directory_upstream_watcher.go | 10 +- .../active_directory_upstream_watcher_test.go | 40 +-- .../endpoints/token/token_handler_test.go | 231 +++++++++++------- .../resolvedldap/resolved_ldap_provider.go | 2 +- .../upstreamprovider/upsteam_provider.go | 6 +- .../testutil/oidctestutil/testldapprovider.go | 25 +- .../testutil/oidctestutil/testoidcprovider.go | 21 +- .../testutil/testidplister/testidplister.go | 136 +++++++---- internal/upstreamldap/upstreamldap.go | 4 +- internal/upstreamldap/upstreamldap_test.go | 18 +- 10 files changed, 295 insertions(+), 198 deletions(-) diff --git a/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher.go b/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher.go index 42aa65097..fa5465cc9 100644 --- a/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher.go +++ b/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher.go @@ -344,7 +344,7 @@ func (c *activeDirectoryWatcherController) validateUpstream(ctx context.Context, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){ "objectGUID": microsoftUUIDFromBinaryAttr("objectGUID"), }, - RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{ pwdLastSetAttribute: attributeUnchangedSinceLogin(pwdLastSetAttribute), userAccountControlAttribute: validUserAccountControl, userAccountControlComputedAttribute: validComputedUserAccountControl, @@ -445,7 +445,7 @@ func getDomainFromDistinguishedName(distinguishedName string) (string, error) { } //nolint:gochecknoglobals // this needs to be a global variable so that tests can check pointer equality -var validUserAccountControl = func(entry *ldap.Entry, _ upstreamprovider.RefreshAttributes) error { +var validUserAccountControl = func(entry *ldap.Entry, _ upstreamprovider.LDAPRefreshAttributes) error { userAccountControl, err := strconv.Atoi(entry.GetAttributeValue(userAccountControlAttribute)) if err != nil { return err @@ -459,7 +459,7 @@ var validUserAccountControl = func(entry *ldap.Entry, _ upstreamprovider.Refresh } //nolint:gochecknoglobals // this needs to be a global variable so that tests can check pointer equality -var validComputedUserAccountControl = func(entry *ldap.Entry, _ upstreamprovider.RefreshAttributes) error { +var validComputedUserAccountControl = func(entry *ldap.Entry, _ upstreamprovider.LDAPRefreshAttributes) error { userAccountControl, err := strconv.Atoi(entry.GetAttributeValue(userAccountControlComputedAttribute)) if err != nil { return err @@ -473,8 +473,8 @@ var validComputedUserAccountControl = func(entry *ldap.Entry, _ upstreamprovider } //nolint:gochecknoglobals // this needs to be a global variable so that tests can check pointer equality -var attributeUnchangedSinceLogin = func(attribute string) func(*ldap.Entry, upstreamprovider.RefreshAttributes) error { - return func(entry *ldap.Entry, storedAttributes upstreamprovider.RefreshAttributes) error { +var attributeUnchangedSinceLogin = func(attribute string) func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error { + return func(entry *ldap.Entry, storedAttributes upstreamprovider.LDAPRefreshAttributes) error { prevAttributeValue := storedAttributes.AdditionalAttributes[attribute] newValues := entry.GetRawAttributeValues(attribute) diff --git a/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher_test.go b/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher_test.go index ed12e87d4..dd148e670 100644 --- a/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher_test.go +++ b/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher_test.go @@ -228,7 +228,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupSearchNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")}, - RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{ "pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"), "userAccountControl": validUserAccountControl, "msDS-User-Account-Control-Computed": validComputedUserAccountControl, @@ -571,7 +571,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupSearchNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")}, - RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{ "pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"), "userAccountControl": validUserAccountControl, "msDS-User-Account-Control-Computed": validComputedUserAccountControl, @@ -641,7 +641,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: "sAMAccountName", }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")}, - RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{ "pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"), "userAccountControl": validUserAccountControl, "msDS-User-Account-Control-Computed": validComputedUserAccountControl, @@ -714,7 +714,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupSearchNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")}, - RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{ "pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"), "userAccountControl": validUserAccountControl, "msDS-User-Account-Control-Computed": validComputedUserAccountControl, @@ -794,7 +794,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupSearchNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")}, - RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{ "pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"), "userAccountControl": validUserAccountControl, "msDS-User-Account-Control-Computed": validComputedUserAccountControl, @@ -858,7 +858,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupSearchNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")}, - RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{ "pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"), "userAccountControl": validUserAccountControl, "msDS-User-Account-Control-Computed": validComputedUserAccountControl, @@ -1009,7 +1009,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupSearchNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")}, - RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{ "pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"), "userAccountControl": validUserAccountControl, "msDS-User-Account-Control-Computed": validComputedUserAccountControl, @@ -1159,7 +1159,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupSearchNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")}, - RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{ "pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"), "userAccountControl": validUserAccountControl, "msDS-User-Account-Control-Computed": validComputedUserAccountControl, @@ -1231,7 +1231,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupSearchNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")}, - RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{ "pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"), "userAccountControl": validUserAccountControl, "msDS-User-Account-Control-Computed": validComputedUserAccountControl, @@ -1498,7 +1498,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")}, GroupAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"sAMAccountName": groupSAMAccountNameWithDomainSuffix}, - RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{ "pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"), "userAccountControl": validUserAccountControl, "msDS-User-Account-Control-Computed": validComputedUserAccountControl, @@ -1558,7 +1558,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupSearchNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")}, - RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{ "pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"), "userAccountControl": validUserAccountControl, "msDS-User-Account-Control-Computed": validComputedUserAccountControl, @@ -1622,7 +1622,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupSearchNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")}, - RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{ "pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"), "userAccountControl": validUserAccountControl, "msDS-User-Account-Control-Computed": validComputedUserAccountControl, @@ -1686,7 +1686,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupSearchNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")}, - RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{ "pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"), "userAccountControl": validUserAccountControl, "msDS-User-Account-Control-Computed": validComputedUserAccountControl, @@ -1898,7 +1898,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupSearchNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")}, - RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{ "pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"), "userAccountControl": validUserAccountControl, "msDS-User-Account-Control-Computed": validComputedUserAccountControl, @@ -1961,7 +1961,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { SkipGroupRefresh: true, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")}, - RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{ "pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"), "userAccountControl": validUserAccountControl, "msDS-User-Account-Control-Computed": validComputedUserAccountControl, @@ -2102,8 +2102,8 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { expectedRefreshAttributeChecks := copyOfExpectedValueForResultingCache.RefreshAttributeChecks actualRefreshAttributeChecks := actualConfig.RefreshAttributeChecks - copyOfExpectedValueForResultingCache.RefreshAttributeChecks = map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{} - actualConfig.RefreshAttributeChecks = map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{} + copyOfExpectedValueForResultingCache.RefreshAttributeChecks = map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{} + actualConfig.RefreshAttributeChecks = map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{} require.Equal(t, len(expectedRefreshAttributeChecks), len(actualRefreshAttributeChecks)) for k, v := range expectedRefreshAttributeChecks { require.NotNil(t, actualRefreshAttributeChecks[k]) @@ -2352,7 +2352,7 @@ func TestValidUserAccountControl(t *testing.T) { for _, test := range tests { tt := test t.Run(tt.name, func(t *testing.T) { - err := validUserAccountControl(tt.entry, upstreamprovider.RefreshAttributes{}) + err := validUserAccountControl(tt.entry, upstreamprovider.LDAPRefreshAttributes{}) if tt.wantErr != "" { require.Error(t, err) @@ -2413,7 +2413,7 @@ func TestValidComputedUserAccountControl(t *testing.T) { for _, test := range tests { tt := test t.Run(tt.name, func(t *testing.T) { - err := validComputedUserAccountControl(tt.entry, upstreamprovider.RefreshAttributes{}) + err := validComputedUserAccountControl(tt.entry, upstreamprovider.LDAPRefreshAttributes{}) if tt.wantErr != "" { require.Error(t, err) @@ -2488,7 +2488,7 @@ func TestAttributeUnchangedSinceLogin(t *testing.T) { tt := test t.Run(tt.name, func(t *testing.T) { initialValRawEncoded := base64.RawURLEncoding.EncodeToString([]byte(initialVal)) - err := attributeUnchangedSinceLogin(attributeName)(tt.entry, upstreamprovider.RefreshAttributes{AdditionalAttributes: map[string]string{attributeName: initialValRawEncoded}}) + err := attributeUnchangedSinceLogin(attributeName)(tt.entry, upstreamprovider.LDAPRefreshAttributes{AdditionalAttributes: map[string]string{attributeName: initialValRawEncoded}}) if tt.wantErr != "" { require.Error(t, err) require.Equal(t, tt.wantErr, err.Error()) diff --git a/internal/federationdomain/endpoints/token/token_handler_test.go b/internal/federationdomain/endpoints/token/token_handler_test.go index 5d11690c1..13be648f4 100644 --- a/internal/federationdomain/endpoints/token/token_handler_test.go +++ b/internal/federationdomain/endpoints/token/token_handler_test.go @@ -269,30 +269,43 @@ var ( } ) -type expectedUpstreamRefresh struct { +type expectedOIDCUpstreamRefresh struct { performedByUpstreamName string - args *oidctestutil.PerformRefreshArgs + args *oidctestutil.PerformOIDCRefreshArgs } -type expectedUpstreamValidateTokens struct { +type expectedLDAPUpstreamRefresh struct { + performedByUpstreamName string + args *oidctestutil.PerformLDAPRefreshArgs +} + +type expectedGithubUpstreamRefresh struct { + performedByUpstreamName string + args *oidctestutil.GetUserArgs +} + +type expectedOIDCUpstreamValidateTokens struct { performedByUpstreamName string args *oidctestutil.ValidateTokenAndMergeWithUserInfoArgs } type tokenEndpointResponseExpectedValues struct { - wantStatus int - wantSuccessBodyFields []string - wantErrorResponseBody string - wantClientID string - wantRequestedScopes []string - wantGrantedScopes []string - wantUsername string - wantGroups []string - wantUpstreamRefreshCall *expectedUpstreamRefresh - wantUpstreamOIDCValidateTokenCall *expectedUpstreamValidateTokens - wantCustomSessionDataStored *psession.CustomSessionData - wantWarnings []RecordedWarning - wantAdditionalClaims map[string]interface{} + wantStatus int + wantSuccessBodyFields []string + wantErrorResponseBody string + wantClientID string + wantRequestedScopes []string + wantGrantedScopes []string + wantUsername string + wantGroups []string + wantOIDCUpstreamRefreshCall *expectedOIDCUpstreamRefresh + wantLDAPUpstreamRefreshCall *expectedLDAPUpstreamRefresh + wantActiveDirectoryUpstreamRefreshCall *expectedLDAPUpstreamRefresh + wantGithubUpstreamRefreshCall *expectedGithubUpstreamRefresh + wantUpstreamOIDCValidateTokenCall *expectedOIDCUpstreamValidateTokens + wantCustomSessionDataStored *psession.CustomSessionData + wantWarnings []RecordedWarning + wantAdditionalClaims map[string]interface{} // The expected lifetime of the ID tokens issued by authcode exchange and refresh, but not token exchange. // When zero, will assume that the test wants the default value for ID token lifetime. wantIDTokenLifetimeSeconds int @@ -1925,48 +1938,63 @@ func TestRefreshGrant(t *testing.T) { return sessionData } - happyOIDCUpstreamRefreshCall := func() *expectedUpstreamRefresh { - return &expectedUpstreamRefresh{ + happyOIDCUpstreamRefreshCall := func() *expectedOIDCUpstreamRefresh { + return &expectedOIDCUpstreamRefresh{ performedByUpstreamName: oidcUpstreamName, - args: &oidctestutil.PerformRefreshArgs{ + args: &oidctestutil.PerformOIDCRefreshArgs{ Ctx: nil, // this will be filled in with the actual request context by the test below RefreshToken: oidcUpstreamInitialRefreshToken, }, } } - happyGitHubUpstreamRefreshCall := func() *expectedUpstreamRefresh { - return &expectedUpstreamRefresh{ + happyGitHubUpstreamRefreshCall := func() *expectedGithubUpstreamRefresh { + return &expectedGithubUpstreamRefresh{ performedByUpstreamName: githubUpstreamName, + args: &oidctestutil.GetUserArgs{ + Ctx: nil, // this will be filled in with the actual request context by the test below + AccessToken: githubUpstreamAccessToken, + IDPDisplayName: githubUpstreamName, + }, } } - happyLDAPUpstreamRefreshCall := func() *expectedUpstreamRefresh { - return &expectedUpstreamRefresh{ + happyLDAPUpstreamRefreshCall := func() *expectedLDAPUpstreamRefresh { + return &expectedLDAPUpstreamRefresh{ performedByUpstreamName: ldapUpstreamName, - args: &oidctestutil.PerformRefreshArgs{ - Ctx: nil, - DN: ldapUpstreamDN, - ExpectedSubject: goodSubject, - ExpectedUsername: goodUsername, + args: &oidctestutil.PerformLDAPRefreshArgs{ + Ctx: nil, // this will be filled in with the actual request context by the test below + StoredRefreshAttributes: upstreamprovider.LDAPRefreshAttributes{ + Username: goodUsername, + Subject: goodSubject, + DN: ldapUpstreamDN, + Groups: goodGroups, + AdditionalAttributes: nil, + }, + IDPDisplayName: ldapUpstreamName, }, } } - happyActiveDirectoryUpstreamRefreshCall := func() *expectedUpstreamRefresh { - return &expectedUpstreamRefresh{ + happyActiveDirectoryUpstreamRefreshCall := func() *expectedLDAPUpstreamRefresh { + return &expectedLDAPUpstreamRefresh{ performedByUpstreamName: activeDirectoryUpstreamName, - args: &oidctestutil.PerformRefreshArgs{ - Ctx: nil, - DN: activeDirectoryUpstreamDN, - ExpectedSubject: goodSubject, - ExpectedUsername: goodUsername, + args: &oidctestutil.PerformLDAPRefreshArgs{ + Ctx: nil, // this will be filled in with the actual request context by the test below + StoredRefreshAttributes: upstreamprovider.LDAPRefreshAttributes{ + Username: goodUsername, + Subject: goodSubject, + DN: activeDirectoryUpstreamDN, + Groups: goodGroups, + AdditionalAttributes: nil, + }, + IDPDisplayName: activeDirectoryUpstreamName, }, } } - happyUpstreamValidateTokenCall := func(expectedTokens *oauth2.Token, requireIDToken bool) *expectedUpstreamValidateTokens { - return &expectedUpstreamValidateTokens{ + happyUpstreamValidateTokenCall := func(expectedTokens *oauth2.Token, requireIDToken bool) *expectedOIDCUpstreamValidateTokens { + return &expectedOIDCUpstreamValidateTokens{ performedByUpstreamName: oidcUpstreamName, args: &oidctestutil.ValidateTokenAndMergeWithUserInfoArgs{ Ctx: nil, // this will be filled in with the actual request context by the test below @@ -2014,7 +2042,7 @@ func TestRefreshGrant(t *testing.T) { // same as the same values as the authcode exchange case. want := happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(wantCustomSessionDataStored) // Should always try to perform an upstream refresh. - want.wantUpstreamRefreshCall = happyOIDCUpstreamRefreshCall() + want.wantOIDCUpstreamRefreshCall = happyOIDCUpstreamRefreshCall() if expectToValidateToken != nil { want.wantUpstreamOIDCValidateTokenCall = happyUpstreamValidateTokenCall(expectToValidateToken, true) } @@ -2026,7 +2054,7 @@ func TestRefreshGrant(t *testing.T) { // same as the same values as the authcode exchange case. want := happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccessWithUsernameAndGroups(wantCustomSessionDataStored, wantDownstreamUsername, wantDownstreamGroups) // Should always try to perform an upstream refresh. - want.wantUpstreamRefreshCall = happyOIDCUpstreamRefreshCall() + want.wantOIDCUpstreamRefreshCall = happyOIDCUpstreamRefreshCall() if expectToValidateToken != nil { want.wantUpstreamOIDCValidateTokenCall = happyUpstreamValidateTokenCall(expectToValidateToken, true) } @@ -2038,7 +2066,7 @@ func TestRefreshGrant(t *testing.T) { // same as the same values as the authcode exchange case. want := happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccessWithUsernameAndGroups(wantCustomSessionDataStored, wantDownstreamUsername, wantDownstreamGroups) // Should always try to perform an upstream refresh. - want.wantUpstreamRefreshCall = happyGitHubUpstreamRefreshCall() + want.wantGithubUpstreamRefreshCall = happyGitHubUpstreamRefreshCall() return want } @@ -2050,19 +2078,19 @@ func TestRefreshGrant(t *testing.T) { happyRefreshTokenResponseForLDAP := func(wantCustomSessionDataStored *psession.CustomSessionData) tokenEndpointResponseExpectedValues { want := happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(wantCustomSessionDataStored) - want.wantUpstreamRefreshCall = happyLDAPUpstreamRefreshCall() + want.wantLDAPUpstreamRefreshCall = happyLDAPUpstreamRefreshCall() return want } happyRefreshTokenResponseForLDAPWithUsernameAndGroups := func(wantCustomSessionDataStored *psession.CustomSessionData, wantDownstreamUsername string, wantDownstreamGroups []string) tokenEndpointResponseExpectedValues { want := happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccessWithUsernameAndGroups(wantCustomSessionDataStored, wantDownstreamUsername, wantDownstreamGroups) - want.wantUpstreamRefreshCall = happyLDAPUpstreamRefreshCall() + want.wantLDAPUpstreamRefreshCall = happyLDAPUpstreamRefreshCall() return want } happyRefreshTokenResponseForActiveDirectory := func(wantCustomSessionDataStored *psession.CustomSessionData) tokenEndpointResponseExpectedValues { want := happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(wantCustomSessionDataStored) - want.wantUpstreamRefreshCall = happyActiveDirectoryUpstreamRefreshCall() + want.wantActiveDirectoryUpstreamRefreshCall = happyActiveDirectoryUpstreamRefreshCall() return want } @@ -2252,7 +2280,7 @@ func TestRefreshGrant(t *testing.T) { wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, wantUsername: transformationUsernamePrefix + goodUsername, wantGroups: testutil.AddPrefixToEach(transformationGroupsPrefix, goodGroups), - wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), + wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithRefreshTokenWithoutIDToken(), false), wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshTokenWithUsername(oidcUpstreamRefreshedRefreshToken, transformationUsernamePrefix+goodUsername), }, @@ -2286,7 +2314,7 @@ func TestRefreshGrant(t *testing.T) { }, refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ - wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), + wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true), wantStatus: http.StatusUnauthorized, wantErrorResponseBody: here.Doc(` @@ -2326,7 +2354,7 @@ func TestRefreshGrant(t *testing.T) { }, refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ - wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), + wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true), wantStatus: http.StatusUnauthorized, wantErrorResponseBody: here.Doc(` @@ -2576,7 +2604,7 @@ func TestRefreshGrant(t *testing.T) { wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), wantUsername: "", wantGroups: goodGroups, - wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), + wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true), }), }, @@ -2631,7 +2659,7 @@ func TestRefreshGrant(t *testing.T) { wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, wantUsername: goodUsername, wantGroups: goodGroups, - wantUpstreamOIDCValidateTokenCall: &expectedUpstreamValidateTokens{ + wantUpstreamOIDCValidateTokenCall: &expectedOIDCUpstreamValidateTokens{ oidcUpstreamName, &oidctestutil.ValidateTokenAndMergeWithUserInfoArgs{ Ctx: nil, // this will be filled in with the actual request context by the test below @@ -2674,7 +2702,7 @@ func TestRefreshGrant(t *testing.T) { wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"}, wantRequestedScopes: []string{"offline_access"}, wantGrantedScopes: []string{"offline_access", "username", "groups"}, - wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), + wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithRefreshTokenWithoutIDToken(), false), wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), wantUsername: goodUsername, @@ -2700,7 +2728,7 @@ func TestRefreshGrant(t *testing.T) { wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, wantUsername: goodUsername, wantGroups: goodGroups, - wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), + wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithRefreshTokenWithoutIDToken(), false), wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), }, @@ -2727,7 +2755,7 @@ func TestRefreshGrant(t *testing.T) { wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, wantUsername: goodUsername, wantGroups: []string{"new-group1", "new-group2", "new-group3"}, - wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), + wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true), wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), wantWarnings: []RecordedWarning{ @@ -2768,7 +2796,7 @@ func TestRefreshGrant(t *testing.T) { wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, wantUsername: goodUsername, wantGroups: []string{"new-group1", "new-group2", "new-group3"}, - wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), + wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true), wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), wantWarnings: nil, // dynamic clients should not get these warnings which are intended for the pinniped-cli client @@ -2796,7 +2824,7 @@ func TestRefreshGrant(t *testing.T) { wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, wantUsername: goodUsername, wantGroups: []string{"new-group1", "new-group2", "new-group3"}, - wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), + wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true), wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), wantWarnings: []RecordedWarning{ @@ -2827,7 +2855,7 @@ func TestRefreshGrant(t *testing.T) { wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, wantUsername: goodUsername, wantGroups: []string{}, // the user no longer belongs to any groups - wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), + wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true), wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), wantWarnings: []RecordedWarning{ @@ -2857,7 +2885,7 @@ func TestRefreshGrant(t *testing.T) { wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, wantUsername: goodUsername, wantGroups: goodGroups, // the same groups as from the initial login - wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), + wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true), wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), }, @@ -2882,7 +2910,7 @@ func TestRefreshGrant(t *testing.T) { wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, wantUsername: goodUsername, wantGroups: []string{"new-group1", "new-group2", "new-group3"}, - wantUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(), + wantLDAPUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(), wantCustomSessionDataStored: happyLDAPCustomSessionData, wantWarnings: []RecordedWarning{ {Text: `User "some-username" has been added to the following groups: ["new-group1" "new-group2" "new-group3"]`}, @@ -2920,7 +2948,7 @@ func TestRefreshGrant(t *testing.T) { wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, wantUsername: goodUsername, wantGroups: []string{"new-group1", "new-group2", "new-group3"}, - wantUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(), + wantLDAPUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(), wantCustomSessionDataStored: happyLDAPCustomSessionData, wantWarnings: nil, // dynamic clients should not get these warnings which are intended for the pinniped-cli client }, @@ -2945,7 +2973,7 @@ func TestRefreshGrant(t *testing.T) { wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, wantUsername: goodUsername, wantGroups: []string{}, - wantUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(), + wantLDAPUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(), wantCustomSessionDataStored: happyLDAPCustomSessionData, wantWarnings: []RecordedWarning{ {Text: `User "some-username" has been removed from the following groups: ["group1" "groups2"]`}, @@ -2988,7 +3016,7 @@ func TestRefreshGrant(t *testing.T) { wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, // username and groups were not requested, but granted anyway for backwards compatibility wantUsername: goodUsername, wantGroups: []string{"new-group1", "new-group2", "new-group3"}, - wantUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(), + wantLDAPUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(), wantCustomSessionDataStored: happyLDAPCustomSessionData, wantWarnings: []RecordedWarning{ {Text: `User "some-username" has been added to the following groups: ["new-group1" "new-group2" "new-group3"]`}, @@ -3034,7 +3062,7 @@ func TestRefreshGrant(t *testing.T) { wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, // username and groups were not requested, but granted anyway for backwards compatibility wantUsername: goodUsername, wantGroups: []string{"new-group1", "new-group2", "new-group3"}, - wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), + wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true), wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), wantWarnings: []RecordedWarning{ @@ -3087,7 +3115,7 @@ func TestRefreshGrant(t *testing.T) { wantGrantedScopes: []string{"openid", "offline_access", "username"}, wantUsername: goodUsername, wantGroups: nil, - wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), + wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true), wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), }, @@ -3138,7 +3166,7 @@ func TestRefreshGrant(t *testing.T) { r.SetBasicAuth(dynamicClientID, testutil.PlaintextPassword1) // Use basic auth header instead. }, want: tokenEndpointResponseExpectedValues{ - wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), + wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true), wantStatus: http.StatusUnauthorized, // auth was rejected because of the upstream group to which the user belonged, as shown by the configured RejectedAuthenticationMessage appearing here @@ -3188,7 +3216,7 @@ func TestRefreshGrant(t *testing.T) { wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, wantUsername: goodUsername, wantGroups: []string{"new-group1", "new-group2", "new-group3"}, // groups are updated even though the scope was not included - wantUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(), + wantLDAPUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(), wantCustomSessionDataStored: happyLDAPCustomSessionData, wantWarnings: []RecordedWarning{ {Text: `User "some-username" has been added to the following groups: ["new-group1" "new-group2" "new-group3"]`}, @@ -3213,7 +3241,7 @@ func TestRefreshGrant(t *testing.T) { want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusUnauthorized, wantErrorResponseBody: fositeUpstreamGroupClaimErrorBody, - wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), + wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true), }, }, @@ -3295,7 +3323,7 @@ func TestRefreshGrant(t *testing.T) { wantGrantedScopes: []string{"openid", "offline_access", "pinniped:request-audience", "username", "groups"}, wantUsername: goodUsername, wantGroups: goodGroups, - wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), + wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true), wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), }, @@ -3793,8 +3821,8 @@ func TestRefreshGrant(t *testing.T) { authcodeExchange: happyAuthcodeExchangeInputsForOIDCUpstream, refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ - wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), - wantStatus: http.StatusUnauthorized, + wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), + wantStatus: http.StatusUnauthorized, wantErrorResponseBody: here.Doc(` { "error": "error", @@ -3814,7 +3842,7 @@ func TestRefreshGrant(t *testing.T) { authcodeExchange: happyAuthcodeExchangeInputsForOIDCUpstream, refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ - wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), + wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true), wantStatus: http.StatusUnauthorized, wantErrorResponseBody: here.Doc(` @@ -3842,7 +3870,7 @@ func TestRefreshGrant(t *testing.T) { authcodeExchange: happyAuthcodeExchangeInputsForOIDCUpstream, refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ - wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), + wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true), wantStatus: http.StatusUnauthorized, wantErrorResponseBody: here.Doc(` @@ -3867,7 +3895,7 @@ func TestRefreshGrant(t *testing.T) { authcodeExchange: happyAuthcodeExchangeInputsForOIDCUpstream, refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ - wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), + wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true), wantStatus: http.StatusUnauthorized, wantErrorResponseBody: here.Doc(` @@ -3894,7 +3922,7 @@ func TestRefreshGrant(t *testing.T) { authcodeExchange: happyAuthcodeExchangeInputsForOIDCUpstream, refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ - wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), + wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true), wantStatus: http.StatusUnauthorized, wantErrorResponseBody: here.Doc(` @@ -3921,7 +3949,7 @@ func TestRefreshGrant(t *testing.T) { authcodeExchange: happyAuthcodeExchangeInputsForOIDCUpstream, refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ - wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), + wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true), wantStatus: http.StatusUnauthorized, wantErrorResponseBody: here.Doc(` @@ -4011,8 +4039,8 @@ func TestRefreshGrant(t *testing.T) { }, refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ - wantUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(), - wantStatus: http.StatusUnauthorized, + wantLDAPUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(), + wantStatus: http.StatusUnauthorized, wantErrorResponseBody: here.Doc(` { "error": "error", @@ -4051,8 +4079,8 @@ func TestRefreshGrant(t *testing.T) { }, refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ - wantUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(), - wantStatus: http.StatusUnauthorized, + wantLDAPUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(), + wantStatus: http.StatusUnauthorized, wantErrorResponseBody: here.Doc(` { "error": "error", @@ -4125,7 +4153,7 @@ func TestRefreshGrant(t *testing.T) { wantCustomSessionDataStored: happyLDAPCustomSessionData, wantUsername: "", wantGroups: goodGroups, - wantUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(), + wantLDAPUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(), }), }, }, @@ -4319,8 +4347,8 @@ func TestRefreshGrant(t *testing.T) { authcodeExchange: happyAuthcodeExchangeInputsForLDAPUpstream, refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ - wantUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(), - wantStatus: http.StatusUnauthorized, + wantLDAPUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(), + wantStatus: http.StatusUnauthorized, wantErrorResponseBody: here.Doc(` { "error": "error", @@ -4348,8 +4376,8 @@ func TestRefreshGrant(t *testing.T) { }, refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ - wantUpstreamRefreshCall: happyActiveDirectoryUpstreamRefreshCall(), - wantStatus: http.StatusUnauthorized, + wantActiveDirectoryUpstreamRefreshCall: happyActiveDirectoryUpstreamRefreshCall(), + wantStatus: http.StatusUnauthorized, wantErrorResponseBody: here.Doc(` { "error": "error", @@ -4602,7 +4630,7 @@ func TestRefreshGrant(t *testing.T) { // Performing an authcode exchange should not have caused any upstream refresh, which should only // happen during a downstream refresh. - test.idps.RequireExactlyZeroCallsToPerformRefresh(t) + test.idps.RequireExactlyZeroCallsToAnyUpstreamRefresh(t) test.idps.RequireExactlyZeroCallsToValidateToken(t) // Wait one second before performing the refresh so we can see that the refreshed ID token has new issued @@ -4634,21 +4662,38 @@ func TestRefreshGrant(t *testing.T) { t.Logf("second response: %#v", refreshResponse) t.Logf("second response body: %q", refreshResponse.Body.String()) - // Test that we did or did not make a call to the upstream OIDC provider interface to perform a token refresh. - if test.refreshRequest.want.wantUpstreamRefreshCall != nil { - if test.authcodeExchange.customSessionData.ProviderType != "github" { - test.refreshRequest.want.wantUpstreamRefreshCall.args.Ctx = reqContext - } - test.idps.RequireExactlyOneCallToPerformRefresh(t, - test.refreshRequest.want.wantUpstreamRefreshCall.performedByUpstreamName, - test.refreshRequest.want.wantUpstreamRefreshCall.args, + // Test that we did or did not make a call to the upstream provider's interface to perform refresh. + switch { + case test.refreshRequest.want.wantOIDCUpstreamRefreshCall != nil: + test.refreshRequest.want.wantOIDCUpstreamRefreshCall.args.Ctx = reqContext + test.idps.RequireExactlyOneCallToOIDCPerformRefresh(t, + test.refreshRequest.want.wantOIDCUpstreamRefreshCall.performedByUpstreamName, + test.refreshRequest.want.wantOIDCUpstreamRefreshCall.args, ) - } else { - test.idps.RequireExactlyZeroCallsToPerformRefresh(t) + case test.refreshRequest.want.wantLDAPUpstreamRefreshCall != nil: + test.refreshRequest.want.wantLDAPUpstreamRefreshCall.args.Ctx = reqContext + test.idps.RequireExactlyOneCallToLDAPPerformRefresh(t, + test.refreshRequest.want.wantLDAPUpstreamRefreshCall.performedByUpstreamName, + test.refreshRequest.want.wantLDAPUpstreamRefreshCall.args, + ) + case test.refreshRequest.want.wantActiveDirectoryUpstreamRefreshCall != nil: + test.refreshRequest.want.wantActiveDirectoryUpstreamRefreshCall.args.Ctx = reqContext + test.idps.RequireExactlyOneCallToActiveDirectoryPerformRefresh(t, + test.refreshRequest.want.wantActiveDirectoryUpstreamRefreshCall.performedByUpstreamName, + test.refreshRequest.want.wantActiveDirectoryUpstreamRefreshCall.args, + ) + case test.refreshRequest.want.wantGithubUpstreamRefreshCall != nil: + test.refreshRequest.want.wantGithubUpstreamRefreshCall.args.Ctx = reqContext + test.idps.RequireExactlyOneCallToGithubGetUser(t, + test.refreshRequest.want.wantGithubUpstreamRefreshCall.performedByUpstreamName, + test.refreshRequest.want.wantGithubUpstreamRefreshCall.args, + ) + default: + test.idps.RequireExactlyZeroCallsToAnyUpstreamRefresh(t) } // Test that we did or did not make a call to the upstream OIDC provider interface to validate the - // new ID token that was returned by the upstream refresh. + // new ID token that was returned by the upstream refresh, in the case of an OIDC upstream. if test.refreshRequest.want.wantUpstreamOIDCValidateTokenCall != nil { test.refreshRequest.want.wantUpstreamOIDCValidateTokenCall.args.Ctx = reqContext test.idps.RequireExactlyOneCallToValidateToken(t, diff --git a/internal/federationdomain/resolvedprovider/resolvedldap/resolved_ldap_provider.go b/internal/federationdomain/resolvedprovider/resolvedldap/resolved_ldap_provider.go index a700ae4fc..69d7096b8 100644 --- a/internal/federationdomain/resolvedprovider/resolvedldap/resolved_ldap_provider.go +++ b/internal/federationdomain/resolvedprovider/resolvedldap/resolved_ldap_provider.go @@ -221,7 +221,7 @@ func (p *FederationDomainResolvedLDAPIdentityProvider) UpstreamRefresh( plog.Debug("attempting upstream refresh request", "providerName", p.Provider.GetName(), "providerType", p.GetSessionProviderType(), "providerUID", p.Provider.GetResourceUID()) - refreshedUntransformedGroups, err := p.Provider.PerformRefresh(ctx, upstreamprovider.RefreshAttributes{ + refreshedUntransformedGroups, err := p.Provider.PerformRefresh(ctx, upstreamprovider.LDAPRefreshAttributes{ Username: identity.UpstreamUsername, Subject: identity.DownstreamSubject, DN: dn, diff --git a/internal/federationdomain/upstreamprovider/upsteam_provider.go b/internal/federationdomain/upstreamprovider/upsteam_provider.go index faf6af015..40da3f922 100644 --- a/internal/federationdomain/upstreamprovider/upsteam_provider.go +++ b/internal/federationdomain/upstreamprovider/upsteam_provider.go @@ -26,9 +26,9 @@ const ( AccessTokenType RevocableTokenType = "access_token" ) -// RefreshAttributes contains information about the user from the original login request +// LDAPRefreshAttributes contains information about the user from the original login request // and previous refreshes to be used during an LDAP session refresh. -type RefreshAttributes struct { +type LDAPRefreshAttributes struct { Username string Subject string DN string @@ -125,7 +125,7 @@ type UpstreamLDAPIdentityProviderI interface { authenticators.UserAuthenticator // PerformRefresh performs a refresh against the upstream LDAP identity provider - PerformRefresh(ctx context.Context, storedRefreshAttributes RefreshAttributes, idpDisplayName string) (groups []string, err error) + PerformRefresh(ctx context.Context, storedRefreshAttributes LDAPRefreshAttributes, idpDisplayName string) (groups []string, err error) } type GitHubUser struct { diff --git a/internal/testutil/oidctestutil/testldapprovider.go b/internal/testutil/oidctestutil/testldapprovider.go index b4ef6363f..b9f3ea153 100644 --- a/internal/testutil/oidctestutil/testldapprovider.go +++ b/internal/testutil/oidctestutil/testldapprovider.go @@ -14,6 +14,12 @@ import ( "go.pinniped.dev/internal/idtransform" ) +type PerformLDAPRefreshArgs struct { + Ctx context.Context + StoredRefreshAttributes upstreamprovider.LDAPRefreshAttributes + IDPDisplayName string +} + func NewTestUpstreamLDAPIdentityProviderBuilder() *TestUpstreamLDAPIdentityProviderBuilder { return &TestUpstreamLDAPIdentityProviderBuilder{} } @@ -102,7 +108,7 @@ type TestUpstreamLDAPIdentityProvider struct { // Fields for tracking actual calls make to mock functions. performRefreshCallCount int - performRefreshArgs []*PerformRefreshArgs + performRefreshArgs []*PerformLDAPRefreshArgs } var _ upstreamprovider.UpstreamLDAPIdentityProviderI = &TestUpstreamLDAPIdentityProvider{} @@ -123,16 +129,15 @@ func (u *TestUpstreamLDAPIdentityProvider) GetURL() *url.URL { return u.URL } -func (u *TestUpstreamLDAPIdentityProvider) PerformRefresh(ctx context.Context, storedRefreshAttributes upstreamprovider.RefreshAttributes, _idpDisplayName string) ([]string, error) { +func (u *TestUpstreamLDAPIdentityProvider) PerformRefresh(ctx context.Context, storedRefreshAttributes upstreamprovider.LDAPRefreshAttributes, idpDisplayName string) ([]string, error) { if u.performRefreshArgs == nil { - u.performRefreshArgs = make([]*PerformRefreshArgs, 0) + u.performRefreshArgs = make([]*PerformLDAPRefreshArgs, 0) } u.performRefreshCallCount++ - u.performRefreshArgs = append(u.performRefreshArgs, &PerformRefreshArgs{ - Ctx: ctx, - DN: storedRefreshAttributes.DN, - ExpectedUsername: storedRefreshAttributes.Username, - ExpectedSubject: storedRefreshAttributes.Subject, + u.performRefreshArgs = append(u.performRefreshArgs, &PerformLDAPRefreshArgs{ + Ctx: ctx, + StoredRefreshAttributes: storedRefreshAttributes, + IDPDisplayName: idpDisplayName, }) if u.PerformRefreshErr != nil { return nil, u.PerformRefreshErr @@ -144,9 +149,9 @@ func (u *TestUpstreamLDAPIdentityProvider) PerformRefreshCallCount() int { return u.performRefreshCallCount } -func (u *TestUpstreamLDAPIdentityProvider) PerformRefreshArgs(call int) *PerformRefreshArgs { +func (u *TestUpstreamLDAPIdentityProvider) PerformRefreshArgs(call int) *PerformLDAPRefreshArgs { if u.performRefreshArgs == nil { - u.performRefreshArgs = make([]*PerformRefreshArgs, 0) + u.performRefreshArgs = make([]*PerformLDAPRefreshArgs, 0) } return u.performRefreshArgs[call] } diff --git a/internal/testutil/oidctestutil/testoidcprovider.go b/internal/testutil/oidctestutil/testoidcprovider.go index 489f6304c..285988917 100644 --- a/internal/testutil/oidctestutil/testoidcprovider.go +++ b/internal/testutil/oidctestutil/testoidcprovider.go @@ -36,14 +36,11 @@ type PasswordCredentialsGrantAndValidateTokensArgs struct { Password string } -// PerformRefreshArgs is used to spy on calls to +// PerformOIDCRefreshArgs is used to spy on calls to // TestUpstreamOIDCIdentityProvider.PerformRefreshFunc(). -type PerformRefreshArgs struct { - Ctx context.Context - RefreshToken string - DN string - ExpectedUsername string - ExpectedSubject string +type PerformOIDCRefreshArgs struct { + Ctx context.Context + RefreshToken string } // RevokeTokenArgs is used to spy on calls to @@ -105,7 +102,7 @@ type TestUpstreamOIDCIdentityProvider struct { passwordCredentialsGrantAndValidateTokensCallCount int passwordCredentialsGrantAndValidateTokensArgs []*PasswordCredentialsGrantAndValidateTokensArgs performRefreshCallCount int - performRefreshArgs []*PerformRefreshArgs + performRefreshArgs []*PerformOIDCRefreshArgs revokeTokenCallCount int revokeTokenArgs []*RevokeTokenArgs validateTokenAndMergeWithUserInfoCallCount int @@ -217,10 +214,10 @@ func (u *TestUpstreamOIDCIdentityProvider) PasswordCredentialsGrantAndValidateTo func (u *TestUpstreamOIDCIdentityProvider) PerformRefresh(ctx context.Context, refreshToken string) (*oauth2.Token, error) { if u.performRefreshArgs == nil { - u.performRefreshArgs = make([]*PerformRefreshArgs, 0) + u.performRefreshArgs = make([]*PerformOIDCRefreshArgs, 0) } u.performRefreshCallCount++ - u.performRefreshArgs = append(u.performRefreshArgs, &PerformRefreshArgs{ + u.performRefreshArgs = append(u.performRefreshArgs, &PerformOIDCRefreshArgs{ Ctx: ctx, RefreshToken: refreshToken, }) @@ -244,9 +241,9 @@ func (u *TestUpstreamOIDCIdentityProvider) PerformRefreshCallCount() int { return u.performRefreshCallCount } -func (u *TestUpstreamOIDCIdentityProvider) PerformRefreshArgs(call int) *PerformRefreshArgs { +func (u *TestUpstreamOIDCIdentityProvider) PerformRefreshArgs(call int) *PerformOIDCRefreshArgs { if u.performRefreshArgs == nil { - u.performRefreshArgs = make([]*PerformRefreshArgs, 0) + u.performRefreshArgs = make([]*PerformOIDCRefreshArgs, 0) } return u.performRefreshArgs[call] } diff --git a/internal/testutil/testidplister/testidplister.go b/internal/testutil/testidplister/testidplister.go index 2dded4809..5aba22f21 100644 --- a/internal/testutil/testidplister/testidplister.go +++ b/internal/testutil/testidplister/testidplister.go @@ -330,60 +330,111 @@ func (b *UpstreamIDPListerBuilder) RequireExactlyZeroAuthcodeExchanges(t *testin ) } -func (b *UpstreamIDPListerBuilder) RequireExactlyOneCallToPerformRefresh( +func (b *UpstreamIDPListerBuilder) RequireExactlyOneCallToOIDCPerformRefresh( t *testing.T, expectedPerformedByUpstreamName string, - expectedArgs *oidctestutil.PerformRefreshArgs, + expectedArgs *oidctestutil.PerformOIDCRefreshArgs, ) { t.Helper() - var actualArgs *oidctestutil.PerformRefreshArgs + var actualArgs *oidctestutil.PerformOIDCRefreshArgs var actualNameOfUpstreamWhichMadeCall string - actualCallCountAcrossAllUpstreams := 0 - for _, upstreamOIDC := range b.upstreamOIDCIdentityProviders { - callCountOnThisUpstream := upstreamOIDC.PerformRefreshCallCount() - actualCallCountAcrossAllUpstreams += callCountOnThisUpstream - if callCountOnThisUpstream == 1 { - actualNameOfUpstreamWhichMadeCall = upstreamOIDC.Name - actualArgs = upstreamOIDC.PerformRefreshArgs(0) - } - } - for _, upstreamLDAP := range b.upstreamLDAPIdentityProviders { - callCountOnThisUpstream := upstreamLDAP.PerformRefreshCallCount() - actualCallCountAcrossAllUpstreams += callCountOnThisUpstream - if callCountOnThisUpstream == 1 { - actualNameOfUpstreamWhichMadeCall = upstreamLDAP.Name - actualArgs = upstreamLDAP.PerformRefreshArgs(0) - } - } - for _, upstreamAD := range b.upstreamActiveDirectoryIdentityProviders { - callCountOnThisUpstream := upstreamAD.PerformRefreshCallCount() - actualCallCountAcrossAllUpstreams += callCountOnThisUpstream - if callCountOnThisUpstream == 1 { - actualNameOfUpstreamWhichMadeCall = upstreamAD.Name - actualArgs = upstreamAD.PerformRefreshArgs(0) - } - } - for _, upstream := range b.upstreamGitHubIdentityProviders { - // Remember that GitHub does not have a traditional PerformRefresh function. - // GitHub calls GetUser during both the original authcode exchange and the refresh. - callCountOnThisUpstream := upstream.GetUserCallCount() - actualCallCountAcrossAllUpstreams += callCountOnThisUpstream + for _, upstream := range b.upstreamOIDCIdentityProviders { + callCountOnThisUpstream := upstream.PerformRefreshCallCount() if callCountOnThisUpstream == 1 { actualNameOfUpstreamWhichMadeCall = upstream.Name - actualArgs = nil + actualArgs = upstream.PerformRefreshArgs(0) } } - require.Equal(t, 1, actualCallCountAcrossAllUpstreams, - "should have been exactly one call to PerformRefresh() by all upstreams", + require.Equal(t, 1, b.CountAllCallsToAnyUpstreamRefresh(), + "should have been exactly one call to upstream refresh by all upstreams", ) require.Equal(t, expectedPerformedByUpstreamName, actualNameOfUpstreamWhichMadeCall, - "PerformRefresh() was called on the wrong upstream", + "upstream refresh was called on the wrong upstream", ) require.Equal(t, expectedArgs, actualArgs) } -func (b *UpstreamIDPListerBuilder) RequireExactlyZeroCallsToPerformRefresh(t *testing.T) { +func (b *UpstreamIDPListerBuilder) RequireExactlyOneCallToActiveDirectoryPerformRefresh( + t *testing.T, + expectedPerformedByUpstreamName string, + expectedArgs *oidctestutil.PerformLDAPRefreshArgs, +) { t.Helper() + var actualArgs *oidctestutil.PerformLDAPRefreshArgs + var actualNameOfUpstreamWhichMadeCall string + for _, upstream := range b.upstreamActiveDirectoryIdentityProviders { + callCountOnThisUpstream := upstream.PerformRefreshCallCount() + if callCountOnThisUpstream == 1 { + actualNameOfUpstreamWhichMadeCall = upstream.Name + actualArgs = upstream.PerformRefreshArgs(0) + } + } + require.Equal(t, 1, b.CountAllCallsToAnyUpstreamRefresh(), + "should have been exactly one call to upstream refresh by all upstreams", + ) + require.Equal(t, expectedPerformedByUpstreamName, actualNameOfUpstreamWhichMadeCall, + "upstream refresh was called on the wrong upstream", + ) + require.Equal(t, expectedArgs, actualArgs) +} + +func (b *UpstreamIDPListerBuilder) RequireExactlyOneCallToLDAPPerformRefresh( + t *testing.T, + expectedPerformedByUpstreamName string, + expectedArgs *oidctestutil.PerformLDAPRefreshArgs, +) { + t.Helper() + var actualArgs *oidctestutil.PerformLDAPRefreshArgs + var actualNameOfUpstreamWhichMadeCall string + for _, upstream := range b.upstreamLDAPIdentityProviders { + callCountOnThisUpstream := upstream.PerformRefreshCallCount() + if callCountOnThisUpstream == 1 { + actualNameOfUpstreamWhichMadeCall = upstream.Name + actualArgs = upstream.PerformRefreshArgs(0) + } + } + require.Equal(t, 1, b.CountAllCallsToAnyUpstreamRefresh(), + "should have been exactly one call to upstream refresh by all upstreams", + ) + require.Equal(t, expectedPerformedByUpstreamName, actualNameOfUpstreamWhichMadeCall, + "upstream refresh was called on the wrong upstream", + ) + require.Equal(t, expectedArgs, actualArgs) +} + +func (b *UpstreamIDPListerBuilder) RequireExactlyOneCallToGithubGetUser( + t *testing.T, + expectedPerformedByUpstreamName string, + expectedArgs *oidctestutil.GetUserArgs, +) { + t.Helper() + var actualArgs *oidctestutil.GetUserArgs + var actualNameOfUpstreamWhichMadeCall string + for _, upstream := range b.upstreamGitHubIdentityProviders { + // GitHub calls GetUser during both the original authcode exchange and the refresh. + callCountOnThisUpstream := upstream.GetUserCallCount() + if callCountOnThisUpstream == 1 { + actualNameOfUpstreamWhichMadeCall = upstream.Name + actualArgs = upstream.GetUserArgs(0) + } + } + require.Equal(t, 1, b.CountAllCallsToAnyUpstreamRefresh(), + "should have been exactly one call to upstream refresh by all upstreams", + ) + require.Equal(t, expectedPerformedByUpstreamName, actualNameOfUpstreamWhichMadeCall, + "upstream refresh was called on the wrong upstream", + ) + require.Equal(t, expectedArgs, actualArgs) +} + +func (b *UpstreamIDPListerBuilder) RequireExactlyZeroCallsToAnyUpstreamRefresh(t *testing.T) { + t.Helper() + require.Equal(t, 0, b.CountAllCallsToAnyUpstreamRefresh(), + "expected exactly zero calls to any upstream refresh mocks", + ) +} + +func (b *UpstreamIDPListerBuilder) CountAllCallsToAnyUpstreamRefresh() int { actualCallCountAcrossAllUpstreams := 0 for _, upstreamOIDC := range b.upstreamOIDCIdentityProviders { actualCallCountAcrossAllUpstreams += upstreamOIDC.PerformRefreshCallCount() @@ -394,11 +445,10 @@ func (b *UpstreamIDPListerBuilder) RequireExactlyZeroCallsToPerformRefresh(t *te for _, upstreamActiveDirectory := range b.upstreamActiveDirectoryIdentityProviders { actualCallCountAcrossAllUpstreams += upstreamActiveDirectory.PerformRefreshCallCount() } - // TODO: probably add GitHub loop once we flesh out the structs - - require.Equal(t, 0, actualCallCountAcrossAllUpstreams, - "expected exactly zero calls to PerformRefresh()", - ) + for _, upstreamGithub := range b.upstreamGitHubIdentityProviders { + actualCallCountAcrossAllUpstreams += upstreamGithub.GetUserCallCount() + } + return actualCallCountAcrossAllUpstreams } func (b *UpstreamIDPListerBuilder) RequireExactlyOneCallToValidateToken( diff --git a/internal/upstreamldap/upstreamldap.go b/internal/upstreamldap/upstreamldap.go index 7735b231b..2509c2921 100644 --- a/internal/upstreamldap/upstreamldap.go +++ b/internal/upstreamldap/upstreamldap.go @@ -118,7 +118,7 @@ type ProviderConfig struct { GroupAttributeParsingOverrides map[string]func(*ldap.Entry) (string, error) // RefreshAttributeChecks are extra checks that attributes in a refresh response are as expected. - RefreshAttributeChecks map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error + RefreshAttributeChecks map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error } // UserSearchConfig contains information about how to search for users in the upstream LDAP IDP. @@ -186,7 +186,7 @@ func closeAndLogError(conn Conn, doingWhat string) { } } -func (p *Provider) PerformRefresh(ctx context.Context, storedRefreshAttributes upstreamprovider.RefreshAttributes, idpDisplayName string) ([]string, error) { +func (p *Provider) PerformRefresh(ctx context.Context, storedRefreshAttributes upstreamprovider.LDAPRefreshAttributes, idpDisplayName string) ([]string, error) { t := trace.FromContext(ctx).Nest("slow ldap refresh attempt", trace.Field{Key: "providerName", Value: p.GetName()}) defer t.LogIfLong(500 * time.Millisecond) // to help users debug slow LDAP searches userDN := storedRefreshAttributes.DN diff --git a/internal/upstreamldap/upstreamldap_test.go b/internal/upstreamldap/upstreamldap_test.go index f7cdcadbe..49e36968e 100644 --- a/internal/upstreamldap/upstreamldap_test.go +++ b/internal/upstreamldap/upstreamldap_test.go @@ -641,8 +641,8 @@ func TestEndUserAuthentication(t *testing.T) { username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(func(p *ProviderConfig) { - p.RefreshAttributeChecks = map[string]func(entry *ldap.Entry, attributes upstreamprovider.RefreshAttributes) error{ - "some-attribute-to-check-during-refresh": func(entry *ldap.Entry, attributes upstreamprovider.RefreshAttributes) error { + p.RefreshAttributeChecks = map[string]func(entry *ldap.Entry, attributes upstreamprovider.LDAPRefreshAttributes) error{ + "some-attribute-to-check-during-refresh": func(entry *ldap.Entry, attributes upstreamprovider.LDAPRefreshAttributes) error { return nil }, } @@ -679,8 +679,8 @@ func TestEndUserAuthentication(t *testing.T) { username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(func(p *ProviderConfig) { - p.RefreshAttributeChecks = map[string]func(entry *ldap.Entry, attributes upstreamprovider.RefreshAttributes) error{ - "some-attribute-to-check-during-refresh": func(entry *ldap.Entry, attributes upstreamprovider.RefreshAttributes) error { + p.RefreshAttributeChecks = map[string]func(entry *ldap.Entry, attributes upstreamprovider.LDAPRefreshAttributes) error{ + "some-attribute-to-check-during-refresh": func(entry *ldap.Entry, attributes upstreamprovider.LDAPRefreshAttributes) error { return nil }, } @@ -1527,8 +1527,8 @@ func TestUpstreamRefresh(t *testing.T) { Filter: testGroupSearchFilter, GroupNameAttribute: testGroupSearchGroupNameAttribute, }, - RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ - pwdLastSetAttribute: func(*ldap.Entry, upstreamprovider.RefreshAttributes) error { return nil }, + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{ + pwdLastSetAttribute: func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error { return nil }, }, } if editFunc != nil { @@ -2124,8 +2124,8 @@ func TestUpstreamRefresh(t *testing.T) { { name: "search result has a changed pwdLastSet value", providerConfig: providerConfig(func(p *ProviderConfig) { - p.RefreshAttributeChecks = map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ - pwdLastSetAttribute: func(*ldap.Entry, upstreamprovider.RefreshAttributes) error { + p.RefreshAttributeChecks = map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{ + pwdLastSetAttribute: func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error { return errors.New(`value for attribute "pwdLastSet" has changed since initial value at login`) }, } @@ -2201,7 +2201,7 @@ func TestUpstreamRefresh(t *testing.T) { "ldaps://ldap.example.com:8443?base=some-upstream-user-base-dn&idpName=%s&sub=c29tZS11cHN0cmVhbS11aWQtdmFsdWU", testUpstreamName, ) - groups, err := ldapProvider.PerformRefresh(context.Background(), upstreamprovider.RefreshAttributes{ + groups, err := ldapProvider.PerformRefresh(context.Background(), upstreamprovider.LDAPRefreshAttributes{ Username: testUserSearchResultUsernameAttributeValue, Subject: subject, DN: tt.refreshUserDN, From 37e654faa08600734b58c39d78155ab27a3b08ea Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Tue, 28 May 2024 13:41:03 -0500 Subject: [PATCH 19/25] bunch of renames Co-authored-by: Ryan Richard --- .../callback/callback_handler_test.go | 482 +++++++++--------- 1 file changed, 243 insertions(+), 239 deletions(-) diff --git a/internal/federationdomain/endpoints/callback/callback_handler_test.go b/internal/federationdomain/endpoints/callback/callback_handler_test.go index 4968f41bd..aee21533b 100644 --- a/internal/federationdomain/endpoints/callback/callback_handler_test.go +++ b/internal/federationdomain/endpoints/callback/callback_handler_test.go @@ -18,7 +18,6 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/crypto/bcrypt" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/kubernetes/fake" configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" @@ -39,8 +38,9 @@ import ( ) const ( - happyUpstreamIDPName = "upstream-idp-name" - happyUpstreamIDPResourceUID = "upstream-uid" + // Upstream OIDC + happyOIDCUpstreamIDPName = "upstream-oidc-idp-name" + happyOIDCUpstreamIDPResourceUID = "upstream-oidc-resource-uid" oidcUpstreamIssuer = "https://my-upstream-issuer.com" oidcUpstreamRefreshToken = "test-refresh-token" @@ -52,12 +52,18 @@ const ( oidcUpstreamUsernameClaim = "the-user-claim" oidcUpstreamGroupsClaim = "the-groups-claim" + // Upstream GitHub + happyGithubIDPName = "upstream-github-idp-name" + happyGithubIDPResourceUID = "upstream-github-idp-resource-uid" + + // Upstream OAuth2 (OIDC or GitHub) happyUpstreamAuthcode = "upstream-auth-code" happyUpstreamRedirectURI = "https://example.com/callback" + // Downstream parameters happyDownstreamState = "8b-state" happyDownstreamCSRF = "test-csrf" - happyDownstreamPKCE = "test-pkce" + happyDownstreamPKCEVerifier = "test-pkce" happyDownstreamNonce = "test-nonce" happyDownstreamStateVersion = "2" @@ -77,11 +83,9 @@ const ( ) var ( - githubIDPName = "upstream-github-idp-name" - githubIDPResourceUID = types.UID("upstream-github-idp-resource-uid") githubUpstreamUsername = "some-github-login" githubUpstreamGroups = []string{"org1/team1", "org2/team2"} - githubDownstreamSubject = fmt.Sprintf("https://github.com?idpName=%s&sub=%s", githubIDPName, githubUpstreamUsername) + githubDownstreamSubject = fmt.Sprintf("https://github.com?idpName=%s&sub=%s", happyGithubIDPName, githubUpstreamUsername) githubUpstreamAccessToken = "some-opaque-access-token-from-github" //nolint:gosec // this is not a credential oidcUpstreamGroupMembership = []string{"test-pinniped-group-0", "test-pinniped-group-1"} @@ -109,8 +113,8 @@ var ( Username: oidcUpstreamUsername, UpstreamUsername: oidcUpstreamUsername, UpstreamGroups: oidcUpstreamGroupMembership, - ProviderUID: happyUpstreamIDPResourceUID, - ProviderName: happyUpstreamIDPName, + ProviderUID: happyOIDCUpstreamIDPResourceUID, + ProviderName: happyOIDCUpstreamIDPName, ProviderType: psession.ProviderTypeOIDC, OIDC: &psession.OIDCSessionData{ UpstreamRefreshToken: oidcUpstreamRefreshToken, @@ -131,8 +135,8 @@ var ( Username: oidcUpstreamUsername, UpstreamUsername: oidcUpstreamUsername, UpstreamGroups: oidcUpstreamGroupMembership, - ProviderUID: happyUpstreamIDPResourceUID, - ProviderName: happyUpstreamIDPName, + ProviderUID: happyOIDCUpstreamIDPResourceUID, + ProviderName: happyOIDCUpstreamIDPName, ProviderType: psession.ProviderTypeOIDC, OIDC: &psession.OIDCSessionData{ UpstreamAccessToken: oidcUpstreamAccessToken, @@ -144,8 +148,8 @@ var ( Username: githubUpstreamUsername, UpstreamUsername: githubUpstreamUsername, UpstreamGroups: githubUpstreamGroups, - ProviderUID: githubIDPResourceUID, - ProviderName: githubIDPName, + ProviderUID: happyGithubIDPResourceUID, + ProviderName: happyGithubIDPName, ProviderType: psession.ProviderTypeGitHub, GitHub: &psession.GitHubSessionData{ UpstreamAccessToken: githubUpstreamAccessToken, @@ -153,14 +157,6 @@ var ( } ) -func happyGitHubUpstream() *oidctestutil.TestUpstreamGitHubIdentityProviderBuilder { - return oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder(). - WithName(githubIDPName). - WithResourceUID(githubIDPResourceUID). - WithClientID("some-client-id"). - WithScopes([]string{"these", "scopes", "appear", "unused"}) -} - func TestCallbackEndpoint(t *testing.T) { require.Len(t, happyDownstreamState, 8, "we expect fosite to allow 8 byte state params, so we want to test that boundary case") @@ -182,21 +178,21 @@ func TestCallbackEndpoint(t *testing.T) { var happyCookieCodec = securecookie.New(cookieEncoderHashKey, cookieEncoderBlockKey) happyCookieCodec.SetSerializer(securecookie.JSONEncoder{}) - happyState := happyUpstreamStateParam().Build(t, happyStateCodec) - happyStateForDynamicClient := happyUpstreamStateParamForDynamicClient().Build(t, happyStateCodec) + happyState := happyOIDCUpstreamStateParam().Build(t, happyStateCodec) + happyStateForDynamicClient := happyOIDCUpstreamStateParamForDynamicClient().Build(t, happyStateCodec) encodedIncomingCookieCSRFValue, err := happyCookieCodec.Encode("csrf", happyDownstreamCSRF) require.NoError(t, err) happyCSRFCookie := "__Host-pinniped-csrf=" + encodedIncomingCookieCSRFValue - happyExchangeAndValidateTokensArgs := &oidctestutil.ExchangeAuthcodeAndValidateTokenArgs{ + happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs := &oidctestutil.ExchangeAuthcodeAndValidateTokenArgs{ Authcode: happyUpstreamAuthcode, - PKCECodeVerifier: oidcpkce.Code(happyDownstreamPKCE), - ExpectedIDTokenNonce: nonce.Nonce(happyDownstreamNonce), RedirectURI: happyUpstreamRedirectURI, + PKCECodeVerifier: oidcpkce.Code(happyDownstreamPKCEVerifier), + ExpectedIDTokenNonce: nonce.Nonce(happyDownstreamNonce), } - happyGitHubExchangeAuthcodeArgs := &oidctestutil.ExchangeAuthcodeArgs{ + happyGitHubUpstreamExchangeAuthcodeArgs := &oidctestutil.ExchangeAuthcodeArgs{ Authcode: happyUpstreamAuthcode, RedirectURI: happyUpstreamRedirectURI, } @@ -245,10 +241,10 @@ func TestCallbackEndpoint(t *testing.T) { }{ { name: "GET with good state and cookie and successful upstream token exchange with response_mode=form_post returns 200 with HTML+JS form", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), method: http.MethodGet, path: newRequestPath().WithState( - happyUpstreamStateParam().WithAuthorizeRequestParams( + happyOIDCUpstreamStateParam().WithAuthorizeRequestParams( shallowCopyAndModifyQuery( happyDownstreamRequestParamsQuery, map[string]string{"response_mode": "form_post"}, @@ -259,7 +255,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusOK, wantContentType: "text/html;charset=UTF-8", wantBodyFormResponseRegexp: `(.+)`, - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyOIDCUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -270,13 +266,13 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "GET with good state and cookie with additional params", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream(). + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream(). WithAdditionalClaimMappings(map[string]string{ "downstreamCustomClaim": "upstreamCustomClaim", "downstreamOtherClaim": "upstreamOtherClaim", @@ -287,7 +283,7 @@ func TestCallbackEndpoint(t *testing.T) { Build()), method: http.MethodGet, path: newRequestPath().WithState( - happyUpstreamStateParam().WithAuthorizeRequestParams( + happyOIDCUpstreamStateParam().WithAuthorizeRequestParams( shallowCopyAndModifyQuery( happyDownstreamRequestParamsQuery, map[string]string{"response_mode": "form_post"}, @@ -298,7 +294,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusOK, wantContentType: "text/html;charset=UTF-8", wantBodyFormResponseRegexp: `(.+)`, - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyOIDCUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -309,8 +305,8 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, wantDownstreamAdditionalClaims: map[string]interface{}{ "downstreamCustomClaim": "i am a claim value", @@ -319,14 +315,14 @@ func TestCallbackEndpoint(t *testing.T) { }, { name: "GET with good state and cookie and successful upstream token exchange returns 303 to downstream client callback with its state and code", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyOIDCUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -337,13 +333,13 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "GET with good state and cookie and successful upstream token exchange returns 303 to downstream client callback with its state and code when using dynamic client", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, method: http.MethodGet, path: newRequestPath().WithState(happyStateForDynamicClient).String(), @@ -351,7 +347,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyOIDCUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -362,20 +358,20 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "GET with authcode exchange that returns an access token but no refresh token when there is a userinfo endpoint returns 303 to downstream client callback with its state and code", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().WithEmptyRefreshToken().WithAccessToken(oidcUpstreamAccessToken, metav1.NewTime(time.Now().Add(9*time.Hour))).WithUserInfoURL().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().WithEmptyRefreshToken().WithAccessToken(oidcUpstreamAccessToken, metav1.NewTime(time.Now().Add(9*time.Hour))).WithUserInfoURL().Build()), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyOIDCUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -386,16 +382,16 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamAccessTokenCustomSessionData, wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "form_post happy path without username or groups scopes requested", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), method: http.MethodGet, path: newRequestPath().WithState( - happyUpstreamStateParam().WithAuthorizeRequestParams( + happyOIDCUpstreamStateParam().WithAuthorizeRequestParams( shallowCopyAndModifyQuery( happyDownstreamRequestParamsQuery, map[string]string{ @@ -409,7 +405,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusOK, wantContentType: "text/html;charset=UTF-8", wantBodyFormResponseRegexp: `(.+)`, - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyOIDCUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamRequestedScopes: []string{"openid"}, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, @@ -421,20 +417,20 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "GET with authcode exchange that returns an access token but no refresh token but has a short token lifetime which is stored as a warning in the session", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().WithEmptyRefreshToken().WithAccessToken(oidcUpstreamAccessToken, metav1.NewTime(time.Now().Add(1*time.Hour))).WithUserInfoURL().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().WithEmptyRefreshToken().WithAccessToken(oidcUpstreamAccessToken, metav1.NewTime(time.Now().Add(1*time.Hour))).WithUserInfoURL().Build()), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyOIDCUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -447,8 +443,8 @@ func TestCallbackEndpoint(t *testing.T) { Username: oidcUpstreamUsername, UpstreamUsername: oidcUpstreamUsername, UpstreamGroups: oidcUpstreamGroupMembership, - ProviderUID: happyUpstreamIDPResourceUID, - ProviderName: happyUpstreamIDPName, + ProviderUID: happyOIDCUpstreamIDPResourceUID, + ProviderName: happyOIDCUpstreamIDPName, ProviderType: psession.ProviderTypeOIDC, Warnings: []string{"Access token from identity provider has lifetime of less than 3 hours. Expect frequent prompts to log in."}, OIDC: &psession.OIDCSessionData{ @@ -458,14 +454,14 @@ func TestCallbackEndpoint(t *testing.T) { }, }, wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "upstream IDP provides no username or group claim configuration, so we use default username claim and skip groups", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC( - happyUpstream().WithoutUsernameClaim().WithoutGroupsClaim().Build(), + happyOIDCUpstream().WithoutUsernameClaim().WithoutGroupsClaim().Build(), ), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), @@ -473,7 +469,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyOIDCUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenGroups: []string{}, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -488,14 +484,14 @@ func TestCallbackEndpoint(t *testing.T) { nil, ), wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "upstream IDP configures username claim as special claim `email` and `email_verified` upstream claim is missing", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC( - happyUpstream().WithUsernameClaim("email").WithIDTokenClaim("email", "joe@whitehouse.gov").Build(), + happyOIDCUpstream().WithUsernameClaim("email").WithIDTokenClaim("email", "joe@whitehouse.gov").Build(), ), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), @@ -503,7 +499,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyOIDCUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: "joe@whitehouse.gov", wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -518,14 +514,14 @@ func TestCallbackEndpoint(t *testing.T) { oidcUpstreamGroupMembership, ), wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "upstream IDP configures username claim as special claim `email` and `email_verified` upstream claim is present with true value", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC( - happyUpstream().WithUsernameClaim("email"). + happyOIDCUpstream().WithUsernameClaim("email"). WithIDTokenClaim("email", "joe@whitehouse.gov"). WithIDTokenClaim("email_verified", true).Build(), ), @@ -535,7 +531,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyOIDCUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: "joe@whitehouse.gov", wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -550,14 +546,14 @@ func TestCallbackEndpoint(t *testing.T) { oidcUpstreamGroupMembership, ), wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "upstream IDP configures username claim as anything other than special claim `email` and `email_verified` upstream claim is present with false value", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC( - happyUpstream().WithUsernameClaim("some-claim"). + happyOIDCUpstream().WithUsernameClaim("some-claim"). WithIDTokenClaim("some-claim", "joe"). WithIDTokenClaim("email", "joe@whitehouse.gov"). WithIDTokenClaim("email_verified", false).Build(), @@ -568,7 +564,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, // succeed despite `email_verified=false` because we're not using the email claim for anything wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyOIDCUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: "joe", wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -583,13 +579,13 @@ func TestCallbackEndpoint(t *testing.T) { oidcUpstreamGroupMembership, ), wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "upstream IDP configures username claim as special claim `email` and `email_verified` upstream claim is present with illegal value", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().WithUsernameClaim("email"). + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().WithUsernameClaim("email"). WithIDTokenClaim("email", "joe@whitehouse.gov"). WithIDTokenClaim("email_verified", "supposed to be boolean").Build(), ), @@ -600,13 +596,13 @@ func TestCallbackEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBody: "Unprocessable Entity: email_verified claim in upstream ID token has invalid format\n", wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "return an error when upstream IDP returned no refresh token with an access token when there is no userinfo endpoint", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().WithoutRefreshToken().WithAccessToken(oidcUpstreamAccessToken, metav1.NewTime(time.Now().Add(9*time.Hour))).WithoutUserInfoURL().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().WithoutRefreshToken().WithAccessToken(oidcUpstreamAccessToken, metav1.NewTime(time.Now().Add(9*time.Hour))).WithoutUserInfoURL().Build()), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), csrfCookie: happyCSRFCookie, @@ -614,13 +610,13 @@ func TestCallbackEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBody: "Unprocessable Entity: access token was returned by upstream provider but there was no userinfo endpoint\n", wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "return an error when upstream IDP returned no refresh token and no access token", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().WithoutRefreshToken().WithoutAccessToken().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().WithoutRefreshToken().WithoutAccessToken().Build()), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), csrfCookie: happyCSRFCookie, @@ -628,13 +624,13 @@ func TestCallbackEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBody: "Unprocessable Entity: neither access token nor refresh token returned by upstream provider\n", wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "return an error when upstream IDP returned an empty refresh token and empty access token", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().WithEmptyRefreshToken().WithEmptyAccessToken().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().WithEmptyRefreshToken().WithEmptyAccessToken().Build()), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), csrfCookie: happyCSRFCookie, @@ -642,13 +638,13 @@ func TestCallbackEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBody: "Unprocessable Entity: neither access token nor refresh token returned by upstream provider\n", wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "return an error when upstream IDP returned no refresh token and empty access token", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().WithoutRefreshToken().WithEmptyAccessToken().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().WithoutRefreshToken().WithEmptyAccessToken().Build()), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), csrfCookie: happyCSRFCookie, @@ -656,13 +652,13 @@ func TestCallbackEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBody: "Unprocessable Entity: neither access token nor refresh token returned by upstream provider\n", wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "return an error when upstream IDP returned an empty refresh token and no access token", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().WithEmptyRefreshToken().WithoutAccessToken().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().WithEmptyRefreshToken().WithoutAccessToken().Build()), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), csrfCookie: happyCSRFCookie, @@ -670,14 +666,14 @@ func TestCallbackEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBody: "Unprocessable Entity: neither access token nor refresh token returned by upstream provider\n", wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "upstream IDP configures username claim as special claim `email` and `email_verified` upstream claim is present with false value", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC( - happyUpstream().WithUsernameClaim("email"). + happyOIDCUpstream().WithUsernameClaim("email"). WithIDTokenClaim("email", "joe@whitehouse.gov"). WithIDTokenClaim("email_verified", false).Build(), ), @@ -688,14 +684,14 @@ func TestCallbackEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBody: "Unprocessable Entity: email_verified claim in upstream ID token has false value\n", wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "upstream IDP provides username claim configuration as `sub`, so the downstream token subject should be exactly what they asked for", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC( - happyUpstream().WithUsernameClaim("sub").Build(), + happyOIDCUpstream().WithUsernameClaim("sub").Build(), ), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), @@ -703,7 +699,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyOIDCUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamSubject, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -718,14 +714,14 @@ func TestCallbackEndpoint(t *testing.T) { oidcUpstreamGroupMembership, ), wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "upstream IDP's configured groups claim in the ID token has a non-array value", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC( - happyUpstream().WithIDTokenClaim(oidcUpstreamGroupsClaim, "notAnArrayGroup1 notAnArrayGroup2").Build(), + happyOIDCUpstream().WithIDTokenClaim(oidcUpstreamGroupsClaim, "notAnArrayGroup1 notAnArrayGroup2").Build(), ), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), @@ -733,7 +729,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyOIDCUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenGroups: []string{"notAnArrayGroup1 notAnArrayGroup2"}, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -748,14 +744,14 @@ func TestCallbackEndpoint(t *testing.T) { []string{"notAnArrayGroup1 notAnArrayGroup2"}, ), wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "upstream IDP's configured groups claim in the ID token is a slice of interfaces", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC( - happyUpstream().WithIDTokenClaim(oidcUpstreamGroupsClaim, []interface{}{"group1", "group2"}).Build(), + happyOIDCUpstream().WithIDTokenClaim(oidcUpstreamGroupsClaim, []interface{}{"group1", "group2"}).Build(), ), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), @@ -763,7 +759,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyOIDCUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenGroups: []string{"group1", "group2"}, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -778,17 +774,17 @@ func TestCallbackEndpoint(t *testing.T) { []string{"group1", "group2"}, ), wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "using dynamic client which is allowed to request username scope, but does not actually request username scope in authorize request, does not get username in ID token", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, method: http.MethodGet, path: newRequestPath().WithState( - happyUpstreamStateParamForDynamicClient(). + happyOIDCUpstreamStateParamForDynamicClient(). WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQueryForDynamicClient, map[string]string{"scope": "openid groups offline_access"}).Encode()). Build(t, happyStateCodec), @@ -797,7 +793,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+groups&state=` + happyDownstreamState, wantBody: "", - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyOIDCUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: "", // username scope was not requested wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: []string{"openid", "groups", "offline_access"}, @@ -808,17 +804,17 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "using dynamic client which is allowed to request groups scope, but does not actually request groups scope in authorize request, does not get groups in ID token", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, method: http.MethodGet, path: newRequestPath().WithState( - happyUpstreamStateParamForDynamicClient(). + happyOIDCUpstreamStateParamForDynamicClient(). WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQueryForDynamicClient, map[string]string{"scope": "openid username offline_access"}).Encode()). Build(t, happyStateCodec), @@ -827,7 +823,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+username&state=` + happyDownstreamState, wantBody: "", - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyOIDCUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenGroups: nil, // groups scope was not requested wantDownstreamRequestedScopes: []string{"openid", "username", "offline_access"}, @@ -838,13 +834,13 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "using dynamic client which is not allowed to request username scope, and does not actually request username scope in authorize request, does not get username in ID token", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), kubeResources: func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) { oidcClient, secret := testutil.OIDCClientAndStorageSecret(t, "some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID, @@ -856,7 +852,7 @@ func TestCallbackEndpoint(t *testing.T) { }, method: http.MethodGet, path: newRequestPath().WithState( - happyUpstreamStateParam().WithAuthorizeRequestParams( + happyOIDCUpstreamStateParam().WithAuthorizeRequestParams( shallowCopyAndModifyQuery( happyDownstreamRequestParamsQuery, map[string]string{ @@ -870,7 +866,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+groups&state=` + happyDownstreamState, wantBody: "", - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyOIDCUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: "", // username scope was not requested wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: []string{"openid", "groups", "offline_access"}, @@ -881,13 +877,13 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "using dynamic client which is not allowed to request groups scope, and does not actually request groups scope in authorize request, does not get groups in ID token", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), kubeResources: func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) { oidcClient, secret := testutil.OIDCClientAndStorageSecret(t, "some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID, @@ -899,7 +895,7 @@ func TestCallbackEndpoint(t *testing.T) { }, method: http.MethodGet, path: newRequestPath().WithState( - happyUpstreamStateParam().WithAuthorizeRequestParams( + happyOIDCUpstreamStateParam().WithAuthorizeRequestParams( shallowCopyAndModifyQuery( happyDownstreamRequestParamsQuery, map[string]string{ @@ -913,7 +909,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+username&state=` + happyDownstreamState, wantBody: "", - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyOIDCUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenGroups: nil, // groups scope was not requested wantDownstreamRequestedScopes: []string{"openid", "username", "offline_access"}, @@ -924,21 +920,21 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "using identity transformations which modify the username and group names", idps: testidplister.NewUpstreamIDPListerBuilder(). - WithOIDC(happyUpstream().WithTransformsForFederationDomain(prefixUsernameAndGroupsPipeline).Build()), + WithOIDC(happyOIDCUpstream().WithTransformsForFederationDomain(prefixUsernameAndGroupsPipeline).Build()), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyOIDCUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: transformationUsernamePrefix + oidcUpstreamUsername, wantDownstreamIDTokenGroups: testutil.AddPrefixToEach(transformationGroupsPrefix, oidcUpstreamGroupMembership), wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -953,15 +949,15 @@ func TestCallbackEndpoint(t *testing.T) { oidcUpstreamGroupMembership, ), wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, // Pre-upstream-exchange verification { name: "PUT method is invalid", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), method: http.MethodPut, path: newRequestPath().String(), wantStatus: http.StatusMethodNotAllowed, @@ -970,7 +966,7 @@ func TestCallbackEndpoint(t *testing.T) { }, { name: "POST method is invalid", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), method: http.MethodPost, path: newRequestPath().String(), wantStatus: http.StatusMethodNotAllowed, @@ -979,7 +975,7 @@ func TestCallbackEndpoint(t *testing.T) { }, { name: "PATCH method is invalid", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), method: http.MethodPatch, path: newRequestPath().String(), wantStatus: http.StatusMethodNotAllowed, @@ -988,7 +984,7 @@ func TestCallbackEndpoint(t *testing.T) { }, { name: "DELETE method is invalid", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), method: http.MethodDelete, path: newRequestPath().String(), wantStatus: http.StatusMethodNotAllowed, @@ -997,7 +993,7 @@ func TestCallbackEndpoint(t *testing.T) { }, { name: "code param was not included on request", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), method: http.MethodGet, path: newRequestPath().WithState(happyState).WithoutCode().String(), csrfCookie: happyCSRFCookie, @@ -1007,7 +1003,7 @@ func TestCallbackEndpoint(t *testing.T) { }, { name: "state param was not included on request", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), method: http.MethodGet, path: newRequestPath().WithoutState().String(), csrfCookie: happyCSRFCookie, @@ -1017,7 +1013,7 @@ func TestCallbackEndpoint(t *testing.T) { }, { name: "state param was not signed correctly, has expired, or otherwise cannot be decoded for any reason", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), method: http.MethodGet, path: newRequestPath().WithState("this-will-not-decode").String(), csrfCookie: happyCSRFCookie, @@ -1029,18 +1025,18 @@ func TestCallbackEndpoint(t *testing.T) { // This shouldn't happen in practice because the authorize endpoint should have already run the same // validations, but we would like to test the error handling in this endpoint anyway. name: "state param contains authorization request params which fail validation", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), method: http.MethodGet, path: newRequestPath().WithState( - happyUpstreamStateParam(). + happyOIDCUpstreamStateParam(). WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, map[string]string{"prompt": "none login"}).Encode()). Build(t, happyStateCodec), ).String(), csrfCookie: happyCSRFCookie, wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, wantStatus: http.StatusInternalServerError, @@ -1049,9 +1045,9 @@ func TestCallbackEndpoint(t *testing.T) { }, { name: "state's internal version does not match what we want", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), method: http.MethodGet, - path: newRequestPath().WithState(happyUpstreamStateParam().WithStateVersion("wrong-state-version").Build(t, happyStateCodec)).String(), + path: newRequestPath().WithState(happyOIDCUpstreamStateParam().WithStateVersion("wrong-state-version").Build(t, happyStateCodec)).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, @@ -1059,9 +1055,9 @@ func TestCallbackEndpoint(t *testing.T) { }, { name: "state's downstream auth params element is invalid", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), method: http.MethodGet, - path: newRequestPath().WithState(happyUpstreamStateParam(). + path: newRequestPath().WithState(happyOIDCUpstreamStateParam(). WithAuthorizeRequestParams("the following is an invalid url encoding token, and therefore this is an invalid param: %z"). Build(t, happyStateCodec)).String(), csrfCookie: happyCSRFCookie, @@ -1071,10 +1067,10 @@ func TestCallbackEndpoint(t *testing.T) { }, { name: "state's downstream auth params are missing required value (e.g., client_id)", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), method: http.MethodGet, path: newRequestPath().WithState( - happyUpstreamStateParam(). + happyOIDCUpstreamStateParam(). WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, map[string]string{"client_id": ""}).Encode()). Build(t, happyStateCodec), @@ -1086,10 +1082,10 @@ func TestCallbackEndpoint(t *testing.T) { }, { name: "state's downstream auth params have invalid client_id", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), method: http.MethodGet, path: newRequestPath().WithState( - happyUpstreamStateParam(). + happyOIDCUpstreamStateParam(). WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, map[string]string{"client_id": "bogus"}).Encode()). Build(t, happyStateCodec), @@ -1101,11 +1097,11 @@ func TestCallbackEndpoint(t *testing.T) { }, { name: "dynamic clients do not allow response_mode=form_post", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, method: http.MethodGet, path: newRequestPath().WithState( - happyUpstreamStateParam().WithAuthorizeRequestParams( + happyOIDCUpstreamStateParam().WithAuthorizeRequestParams( shallowCopyAndModifyQuery( happyDownstreamRequestParamsQuery, map[string]string{ @@ -1123,7 +1119,7 @@ func TestCallbackEndpoint(t *testing.T) { }, { name: "using dynamic client which is not allowed to request username scope in authorize request but requests it anyway", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), kubeResources: func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) { oidcClient, secret := testutil.OIDCClientAndStorageSecret(t, "some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID, @@ -1135,7 +1131,7 @@ func TestCallbackEndpoint(t *testing.T) { }, method: http.MethodGet, path: newRequestPath().WithState( - happyUpstreamStateParam().WithAuthorizeRequestParams( + happyOIDCUpstreamStateParam().WithAuthorizeRequestParams( shallowCopyAndModifyQuery( happyDownstreamRequestParamsQuery, map[string]string{ @@ -1152,7 +1148,7 @@ func TestCallbackEndpoint(t *testing.T) { }, { name: "using dynamic client which is not allowed to request groups scope in authorize request but requests it anyway", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), kubeResources: func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) { oidcClient, secret := testutil.OIDCClientAndStorageSecret(t, "some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID, @@ -1164,7 +1160,7 @@ func TestCallbackEndpoint(t *testing.T) { }, method: http.MethodGet, path: newRequestPath().WithState( - happyUpstreamStateParam().WithAuthorizeRequestParams( + happyOIDCUpstreamStateParam().WithAuthorizeRequestParams( shallowCopyAndModifyQuery( happyDownstreamRequestParamsQuery, map[string]string{ @@ -1181,11 +1177,11 @@ func TestCallbackEndpoint(t *testing.T) { }, { name: "state's downstream auth params does not contain openid scope", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), method: http.MethodGet, path: newRequestPath(). WithState( - happyUpstreamStateParam(). + happyOIDCUpstreamStateParam(). WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, map[string]string{"scope": "profile username email groups"}).Encode()). Build(t, happyStateCodec), @@ -1194,7 +1190,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=username\+groups&state=` + happyDownstreamState, wantDownstreamIDTokenUsername: oidcUpstreamUsername, - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyOIDCUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamRequestedScopes: []string{"profile", "email", "username", "groups"}, wantDownstreamGrantedScopes: []string{"username", "groups"}, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, @@ -1204,17 +1200,17 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "state's downstream auth params does not contain openid, username, or groups scope", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), method: http.MethodGet, path: newRequestPath(). WithState( - happyUpstreamStateParam(). + happyOIDCUpstreamStateParam(). WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, map[string]string{"scope": "profile email"}).Encode()). Build(t, happyStateCodec), @@ -1224,7 +1220,7 @@ func TestCallbackEndpoint(t *testing.T) { wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=username\+groups&state=` + happyDownstreamState, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyOIDCUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamRequestedScopes: []string{"profile", "email"}, // username and groups scopes were not requested but are granted anyway for the pinniped-cli client for backwards compatibility wantDownstreamGrantedScopes: []string{"username", "groups"}, @@ -1234,17 +1230,17 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "state's downstream auth params also included offline_access scope", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), method: http.MethodGet, path: newRequestPath(). WithState( - happyUpstreamStateParam(). + happyOIDCUpstreamStateParam(). WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, map[string]string{"scope": "openid offline_access username groups"}).Encode()). Build(t, happyStateCodec), @@ -1253,7 +1249,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+username\+groups&state=` + happyDownstreamState, wantDownstreamIDTokenUsername: oidcUpstreamUsername, - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyOIDCUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamRequestedScopes: []string{"openid", "offline_access", "username", "groups"}, wantDownstreamGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, @@ -1263,8 +1259,8 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { @@ -1280,8 +1276,8 @@ func TestCallbackEndpoint(t *testing.T) { Build()), method: http.MethodGet, path: newRequestPath().WithState( - happyUpstreamStateParam(). - WithUpstreamIDPName(githubIDPName). + happyOIDCUpstreamStateParam(). + WithUpstreamIDPName(happyGithubIDPName). WithUpstreamIDPType(idpdiscoveryv1alpha1.IDPTypeGitHub). WithAuthorizeRequestParams( happyDownstreamRequestParamsQuery.Encode(), @@ -1302,8 +1298,8 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamGitHubCustomSessionData, wantGitHubAuthcodeExchangeCall: &expectedGitHubAuthcodeExchange{ - performedByUpstreamName: githubIDPName, - args: happyGitHubExchangeAuthcodeArgs, + performedByUpstreamName: happyGithubIDPName, + args: happyGitHubUpstreamExchangeAuthcodeArgs, }, }, { @@ -1320,8 +1316,8 @@ func TestCallbackEndpoint(t *testing.T) { method: http.MethodGet, kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, path: newRequestPath().WithState( - happyUpstreamStateParam(). - WithUpstreamIDPName(githubIDPName). + happyOIDCUpstreamStateParam(). + WithUpstreamIDPName(happyGithubIDPName). WithUpstreamIDPType(idpdiscoveryv1alpha1.IDPTypeGitHub). WithAuthorizeRequestParams( shallowCopyAndModifyQuery( @@ -1347,8 +1343,8 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamGitHubCustomSessionData, wantGitHubAuthcodeExchangeCall: &expectedGitHubAuthcodeExchange{ - performedByUpstreamName: githubIDPName, - args: happyGitHubExchangeAuthcodeArgs, + performedByUpstreamName: happyGithubIDPName, + args: happyGitHubUpstreamExchangeAuthcodeArgs, }, }, { @@ -1363,7 +1359,7 @@ func TestCallbackEndpoint(t *testing.T) { }, { name: "the CSRF cookie does not exist on request", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), wantStatus: http.StatusForbidden, @@ -1372,7 +1368,7 @@ func TestCallbackEndpoint(t *testing.T) { }, { name: "cookie was not signed correctly, has expired, or otherwise cannot be decoded for any reason", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), csrfCookie: "__Host-pinniped-csrf=this-value-was-not-signed-by-pinniped", @@ -1382,9 +1378,9 @@ func TestCallbackEndpoint(t *testing.T) { }, { name: "cookie csrf value does not match state csrf value", - idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), method: http.MethodGet, - path: newRequestPath().WithState(happyUpstreamStateParam().WithCSRF("wrong-csrf-value").Build(t, happyStateCodec)).String(), + path: newRequestPath().WithState(happyOIDCUpstreamStateParam().WithCSRF("wrong-csrf-value").Build(t, happyStateCodec)).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusForbidden, wantContentType: htmlContentType, @@ -1395,7 +1391,7 @@ func TestCallbackEndpoint(t *testing.T) { { name: "upstream auth code exchange fails", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC( - happyUpstream().WithUpstreamAuthcodeExchangeError(errors.New("some error")).Build(), + happyOIDCUpstream().WithUpstreamAuthcodeExchangeError(errors.New("some error")).Build(), ), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), @@ -1404,14 +1400,14 @@ func TestCallbackEndpoint(t *testing.T) { wantBody: "Bad Gateway: error exchanging and validating upstream tokens\n", wantContentType: htmlContentType, wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "upstream ID token does not contain requested username claim", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC( - happyUpstream().WithoutIDTokenClaim(oidcUpstreamUsernameClaim).Build(), + happyOIDCUpstream().WithoutIDTokenClaim(oidcUpstreamUsernameClaim).Build(), ), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), @@ -1420,14 +1416,14 @@ func TestCallbackEndpoint(t *testing.T) { wantBody: "Unprocessable Entity: required claim in upstream ID token missing\n", wantContentType: htmlContentType, wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "upstream ID token does not contain requested groups claim", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC( - happyUpstream().WithoutIDTokenClaim(oidcUpstreamGroupsClaim).Build(), + happyOIDCUpstream().WithoutIDTokenClaim(oidcUpstreamGroupsClaim).Build(), ), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), @@ -1435,7 +1431,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyOIDCUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, wantDownstreamGrantedScopes: happyDownstreamScopesGranted, @@ -1450,14 +1446,14 @@ func TestCallbackEndpoint(t *testing.T) { nil, ), wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "upstream ID token contains username claim with weird format", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC( - happyUpstream().WithIDTokenClaim(oidcUpstreamUsernameClaim, 42).Build(), + happyOIDCUpstream().WithIDTokenClaim(oidcUpstreamUsernameClaim, 42).Build(), ), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), @@ -1466,14 +1462,14 @@ func TestCallbackEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBody: "Unprocessable Entity: required claim in upstream ID token has invalid format\n", wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "upstream ID token contains username claim with empty string value", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC( - happyUpstream().WithIDTokenClaim(oidcUpstreamUsernameClaim, "").Build(), + happyOIDCUpstream().WithIDTokenClaim(oidcUpstreamUsernameClaim, "").Build(), ), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), @@ -1482,14 +1478,14 @@ func TestCallbackEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBody: "Unprocessable Entity: required claim in upstream ID token is empty\n", wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "upstream ID token does not contain iss claim when using default username claim config", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC( - happyUpstream().WithoutIDTokenClaim("iss").WithoutUsernameClaim().Build(), + happyOIDCUpstream().WithoutIDTokenClaim("iss").WithoutUsernameClaim().Build(), ), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), @@ -1498,14 +1494,14 @@ func TestCallbackEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBody: "Unprocessable Entity: required claim in upstream ID token missing\n", wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "upstream ID token does has an empty string value for iss claim when using default username claim config", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC( - happyUpstream().WithIDTokenClaim("iss", "").WithoutUsernameClaim().Build(), + happyOIDCUpstream().WithIDTokenClaim("iss", "").WithoutUsernameClaim().Build(), ), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), @@ -1514,14 +1510,14 @@ func TestCallbackEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBody: "Unprocessable Entity: required claim in upstream ID token is empty\n", wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "upstream ID token has an non-string iss claim when using default username claim config", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC( - happyUpstream().WithIDTokenClaim("iss", 42).WithoutUsernameClaim().Build(), + happyOIDCUpstream().WithIDTokenClaim("iss", 42).WithoutUsernameClaim().Build(), ), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), @@ -1530,14 +1526,14 @@ func TestCallbackEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBody: "Unprocessable Entity: required claim in upstream ID token has invalid format\n", wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "upstream ID token does not contain sub claim when using default username claim config", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC( - happyUpstream().WithoutIDTokenClaim("sub").WithoutUsernameClaim().Build(), + happyOIDCUpstream().WithoutIDTokenClaim("sub").WithoutUsernameClaim().Build(), ), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), @@ -1546,14 +1542,14 @@ func TestCallbackEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBody: "Unprocessable Entity: required claim in upstream ID token missing\n", wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "upstream ID token does has an empty string value for sub claim when using default username claim config", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC( - happyUpstream().WithIDTokenClaim("sub", "").WithoutUsernameClaim().Build(), + happyOIDCUpstream().WithIDTokenClaim("sub", "").WithoutUsernameClaim().Build(), ), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), @@ -1562,14 +1558,14 @@ func TestCallbackEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBody: "Unprocessable Entity: required claim in upstream ID token is empty\n", wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "upstream ID token has an non-string sub claim when using default username claim config", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC( - happyUpstream().WithIDTokenClaim("sub", 42).WithoutUsernameClaim().Build(), + happyOIDCUpstream().WithIDTokenClaim("sub", 42).WithoutUsernameClaim().Build(), ), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), @@ -1578,14 +1574,14 @@ func TestCallbackEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBody: "Unprocessable Entity: required claim in upstream ID token has invalid format\n", wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "upstream ID token contains groups claim with weird format", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC( - happyUpstream().WithIDTokenClaim(oidcUpstreamGroupsClaim, 42).Build(), + happyOIDCUpstream().WithIDTokenClaim(oidcUpstreamGroupsClaim, 42).Build(), ), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), @@ -1594,14 +1590,14 @@ func TestCallbackEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBody: "Unprocessable Entity: required claim in upstream ID token has invalid format\n", wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "upstream ID token contains groups claim where one element is invalid", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC( - happyUpstream().WithIDTokenClaim(oidcUpstreamGroupsClaim, []interface{}{"foo", 7}).Build(), + happyOIDCUpstream().WithIDTokenClaim(oidcUpstreamGroupsClaim, []interface{}{"foo", 7}).Build(), ), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), @@ -1610,14 +1606,14 @@ func TestCallbackEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBody: "Unprocessable Entity: required claim in upstream ID token has invalid format\n", wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "upstream ID token contains groups claim with invalid null type", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC( - happyUpstream().WithIDTokenClaim(oidcUpstreamGroupsClaim, nil).Build(), + happyOIDCUpstream().WithIDTokenClaim(oidcUpstreamGroupsClaim, nil).Build(), ), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), @@ -1626,14 +1622,14 @@ func TestCallbackEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBody: "Unprocessable Entity: required claim in upstream ID token has invalid format\n", wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { name: "using identity transformations which reject the authentication", idps: testidplister.NewUpstreamIDPListerBuilder(). - WithOIDC(happyUpstream().WithTransformsForFederationDomain(rejectAuthPipeline).Build()), + WithOIDC(happyOIDCUpstream().WithTransformsForFederationDomain(rejectAuthPipeline).Build()), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), csrfCookie: happyCSRFCookie, @@ -1641,8 +1637,8 @@ func TestCallbackEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBody: "Unprocessable Entity: configured identity policy rejected this authentication: authentication was rejected by a configured policy\n", wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ - performedByUpstreamName: happyUpstreamIDPName, - args: happyExchangeAndValidateTokensArgs, + performedByUpstreamName: happyOIDCUpstreamIDPName, + args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, } @@ -1816,28 +1812,28 @@ func (r *requestPath) String() string { return path + params.Encode() } -func happyUpstreamStateParam() *oidctestutil.UpstreamStateParamBuilder { +func happyOIDCUpstreamStateParam() *oidctestutil.UpstreamStateParamBuilder { return &oidctestutil.UpstreamStateParamBuilder{ - U: happyUpstreamIDPName, + U: happyOIDCUpstreamIDPName, P: happyDownstreamRequestParams, T: "oidc", N: happyDownstreamNonce, C: happyDownstreamCSRF, - K: happyDownstreamPKCE, + K: happyDownstreamPKCEVerifier, V: happyDownstreamStateVersion, } } -func happyUpstreamStateParamForDynamicClient() *oidctestutil.UpstreamStateParamBuilder { - p := happyUpstreamStateParam() +func happyOIDCUpstreamStateParamForDynamicClient() *oidctestutil.UpstreamStateParamBuilder { + p := happyOIDCUpstreamStateParam() p.P = happyDownstreamRequestParamsForDynamicClient return p } -func happyUpstream() *oidctestutil.TestUpstreamOIDCIdentityProviderBuilder { +func happyOIDCUpstream() *oidctestutil.TestUpstreamOIDCIdentityProviderBuilder { return oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder(). - WithName(happyUpstreamIDPName). - WithResourceUID(happyUpstreamIDPResourceUID). + WithName(happyOIDCUpstreamIDPName). + WithResourceUID(happyOIDCUpstreamIDPResourceUID). WithClientID("some-client-id"). WithScopes([]string{"scope1", "scope2"}). WithUsernameClaim(oidcUpstreamUsernameClaim). @@ -1852,6 +1848,14 @@ func happyUpstream() *oidctestutil.TestUpstreamOIDCIdentityProviderBuilder { WithPasswordGrantError(errors.New("the callback endpoint should not use password grants")) } +func happyGitHubUpstream() *oidctestutil.TestUpstreamGitHubIdentityProviderBuilder { + return oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder(). + WithName(happyGithubIDPName). + WithResourceUID(happyGithubIDPResourceUID). + WithClientID("some-client-id"). + WithScopes([]string{"these", "scopes", "appear", "unused"}) +} + func shallowCopyAndModifyQuery(query url.Values, modifications map[string]string) url.Values { copied := url.Values{} for key, value := range query { From 8b1e5aa320edfacdf01ecc7862dc424cb39e118d Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Tue, 28 May 2024 15:16:48 -0500 Subject: [PATCH 20/25] Add callback_handler tests to confirm GitHub with downstream form_post and GitHub with an error case Co-authored-by: Ryan Richard --- .../callback/callback_handler_test.go | 189 +++++++++++------- .../resolved_github_provider.go | 10 +- .../resolved_github_provider_test.go | 8 +- 3 files changed, 129 insertions(+), 78 deletions(-) diff --git a/internal/federationdomain/endpoints/callback/callback_handler_test.go b/internal/federationdomain/endpoints/callback/callback_handler_test.go index aee21533b..dfcfc0ee1 100644 --- a/internal/federationdomain/endpoints/callback/callback_handler_test.go +++ b/internal/federationdomain/endpoints/callback/callback_handler_test.go @@ -21,7 +21,6 @@ import ( "k8s.io/client-go/kubernetes/fake" configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" - idpdiscoveryv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1" supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake" "go.pinniped.dev/internal/federationdomain/endpoints/jwks" "go.pinniped.dev/internal/federationdomain/oidc" @@ -178,8 +177,8 @@ func TestCallbackEndpoint(t *testing.T) { var happyCookieCodec = securecookie.New(cookieEncoderHashKey, cookieEncoderBlockKey) happyCookieCodec.SetSerializer(securecookie.JSONEncoder{}) - happyState := happyOIDCUpstreamStateParam().Build(t, happyStateCodec) - happyStateForDynamicClient := happyOIDCUpstreamStateParamForDynamicClient().Build(t, happyStateCodec) + happyOIDCState := happyOIDCUpstreamStateParam().Build(t, happyStateCodec) + happyOIDCStateForDynamicClient := happyOIDCUpstreamStateParamForDynamicClient().Build(t, happyStateCodec) encodedIncomingCookieCSRFValue, err := happyCookieCodec.Encode("csrf", happyDownstreamCSRF) require.NoError(t, err) @@ -240,7 +239,7 @@ func TestCallbackEndpoint(t *testing.T) { wantGitHubAuthcodeExchangeCall *expectedGitHubAuthcodeExchange }{ { - name: "GET with good state and cookie and successful upstream token exchange with response_mode=form_post returns 200 with HTML+JS form", + name: "OIDC: GET with good state and cookie and successful upstream token exchange with response_mode=form_post returns 200 with HTML+JS form", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), method: http.MethodGet, path: newRequestPath().WithState( @@ -270,6 +269,37 @@ func TestCallbackEndpoint(t *testing.T) { args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, + { + name: "GitHub: GET with good state and cookie and successful upstream token exchange with response_mode=form_post returns 200 with HTML+JS form", + idps: testidplister.NewUpstreamIDPListerBuilder().WithGitHub(happyGitHubUpstream().Build()), + method: http.MethodGet, + path: newRequestPath().WithState( + happyGitHubUpstreamStateParam().WithAuthorizeRequestParams( + shallowCopyAndModifyQuery( + happyDownstreamRequestParamsQuery, + map[string]string{"response_mode": "form_post"}, + ).Encode(), + ).Build(t, happyStateCodec), + ).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusOK, + wantContentType: "text/html;charset=UTF-8", + wantBodyFormResponseRegexp: `(.+)`, + wantDownstreamIDTokenSubject: githubDownstreamSubject, + wantDownstreamIDTokenUsername: githubUpstreamUsername, + wantDownstreamIDTokenGroups: githubUpstreamGroups, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamClientID: downstreamPinnipedClientID, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: happyDownstreamGitHubCustomSessionData, + wantGitHubAuthcodeExchangeCall: &expectedGitHubAuthcodeExchange{ + performedByUpstreamName: happyGithubIDPName, + args: happyGitHubUpstreamExchangeAuthcodeArgs, + }, + }, { name: "GET with good state and cookie with additional params", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream(). @@ -317,7 +347,7 @@ func TestCallbackEndpoint(t *testing.T) { name: "GET with good state and cookie and successful upstream token exchange returns 303 to downstream client callback with its state and code", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, @@ -342,7 +372,7 @@ func TestCallbackEndpoint(t *testing.T) { idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, method: http.MethodGet, - path: newRequestPath().WithState(happyStateForDynamicClient).String(), + path: newRequestPath().WithState(happyOIDCStateForDynamicClient).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, @@ -366,7 +396,7 @@ func TestCallbackEndpoint(t *testing.T) { name: "GET with authcode exchange that returns an access token but no refresh token when there is a userinfo endpoint returns 303 to downstream client callback with its state and code", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().WithEmptyRefreshToken().WithAccessToken(oidcUpstreamAccessToken, metav1.NewTime(time.Now().Add(9*time.Hour))).WithUserInfoURL().Build()), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, @@ -425,7 +455,7 @@ func TestCallbackEndpoint(t *testing.T) { name: "GET with authcode exchange that returns an access token but no refresh token but has a short token lifetime which is stored as a warning in the session", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().WithEmptyRefreshToken().WithAccessToken(oidcUpstreamAccessToken, metav1.NewTime(time.Now().Add(1*time.Hour))).WithUserInfoURL().Build()), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, @@ -464,7 +494,7 @@ func TestCallbackEndpoint(t *testing.T) { happyOIDCUpstream().WithoutUsernameClaim().WithoutGroupsClaim().Build(), ), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, @@ -494,7 +524,7 @@ func TestCallbackEndpoint(t *testing.T) { happyOIDCUpstream().WithUsernameClaim("email").WithIDTokenClaim("email", "joe@whitehouse.gov").Build(), ), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, @@ -526,7 +556,7 @@ func TestCallbackEndpoint(t *testing.T) { WithIDTokenClaim("email_verified", true).Build(), ), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, @@ -559,7 +589,7 @@ func TestCallbackEndpoint(t *testing.T) { WithIDTokenClaim("email_verified", false).Build(), ), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusSeeOther, // succeed despite `email_verified=false` because we're not using the email claim for anything wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, @@ -590,7 +620,7 @@ func TestCallbackEndpoint(t *testing.T) { WithIDTokenClaim("email_verified", "supposed to be boolean").Build(), ), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, @@ -604,7 +634,7 @@ func TestCallbackEndpoint(t *testing.T) { name: "return an error when upstream IDP returned no refresh token with an access token when there is no userinfo endpoint", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().WithoutRefreshToken().WithAccessToken(oidcUpstreamAccessToken, metav1.NewTime(time.Now().Add(9*time.Hour))).WithoutUserInfoURL().Build()), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, @@ -618,7 +648,7 @@ func TestCallbackEndpoint(t *testing.T) { name: "return an error when upstream IDP returned no refresh token and no access token", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().WithoutRefreshToken().WithoutAccessToken().Build()), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, @@ -632,7 +662,7 @@ func TestCallbackEndpoint(t *testing.T) { name: "return an error when upstream IDP returned an empty refresh token and empty access token", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().WithEmptyRefreshToken().WithEmptyAccessToken().Build()), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, @@ -646,7 +676,7 @@ func TestCallbackEndpoint(t *testing.T) { name: "return an error when upstream IDP returned no refresh token and empty access token", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().WithoutRefreshToken().WithEmptyAccessToken().Build()), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, @@ -660,7 +690,7 @@ func TestCallbackEndpoint(t *testing.T) { name: "return an error when upstream IDP returned an empty refresh token and no access token", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().WithEmptyRefreshToken().WithoutAccessToken().Build()), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, @@ -678,7 +708,7 @@ func TestCallbackEndpoint(t *testing.T) { WithIDTokenClaim("email_verified", false).Build(), ), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, @@ -694,7 +724,7 @@ func TestCallbackEndpoint(t *testing.T) { happyOIDCUpstream().WithUsernameClaim("sub").Build(), ), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, @@ -724,7 +754,7 @@ func TestCallbackEndpoint(t *testing.T) { happyOIDCUpstream().WithIDTokenClaim(oidcUpstreamGroupsClaim, "notAnArrayGroup1 notAnArrayGroup2").Build(), ), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, @@ -754,7 +784,7 @@ func TestCallbackEndpoint(t *testing.T) { happyOIDCUpstream().WithIDTokenClaim(oidcUpstreamGroupsClaim, []interface{}{"group1", "group2"}).Build(), ), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, @@ -929,7 +959,7 @@ func TestCallbackEndpoint(t *testing.T) { idps: testidplister.NewUpstreamIDPListerBuilder(). WithOIDC(happyOIDCUpstream().WithTransformsForFederationDomain(prefixUsernameAndGroupsPipeline).Build()), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, @@ -995,7 +1025,7 @@ func TestCallbackEndpoint(t *testing.T) { name: "code param was not included on request", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), method: http.MethodGet, - path: newRequestPath().WithState(happyState).WithoutCode().String(), + path: newRequestPath().WithState(happyOIDCState).WithoutCode().String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusBadRequest, wantContentType: htmlContentType, @@ -1264,21 +1294,11 @@ func TestCallbackEndpoint(t *testing.T) { }, }, { - name: "GitHub IDP: GET with good state and cookie and successful upstream token exchange returns 303 to downstream client callback", - idps: testidplister.NewUpstreamIDPListerBuilder().WithGitHub( - happyGitHubUpstream(). - WithAccessToken(githubUpstreamAccessToken). - WithUser(&upstreamprovider.GitHubUser{ - Username: githubUpstreamUsername, - Groups: githubUpstreamGroups, - DownstreamSubject: githubDownstreamSubject, - }). - Build()), + name: "GitHub IDP: GET with good state and cookie and successful upstream token exchange returns 303 to downstream client callback", + idps: testidplister.NewUpstreamIDPListerBuilder().WithGitHub(happyGitHubUpstream().Build()), method: http.MethodGet, path: newRequestPath().WithState( - happyOIDCUpstreamStateParam(). - WithUpstreamIDPName(happyGithubIDPName). - WithUpstreamIDPType(idpdiscoveryv1alpha1.IDPTypeGitHub). + happyGitHubUpstreamStateParam(). WithAuthorizeRequestParams( happyDownstreamRequestParamsQuery.Encode(), ).Build(t, happyStateCodec), @@ -1303,22 +1323,12 @@ func TestCallbackEndpoint(t *testing.T) { }, }, { - name: "GitHub IDP: GET with good state and cookie and successful upstream token exchange with dynamic client returns 303 to downstream client callback, with dynamic client", - idps: testidplister.NewUpstreamIDPListerBuilder().WithGitHub( - happyGitHubUpstream(). - WithAccessToken(githubUpstreamAccessToken). - WithUser(&upstreamprovider.GitHubUser{ - Username: githubUpstreamUsername, - Groups: githubUpstreamGroups, - DownstreamSubject: githubDownstreamSubject, - }). - Build()), + name: "GitHub IDP: GET with good state and cookie and successful upstream token exchange with dynamic client returns 303 to downstream client callback, with dynamic client", + idps: testidplister.NewUpstreamIDPListerBuilder().WithGitHub(happyGitHubUpstream().Build()), method: http.MethodGet, kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, path: newRequestPath().WithState( - happyOIDCUpstreamStateParam(). - WithUpstreamIDPName(happyGithubIDPName). - WithUpstreamIDPType(idpdiscoveryv1alpha1.IDPTypeGitHub). + happyGitHubUpstreamStateParam(). WithAuthorizeRequestParams( shallowCopyAndModifyQuery( happyDownstreamRequestParamsQuery, @@ -1351,7 +1361,7 @@ func TestCallbackEndpoint(t *testing.T) { name: "the OIDCIdentityProvider resource has been deleted", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(otherUpstreamOIDCIdentityProvider), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, @@ -1361,7 +1371,7 @@ func TestCallbackEndpoint(t *testing.T) { name: "the CSRF cookie does not exist on request", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), wantStatus: http.StatusForbidden, wantContentType: htmlContentType, wantBody: "Forbidden: CSRF cookie is missing\n", @@ -1370,7 +1380,7 @@ func TestCallbackEndpoint(t *testing.T) { name: "cookie was not signed correctly, has expired, or otherwise cannot be decoded for any reason", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: "__Host-pinniped-csrf=this-value-was-not-signed-by-pinniped", wantStatus: http.StatusForbidden, wantContentType: htmlContentType, @@ -1389,12 +1399,12 @@ func TestCallbackEndpoint(t *testing.T) { // Upstream exchange { - name: "upstream auth code exchange fails", + name: "OIDC: upstream auth code exchange fails", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC( happyOIDCUpstream().WithUpstreamAuthcodeExchangeError(errors.New("some error")).Build(), ), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusBadGateway, wantBody: "Bad Gateway: error exchanging and validating upstream tokens\n", @@ -1404,13 +1414,34 @@ func TestCallbackEndpoint(t *testing.T) { args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, + { + name: "GitHub upstream auth code exchange fails", + idps: testidplister.NewUpstreamIDPListerBuilder().WithGitHub( + happyGitHubUpstream().WithAuthcodeExchangeError(errors.New("some error")).Build(), + ), + method: http.MethodGet, + path: newRequestPath().WithState( + happyGitHubUpstreamStateParam(). + WithAuthorizeRequestParams( + happyDownstreamRequestParamsQuery.Encode(), + ).Build(t, happyStateCodec), + ).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusBadGateway, + wantBody: "Bad Gateway: failed to exchange authcode using GitHub API\n", + wantContentType: htmlContentType, + wantGitHubAuthcodeExchangeCall: &expectedGitHubAuthcodeExchange{ + performedByUpstreamName: happyGithubIDPName, + args: happyGitHubUpstreamExchangeAuthcodeArgs, + }, + }, { name: "upstream ID token does not contain requested username claim", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC( happyOIDCUpstream().WithoutIDTokenClaim(oidcUpstreamUsernameClaim).Build(), ), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, wantBody: "Unprocessable Entity: required claim in upstream ID token missing\n", @@ -1426,7 +1457,7 @@ func TestCallbackEndpoint(t *testing.T) { happyOIDCUpstream().WithoutIDTokenClaim(oidcUpstreamGroupsClaim).Build(), ), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, @@ -1456,7 +1487,7 @@ func TestCallbackEndpoint(t *testing.T) { happyOIDCUpstream().WithIDTokenClaim(oidcUpstreamUsernameClaim, 42).Build(), ), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, @@ -1472,7 +1503,7 @@ func TestCallbackEndpoint(t *testing.T) { happyOIDCUpstream().WithIDTokenClaim(oidcUpstreamUsernameClaim, "").Build(), ), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, @@ -1488,7 +1519,7 @@ func TestCallbackEndpoint(t *testing.T) { happyOIDCUpstream().WithoutIDTokenClaim("iss").WithoutUsernameClaim().Build(), ), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, @@ -1504,7 +1535,7 @@ func TestCallbackEndpoint(t *testing.T) { happyOIDCUpstream().WithIDTokenClaim("iss", "").WithoutUsernameClaim().Build(), ), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, @@ -1520,7 +1551,7 @@ func TestCallbackEndpoint(t *testing.T) { happyOIDCUpstream().WithIDTokenClaim("iss", 42).WithoutUsernameClaim().Build(), ), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, @@ -1536,7 +1567,7 @@ func TestCallbackEndpoint(t *testing.T) { happyOIDCUpstream().WithoutIDTokenClaim("sub").WithoutUsernameClaim().Build(), ), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, @@ -1552,7 +1583,7 @@ func TestCallbackEndpoint(t *testing.T) { happyOIDCUpstream().WithIDTokenClaim("sub", "").WithoutUsernameClaim().Build(), ), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, @@ -1568,7 +1599,7 @@ func TestCallbackEndpoint(t *testing.T) { happyOIDCUpstream().WithIDTokenClaim("sub", 42).WithoutUsernameClaim().Build(), ), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, @@ -1584,7 +1615,7 @@ func TestCallbackEndpoint(t *testing.T) { happyOIDCUpstream().WithIDTokenClaim(oidcUpstreamGroupsClaim, 42).Build(), ), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, @@ -1600,7 +1631,7 @@ func TestCallbackEndpoint(t *testing.T) { happyOIDCUpstream().WithIDTokenClaim(oidcUpstreamGroupsClaim, []interface{}{"foo", 7}).Build(), ), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, @@ -1616,7 +1647,7 @@ func TestCallbackEndpoint(t *testing.T) { happyOIDCUpstream().WithIDTokenClaim(oidcUpstreamGroupsClaim, nil).Build(), ), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, @@ -1631,7 +1662,7 @@ func TestCallbackEndpoint(t *testing.T) { idps: testidplister.NewUpstreamIDPListerBuilder(). WithOIDC(happyOIDCUpstream().WithTransformsForFederationDomain(rejectAuthPipeline).Build()), method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), + path: newRequestPath().WithState(happyOIDCState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, @@ -1824,6 +1855,18 @@ func happyOIDCUpstreamStateParam() *oidctestutil.UpstreamStateParamBuilder { } } +func happyGitHubUpstreamStateParam() *oidctestutil.UpstreamStateParamBuilder { + return &oidctestutil.UpstreamStateParamBuilder{ + U: happyGithubIDPName, + P: happyDownstreamRequestParams, + T: "github", + N: happyDownstreamNonce, + C: happyDownstreamCSRF, + K: happyDownstreamPKCEVerifier, + V: happyDownstreamStateVersion, + } +} + func happyOIDCUpstreamStateParamForDynamicClient() *oidctestutil.UpstreamStateParamBuilder { p := happyOIDCUpstreamStateParam() p.P = happyDownstreamRequestParamsForDynamicClient @@ -1853,7 +1896,13 @@ func happyGitHubUpstream() *oidctestutil.TestUpstreamGitHubIdentityProviderBuild WithName(happyGithubIDPName). WithResourceUID(happyGithubIDPResourceUID). WithClientID("some-client-id"). - WithScopes([]string{"these", "scopes", "appear", "unused"}) + WithScopes([]string{"these", "scopes", "appear", "unused"}). // TODO: What do we do with these scopes? + WithAccessToken(githubUpstreamAccessToken). + WithUser(&upstreamprovider.GitHubUser{ + Username: githubUpstreamUsername, + Groups: githubUpstreamGroups, + DownstreamSubject: githubDownstreamSubject, + }) } func shallowCopyAndModifyQuery(query url.Values, modifications map[string]string) url.Values { diff --git a/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider.go b/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider.go index 3f53538a9..f363d5efe 100644 --- a/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider.go +++ b/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider.go @@ -103,17 +103,18 @@ func (p *FederationDomainResolvedGitHubIdentityProvider) LoginFromCallback( ) (*resolvedprovider.Identity, *resolvedprovider.IdentityLoginExtras, error) { accessToken, err := p.Provider.ExchangeAuthcode(ctx, authCode, redirectURI) if err != nil { - plog.WarningErr("error exchanging GitHub authcode", err, "upstreamName", p.Provider.GetName()) + plog.WarningErr("failed to exchange authcode using GitHub API", err, "upstreamName", p.Provider.GetName()) return nil, nil, httperr.Wrap(http.StatusBadGateway, - fmt.Sprintf("failed to exchange authcode using GitHub API: %s", err.Error()), + "failed to exchange authcode using GitHub API", err, ) } user, err := p.Provider.GetUser(ctx, accessToken, p.GetDisplayName()) if err != nil { + plog.WarningErr("failed to get user info from GitHub API", err, "upstreamName", p.Provider.GetName()) return nil, nil, httperr.Wrap(http.StatusUnprocessableEntity, - fmt.Sprintf("failed to get user info from GitHub API: %s", err.Error()), + "failed to get user info from GitHub API", err, ) } @@ -150,7 +151,8 @@ func (p *FederationDomainResolvedGitHubIdentityProvider) UpstreamRefresh( // Get the user's GitHub identity and groups again using the cached access token. refreshedUserInfo, err := p.Provider.GetUser(ctx, githubSessionData.UpstreamAccessToken, p.GetDisplayName()) if err != nil { - return nil, p.refreshErr(fmt.Errorf("failed to get user info from GitHub API: %w", err)) + plog.WarningErr("failed to refresh user info from GitHub API", err, "upstreamName", p.Provider.GetName()) + return nil, p.refreshErr(errors.New("failed to refresh user info from GitHub API")) } if refreshedUserInfo.DownstreamSubject != identity.DownstreamSubject { diff --git a/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider_test.go b/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider_test.go index 824b2bead..35f657538 100644 --- a/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider_test.go +++ b/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider_test.go @@ -179,7 +179,7 @@ func TestLoginFromCallback(t *testing.T) { wantGetUserCall: false, wantIdentity: nil, wantExtras: nil, - wantErr: "failed to exchange authcode using GitHub API: fake authcode exchange error: fake authcode exchange error", + wantErr: "failed to exchange authcode using GitHub API: fake authcode exchange error", }, { name: "error while getting user info", @@ -204,7 +204,7 @@ func TestLoginFromCallback(t *testing.T) { }, wantIdentity: nil, wantExtras: nil, - wantErr: "failed to get user info from GitHub API: fake user info error: fake user info error", + wantErr: "failed to get user info from GitHub API: fake user info error", }, } @@ -297,7 +297,7 @@ func TestUpstreamRefresh(t *testing.T) { name: "error while getting user info", provider: oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder(). WithName("fake-provider-name"). - WithGetUserError(errors.New("fake user info error")). + WithGetUserError(errors.New("any error message")). Build(), identity: &resolvedprovider.Identity{ UpstreamUsername: "initial-username", @@ -313,7 +313,7 @@ func TestUpstreamRefresh(t *testing.T) { IDPDisplayName: "fake-display-name", }, wantRefreshedIdentity: nil, - wantWrappedErr: "failed to get user info from GitHub API: fake user info error", + wantWrappedErr: "failed to refresh user info from GitHub API", }, { name: "wrong session data type, which should not really happen", From d3fb567fdbf86d5ea29b0f2a7df2da41ac993bfe Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Tue, 28 May 2024 15:59:52 -0500 Subject: [PATCH 21/25] Add callback_handler tests for GitHub+IdentityTransformations Co-authored-by: Ryan Richard --- .../callback/callback_handler_test.go | 133 +++++++++++++----- .../oidctestutil/testgithubprovider.go | 5 + 2 files changed, 102 insertions(+), 36 deletions(-) diff --git a/internal/federationdomain/endpoints/callback/callback_handler_test.go b/internal/federationdomain/endpoints/callback/callback_handler_test.go index dfcfc0ee1..67e2a3bb2 100644 --- a/internal/federationdomain/endpoints/callback/callback_handler_test.go +++ b/internal/federationdomain/endpoints/callback/callback_handler_test.go @@ -82,10 +82,10 @@ const ( ) var ( - githubUpstreamUsername = "some-github-login" - githubUpstreamGroups = []string{"org1/team1", "org2/team2"} - githubDownstreamSubject = fmt.Sprintf("https://github.com?idpName=%s&sub=%s", happyGithubIDPName, githubUpstreamUsername) - githubUpstreamAccessToken = "some-opaque-access-token-from-github" //nolint:gosec // this is not a credential + githubUpstreamUsername = "some-github-login" + githubUpstreamGroupMembership = []string{"org1/team1", "org2/team2"} + githubDownstreamSubject = fmt.Sprintf("https://github.com?idpName=%s&sub=%s", happyGithubIDPName, githubUpstreamUsername) + githubUpstreamAccessToken = "some-opaque-access-token-from-github" //nolint:gosec // this is not a credential oidcUpstreamGroupMembership = []string{"test-pinniped-group-0", "test-pinniped-group-1"} happyDownstreamScopesRequested = []string{"openid", "username", "groups"} @@ -108,7 +108,7 @@ var ( ) happyDownstreamRequestParamsForDynamicClient = happyDownstreamRequestParamsQueryForDynamicClient.Encode() - happyDownstreamCustomSessionData = &psession.CustomSessionData{ + happyDownstreamCustomSessionDataForOIDCUpstream = &psession.CustomSessionData{ Username: oidcUpstreamUsername, UpstreamUsername: oidcUpstreamUsername, UpstreamGroups: oidcUpstreamGroupMembership, @@ -121,10 +121,16 @@ var ( UpstreamSubject: oidcUpstreamSubject, }, } - happyDownstreamCustomSessionDataWithUsernameAndGroups = func(wantDownstreamUsername, wantUpstreamUsername string, wantUpstreamGroups []string) *psession.CustomSessionData { - copyOfCustomSession := *happyDownstreamCustomSessionData - copyOfOIDC := *(happyDownstreamCustomSessionData.OIDC) - copyOfCustomSession.OIDC = ©OfOIDC + happyDownstreamCustomSessionDataWithUsernameAndGroups = func(startingSessionData *psession.CustomSessionData, wantDownstreamUsername, wantUpstreamUsername string, wantUpstreamGroups []string) *psession.CustomSessionData { + copyOfCustomSession := *startingSessionData + if startingSessionData.OIDC != nil { + copyOfOIDC := *(startingSessionData.OIDC) + copyOfCustomSession.OIDC = ©OfOIDC + } + if startingSessionData.GitHub != nil { + copyOfGitHub := *(startingSessionData.GitHub) + copyOfCustomSession.GitHub = ©OfGitHub + } copyOfCustomSession.Username = wantDownstreamUsername copyOfCustomSession.UpstreamUsername = wantUpstreamUsername copyOfCustomSession.UpstreamGroups = wantUpstreamGroups @@ -143,10 +149,10 @@ var ( UpstreamSubject: oidcUpstreamSubject, }, } - happyDownstreamGitHubCustomSessionData = &psession.CustomSessionData{ + happyDownstreamCustomSessionDataForGitHubUpstream = &psession.CustomSessionData{ Username: githubUpstreamUsername, UpstreamUsername: githubUpstreamUsername, - UpstreamGroups: githubUpstreamGroups, + UpstreamGroups: githubUpstreamGroupMembership, ProviderUID: happyGithubIDPResourceUID, ProviderName: happyGithubIDPName, ProviderType: psession.ProviderTypeGitHub, @@ -180,6 +186,8 @@ func TestCallbackEndpoint(t *testing.T) { happyOIDCState := happyOIDCUpstreamStateParam().Build(t, happyStateCodec) happyOIDCStateForDynamicClient := happyOIDCUpstreamStateParamForDynamicClient().Build(t, happyStateCodec) + happyGitHubPath := newRequestPath().WithState(happyGitHubUpstreamStateParam().Build(t, happyStateCodec)).String() + encodedIncomingCookieCSRFValue, err := happyCookieCodec.Encode("csrf", happyDownstreamCSRF) require.NoError(t, err) happyCSRFCookie := "__Host-pinniped-csrf=" + encodedIncomingCookieCSRFValue @@ -263,7 +271,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataForOIDCUpstream, wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyOIDCUpstreamIDPName, args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, @@ -287,14 +295,14 @@ func TestCallbackEndpoint(t *testing.T) { wantBodyFormResponseRegexp: `(.+)`, wantDownstreamIDTokenSubject: githubDownstreamSubject, wantDownstreamIDTokenUsername: githubUpstreamUsername, - wantDownstreamIDTokenGroups: githubUpstreamGroups, + wantDownstreamIDTokenGroups: githubUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, wantDownstreamGrantedScopes: happyDownstreamScopesGranted, wantDownstreamNonce: downstreamNonce, wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: happyDownstreamGitHubCustomSessionData, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataForGitHubUpstream, wantGitHubAuthcodeExchangeCall: &expectedGitHubAuthcodeExchange{ performedByUpstreamName: happyGithubIDPName, args: happyGitHubUpstreamExchangeAuthcodeArgs, @@ -333,7 +341,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataForOIDCUpstream, wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyOIDCUpstreamIDPName, args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, @@ -361,7 +369,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataForOIDCUpstream, wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyOIDCUpstreamIDPName, args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, @@ -386,7 +394,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamClientID: downstreamDynamicClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataForOIDCUpstream, wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyOIDCUpstreamIDPName, args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, @@ -445,7 +453,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataForOIDCUpstream, wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyOIDCUpstreamIDPName, args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, @@ -509,6 +517,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsernameAndGroups( + happyDownstreamCustomSessionDataForOIDCUpstream, oidcUpstreamIssuer+"?sub="+oidcUpstreamSubjectQueryEscaped, oidcUpstreamIssuer+"?sub="+oidcUpstreamSubjectQueryEscaped, nil, @@ -539,6 +548,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsernameAndGroups( + happyDownstreamCustomSessionDataForOIDCUpstream, "joe@whitehouse.gov", "joe@whitehouse.gov", oidcUpstreamGroupMembership, @@ -571,6 +581,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsernameAndGroups( + happyDownstreamCustomSessionDataForOIDCUpstream, "joe@whitehouse.gov", "joe@whitehouse.gov", oidcUpstreamGroupMembership, @@ -604,6 +615,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsernameAndGroups( + happyDownstreamCustomSessionDataForOIDCUpstream, "joe", "joe", oidcUpstreamGroupMembership, @@ -739,6 +751,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsernameAndGroups( + happyDownstreamCustomSessionDataForOIDCUpstream, oidcUpstreamSubject, oidcUpstreamSubject, oidcUpstreamGroupMembership, @@ -769,6 +782,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsernameAndGroups( + happyDownstreamCustomSessionDataForOIDCUpstream, oidcUpstreamUsername, oidcUpstreamUsername, []string{"notAnArrayGroup1 notAnArrayGroup2"}, @@ -799,6 +813,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsernameAndGroups( + happyDownstreamCustomSessionDataForOIDCUpstream, oidcUpstreamUsername, oidcUpstreamUsername, []string{"group1", "group2"}, @@ -832,7 +847,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamClientID: downstreamDynamicClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataForOIDCUpstream, wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyOIDCUpstreamIDPName, args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, @@ -862,7 +877,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamClientID: downstreamDynamicClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataForOIDCUpstream, wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyOIDCUpstreamIDPName, args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, @@ -905,7 +920,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamClientID: downstreamDynamicClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataForOIDCUpstream, wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyOIDCUpstreamIDPName, args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, @@ -948,14 +963,14 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamClientID: downstreamDynamicClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataForOIDCUpstream, wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyOIDCUpstreamIDPName, args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { - name: "using identity transformations which modify the username and group names", + name: "OIDC: using identity transformations which modify the username and group names", idps: testidplister.NewUpstreamIDPListerBuilder(). WithOIDC(happyOIDCUpstream().WithTransformsForFederationDomain(prefixUsernameAndGroupsPipeline).Build()), method: http.MethodGet, @@ -974,6 +989,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsernameAndGroups( + happyDownstreamCustomSessionDataForOIDCUpstream, transformationUsernamePrefix+oidcUpstreamUsername, oidcUpstreamUsername, oidcUpstreamGroupMembership, @@ -983,6 +999,36 @@ func TestCallbackEndpoint(t *testing.T) { args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, + { + name: "GitHub: using identity transformations which modify the username and group names", + idps: testidplister.NewUpstreamIDPListerBuilder(). + WithGitHub(happyGitHubUpstream().WithTransformsForFederationDomain(prefixUsernameAndGroupsPipeline).Build()), + method: http.MethodGet, + path: happyGitHubPath, + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusSeeOther, + wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, + wantBody: "", + wantDownstreamIDTokenSubject: githubDownstreamSubject, + wantDownstreamIDTokenUsername: transformationUsernamePrefix + githubUpstreamUsername, + wantDownstreamIDTokenGroups: testutil.AddPrefixToEach(transformationGroupsPrefix, githubUpstreamGroupMembership), + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamClientID: downstreamPinnipedClientID, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsernameAndGroups( + happyDownstreamCustomSessionDataForGitHubUpstream, + transformationUsernamePrefix+githubUpstreamUsername, + githubUpstreamUsername, + githubUpstreamGroupMembership, + ), + wantGitHubAuthcodeExchangeCall: &expectedGitHubAuthcodeExchange{ + performedByUpstreamName: happyGithubIDPName, + args: happyGitHubUpstreamExchangeAuthcodeArgs, + }, + }, // Pre-upstream-exchange verification { @@ -1228,7 +1274,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataForOIDCUpstream, wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyOIDCUpstreamIDPName, args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, @@ -1258,7 +1304,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataForOIDCUpstream, wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyOIDCUpstreamIDPName, args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, @@ -1287,14 +1333,14 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataForOIDCUpstream, wantOIDCAuthcodeExchangeCall: &expectedOIDCAuthcodeExchange{ performedByUpstreamName: happyOIDCUpstreamIDPName, args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, { - name: "GitHub IDP: GET with good state and cookie and successful upstream token exchange returns 303 to downstream client callback", + name: "GitHub: GET with good state and cookie and successful upstream token exchange returns 303 to downstream client callback", idps: testidplister.NewUpstreamIDPListerBuilder().WithGitHub(happyGitHubUpstream().Build()), method: http.MethodGet, path: newRequestPath().WithState( @@ -1309,21 +1355,21 @@ func TestCallbackEndpoint(t *testing.T) { wantBody: "", wantDownstreamIDTokenSubject: githubDownstreamSubject, wantDownstreamIDTokenUsername: githubUpstreamUsername, - wantDownstreamIDTokenGroups: githubUpstreamGroups, + wantDownstreamIDTokenGroups: githubUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, wantDownstreamGrantedScopes: happyDownstreamScopesGranted, wantDownstreamNonce: downstreamNonce, wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: happyDownstreamGitHubCustomSessionData, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataForGitHubUpstream, wantGitHubAuthcodeExchangeCall: &expectedGitHubAuthcodeExchange{ performedByUpstreamName: happyGithubIDPName, args: happyGitHubUpstreamExchangeAuthcodeArgs, }, }, { - name: "GitHub IDP: GET with good state and cookie and successful upstream token exchange with dynamic client returns 303 to downstream client callback, with dynamic client", + name: "GitHub: GET with good state and cookie and successful upstream token exchange with dynamic client returns 303 to downstream client callback, with dynamic client", idps: testidplister.NewUpstreamIDPListerBuilder().WithGitHub(happyGitHubUpstream().Build()), method: http.MethodGet, kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, @@ -1344,14 +1390,14 @@ func TestCallbackEndpoint(t *testing.T) { wantBody: "", wantDownstreamIDTokenSubject: githubDownstreamSubject, wantDownstreamIDTokenUsername: githubUpstreamUsername, - wantDownstreamIDTokenGroups: githubUpstreamGroups, + wantDownstreamIDTokenGroups: githubUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, wantDownstreamGrantedScopes: happyDownstreamScopesGranted, wantDownstreamNonce: downstreamNonce, wantDownstreamClientID: downstreamDynamicClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: happyDownstreamGitHubCustomSessionData, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataForGitHubUpstream, wantGitHubAuthcodeExchangeCall: &expectedGitHubAuthcodeExchange{ performedByUpstreamName: happyGithubIDPName, args: happyGitHubUpstreamExchangeAuthcodeArgs, @@ -1415,7 +1461,7 @@ func TestCallbackEndpoint(t *testing.T) { }, }, { - name: "GitHub upstream auth code exchange fails", + name: "GitHub: upstream auth code exchange fails", idps: testidplister.NewUpstreamIDPListerBuilder().WithGitHub( happyGitHubUpstream().WithAuthcodeExchangeError(errors.New("some error")).Build(), ), @@ -1472,6 +1518,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsernameAndGroups( + happyDownstreamCustomSessionDataForOIDCUpstream, oidcUpstreamUsername, oidcUpstreamUsername, nil, @@ -1658,7 +1705,7 @@ func TestCallbackEndpoint(t *testing.T) { }, }, { - name: "using identity transformations which reject the authentication", + name: "OIDC: using identity transformations which reject the authentication", idps: testidplister.NewUpstreamIDPListerBuilder(). WithOIDC(happyOIDCUpstream().WithTransformsForFederationDomain(rejectAuthPipeline).Build()), method: http.MethodGet, @@ -1672,6 +1719,21 @@ func TestCallbackEndpoint(t *testing.T) { args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, }, + { + name: "GitHub: using identity transformations which reject the authentication", + idps: testidplister.NewUpstreamIDPListerBuilder(). + WithGitHub(happyGitHubUpstream().WithTransformsForFederationDomain(rejectAuthPipeline).Build()), + method: http.MethodGet, + path: happyGitHubPath, + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusUnprocessableEntity, + wantContentType: htmlContentType, + wantBody: "Unprocessable Entity: configured identity policy rejected this authentication: authentication was rejected by a configured policy\n", + wantGitHubAuthcodeExchangeCall: &expectedGitHubAuthcodeExchange{ + performedByUpstreamName: happyGithubIDPName, + args: happyGitHubUpstreamExchangeAuthcodeArgs, + }, + }, } for _, test := range tests { @@ -1896,11 +1958,10 @@ func happyGitHubUpstream() *oidctestutil.TestUpstreamGitHubIdentityProviderBuild WithName(happyGithubIDPName). WithResourceUID(happyGithubIDPResourceUID). WithClientID("some-client-id"). - WithScopes([]string{"these", "scopes", "appear", "unused"}). // TODO: What do we do with these scopes? WithAccessToken(githubUpstreamAccessToken). WithUser(&upstreamprovider.GitHubUser{ Username: githubUpstreamUsername, - Groups: githubUpstreamGroups, + Groups: githubUpstreamGroupMembership, DownstreamSubject: githubDownstreamSubject, }) } diff --git a/internal/testutil/oidctestutil/testgithubprovider.go b/internal/testutil/oidctestutil/testgithubprovider.go index aa210e448..88f183e15 100644 --- a/internal/testutil/oidctestutil/testgithubprovider.go +++ b/internal/testutil/oidctestutil/testgithubprovider.go @@ -112,6 +112,11 @@ func (u *TestUpstreamGitHubIdentityProviderBuilder) WithGetUserError(err error) return u } +func (u *TestUpstreamGitHubIdentityProviderBuilder) WithTransformsForFederationDomain(transforms *idtransform.TransformationPipeline) *TestUpstreamGitHubIdentityProviderBuilder { + u.transformsForFederationDomain = transforms + return u +} + func (u *TestUpstreamGitHubIdentityProviderBuilder) Build() *TestUpstreamGitHubIdentityProvider { if u.displayNameForFederationDomain == "" { // default it to the CR name From cc8d637715eac4e6aa5dfc4836607ff9bd6674ad Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Tue, 28 May 2024 20:33:55 -0500 Subject: [PATCH 22/25] Fix lint --- .../endpoints/callback/callback_handler_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/federationdomain/endpoints/callback/callback_handler_test.go b/internal/federationdomain/endpoints/callback/callback_handler_test.go index 67e2a3bb2..98d884b17 100644 --- a/internal/federationdomain/endpoints/callback/callback_handler_test.go +++ b/internal/federationdomain/endpoints/callback/callback_handler_test.go @@ -37,7 +37,7 @@ import ( ) const ( - // Upstream OIDC + // Upstream OIDC. happyOIDCUpstreamIDPName = "upstream-oidc-idp-name" happyOIDCUpstreamIDPResourceUID = "upstream-oidc-resource-uid" @@ -51,15 +51,15 @@ const ( oidcUpstreamUsernameClaim = "the-user-claim" oidcUpstreamGroupsClaim = "the-groups-claim" - // Upstream GitHub + // Upstream GitHub. happyGithubIDPName = "upstream-github-idp-name" happyGithubIDPResourceUID = "upstream-github-idp-resource-uid" - // Upstream OAuth2 (OIDC or GitHub) + // Upstream OAuth2 (OIDC or GitHub). happyUpstreamAuthcode = "upstream-auth-code" happyUpstreamRedirectURI = "https://example.com/callback" - // Downstream parameters + // Downstream parameters. happyDownstreamState = "8b-state" happyDownstreamCSRF = "test-csrf" happyDownstreamPKCEVerifier = "test-pkce" From bb9cb739c655cfe8ef0dcef40decf27e2299a77d Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Wed, 29 May 2024 08:55:41 -0700 Subject: [PATCH 23/25] more unit tests for github in token_handler_test.go --- .../endpoints/token/token_handler_test.go | 105 ++++++++++++++++-- 1 file changed, 95 insertions(+), 10 deletions(-) diff --git a/internal/federationdomain/endpoints/token/token_handler_test.go b/internal/federationdomain/endpoints/token/token_handler_test.go index 13be648f4..ea9710cbb 100644 --- a/internal/federationdomain/endpoints/token/token_handler_test.go +++ b/internal/federationdomain/endpoints/token/token_handler_test.go @@ -2154,6 +2154,14 @@ func TestRefreshGrant(t *testing.T) { ), } + happyAuthcodeExchangeInputsForGithubUpstream := authcodeExchangeInputs{ + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access username groups") }, + customSessionData: initialUpstreamGitHubCustomSessionData(), + want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( + initialUpstreamGitHubCustomSessionData(), + ), + } + happyAuthcodeExchangeInputsForLDAPUpstream := authcodeExchangeInputs{ modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access username groups") }, customSessionData: happyLDAPCustomSessionData, @@ -2230,15 +2238,7 @@ func TestRefreshGrant(t *testing.T) { name: "happy path refresh grant with GitHub upstream", idps: testidplister.NewUpstreamIDPListerBuilder().WithGitHub( upstreamGitHubIdentityProviderBuilder().Build()), - authcodeExchange: authcodeExchangeInputs{ - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access username groups") }, - customSessionData: initialUpstreamGitHubCustomSessionData(), - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccessWithUsernameAndGroups( - initialUpstreamGitHubCustomSessionData(), - goodUsername, - goodGroups, - ), - }, + authcodeExchange: happyAuthcodeExchangeInputsForGithubUpstream, refreshRequest: refreshRequestInputs{ want: happyRefreshTokenResponseForGitHubAndOfflineAccessWithUsernameAndGroups( initialUpstreamGitHubCustomSessionData(), @@ -2954,6 +2954,73 @@ func TestRefreshGrant(t *testing.T) { }, }, }, + { + name: "happy path refresh grant when the upstream refresh returns new group memberships from GitHub, it updates groups", + idps: testidplister.NewUpstreamIDPListerBuilder().WithGitHub(oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder(). + WithName(githubUpstreamName). + WithResourceUID(githubUpstreamResourceUID). + WithUser(&upstreamprovider.GitHubUser{ + Username: goodUsername, + Groups: []string{goodGroups[0], "new-group1", "new-group2", "new-group3"}, + DownstreamSubject: goodSubject, + }).Build(), + ), + authcodeExchange: happyAuthcodeExchangeInputsForGithubUpstream, + refreshRequest: refreshRequestInputs{ + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, + wantSuccessBodyFields: []string{"refresh_token", "access_token", "id_token", "token_type", "expires_in", "scope"}, + wantRequestedScopes: []string{"openid", "offline_access", "username", "groups"}, + wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, + wantUsername: goodUsername, + wantGroups: []string{goodGroups[0], "new-group1", "new-group2", "new-group3"}, + wantGithubUpstreamRefreshCall: happyGitHubUpstreamRefreshCall(), + wantCustomSessionDataStored: initialUpstreamGitHubCustomSessionData(), + wantWarnings: []RecordedWarning{ + {Text: `User "some-username" has been added to the following groups: ["new-group1" "new-group2" "new-group3"]`}, + {Text: `User "some-username" has been removed from the following groups: ["groups2"]`}, + }, + }, + }, + }, + { + name: "happy path refresh grant when the upstream refresh returns new group memberships from GitHub, it updates groups, using dynamic client - updates groups without outputting warnings", + idps: testidplister.NewUpstreamIDPListerBuilder().WithGitHub(oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder(). + WithName(githubUpstreamName). + WithResourceUID(githubUpstreamResourceUID). + WithUser(&upstreamprovider.GitHubUser{ + Username: goodUsername, + Groups: []string{goodGroups[0], "new-group1", "new-group2", "new-group3"}, + DownstreamSubject: goodSubject, + }).Build(), + ), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + authcodeExchange: authcodeExchangeInputs{ + customSessionData: initialUpstreamGitHubCustomSessionData(), + modifyAuthRequest: func(r *http.Request) { + addDynamicClientIDToFormPostBody(r) + r.Form.Set("scope", "openid offline_access username groups") + }, + modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth, + want: withWantDynamicClientID(happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamGitHubCustomSessionData())), + }, + refreshRequest: refreshRequestInputs{ + modifyTokenRequest: modifyRefreshTokenRequestWithDynamicClientAuth, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantClientID: dynamicClientID, + wantSuccessBodyFields: []string{"refresh_token", "access_token", "id_token", "token_type", "expires_in", "scope"}, + wantRequestedScopes: []string{"openid", "offline_access", "username", "groups"}, + wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, + wantUsername: goodUsername, + wantGroups: []string{goodGroups[0], "new-group1", "new-group2", "new-group3"}, + wantGithubUpstreamRefreshCall: happyGitHubUpstreamRefreshCall(), + wantCustomSessionDataStored: initialUpstreamGitHubCustomSessionData(), + wantWarnings: nil, // dynamic clients should not get these warnings which are intended for the pinniped-cli client + }, + }, + }, { name: "happy path refresh grant when the upstream refresh returns empty list of group memberships from LDAP, it updates groups to an empty list", idps: testidplister.NewUpstreamIDPListerBuilder().WithLDAP(oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder(). @@ -3815,7 +3882,7 @@ func TestRefreshGrant(t *testing.T) { }, }, { - name: "when the upstream refresh fails during the refresh request", + name: "when the upstream refresh fails during the refresh request using OIDC upstream", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder(). WithPerformRefreshError(errors.New("some upstream refresh error")).Build()), authcodeExchange: happyAuthcodeExchangeInputsForOIDCUpstream, @@ -3832,6 +3899,24 @@ func TestRefreshGrant(t *testing.T) { }, }, }, + { + name: "when the upstream refresh fails during the refresh request using GitHub upstream", + idps: testidplister.NewUpstreamIDPListerBuilder().WithGitHub(upstreamGitHubIdentityProviderBuilder(). + WithGetUserError(errors.New("some upstream refresh error")).Build()), + authcodeExchange: happyAuthcodeExchangeInputsForGithubUpstream, + refreshRequest: refreshRequestInputs{ + want: tokenEndpointResponseExpectedValues{ + wantGithubUpstreamRefreshCall: happyGitHubUpstreamRefreshCall(), + wantStatus: http.StatusUnauthorized, + wantErrorResponseBody: here.Doc(` + { + "error": "error", + "error_description": "Error during upstream refresh. Upstream refresh failed." + } + `), + }, + }, + }, { name: "when the upstream refresh returns an invalid ID token during the refresh request", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder(). From 2bf11ffde1dacab293486aa99156ced3dd4fa6e7 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Wed, 29 May 2024 09:45:43 -0700 Subject: [PATCH 24/25] update error message assertion for github in supervisor_login_test.go --- test/integration/supervisor_login_test.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/integration/supervisor_login_test.go b/test/integration/supervisor_login_test.go index d17c3ee49..782c8ada2 100644 --- a/test/integration/supervisor_login_test.go +++ b/test/integration/supervisor_login_test.go @@ -613,8 +613,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { // Get the text of the preformatted error message showing on the page. textOfPreTag := browser.TextOfFirstMatch(t, "pre") require.Equal(t, - "Unprocessable Entity: failed to get user info from GitHub API: "+ - "user is not allowed to log in due to organization membership policy\n", + "Unprocessable Entity: failed to get user info from GitHub API\n", textOfPreTag) }, wantLocalhostCallbackToNeverHappen: true, From 6327f51f5bb92f53a4e8e3646658d23b2a1d3337 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 30 May 2024 09:58:10 -0700 Subject: [PATCH 25/25] repeat same github int tests using OAuth client in supervisor_login_test --- test/integration/supervisor_login_test.go | 510 ++++++++++++---------- test/testlib/env.go | 42 +- test/testlib/skip.go | 11 + 3 files changed, 309 insertions(+), 254 deletions(-) diff --git a/test/integration/supervisor_login_test.go b/test/integration/supervisor_login_test.go index 782c8ada2..42af416c7 100644 --- a/test/integration/supervisor_login_test.go +++ b/test/integration/supervisor_login_test.go @@ -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,11 +163,16 @@ func TestSupervisorLogin_Browser(t *testing.T) { testlib.SkipTestWhenLDAPIsUnavailable(t, env) } - skipGitHubTests := func(t *testing.T) { + 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) @@ -78,19 +190,6 @@ func TestSupervisorLogin_Browser(t *testing.T) { } } - basicGitHubIdentityProviderSpec := func() idpv1alpha1.GitHubIdentityProviderSpec { - return idpv1alpha1.GitHubIdentityProviderSpec{ - AllowAuthentication: idpv1alpha1.GitHubAllowAuthenticationSpec{ - Organizations: idpv1alpha1.GitHubOrganizationsSpec{ - Policy: ptr.To(idpv1alpha1.GitHubAllowedAuthOrganizationsPolicyAllGitHubUsers), - }, - }, - Client: idpv1alpha1.GitHubClientSpec{ - SecretName: testlib.CreateGitHubClientCredentialsSecret(t, env.SupervisorUpstreamGithub.GithubAppClientID, env.SupervisorUpstreamGithub.GithubAppClientSecret).Name, - }, - } - } - createActiveDirectoryIdentityProvider := func(t *testing.T, edit func(spec *idpv1alpha1.ActiveDirectoryIdentityProviderSpec)) (*idpv1alpha1.ActiveDirectoryIdentityProvider, *corev1.Secret) { t.Helper() @@ -187,14 +286,6 @@ func TestSupervisorLogin_Browser(t *testing.T) { regexp.QuoteMeta("&sub=") + ".+" + "$" - // 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) + - "$" - // The downstream ID token Subject should be the Host URL, plus the user search base, plus the IDP display name, // plus value pulled from the requested UserSearch.Attributes.UID attribute. expectedIDTokenSubjectRegexForUpstreamLDAP := "^" + @@ -231,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 []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) - }{ + tests := []*supervisorLoginTestcase{ { name: "oidc with default username and groups claim settings", maybeSkip: skipNever, @@ -510,114 +496,6 @@ func TestSupervisorLogin_Browser(t *testing.T) { "upstream_username": env.SupervisorUpstreamOIDC.Username, }, "upstream_groups", env.SupervisorUpstreamOIDC.ExpectedGroups), }, - { - name: "github with all orgs allowed and default claim settings", - maybeSkip: skipGitHubTests, - createIDP: func(t *testing.T) string { - return testlib.CreateTestGitHubIdentityProvider(t, basicGitHubIdentityProviderSpec(), 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: "github with list of allowed orgs, username as login, and groups as names", - maybeSkip: skipGitHubTests, - createIDP: func(t *testing.T) string { - spec := basicGitHubIdentityProviderSpec() - 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: "github with list of allowed orgs differently cased, username as id, and groups as names", - maybeSkip: skipGitHubTests, - createIDP: func(t *testing.T) string { - spec := basicGitHubIdentityProviderSpec() - 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: "github when user does not belong to any of the allowed orgs, should fail at the Supervisor callback endpoint", - maybeSkip: skipGitHubTests, - createIDP: func(t *testing.T) string { - spec := basicGitHubIdentityProviderSpec() - 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 - }, - 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, - "Unprocessable Entity: failed to get user info from GitHub API\n", - textOfPreTag) - }, - wantLocalhostCallbackToNeverHappen: true, - }, { name: "ldap with email as username and groups names as DNs and using an LDAP provider which supports TLS", maybeSkip: skipLDAPTests, @@ -2308,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) { @@ -2340,6 +2236,148 @@ func TestSupervisorLogin_Browser(t *testing.T) { } } +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 + }, + 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, + "Unprocessable Entity: failed to get user info from GitHub API\n", + textOfPreTag) + }, + wantLocalhostCallbackToNeverHappen: true, + }, + } +} + func wantGroupsInAdditionalClaimsIfGroupsExist(additionalClaims map[string]interface{}, wantGroupsAdditionalClaimName string, wantGroups []string) map[string]interface{} { if len(wantGroups) > 0 { var wantGroupsAnyType []interface{} diff --git a/test/testlib/env.go b/test/testlib/env.go index 6b3615b81..0ca9cfa9c 100644 --- a/test/testlib/env.go +++ b/test/testlib/env.go @@ -112,15 +112,18 @@ type TestLDAPUpstream struct { } type TestGithubUpstream struct { - GithubAppClientID string `json:"githubAppClientId"` - GithubAppClientSecret string `json:"githubAppClientSecret"` - 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"` + 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. @@ -333,15 +336,18 @@ func loadEnvVars(t *testing.T, result *TestEnv) { } result.SupervisorUpstreamGithub = TestGithubUpstream{ - GithubAppClientID: wantEnv("PINNIPED_TEST_GITHUB_APP_CLIENT_ID", ""), - GithubAppClientSecret: wantEnv("PINNIPED_TEST_GITHUB_APP_CLIENT_SECRET", ""), - 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", ""), ",")), + 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) diff --git a/test/testlib/skip.go b/test/testlib/skip.go index 3ad283faf..9a052ecad 100644 --- a/test/testlib/skip.go +++ b/test/testlib/skip.go @@ -41,3 +41,14 @@ func SkipTestWhenGitHubIsUnavailable(t *testing.T) { 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") + } +}