Files
pinniped/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider_test.go

414 lines
16 KiB
Go

// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package resolvedgithub
import (
"context"
"errors"
"net/http"
"testing"
"github.com/ory/fosite"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
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/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"
)
func TestFederationDomainResolvedGitHubIdentityProvider(t *testing.T) {
transforms := transformtestutil.NewRejectAllAuthPipeline(t)
provider := upstreamgithub.New(upstreamgithub.ProviderConfig{
Name: "fake-provider-config",
ResourceUID: "fake-resource-uid",
APIBaseURL: "https://fake-api-host.com",
UsernameAttribute: idpv1alpha1.GitHubUsernameID,
GroupNameAttribute: idpv1alpha1.GitHubUseTeamSlugForGroupName,
AllowedOrganizations: setutil.NewCaseInsensitiveSet("org1", "org2"),
HttpClient: nil, // not needed yet for this test
OAuth2Config: &oauth2.Config{
ClientID: "fake-client-id",
ClientSecret: "fake-client-secret",
Scopes: []string{"read:user", "read:org"},
Endpoint: oauth2.Endpoint{
AuthURL: "https://fake-authorization-url",
DeviceAuthURL: "",
TokenURL: "https://fake-token-url",
AuthStyle: oauth2.AuthStyleInParams,
},
},
})
subject := FederationDomainResolvedGitHubIdentityProvider{
DisplayName: "fake-display-name",
Provider: provider,
SessionProviderType: psession.ProviderTypeGitHub,
Transforms: transforms,
}
require.Equal(t, "fake-display-name", subject.GetDisplayName())
require.Equal(t, provider, subject.GetProvider())
require.Equal(t, psession.ProviderTypeGitHub, subject.GetSessionProviderType())
require.Equal(t, idpdiscoveryv1alpha1.IDPTypeGitHub, subject.GetIDPDiscoveryType())
require.Equal(t, []idpdiscoveryv1alpha1.IDPFlow{idpdiscoveryv1alpha1.IDPFlowBrowserAuthcode}, subject.GetIDPDiscoveryFlows())
require.Equal(t, transforms, subject.GetTransforms())
originalCustomSession := &psession.CustomSessionData{
Username: "fake-username",
UpstreamUsername: "fake-upstream-username",
GitHub: &psession.GitHubSessionData{UpstreamAccessToken: "fake-upstream-access-token"},
}
clonedCustomSession := subject.CloneIDPSpecificSessionDataFromSession(originalCustomSession)
require.Equal(t,
&psession.GitHubSessionData{UpstreamAccessToken: "fake-upstream-access-token"},
clonedCustomSession,
)
require.NotSame(t, originalCustomSession, clonedCustomSession)
customSessionToBeMutated := &psession.CustomSessionData{
Username: "fake-username2",
UpstreamUsername: "fake-upstream-username2",
}
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: "OTHER-upstream-access-token"},
}, customSessionToBeMutated)
redirectURL, err := subject.UpstreamAuthorizeRedirectURL(
&resolvedprovider.UpstreamAuthorizeRequestState{
EncodedStateParam: "encodedStateParam12345",
PKCE: "pkce6789",
Nonce: "nonce1289",
},
"https://localhost/fake/path",
)
require.NoError(t, err)
// Note that GitHub does not require (or document) the standard response_type=code param, but in manual testing
// of GitHub authorize endpoint, it seems to ignore the param. The oauth2 package wants to add the param, so
// we will let it.
require.Equal(t,
"https://fake-authorization-url?"+
"client_id=fake-client-id&"+
"redirect_uri=https%3A%2F%2Flocalhost%2Ffake%2Fpath%2Fcallback&"+
"response_type=code&"+
"scope=read%3Auser+read%3Aorg&"+
"state=encodedStateParam12345",
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
idpDisplayName string
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(),
idpDisplayName: "fake-display-name",
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",
IDPDisplayName: "fake-display-name",
},
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(),
idpDisplayName: "fake-display-name",
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 authcode 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(),
idpDisplayName: "fake-display-name",
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",
IDPDisplayName: "fake-display-name",
},
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) {
subject := FederationDomainResolvedGitHubIdentityProvider{
DisplayName: test.idpDisplayName,
Provider: test.provider,
SessionProviderType: psession.ProviderTypeGitHub,
Transforms: transformtestutil.NewRejectAllAuthPipeline(t),
}
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 {
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)
})
}
}
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("any error message")).
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 refresh user info from GitHub API",
},
{
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)
})
}
}