mirror of
https://github.com/vmware-tanzu/pinniped.git
synced 2026-07-22 16:02:50 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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.
|
||||
|
||||
+5
-5
@@ -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)
|
||||
|
||||
|
||||
+20
-20
@@ -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())
|
||||
|
||||
+2
-1
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
+10
-6
@@ -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,
|
||||
|
||||
+78
-10
@@ -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())
|
||||
}
|
||||
|
||||
+314
-3
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -1,4 +1,4 @@
|
||||
// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved.
|
||||
// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package testlib
|
||||
@@ -33,3 +33,22 @@ func SkipTestWhenActiveDirectoryIsUnavailable(t *testing.T, env *TestEnv) {
|
||||
t.Skip("Active Directory hostname not specified")
|
||||
}
|
||||
}
|
||||
|
||||
func SkipTestWhenGitHubIsUnavailable(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
if IntegrationEnv(t).SupervisorUpstreamGithub.GithubAppClientID == "" {
|
||||
t.Skip("GitHub test env vars not specified")
|
||||
}
|
||||
}
|
||||
|
||||
func SkipTestWhenGitHubOAuthClientCallbackDoesNotMatchFederationDomainIssuerCallback(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
SkipTestWhenGitHubIsUnavailable(t)
|
||||
|
||||
env := IntegrationEnv(t)
|
||||
if env.SupervisorUpstreamGithub.GithubOAuthAppAllowedCallbackURL != env.SupervisorUpstreamOIDC.CallbackURL {
|
||||
t.Skip("GitHub OAuth App client allowed callback URL does not match the callback URL for the FederationDomain")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user