Merge pull request #1963 from vmware-tanzu/github-issues-downstream-tokens

Issue and refresh downstream ID tokens derived from a GitHub IDP
This commit is contained in:
Joshua Casey
2024-05-30 16:16:37 -05:00
committed by GitHub
37 changed files with 3995 additions and 742 deletions
+5
View File
@@ -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
+10
View File
@@ -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=
+3 -4
View File
@@ -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.
@@ -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)
@@ -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())
@@ -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,
},
)
@@ -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
},
},
@@ -24,3 +24,11 @@ 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),
)
}
@@ -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)
})
}
}
File diff suppressed because it is too large Load Diff
@@ -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"
@@ -268,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
@@ -1828,6 +1842,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" //nolint:gosec // this is not a credential
transformationUsernamePrefix = "username_prefix:"
transformationGroupsPrefix = "groups_prefix:"
)
@@ -1843,6 +1862,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 +1890,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
@@ -1893,42 +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,
},
}
}
happyLDAPUpstreamRefreshCall := 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() *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
@@ -1976,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)
}
@@ -1988,13 +2054,22 @@ 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)
}
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.wantGithubUpstreamRefreshCall = happyGitHubUpstreamRefreshCall()
return want
}
happyRefreshTokenResponseForOpenIDAndOfflineAccessWithAdditionalClaims := func(wantCustomSessionDataStored *psession.CustomSessionData, expectToValidateToken *oauth2.Token, wantAdditionalClaims map[string]interface{}) tokenEndpointResponseExpectedValues {
want := happyRefreshTokenResponseForOpenIDAndOfflineAccess(wantCustomSessionDataStored, expectToValidateToken)
want.wantAdditionalClaims = wantAdditionalClaims
@@ -2003,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
}
@@ -2079,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,
@@ -2151,6 +2234,19 @@ func TestRefreshGrant(t *testing.T) {
),
},
},
{
name: "happy path refresh grant with GitHub upstream",
idps: testidplister.NewUpstreamIDPListerBuilder().WithGitHub(
upstreamGitHubIdentityProviderBuilder().Build()),
authcodeExchange: happyAuthcodeExchangeInputsForGithubUpstream,
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(
@@ -2184,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),
},
@@ -2218,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(`
@@ -2258,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(`
@@ -2508,7 +2604,7 @@ func TestRefreshGrant(t *testing.T) {
wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken),
wantUsername: "",
wantGroups: goodGroups,
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true),
}),
},
@@ -2563,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
@@ -2606,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,
@@ -2632,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),
},
@@ -2659,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{
@@ -2700,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
@@ -2728,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{
@@ -2759,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{
@@ -2789,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),
},
@@ -2814,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"]`},
@@ -2852,12 +2948,79 @@ 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
},
},
},
{
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().
@@ -2877,7 +3040,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"]`},
@@ -2920,7 +3083,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"]`},
@@ -2966,7 +3129,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{
@@ -3019,7 +3182,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),
},
@@ -3070,7 +3233,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
@@ -3120,7 +3283,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"]`},
@@ -3145,7 +3308,7 @@ func TestRefreshGrant(t *testing.T) {
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusUnauthorized,
wantErrorResponseBody: fositeUpstreamGroupClaimErrorBody,
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true),
},
},
@@ -3227,7 +3390,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),
},
@@ -3719,14 +3882,32 @@ 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,
refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantStatus: http.StatusUnauthorized,
wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantStatus: http.StatusUnauthorized,
wantErrorResponseBody: here.Doc(`
{
"error": "error",
"error_description": "Error during upstream refresh. Upstream refresh failed."
}
`),
},
},
},
{
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",
@@ -3746,7 +3927,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(`
@@ -3774,7 +3955,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(`
@@ -3799,7 +3980,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(`
@@ -3826,7 +4007,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(`
@@ -3853,7 +4034,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(`
@@ -3943,8 +4124,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",
@@ -3983,8 +4164,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",
@@ -4057,7 +4238,7 @@ func TestRefreshGrant(t *testing.T) {
wantCustomSessionDataStored: happyLDAPCustomSessionData,
wantUsername: "",
wantGroups: goodGroups,
wantUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(),
wantLDAPUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(),
}),
},
},
@@ -4251,8 +4432,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",
@@ -4280,8 +4461,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",
@@ -4534,7 +4715,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
@@ -4566,19 +4747,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 {
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,
@@ -7,13 +7,17 @@ import (
"context"
"errors"
"fmt"
"net/http"
"github.com/ory/fosite"
"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"
@@ -91,18 +95,82 @@ 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")
accessToken, err := p.Provider.ExchangeAuthcode(ctx, authCode, redirectURI)
if err != nil {
plog.WarningErr("failed to exchange authcode using GitHub API", err, "upstreamName", p.Provider.GetName())
return nil, nil, httperr.Wrap(http.StatusBadGateway,
"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,
"failed to get user info from GitHub API",
err,
)
}
return &resolvedprovider.Identity{
UpstreamUsername: user.Username,
UpstreamGroups: user.Groups,
DownstreamSubject: user.DownstreamSubject,
IDPSpecificSessionData: &psession.GitHubSessionData{
UpstreamAccessToken: accessToken,
},
},
&resolvedprovider.IdentityLoginExtras{
DownstreamAdditionalClaims: nil, // not using this for GitHub
Warnings: nil, // not using this for GitHub
},
nil // no error
}
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")
ctx context.Context,
identity *resolvedprovider.Identity,
) (*resolvedprovider.RefreshedIdentity, error) {
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 {
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 {
// 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: 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())
}
@@ -4,15 +4,23 @@
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"
)
@@ -26,7 +34,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",
@@ -71,11 +79,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: "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: "OTHER-upstream-access-token"},
}, customSessionToBeMutated)
redirectURL, err := subject.UpstreamAuthorizeRedirectURL(
@@ -100,3 +108,306 @@ 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
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)
})
}
}
@@ -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,
@@ -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"
@@ -25,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
@@ -124,7 +125,13 @@ 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 {
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 {
@@ -151,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://<spec.githubAPI.host>/login/oauth/authorize
@@ -159,13 +166,12 @@ 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)
// 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.
// 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)
}
+235
View File
@@ -0,0 +1,235 @@
// Copyright 2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package githubclient
import (
"context"
"errors"
"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"
"go.pinniped.dev/internal/setutil"
)
const (
emptyUserMeansTheAuthenticatedUser = ""
pageSize = 100
)
type UserInfo struct {
ID string
Login string
}
type TeamInfo struct {
Name string
Slug string
Org string
}
type GitHubInterface interface {
GetUserInfo(ctx context.Context) (*UserInfo, error)
GetOrgMembership(ctx context.Context) ([]string, error)
GetTeamMembership(ctx context.Context, allowedOrganizations *setutil.CaseInsensitiveSet) ([]TeamInfo, error)
}
type githubClient struct {
client *github.Client
}
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("%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("%s: token cannot be empty string", errorPrefix)
}
client := github.NewClient(httpClient).WithAuthToken(token)
client.BaseURL = parsedURL
return &githubClient{
client: client,
}, nil
}
// GetUserInfo returns the "Login" and "ID" attributes of the logged-in user.
func (g *githubClient) GetUserInfo(ctx context.Context) (*UserInfo, error) {
const errorPrefix = "error fetching authenticated user"
user, _, err := g.client.Users.Get(ctx, emptyUserMeansTheAuthenticatedUser)
if err != nil {
return nil, fmt.Errorf("%s: %w", errorPrefix, err)
}
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(),
ID: fmt.Sprintf("%d", user.GetID()),
}
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)
}
plog.Trace("calculated response from GitHub user endpoint", "user", userInfo)
return userInfo, nil
}
// GetOrgMembership returns an array of the "Login" attributes for all organizations to which the authenticated user belongs.
func (g *githubClient) GetOrgMembership(ctx context.Context) ([]string, error) {
const errorPrefix = "error fetching organizations for authenticated user"
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("%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())
}
if response.NextPage == 0 {
break
}
opt.Page = response.NextPage
}
if organizationLogins.Has("") {
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.UnsortedList(), nil
}
func isOrgAllowed(allowedOrganizations *setutil.CaseInsensitiveSet, login string) bool {
return allowedOrganizations.Empty() || allowedOrganizations.ContainsIgnoringCase(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`)
}
organizationLogin := githubTeam.GetOrganization().GetLogin()
if organizationLogin == "" {
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(),
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.
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]()
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("%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)
if err != nil {
return nil, fmt.Errorf("%s: %w", errorPrefix, err)
}
if !isOrgAllowed(allowedOrganizations, teamInfo.Org) {
continue
}
teamInfos.Insert(*teamInfo)
parent := team.GetParent()
if parent != nil {
// 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)
}
teamInfos.Insert(*parentTeamInfo)
}
}
if response.NextPage == 0 {
break
}
opt.Page = response.NextPage
}
// 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
}
+833
View File
@@ -0,0 +1,833 @@
// Copyright 2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package githubclient
import (
"context"
"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/setutil"
"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, "unable to build new github client: 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: "adds trailing slash to path for https://api.github.com",
apiBaseURL: "https://api.github.com",
token: "other-token",
wantBaseURL: "https://api.github.com/",
},
{
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: "rejects apiBaseURL without https:// scheme",
apiBaseURL: "scp://github.com",
token: "some-token",
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: `unable to build new github client: apiBaseURL must use "https" protocol, found "" instead`,
},
{
name: "rejects empty token",
apiBaseURL: "https://api.github.com/",
wantErr: "unable to build new github client: token cannot be empty string",
},
{
name: "returns errors from url.Parse",
apiBaseURL: "https:// example.com",
token: "some-token",
wantErr: `unable to build new github client: 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)
} 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)
}
})
}
}
func TestGetUser(t *testing.T) {
t.Parallel()
tests := []struct {
name string
httpClient *http.Client
token string
ctx context.Context
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.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)
}),
),
),
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: `error fetching authenticated user: the "login" attribute is missing`,
},
{
name: "handles missing ID",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetUser,
github.User{
Login: github.String("some-username"),
},
),
),
token: "does-this-token-work",
wantErr: `error fetching authenticated user: the "id" attribute is missing`,
},
{
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(
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),
}
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)
test.wantErr = strings.ReplaceAll(test.wantErr, "{SERVER_URL}", rt.Host)
require.EqualError(t, err, test.wantErr)
} else {
require.NoError(t, err)
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
ctx context.Context
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.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)
}),
),
),
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",
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(
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=100: 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),
}
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)
test.wantErr = strings.ReplaceAll(test.wantErr, "{SERVER_URL}", rt.Host)
require.EqualError(t, err, test.wantErr)
return
}
require.NotNil(t, actual)
require.ElementsMatch(t, test.wantOrgs, actual)
})
}
}
func TestGetTeamMembership(t *testing.T) {
t.Parallel()
tests := []struct {
name string
httpClient *http.Client
token string
ctx context.Context
allowedOrganizations *setutil.CaseInsensitiveSet
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: setutil.NewCaseInsensitiveSet("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 in a case-insensitive way, but preserves case as returned by GitHub API in the result",
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: setutil.NewCaseInsensitiveSet("ALPHA", "gamma"),
wantTeams: []TeamInfo{
{
Name: "team1-name",
Slug: "team1-slug",
Org: "alPhA",
},
{
Name: "team3-name",
Slug: "team3-slug",
Org: "gAmmA",
},
},
},
{
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: nil, // the real GitHub API does not return Org on "Parent" team.
},
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: "delta-team-name",
Slug: "delta-team-slug",
Org: "gamma",
},
{
Name: "team3-name",
Slug: "team3-slug",
Org: "gamma",
},
},
},
{
name: "includes parent team in allowed orgs 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: 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-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("org-with-nested-teams"),
},
},
{
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"),
},
},
},
),
),
token: "some-token",
allowedOrganizations: setutil.NewCaseInsensitiveSet("org-with-nested-teams", "beta"),
wantTeams: []TeamInfo{
{
Name: "team-name-without-parent",
Slug: "team-slug-without-parent",
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",
},
},
},
{
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: setutil.NewCaseInsensitiveSet("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: "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: "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.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)
}),
),
),
token: "does-this-token-work",
allowedOrganizations: setutil.NewCaseInsensitiveSet("org-login"),
wantTeams: []TeamInfo{
{
Name: "team1-name",
Slug: "team1-slug",
Org: "org-login",
},
},
},
{
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(
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=100: 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),
}
ctx := context.Background()
if test.ctx != nil {
ctx = test.ctx
}
actual, err := githubClient.GetTeamMembership(ctx, 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)
} else {
require.NoError(t, err)
require.NotNil(t, actual)
require.Equal(t, test.wantTeams, actual)
}
})
}
}
@@ -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
@@ -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"
setutil "go.pinniped.dev/internal/setutil"
gomock "go.uber.org/mock/gomock"
)
// 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) ([]string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetOrgMembership", arg0)
ret0, _ := ret[0].([]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 *setutil.CaseInsensitiveSet) ([]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)
}
+43
View File
@@ -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
}
+34
View File
@@ -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"))
}
+13
View File
@@ -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
}
+74
View File
@@ -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)
})
}
}
@@ -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
}
@@ -4,13 +4,32 @@
package oidctestutil
import (
"context"
"k8s.io/apimachinery/pkg/types"
"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
// 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
IDPDisplayName string
}
type TestUpstreamGitHubIdentityProviderBuilder struct {
name string
resourceUID types.UID
@@ -20,8 +39,12 @@ type TestUpstreamGitHubIdentityProviderBuilder struct {
transformsForFederationDomain *idtransform.TransformationPipeline
usernameAttribute v1alpha1.GitHubUsernameAttribute
groupNameAttribute v1alpha1.GitHubGroupNameAttribute
allowedOrganizations []string
allowedOrganizations *setutil.CaseInsensitiveSet
authorizationURL string
authcodeExchangeErr error
accessToken string
getUserErr error
getUserUser *upstreamprovider.GitHubUser
}
func (u *TestUpstreamGitHubIdentityProviderBuilder) WithName(value string) *TestUpstreamGitHubIdentityProviderBuilder {
@@ -59,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
}
@@ -69,6 +92,31 @@ func (u *TestUpstreamGitHubIdentityProviderBuilder) WithAuthorizationURL(value s
return u
}
func (u *TestUpstreamGitHubIdentityProviderBuilder) WithAccessToken(token string) *TestUpstreamGitHubIdentityProviderBuilder {
u.accessToken = token
return u
}
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
}
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
@@ -80,8 +128,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,
@@ -89,6 +137,18 @@ 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
}
return u.accessToken, nil
},
}
}
@@ -105,8 +165,16 @@ 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)
// Fields for tracking actual calls make to mock functions.
exchangeAuthcodeCallCount int
exchangeAuthcodeArgs []*ExchangeAuthcodeArgs
getUserCallCount int
getUserArgs []*GetUserArgs
}
var _ upstreamprovider.UpstreamGithubIdentityProviderI = &TestUpstreamGitHubIdentityProvider{}
@@ -135,10 +203,62 @@ func (u *TestUpstreamGitHubIdentityProvider) GetGroupNameAttribute() v1alpha1.Gi
return u.GroupNameAttribute
}
func (u *TestUpstreamGitHubIdentityProvider) GetAllowedOrganizations() []string {
func (u *TestUpstreamGitHubIdentityProvider) GetAllowedOrganizations() *setutil.CaseInsensitiveSet {
return u.AllowedOrganizations
}
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]
}
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,
IDPDisplayName: idpDisplayName,
})
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]
}
@@ -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]
}
@@ -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]
}
+145 -57
View File
@@ -263,7 +263,7 @@ func (b *UpstreamIDPListerBuilder) RequireExactlyZeroCallsToPasswordCredentialsG
)
}
func (b *UpstreamIDPListerBuilder) RequireExactlyOneCallToExchangeAuthcodeAndValidateTokens(
func (b *UpstreamIDPListerBuilder) RequireExactlyOneOIDCAuthcodeExchange(
t *testing.T,
expectedPerformedByUpstreamName string,
expectedArgs *oidctestutil.ExchangeAuthcodeAndValidateTokenArgs,
@@ -271,81 +271,170 @@ func (b *UpstreamIDPListerBuilder) RequireExactlyOneCallToExchangeAuthcodeAndVal
t.Helper()
var actualArgs *oidctestutil.ExchangeAuthcodeAndValidateTokenArgs
var actualNameOfUpstreamWhichMadeCall string
actualCallCountAcrossAllOIDCUpstreams := 0
for _, upstreamOIDC := range b.upstreamOIDCIdentityProviders {
callCountOnThisUpstream := upstreamOIDC.ExchangeAuthcodeAndValidateTokensCallCount()
actualCallCountAcrossAllOIDCUpstreams += callCountOnThisUpstream
actualCallCount := 0
for _, upstream := range b.upstreamOIDCIdentityProviders {
callCountOnThisUpstream := upstream.ExchangeAuthcodeAndValidateTokensCallCount()
actualCallCount += callCountOnThisUpstream
if callCountOnThisUpstream == 1 {
actualNameOfUpstreamWhichMadeCall = upstreamOIDC.Name
actualArgs = upstreamOIDC.ExchangeAuthcodeAndValidateTokensArgs(0)
actualNameOfUpstreamWhichMadeCall = upstream.Name
actualArgs = upstream.ExchangeAuthcodeAndValidateTokensArgs(0)
}
}
require.Equal(t, 1, actualCallCountAcrossAllOIDCUpstreams,
"should have been exactly one call to ExchangeAuthcodeAndValidateTokens() by all OIDC upstreams",
require.Equal(t, 1, actualCallCount,
"expected exactly one call to OIDC ExchangeAuthcodeAndValidateTokens()",
)
require.Equal(t, expectedPerformedByUpstreamName, actualNameOfUpstreamWhichMadeCall,
"ExchangeAuthcodeAndValidateTokens() was called on the wrong OIDC upstream",
"OIDC ExchangeAuthcodeAndValidateTokens() was called on the wrong upstream name",
)
require.Equal(t, expectedArgs, actualArgs)
}
func (b *UpstreamIDPListerBuilder) RequireExactlyZeroCallsToExchangeAuthcodeAndValidateTokens(t *testing.T) {
t.Helper()
actualCallCountAcrossAllOIDCUpstreams := 0
for _, upstreamOIDC := range b.upstreamOIDCIdentityProviders {
actualCallCountAcrossAllOIDCUpstreams += upstreamOIDC.ExchangeAuthcodeAndValidateTokensCallCount()
}
require.Equal(t, 0, actualCallCountAcrossAllOIDCUpstreams,
"expected exactly zero calls to ExchangeAuthcodeAndValidateTokens()",
)
}
func (b *UpstreamIDPListerBuilder) RequireExactlyOneCallToPerformRefresh(
func (b *UpstreamIDPListerBuilder) RequireExactlyOneGitHubAuthcodeExchange(
t *testing.T,
expectedPerformedByUpstreamName string,
expectedArgs *oidctestutil.PerformRefreshArgs,
expectedArgs *oidctestutil.ExchangeAuthcodeArgs,
) {
t.Helper()
var actualArgs *oidctestutil.PerformRefreshArgs
var actualArgs *oidctestutil.ExchangeAuthcodeArgs
var actualNameOfUpstreamWhichMadeCall string
actualCallCountAcrossAllUpstreams := 0
for _, upstreamOIDC := range b.upstreamOIDCIdentityProviders {
callCountOnThisUpstream := upstreamOIDC.PerformRefreshCallCount()
actualCallCountAcrossAllUpstreams += callCountOnThisUpstream
actualCallCount := 0
for _, upstream := range b.upstreamGitHubIdentityProviders {
callCountOnThisUpstream := upstream.ExchangeAuthcodeCallCount()
actualCallCount += callCountOnThisUpstream
if callCountOnThisUpstream == 1 {
actualNameOfUpstreamWhichMadeCall = upstreamOIDC.Name
actualArgs = upstreamOIDC.PerformRefreshArgs(0)
actualNameOfUpstreamWhichMadeCall = upstream.Name
actualArgs = upstream.ExchangeAuthcodeArgs(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)
}
}
// TODO: probably add GitHub loop once we flesh out the structs
require.Equal(t, 1, actualCallCountAcrossAllUpstreams,
"should have been exactly one call to PerformRefresh() by all upstreams",
require.Equal(t, 1, actualCallCount,
"expected exactly one call to GitHub ExchangeAuthcode()",
)
require.Equal(t, expectedPerformedByUpstreamName, actualNameOfUpstreamWhichMadeCall,
"PerformRefresh() was called on the wrong upstream",
"GitHub ExchangeAuthcode() was called on the wrong upstream name",
)
require.Equal(t, expectedArgs, actualArgs)
}
func (b *UpstreamIDPListerBuilder) RequireExactlyZeroCallsToPerformRefresh(t *testing.T) {
func (b *UpstreamIDPListerBuilder) RequireExactlyZeroAuthcodeExchanges(t *testing.T) {
t.Helper()
actualCallCount := 0
for _, upstreamOIDC := range b.upstreamOIDCIdentityProviders {
actualCallCount += upstreamOIDC.ExchangeAuthcodeAndValidateTokensCallCount()
}
for _, upstreamGitHub := range b.upstreamGitHubIdentityProviders {
actualCallCount += upstreamGitHub.ExchangeAuthcodeCallCount()
}
require.Equal(t, 0, actualCallCount,
"expected exactly zero calls to OIDC ExchangeAuthcodeAndValidateTokens() or GitHub ExchangeAuthcode()",
)
}
func (b *UpstreamIDPListerBuilder) RequireExactlyOneCallToOIDCPerformRefresh(
t *testing.T,
expectedPerformedByUpstreamName string,
expectedArgs *oidctestutil.PerformOIDCRefreshArgs,
) {
t.Helper()
var actualArgs *oidctestutil.PerformOIDCRefreshArgs
var actualNameOfUpstreamWhichMadeCall string
for _, upstream := range b.upstreamOIDCIdentityProviders {
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) 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()
@@ -356,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(
+96 -10
View File
@@ -5,13 +5,20 @@
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"
"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"
"go.pinniped.dev/internal/setutil"
)
// ProviderConfig holds the active configuration of an upstream GitHub provider.
@@ -24,11 +31,11 @@ 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
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.
@@ -44,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{}
@@ -52,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 {
@@ -71,15 +82,15 @@ 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
}
func (p *Provider) GetAllowedOrganizations() []string {
func (p *Provider) GetAllowedOrganizations() *setutil.CaseInsensitiveSet {
return p.c.AllowedOrganizations
}
@@ -87,7 +98,82 @@ func (p *Provider) GetAuthorizationURL() string {
return p.c.OAuth2Config.Endpoint.AuthURL
}
// GetConfig returns the config. This is not part of the interface and is mostly just for testing.
func (p *Provider) ExchangeAuthcode(ctx context.Context, authcode string, redirectURI string) (string, error) {
tok, err := p.c.OAuth2Config.Exchange(
coreosoidc.ClientContext(ctx, p.c.HttpClient),
authcode,
oauth2.SetAuthURLParam("redirect_uri", redirectURI),
)
if err != nil {
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 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.
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
}
githubUser := upstreamprovider.GitHubUser{}
userInfo, err := githubClient.GetUserInfo(ctx)
if err != nil {
return nil, err
}
githubUser.DownstreamSubject = downstreamsubject.GitHub(p.c.APIBaseURL, idpDisplayName, 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
default:
return nil, fmt.Errorf("bad configuration: unknown GitHub username attribute: %s", p.c.UsernameAttribute)
}
orgMembership, err := githubClient.GetOrgMembership(ctx)
if err != nil {
return nil, err
}
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, p.c.AllowedOrganizations)
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)
default:
return nil, fmt.Errorf("bad configuration: unknown GitHub group name attribute: %s", p.c.GroupNameAttribute)
}
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.
func (p *Provider) GetConfig() ProviderConfig {
return p.c
}
+436 -6
View File
@@ -4,14 +4,27 @@
package upstreamgithub
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net/http"
"testing"
"time"
"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/client-go/util/cert"
"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"
"go.pinniped.dev/internal/setutil"
"go.pinniped.dev/internal/testutil/tlsserver"
)
func TestGitHubProvider(t *testing.T) {
@@ -32,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,
},
@@ -55,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,
},
@@ -65,11 +78,428 @@ 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, []string{"fake-org", "fake-org2"}, subject.GetAllowedOrganizations())
require.Equal(t, supervisoridpv1alpha1.GitHubUsernameAttribute("fake-username-attribute"), subject.GetUsernameAttribute())
require.Equal(t, supervisoridpv1alpha1.GitHubGroupNameAttribute("fake-group-name-attribute"), subject.GetGroupNameAttribute())
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,
}, 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"
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, gomock.Any()).Return(nil, nil)
},
wantUser: &upstreamprovider.GitHubUser{
Username: "some-github-login:some-github-id",
DownstreamSubject: fmt.Sprintf("https://some-url?idpName=%s&login=some-github-login&id=some-github-id", encodedIDPDisplayName),
},
},
{
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, nil).Return(nil, nil)
},
wantUser: &upstreamprovider.GitHubUser{
Username: "some-github-login",
DownstreamSubject: fmt.Sprintf("https://some-url?idpName=%s&login=some-github-login&id=some-github-id", encodedIDPDisplayName),
},
},
{
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, nil).Return(nil, nil)
},
wantUser: &upstreamprovider.GitHubUser{
Username: "some-github-id",
DownstreamSubject: fmt.Sprintf("https://some-url?idpName=%s&login=some-github-login&id=some-github-id", encodedIDPDisplayName),
},
},
{
name: "happy path with user in allowed organizations",
providerConfig: ProviderConfig{
APIBaseURL: "https://some-url",
HttpClient: someHttpClient,
UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameLoginAndID,
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([]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",
DownstreamSubject: fmt.Sprintf("https://some-url?idpName=%s&login=some-github-login&id=some-github-id", encodedIDPDisplayName),
},
},
{
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: 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([]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: setutil.NewCaseInsensitiveSet("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([]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",
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: fmt.Sprintf("https://some-url?idpName=%s&login=some-github-login&id=some-github-id", encodedIDPDisplayName),
},
},
{
name: "happy path with groups=slug",
providerConfig: ProviderConfig{
APIBaseURL: "https://some-url",
HttpClient: someHttpClient,
UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameLoginAndID,
AllowedOrganizations: setutil.NewCaseInsensitiveSet("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([]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",
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: fmt.Sprintf("https://some-url?idpName=%s&login=some-github-login&id=some-github-id", encodedIDPDisplayName),
},
},
{
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,
UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameLoginAndID,
},
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,
UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameLoginAndID,
},
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",
},
{
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, nil).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) {
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 {
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, idpDisplayName)
if test.wantErr != "" {
require.EqualError(t, actualErr, test.wantErr)
require.Nil(t, actualUser)
return
}
require.NoError(t, actualErr)
require.Equal(t, test.wantUser, actualUser)
})
}
}
+2 -2
View File
@@ -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
+9 -9
View File
@@ -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,
+28 -16
View File
@@ -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) {
+303 -109
View File
@@ -44,6 +44,113 @@ import (
"go.pinniped.dev/test/testlib/browsertest"
)
// These tests attempt to exercise the entire login and refresh flow of the Supervisor for various cases.
// They do not use the Pinniped CLI as the client, which allows them to exercise the Supervisor as an
// OIDC provider in ways that the CLI might not use. Similar tests exist using the CLI in e2e_test.go.
//
// Each of these tests perform the following flow:
// 1. Configure an IDP CR.
// 2. Create a FederationDomain with TLS configured and wait for its JWKS endpoint to be available.
// 3. Call the authorization endpoint and log in as a specific user.
// Note that these tests do not use form_post response type (which is tested by e2e_test.go).
// 4. Listen on a local callback server for the authorization redirect, and assert that it was success or failure.
// 5. Call the token endpoint to exchange the authcode.
// 6. Call the token endpoint to perform the RFC8693 token exchange for the cluster-scoped ID token.
// 7. Potentially edit the refresh session data or IDP settings before the refresh.
// 8. Call the token endpoint to perform a refresh, and expect it to succeed.
// 9. Call the token endpoint again to perform another RFC8693 token exchange for the cluster-scoped ID token,
// this time using the recently refreshed tokens when submitting the request.
// 10. Potentially edit the refresh session data or IDP settings again, this time in such a way that the next
// refresh should fail. If done, then perform one more refresh and expect failure.
type supervisorLoginTestcase struct {
name string
// This required function might choose to skip the test case, for example if the LDAP server is not
// available for an LDAP test.
maybeSkip func(t *testing.T)
// This required function should configure an IDP CR. It should also wait for it to be ready and schedule
// its cleanup. Return the name of the IDP CR.
createIDP func(t *testing.T) string
// Optionally specify the identityProviders part of the FederationDomain's spec by returning it from this function.
// Also return the displayName of the IDP that should be used during authentication (or empty string for no IDP name in the auth request).
// This function takes the name of the IDP CR which was returned by createIDP() as as argument.
federationDomainIDPs func(t *testing.T, idpName string) (idps []configv1alpha1.FederationDomainIdentityProvider, useIDPDisplayName string)
// Optionally create an OIDCClient CR for the test to use. Return the client ID and client secret for the
// test to use. When not set, the test will default to using the "pinniped-cli" static client with no secret.
// When a client secret is returned, it will be used for authcode exchange, refresh requests, and RFC8693
// token exchanges for cluster-scoped tokens (client secrets are not needed in authorization requests).
createOIDCClient func(t *testing.T, callbackURL string) (string, string)
// Optionally return the username and password for the test to use when logging in. This username/password
// will be passed to requestAuthorization(), or empty strings will be passed to indicate that the defaults
// should be used. If there is any cleanup required, then this function should also schedule that cleanup.
testUser func(t *testing.T) (string, string)
// This required function should call the authorization endpoint using the given URL and also perform whatever
// interactions are needed to log in as the user.
requestAuthorization func(t *testing.T, downstreamIssuer, downstreamAuthorizeURL, downstreamCallbackURL, username, password string, httpClient *http.Client)
// This string will be used as the requested audience in the RFC8693 token exchange for
// the cluster-scoped ID token. When it is not specified, a default string will be used.
requestTokenExchangeAud string
// The scopes to request from the authorization endpoint. Defaults will be used when not specified.
downstreamScopes []string
// The scopes to want granted from the authorization endpoint. Defaults to the downstreamScopes value when not,
// specified, i.e. by default it expects that all requested scopes were granted.
wantDownstreamScopes []string
// When we want the localhost callback to have never happened, then the flow will stop there. The login was
// unable to finish so there is nothing to assert about what should have happened with the callback, and there
// won't be any error sent to the callback either. This would happen, for example, when the user fails to log
// in at the LDAP/AD login page, because then they would be redirected back to that page again, instead of
// getting a callback success/error redirect.
wantLocalhostCallbackToNeverHappen bool
// The expected ID token subject claim value as a regexp, for the original ID token and the refreshed ID token.
wantDownstreamIDTokenSubjectToMatch string
// The expected ID token username claim value as a regexp, for the original ID token and the refreshed ID token.
// This function should return an empty string when there should be no username claim in the ID tokens.
wantDownstreamIDTokenUsernameToMatch func(username string) string
// The expected ID token groups claim value, for the original ID token and the refreshed ID token.
wantDownstreamIDTokenGroups []string
// The expected ID token additional claims, which will be nested under claim "additionalClaims",
// for the original ID token and the refreshed ID token.
wantDownstreamIDTokenAdditionalClaims map[string]interface{}
// The expected ID token lifetime, as calculated by token claim 'exp' subtracting token claim 'iat'.
// ID tokens issued through authcode exchange or token refresh should have the configured lifetime (or default if not configured).
// ID tokens issued through a token exchange should have the default lifetime.
wantDownstreamIDTokenLifetime *time.Duration
// Want the authorization endpoint to redirect to the callback with this error type.
// The rest of the flow will be skipped since the initial authorization failed.
wantAuthorizationErrorType string
// Want the authorization endpoint to redirect to the callback with this error description.
// Should be used with wantAuthorizationErrorType.
wantAuthorizationErrorDescription string
// Optionally want to the authcode exchange at the token endpoint to fail. The rest of the flow will be
// skipped since the authcode exchange failed.
wantAuthcodeExchangeError string
// Optionally make all required assertions about the response of the RFC8693 token exchange for
// the cluster-scoped ID token, given the http response status and response body from the token endpoint.
// When this is not specified then the appropriate default assertions for a successful exchange are made.
// Even if this expects failures, the rest of the flow will continue.
wantTokenExchangeResponse func(t *testing.T, status int, body string)
// Optionally edit the refresh session data between the initial login and the first refresh,
// which is still expected to succeed after these edits. Returns the group memberships expected after the
// refresh is performed.
editRefreshSessionDataWithoutBreaking func(t *testing.T, sessionData *psession.PinnipedSession, idpName, username string) []string
// Optionally either revoke the user's session on the upstream provider, or manipulate the user's session
// data in such a way that it should cause the next upstream refresh attempt to fail.
breakRefreshSessionData func(t *testing.T, sessionData *psession.PinnipedSession, idpName, username string)
}
func TestSupervisorLogin_Browser(t *testing.T) {
env := testlib.IntegrationEnv(t)
@@ -56,6 +163,16 @@ func TestSupervisorLogin_Browser(t *testing.T) {
testlib.SkipTestWhenLDAPIsUnavailable(t, env)
}
skipAnyGitHubTests := func(t *testing.T) {
t.Helper()
testlib.SkipTestWhenGitHubIsUnavailable(t)
}
skipGitHubOAuthAppTestsButRunOtherGitHubTests := func(t *testing.T) {
t.Helper()
testlib.SkipTestWhenGitHubOAuthClientCallbackDoesNotMatchFederationDomainIssuerCallback(t)
}
skipActiveDirectoryTests := func(t *testing.T) {
t.Helper()
testlib.SkipTestWhenActiveDirectoryIsUnavailable(t, env)
@@ -205,112 +322,7 @@ func TestSupervisorLogin_Browser(t *testing.T) {
regexp.QuoteMeta("&sub=") + ".+" +
"$"
// These tests attempt to exercise the entire login and refresh flow of the Supervisor for various cases.
// They do not use the Pinniped CLI as the client, which allows them to exercise the Supervisor as an
// OIDC provider in ways that the CLI might not use. Similar tests exist using the CLI in e2e_test.go.
//
// Each of these tests perform the following flow:
// 1. Configure an IDP CR.
// 2. Create a FederationDomain with TLS configured and wait for its JWKS endpoint to be available.
// 3. Call the authorization endpoint and log in as a specific user.
// Note that these tests do not use form_post response type (which is tested by e2e_test.go).
// 4. Listen on a local callback server for the authorization redirect, and assert that it was success or failure.
// 5. Call the token endpoint to exchange the authcode.
// 6. Call the token endpoint to perform the RFC8693 token exchange for the cluster-scoped ID token.
// 7. Potentially edit the refresh session data or IDP settings before the refresh.
// 8. Call the token endpoint to perform a refresh, and expect it to succeed.
// 9. Call the token endpoint again to perform another RFC8693 token exchange for the cluster-scoped ID token,
// this time using the recently refreshed tokens when submitting the request.
// 10. Potentially edit the refresh session data or IDP settings again, this time in such a way that the next
// refresh should fail. If done, then perform one more refresh and expect failure.
tests := []struct {
name string
// This required function might choose to skip the test case, for example if the LDAP server is not
// available for an LDAP test.
maybeSkip func(t *testing.T)
// This required function should configure an IDP CR. It should also wait for it to be ready and schedule
// its cleanup. Return the name of the IDP CR.
createIDP func(t *testing.T) string
// Optionally specify the identityProviders part of the FederationDomain's spec by returning it from this function.
// Also return the displayName of the IDP that should be used during authentication (or empty string for no IDP name in the auth request).
// This function takes the name of the IDP CR which was returned by createIDP() as as argument.
federationDomainIDPs func(t *testing.T, idpName string) (idps []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,
@@ -2174,6 +2186,24 @@ func TestSupervisorLogin_Browser(t *testing.T) {
},
}
// Append testcases for GitHub using a GitHub App as the client.
tests = append(tests,
supervisorLoginGithubTestcases(env,
env.SupervisorUpstreamGithub.GithubAppClientID,
env.SupervisorUpstreamGithub.GithubAppClientSecret,
"using GitHub App as client",
skipAnyGitHubTests)...,
)
// Append those same testcases for GitHub again, but this time using one of GitHub's old-style OAuth Apps as the client.
tests = append(tests,
supervisorLoginGithubTestcases(env,
env.SupervisorUpstreamGithub.GithubOAuthAppClientID,
env.SupervisorUpstreamGithub.GithubOAuthAppClientSecret,
"using old-style GitHub OAuth App as client",
skipGitHubOAuthAppTestsButRunOtherGitHubTests)...,
)
for _, test := range tests {
tt := test
t.Run(tt.name, func(t *testing.T) {
@@ -2206,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{}
@@ -2427,7 +2599,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 +2648,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 +2699,7 @@ func testSupervisorLogin(
downstreamAuthorizeURL := downstreamOAuth2Config.AuthCodeURL(stateParam.String(), authorizeRequestParams...)
// Perform parameterized auth code acquisition.
requestAuthorization(t, downstream.Spec.Issuer, downstreamAuthorizeURL, localCallbackServer.URL, username, password, httpClient)
requestAuthorization(t, federationDomain.Spec.Issuer, downstreamAuthorizeURL, localCallbackServer.URL, username, password, httpClient)
// Expect that our callback handler was invoked.
callback, err := localCallbackServer.waitForCallback(10 * time.Second)
@@ -2718,6 +2890,7 @@ func testSupervisorLogin(
// Should have got an error since the upstream refresh should have failed.
require.Error(t, err)
require.EqualError(t, err, `oauth2: "error" "Error during upstream refresh. Upstream refresh failed."`)
t.Log("successfully confirmed that breaking the refresh session data caused the refresh to fail")
}
}
@@ -2884,6 +3057,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 +3078,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()
+65 -11
View File
@@ -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"
@@ -387,6 +396,12 @@ func LoginToUpstreamGitHub(t *testing.T, b *Browser, upstream testlib.TestGithub
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")
@@ -397,15 +412,23 @@ 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)
}
// handleOccasionalGithubLoginPage handles the interstitial pages which GitHub might show during a login flow.
// None of these will always happen.
func handleOccasionalGithubLoginPage(t *testing.T, b *Browser, upstream testlib.TestGithubUpstream) bool {
t.Helper()
t.Log("sleeping for 2 seconds before looking at page title")
time.Sleep(2 * time.Second)
pageTitle := b.Title(t)
t.Logf("saw page title %q", pageTitle)
lowercaseTitle := strings.ToLower(pageTitle)
// 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 <App Name>"
switch {
case strings.HasPrefix(lowercaseTitle, "authorize "): // the title is "Authorize <App Name>"
// Next Github might go to another page asking if you authorize the GitHub App to act on your behalf,
// if this user has never authorized this app.
// Wait for the authorize app page to be rendered.
t.Logf("waiting for GitHub authorize button")
// There are unfortunately two very similar buttons on this page:
@@ -415,16 +438,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:
// <button type="submit" name="type" value="confirmed" class="btn btn-block btn-primary ml-3">Confirm</button>
submitConfirmButtonSelector := "button.btn-primary"
b.WaitForVisibleElements(t, submitConfirmButtonSelector)
t.Logf("clicking confirm button")
b.ClickFirstMatch(t, submitConfirmButtonSelector)
return true
// 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:
// <input class="btn-link" type="submit" value="Don't ask again for this browser">
dontAskAgainLinkSelector := `input[value="Don't ask again for this browser"]`
@@ -434,6 +464,30 @@ 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, "two-factor authentication"):
// Sometimes this happens after the OTP page when we try to use the same OTP code again too quickly.
// GitHub stays on the same page and shows an error banner saying that we used the same code again.
// Sleep for a long time to try to avoid this error from GitHub, which seems to be some type of rate limiting on OTP codes:
// "We were unable to authenticate your request because too many codes have been submitted".
otpSleepSeconds := 60
t.Logf("sleeping %d seconds before generating another GitHub OTP code after a previous code failed", otpSleepSeconds)
time.Sleep(time.Duration(otpSleepSeconds) * time.Second)
handleGithubOTPLoginPage(t, b, upstream)
return true
case strings.HasPrefix(lowercaseTitle, "server error"):
// Sometimes this happens after the OTP page. Not sure why. The page has a cute cartoon, but no helpful information.
// The URL bar shows https://github.com/sessions/trusted-device for this error page, which is the URL that usually
// asks if you want to configure passwordless authentication (aka passkey).
t.Fatal("Got GitHub server internal error page during login flow. This is not expected, but is unfortunately unrecoverable.")
return false // we recognized the title, but we don't know how to handle this page because it has no buttons or other way forward
default:
// We did not know how to handle the page given its title.
// Maybe we successfully got through all the interstitial pages and finished the login.
return false
}
}
+26 -10
View File
@@ -112,11 +112,18 @@ 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"` // 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.
@@ -329,11 +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", ""),
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)
@@ -341,6 +355,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 {
+20 -1
View File
@@ -1,4 +1,4 @@
// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved.
// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package testlib
@@ -33,3 +33,22 @@ func SkipTestWhenActiveDirectoryIsUnavailable(t *testing.T, env *TestEnv) {
t.Skip("Active Directory hostname not specified")
}
}
func SkipTestWhenGitHubIsUnavailable(t *testing.T) {
t.Helper()
if IntegrationEnv(t).SupervisorUpstreamGithub.GithubAppClientID == "" {
t.Skip("GitHub test env vars not specified")
}
}
func SkipTestWhenGitHubOAuthClientCallbackDoesNotMatchFederationDomainIssuerCallback(t *testing.T) {
t.Helper()
SkipTestWhenGitHubIsUnavailable(t)
env := IntegrationEnv(t)
if env.SupervisorUpstreamGithub.GithubOAuthAppAllowedCallbackURL != env.SupervisorUpstreamOIDC.CallbackURL {
t.Skip("GitHub OAuth App client allowed callback URL does not match the callback URL for the FederationDomain")
}
}