diff --git a/internal/authenticators/authenticators.go b/internal/authenticators/authenticators.go index f7a59e33d..60151c18f 100644 --- a/internal/authenticators/authenticators.go +++ b/internal/authenticators/authenticators.go @@ -35,6 +35,7 @@ type UserAuthenticator interface { } type Response struct { - User user.Info - DN string + User user.Info + DN string + ExtraRefreshAttributes map[string]string } diff --git a/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher.go b/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher.go index 7bd83f39d..633fb4384 100644 --- a/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher.go +++ b/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher.go @@ -7,8 +7,12 @@ package activedirectoryupstreamwatcher import ( "context" "fmt" + "regexp" + "strconv" + "strings" "github.com/go-ldap/ldap/v3" + "github.com/google/uuid" "k8s.io/apimachinery/pkg/api/equality" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -52,6 +56,21 @@ const ( // - has a member that matches the DN of the user we successfully logged in as. // - perform nested group search by default. defaultActiveDirectoryGroupSearchFilter = "(&(objectClass=group)(member:1.2.840.113556.1.4.1941:={}))" + + sAMAccountNameAttribute = "sAMAccountName" + // PwdLastSetAttribute is the date and time that the password for this account was last changed. + // https://docs.microsoft.com/en-us/windows/win32/adschema/a-pwdlastset + PwdLastSetAttribute = "pwdLastSet" + // UserAccountControlAttribute represents a bitmap of user properties. + // https://docs.microsoft.com/en-us/troubleshoot/windows-server/identity/useraccountcontrol-manipulate-account-properties + UserAccountControlAttribute = "userAccountControl" + // UserAccountControlComputedAttribute represents a bitmap of user properties. + // https://docs.microsoft.com/en-us/windows/win32/adschema/a-msds-user-account-control-computed + UserAccountControlComputedAttribute = "msDS-User-Account-Control-Computed" + // 0x0002 ACCOUNTDISABLE in userAccountControl bitmap. + accountDisabledBitmapValue = 2 + // 0x0010 UF_LOCKOUT in msDS-User-Account-Control-Computed bitmap. + accountLockedBitmapValue = 16 ) type activeDirectoryUpstreamGenericLDAPImpl struct { @@ -316,11 +335,16 @@ func (c *activeDirectoryWatcherController) validateUpstream(ctx context.Context, GroupNameAttribute: adUpstreamImpl.Spec().GroupSearch().GroupNameAttribute(), }, Dialer: c.ldapDialer, - UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, + UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": MicrosoftUUIDFromBinary("objectGUID")}, + RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{ + PwdLastSetAttribute: upstreamldap.AttributeUnchangedSinceLogin(PwdLastSetAttribute), + UserAccountControlAttribute: ValidUserAccountControl, + UserAccountControlComputedAttribute: ValidComputedUserAccountControl, + }, } if spec.GroupSearch.Attributes.GroupName == "" { - config.GroupAttributeParsingOverrides = map[string]func(*ldap.Entry) (string, error){defaultActiveDirectoryGroupNameAttributeName: upstreamldap.GroupSAMAccountNameWithDomainSuffix} + config.GroupAttributeParsingOverrides = map[string]func(*ldap.Entry) (string, error){defaultActiveDirectoryGroupNameAttributeName: GroupSAMAccountNameWithDomainSuffix} } conditions := upstreamwatchers.ValidateGenericLDAP(ctx, adUpstreamImpl, c.secretInformer, c.validatedSecretVersionsCache, config) @@ -353,3 +377,84 @@ func (c *activeDirectoryWatcherController) updateStatus(ctx context.Context, ups log.Error(err, "failed to update status") } } + +func MicrosoftUUIDFromBinary(attributeName string) func(entry *ldap.Entry) (string, error) { + // validation has already been done so we can just get the attribute... + return func(entry *ldap.Entry) (string, error) { + binaryUUID := entry.GetRawAttributeValue(attributeName) + return microsoftUUIDFromBinary(binaryUUID) + } +} + +func microsoftUUIDFromBinary(binaryUUID []byte) (string, error) { + uuidVal, err := uuid.FromBytes(binaryUUID) // start out with the RFC4122 version + if err != nil { + return "", err + } + // then swap it because AD stores the first 3 fields little-endian rather than the expected + // big-endian. + uuidVal[0], uuidVal[1], uuidVal[2], uuidVal[3] = uuidVal[3], uuidVal[2], uuidVal[1], uuidVal[0] + uuidVal[4], uuidVal[5] = uuidVal[5], uuidVal[4] + uuidVal[6], uuidVal[7] = uuidVal[7], uuidVal[6] + return uuidVal.String(), nil +} + +func GroupSAMAccountNameWithDomainSuffix(entry *ldap.Entry) (string, error) { + sAMAccountNameAttributeValues := entry.GetAttributeValues(sAMAccountNameAttribute) + + if len(sAMAccountNameAttributeValues) != 1 { + return "", fmt.Errorf(`found %d values for attribute "%s", but expected 1 result`, + len(sAMAccountNameAttributeValues), sAMAccountNameAttribute, + ) + } + + sAMAccountName := sAMAccountNameAttributeValues[0] + if len(sAMAccountName) == 0 { + return "", fmt.Errorf(`found empty value for attribute "%s", but expected value to be non-empty`, + sAMAccountNameAttribute, + ) + } + + distinguishedName := entry.DN + domain, err := getDomainFromDistinguishedName(distinguishedName) + if err != nil { + return "", err + } + return sAMAccountName + "@" + domain, nil +} + +var domainComponentsRegexp = regexp.MustCompile(",DC=|,dc=") + +func getDomainFromDistinguishedName(distinguishedName string) (string, error) { + domainComponents := domainComponentsRegexp.Split(distinguishedName, -1) + if len(domainComponents) == 1 { + return "", fmt.Errorf("did not find domain components in group dn: %s", distinguishedName) + } + return strings.Join(domainComponents[1:], "."), nil +} + +func ValidUserAccountControl(entry *ldap.Entry, _ provider.StoredRefreshAttributes) error { + userAccountControl, err := strconv.Atoi(entry.GetAttributeValue(UserAccountControlAttribute)) + if err != nil { + return err + } + + deactivated := userAccountControl & accountDisabledBitmapValue // bitwise and. + if deactivated != 0 { + return fmt.Errorf("user has been deactivated") + } + return nil +} + +func ValidComputedUserAccountControl(entry *ldap.Entry, _ provider.StoredRefreshAttributes) error { + userAccountControl, err := strconv.Atoi(entry.GetAttributeValue(UserAccountControlComputedAttribute)) + if err != nil { + return err + } + + locked := userAccountControl & accountLockedBitmapValue // bitwise and + if locked != 0 { + return fmt.Errorf("user has been locked") + } + return nil +} diff --git a/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher_test.go b/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher_test.go index da2042164..5c47a5b84 100644 --- a/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher_test.go +++ b/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher_test.go @@ -220,7 +220,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { Filter: testGroupSearchFilter, GroupNameAttribute: testGroupNameAttrName, }, - UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, + UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": MicrosoftUUIDFromBinary("objectGUID")}, + RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{ + "pwdLastSet": upstreamldap.AttributeUnchangedSinceLogin("pwdLastSet"), + "userAccountControl": ValidUserAccountControl, + "msDS-User-Account-Control-Computed": ValidComputedUserAccountControl, + }, } // Make a copy with targeted changes. @@ -536,7 +541,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { Filter: testGroupSearchFilter, GroupNameAttribute: testGroupNameAttrName, }, - UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, + UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": MicrosoftUUIDFromBinary("objectGUID")}, + RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{ + "pwdLastSet": upstreamldap.AttributeUnchangedSinceLogin("pwdLastSet"), + "userAccountControl": ValidUserAccountControl, + "msDS-User-Account-Control-Computed": ValidComputedUserAccountControl, + }, }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ @@ -592,7 +602,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { Filter: testGroupSearchFilter, GroupNameAttribute: "sAMAccountName", }, - UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, + UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": MicrosoftUUIDFromBinary("objectGUID")}, + RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{ + "pwdLastSet": upstreamldap.AttributeUnchangedSinceLogin("pwdLastSet"), + "userAccountControl": ValidUserAccountControl, + "msDS-User-Account-Control-Computed": ValidComputedUserAccountControl, + }, }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ @@ -651,7 +666,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { Filter: testGroupSearchFilter, GroupNameAttribute: testGroupNameAttrName, }, - UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, + UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": MicrosoftUUIDFromBinary("objectGUID")}, + RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{ + "pwdLastSet": upstreamldap.AttributeUnchangedSinceLogin("pwdLastSet"), + "userAccountControl": ValidUserAccountControl, + "msDS-User-Account-Control-Computed": ValidComputedUserAccountControl, + }, }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ @@ -710,7 +730,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { Filter: testGroupSearchFilter, GroupNameAttribute: testGroupNameAttrName, }, - UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, + UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": MicrosoftUUIDFromBinary("objectGUID")}, + RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{ + "pwdLastSet": upstreamldap.AttributeUnchangedSinceLogin("pwdLastSet"), + "userAccountControl": ValidUserAccountControl, + "msDS-User-Account-Control-Computed": ValidComputedUserAccountControl, + }, }, }, wantErr: controllerlib.ErrSyntheticRequeue.Error(), @@ -768,7 +793,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { Filter: testGroupSearchFilter, GroupNameAttribute: testGroupNameAttrName, }, - UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, + UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": MicrosoftUUIDFromBinary("objectGUID")}, + RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{ + "pwdLastSet": upstreamldap.AttributeUnchangedSinceLogin("pwdLastSet"), + "userAccountControl": ValidUserAccountControl, + "msDS-User-Account-Control-Computed": ValidComputedUserAccountControl, + }, }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ @@ -897,7 +927,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { Filter: testGroupSearchFilter, GroupNameAttribute: testGroupNameAttrName, }, - UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, + UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": MicrosoftUUIDFromBinary("objectGUID")}, + RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{ + "pwdLastSet": upstreamldap.AttributeUnchangedSinceLogin("pwdLastSet"), + "userAccountControl": ValidUserAccountControl, + "msDS-User-Account-Control-Computed": ValidComputedUserAccountControl, + }, }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ @@ -1021,8 +1056,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { Filter: testGroupSearchFilter, GroupNameAttribute: testGroupNameAttrName, }, - UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, - }, + UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": MicrosoftUUIDFromBinary("objectGUID")}, + RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{ + "pwdLastSet": upstreamldap.AttributeUnchangedSinceLogin("pwdLastSet"), + "userAccountControl": ValidUserAccountControl, + "msDS-User-Account-Control-Computed": ValidComputedUserAccountControl, + }}, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234}, @@ -1072,7 +1111,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { Filter: testGroupSearchFilter, GroupNameAttribute: testGroupNameAttrName, }, - UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, + UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": MicrosoftUUIDFromBinary("objectGUID")}, + RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{ + "pwdLastSet": upstreamldap.AttributeUnchangedSinceLogin("pwdLastSet"), + "userAccountControl": ValidUserAccountControl, + "msDS-User-Account-Control-Computed": ValidComputedUserAccountControl, + }, }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ @@ -1271,8 +1315,13 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { Filter: "(&(objectClass=group)(member:1.2.840.113556.1.4.1941:={}))", GroupNameAttribute: "sAMAccountName", }, - UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, - GroupAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"sAMAccountName": upstreamldap.GroupSAMAccountNameWithDomainSuffix}, + UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": MicrosoftUUIDFromBinary("objectGUID")}, + GroupAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"sAMAccountName": GroupSAMAccountNameWithDomainSuffix}, + RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{ + "pwdLastSet": upstreamldap.AttributeUnchangedSinceLogin("pwdLastSet"), + "userAccountControl": ValidUserAccountControl, + "msDS-User-Account-Control-Computed": ValidComputedUserAccountControl, + }, }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ @@ -1324,7 +1373,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { Filter: testGroupSearchFilter, GroupNameAttribute: testGroupNameAttrName, }, - UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, + UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": MicrosoftUUIDFromBinary("objectGUID")}, + RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{ + "pwdLastSet": upstreamldap.AttributeUnchangedSinceLogin("pwdLastSet"), + "userAccountControl": ValidUserAccountControl, + "msDS-User-Account-Control-Computed": ValidComputedUserAccountControl, + }, }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ @@ -1380,7 +1434,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { Filter: testGroupSearchFilter, GroupNameAttribute: testGroupNameAttrName, }, - UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, + UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": MicrosoftUUIDFromBinary("objectGUID")}, + RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{ + "pwdLastSet": upstreamldap.AttributeUnchangedSinceLogin("pwdLastSet"), + "userAccountControl": ValidUserAccountControl, + "msDS-User-Account-Control-Computed": ValidComputedUserAccountControl, + }, }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ @@ -1430,7 +1489,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { Filter: testGroupSearchFilter, GroupNameAttribute: testGroupNameAttrName, }, - UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, + UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": MicrosoftUUIDFromBinary("objectGUID")}, + RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{ + "pwdLastSet": upstreamldap.AttributeUnchangedSinceLogin("pwdLastSet"), + "userAccountControl": ValidUserAccountControl, + "msDS-User-Account-Control-Computed": ValidComputedUserAccountControl, + }, }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ @@ -1626,7 +1690,12 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { Filter: testGroupSearchFilter, GroupNameAttribute: testGroupNameAttrName, }, - UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, + UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": MicrosoftUUIDFromBinary("objectGUID")}, + RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{ + "pwdLastSet": upstreamldap.AttributeUnchangedSinceLogin("pwdLastSet"), + "userAccountControl": ValidUserAccountControl, + "msDS-User-Account-Control-Computed": ValidComputedUserAccountControl, + }, }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ @@ -1753,6 +1822,16 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { require.Equal(t, reflect.ValueOf(v).Pointer(), reflect.ValueOf(actualGroupAttributeParsingOverrides[k]).Pointer()) } + expectedRefreshAttributeChecks := copyOfExpectedValueForResultingCache.RefreshAttributeChecks + actualRefreshAttributeChecks := actualConfig.RefreshAttributeChecks + copyOfExpectedValueForResultingCache.RefreshAttributeChecks = map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{} + actualConfig.RefreshAttributeChecks = map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{} + require.Equal(t, len(expectedRefreshAttributeChecks), len(actualRefreshAttributeChecks)) + for k, v := range expectedRefreshAttributeChecks { + require.NotNil(t, actualRefreshAttributeChecks[k]) + require.Equal(t, reflect.ValueOf(v).Pointer(), reflect.ValueOf(actualRefreshAttributeChecks[k]).Pointer()) + } + require.Equal(t, copyOfExpectedValueForResultingCache, actualConfig) } @@ -1800,3 +1879,270 @@ func normalizeActiveDirectoryUpstreams(upstreams []v1alpha1.ActiveDirectoryIdent return result } + +func TestGroupSAMAccountNameWithDomainSuffix(t *testing.T) { + tests := []struct { + name string + entry *ldap.Entry + wantResult string + wantErr string + }{ + { + name: "happy path with DN and valid sAMAccountName", + entry: &ldap.Entry{ + DN: "CN=animals,OU=Users,OU=pinniped-ad,DC=mycompany,DC=example,DC=com", + Attributes: []*ldap.EntryAttribute{ + ldap.NewEntryAttribute("sAMAccountName", []string{"Mammals"}), + }, + }, + wantResult: "Mammals@mycompany.example.com", + }, + { + name: "no domain components in DN", + entry: &ldap.Entry{ + DN: "no-domain-components", + Attributes: []*ldap.EntryAttribute{ + ldap.NewEntryAttribute("sAMAccountName", []string{"Mammals"}), + }, + }, + wantErr: "did not find domain components in group dn: no-domain-components", + }, + { + name: "multiple values for sAMAccountName attribute", + entry: &ldap.Entry{ + DN: "CN=animals,OU=Users,OU=pinniped-ad,DC=mycompany,DC=example,DC=com", + Attributes: []*ldap.EntryAttribute{ + ldap.NewEntryAttribute("sAMAccountName", []string{"Mammals", "Eukaryotes"}), + }, + }, + wantErr: "found 2 values for attribute \"sAMAccountName\", but expected 1 result", + }, + { + name: "no values for sAMAccountName attribute", + entry: &ldap.Entry{ + DN: "CN=animals,OU=Users,OU=pinniped-ad,DC=mycompany,DC=example,DC=com", + Attributes: []*ldap.EntryAttribute{ + ldap.NewEntryAttribute("sAMAccountName", []string{}), + }, + }, + wantErr: "found 0 values for attribute \"sAMAccountName\", but expected 1 result", + }, + } + for _, test := range tests { + tt := test + t.Run(tt.name, func(t *testing.T) { + suffixedSAMAccountName, err := GroupSAMAccountNameWithDomainSuffix(tt.entry) + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + require.Equal(t, tt.wantResult, suffixedSAMAccountName) + }) + } +} + +func TestGetMicrosoftFormattedUUID(t *testing.T) { + tests := []struct { + name string + binaryUUID []byte + wantString string + wantErr string + }{ + { + name: "happy path", + binaryUUID: []byte("\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16"), + wantString: "04030201-0605-0807-0910-111213141516", + }, + { + name: "not the right length", + binaryUUID: []byte("2\xf8\xb0\xaa\xb6V\xb1D\x8b(\xee"), + wantErr: "invalid UUID (got 11 bytes)", + }, + } + + for _, test := range tests { + tt := test + t.Run(tt.name, func(t *testing.T) { + actualUUIDString, err := microsoftUUIDFromBinary(tt.binaryUUID) + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + require.Equal(t, tt.wantString, actualUUIDString) + }) + } +} + +func TestGetDomainFromDistinguishedName(t *testing.T) { + tests := []struct { + name string + distinguishedName string + wantDomain string + wantErr string + }{ + { + name: "happy path", + distinguishedName: "CN=Mammals,OU=Users,OU=pinniped-ad,DC=activedirectory,DC=mycompany,DC=example,DC=com", + wantDomain: "activedirectory.mycompany.example.com", + }, + { + name: "lowercased happy path", + distinguishedName: "cn=Mammals,ou=Users,ou=pinniped-ad,dc=activedirectory,dc=mycompany,dc=example,dc=com", + wantDomain: "activedirectory.mycompany.example.com", + }, + { + name: "no domain components", + distinguishedName: "not-a-dn", + wantErr: "did not find domain components in group dn: not-a-dn", + }, + } + + for _, test := range tests { + tt := test + t.Run(tt.name, func(t *testing.T) { + actualDomain, err := getDomainFromDistinguishedName(tt.distinguishedName) + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + require.Equal(t, tt.wantDomain, actualDomain) + }) + } +} + +func TestValidUserAccountControl(t *testing.T) { + tests := []struct { + name string + entry *ldap.Entry + wantErr string + }{ + { + name: "happy normal user", + entry: &ldap.Entry{ + DN: "some-dn", + Attributes: []*ldap.EntryAttribute{ + { + Name: "userAccountControl", + Values: []string{"512"}, + }, + }, + }, + }, + { + name: "happy user whose password doesn't expire", + entry: &ldap.Entry{ + DN: "some-dn", + Attributes: []*ldap.EntryAttribute{ + { + Name: "userAccountControl", + Values: []string{"65536"}, + }, + }, + }, + }, + { + name: "deactivated user", + entry: &ldap.Entry{ + DN: "some-dn", + Attributes: []*ldap.EntryAttribute{ + { + Name: "userAccountControl", + Values: []string{"514"}, + }, + }, + }, + wantErr: "user has been deactivated", + }, + { + name: "non-integer result", + entry: &ldap.Entry{ + DN: "some-dn", + Attributes: []*ldap.EntryAttribute{ + { + Name: "userAccountControl", + Values: []string{"not-an-int"}, + }, + }, + }, + wantErr: "strconv.Atoi: parsing \"not-an-int\": invalid syntax", + }, + } + + for _, test := range tests { + tt := test + t.Run(tt.name, func(t *testing.T) { + err := ValidUserAccountControl(tt.entry, provider.StoredRefreshAttributes{}) + + if tt.wantErr != "" { + require.Error(t, err) + require.Equal(t, tt.wantErr, err.Error()) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestValidComputedUserAccountControl(t *testing.T) { + tests := []struct { + name string + entry *ldap.Entry + wantErr string + }{ + { + name: "happy normal user", + entry: &ldap.Entry{ + DN: "some-dn", + Attributes: []*ldap.EntryAttribute{ + { + Name: "msDS-User-Account-Control-Computed", + Values: []string{"0"}, + }, + }, + }, + }, + { + name: "locked user", + entry: &ldap.Entry{ + DN: "some-dn", + Attributes: []*ldap.EntryAttribute{ + { + Name: "msDS-User-Account-Control-Computed", + Values: []string{"16"}, + }, + }, + }, + wantErr: "user has been locked", + }, + { + name: "non-integer result", + entry: &ldap.Entry{ + DN: "some-dn", + Attributes: []*ldap.EntryAttribute{ + { + Name: "msDS-User-Account-Control-Computed", + Values: []string{"not-an-int"}, + }, + }, + }, + wantErr: "strconv.Atoi: parsing \"not-an-int\": invalid syntax", + }, + } + + for _, test := range tests { + tt := test + t.Run(tt.name, func(t *testing.T) { + err := ValidComputedUserAccountControl(tt.entry, provider.StoredRefreshAttributes{}) + + if tt.wantErr != "" { + require.Error(t, err) + require.Equal(t, tt.wantErr, err.Error()) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/internal/fositestorage/authorizationcode/authorizationcode.go b/internal/fositestorage/authorizationcode/authorizationcode.go index 50c35788e..9a1525214 100644 --- a/internal/fositestorage/authorizationcode/authorizationcode.go +++ b/internal/fositestorage/authorizationcode/authorizationcode.go @@ -348,20 +348,25 @@ const ExpectedAuthorizeCodeSessionJSONFromFuzzing = `{ "upstreamRefreshToken": "嵽痊w©Ź榨Q|ôɵt毇妬" }, "ldap": { - "userDN": "6鉢緋uƴŤȱʀļÂ?墖\u003cƬb獭潜Ʃ饾" + "userDN": "6鉢緋uƴŤȱʀļÂ?墖\u003cƬb獭潜Ʃ饾", + "extraRefreshAttributes": { + "ď逳鞪?3)藵睋邔\u0026Ű惫蜀Ģ¡圔": "墀jMʥ", + "齁š%OpKȱ藚ɏ¬Ê蒭堜]ȗ韚ʫ": "鷞aŚB碠k9帴ʘ赱" + } }, "activedirectory": { - "userDN": "|鬌R蜚蠣麹概÷驣7Ʀ澉1æɽ誮rʨ鷞" + "userDN": "0D餹sêĝɓ", + "extraRefreshAttributes": { + "摱ì": "bEǎ儯惝Io" + } } } }, "requestedAudience": [ - "ŚB碠k9" + "Ł" ], "grantedAudience": [ - "ʘ赱", - "ď逳鞪?3)藵睋邔\u0026Ű惫蜀Ģ¡圔", - "墀jMʥ" + "r" ] }, "version": "2" diff --git a/internal/oidc/auth/auth_handler.go b/internal/oidc/auth/auth_handler.go index bbfdba754..fdf1787ed 100644 --- a/internal/oidc/auth/auth_handler.go +++ b/internal/oidc/auth/auth_handler.go @@ -122,12 +122,14 @@ func handleAuthRequestForLDAPUpstream( if idpType == psession.ProviderTypeLDAP { customSessionData.LDAP = &psession.LDAPSessionData{ - UserDN: dn, + UserDN: dn, + ExtraRefreshAttributes: authenticateResponse.ExtraRefreshAttributes, } } if idpType == psession.ProviderTypeActiveDirectory { customSessionData.ActiveDirectory = &psession.ActiveDirectorySessionData{ - UserDN: dn, + UserDN: dn, + ExtraRefreshAttributes: authenticateResponse.ExtraRefreshAttributes, } } diff --git a/internal/oidc/auth/auth_handler_test.go b/internal/oidc/auth/auth_handler_test.go index 564ef3802..0fb0dd70c 100644 --- a/internal/oidc/auth/auth_handler_test.go +++ b/internal/oidc/auth/auth_handler_test.go @@ -269,6 +269,8 @@ func TestAuthorizationEndpoint(t *testing.T) { happyLDAPUID := "some-ldap-uid" happyLDAPUserDN := "cn=foo,dn=bar" happyLDAPGroups := []string{"group1", "group2", "group3"} + happyLDAPExtraRefreshAttribute := "some-refresh-attribute" + happyLDAPExtraRefreshValue := "some-refresh-attribute-value" parsedUpstreamLDAPURL, err := url.Parse(upstreamLDAPURL) require.NoError(t, err) @@ -285,6 +287,9 @@ func TestAuthorizationEndpoint(t *testing.T) { Groups: happyLDAPGroups, }, DN: happyLDAPUserDN, + ExtraRefreshAttributes: map[string]string{ + happyLDAPExtraRefreshAttribute: happyLDAPExtraRefreshValue, + }, }, true, nil } return nil, false, nil @@ -442,7 +447,8 @@ func TestAuthorizationEndpoint(t *testing.T) { OIDC: nil, LDAP: nil, ActiveDirectory: &psession.ActiveDirectorySessionData{ - UserDN: happyLDAPUserDN, + UserDN: happyLDAPUserDN, + ExtraRefreshAttributes: map[string]string{happyLDAPExtraRefreshAttribute: happyLDAPExtraRefreshValue}, }, } @@ -452,7 +458,8 @@ func TestAuthorizationEndpoint(t *testing.T) { ProviderType: psession.ProviderTypeLDAP, OIDC: nil, LDAP: &psession.LDAPSessionData{ - UserDN: happyLDAPUserDN, + UserDN: happyLDAPUserDN, + ExtraRefreshAttributes: map[string]string{happyLDAPExtraRefreshAttribute: happyLDAPExtraRefreshValue}, }, ActiveDirectory: nil, } diff --git a/internal/oidc/provider/dynamic_upstream_idp_provider.go b/internal/oidc/provider/dynamic_upstream_idp_provider.go index a054a4c84..0770e2c3b 100644 --- a/internal/oidc/provider/dynamic_upstream_idp_provider.go +++ b/internal/oidc/provider/dynamic_upstream_idp_provider.go @@ -7,6 +7,7 @@ import ( "context" "net/url" "sync" + "time" "golang.org/x/oauth2" "k8s.io/apimachinery/pkg/types" @@ -93,7 +94,15 @@ type UpstreamLDAPIdentityProviderI interface { authenticators.UserAuthenticator // PerformRefresh performs a refresh against the upstream LDAP identity provider - PerformRefresh(ctx context.Context, userDN, expectedUsername, expectedSubject string) error + PerformRefresh(ctx context.Context, storedRefreshAttributes StoredRefreshAttributes) error +} + +type StoredRefreshAttributes struct { + Username string + Subject string + DN string + AuthTime time.Time + AdditionalAttributes map[string]string } type DynamicUpstreamIDPProvider interface { diff --git a/internal/oidc/token/token_handler.go b/internal/oidc/token/token_handler.go index c9703e6e7..afaee0b11 100644 --- a/internal/oidc/token/token_handler.go +++ b/internal/oidc/token/token_handler.go @@ -75,11 +75,6 @@ func NewHandler( func upstreamRefresh(ctx context.Context, accessRequest fosite.AccessRequester, providerCache oidc.UpstreamIdentityProvidersLister) error { session := accessRequest.GetSession().(*psession.PinnipedSession) - downstreamUsername, err := getDownstreamUsernameFromPinnipedSession(session) - if err != nil { - return err - } - downstreamSubject := session.Fosite.Claims.Subject customSessionData := session.Custom if customSessionData == nil { @@ -95,9 +90,9 @@ func upstreamRefresh(ctx context.Context, accessRequest fosite.AccessRequester, case psession.ProviderTypeOIDC: return upstreamOIDCRefresh(ctx, customSessionData, providerCache) case psession.ProviderTypeLDAP: - return upstreamLDAPRefresh(ctx, customSessionData, providerCache, downstreamUsername, downstreamSubject) + return upstreamLDAPRefresh(ctx, providerCache, session) case psession.ProviderTypeActiveDirectory: - return upstreamLDAPRefresh(ctx, customSessionData, providerCache, downstreamUsername, downstreamSubject) + return upstreamLDAPRefresh(ctx, providerCache, session) default: return errorsx.WithStack(errMissingUpstreamSessionInternalError) } @@ -169,7 +164,15 @@ func findOIDCProviderByNameAndValidateUID( WithHint("Provider from upstream session data was not found.").WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType)) } -func upstreamLDAPRefresh(ctx context.Context, s *psession.CustomSessionData, providerCache oidc.UpstreamIdentityProvidersLister, username string, subject string) error { +func upstreamLDAPRefresh(ctx context.Context, providerCache oidc.UpstreamIdentityProvidersLister, session *psession.PinnipedSession) error { + username, err := getDownstreamUsernameFromPinnipedSession(session) + if err != nil { + return err + } + subject := session.Fosite.Claims.Subject + + s := session.Custom + // if you have neither a valid ldap session config nor a valid active directory session config validLDAP := s.ProviderType == psession.ProviderTypeLDAP && s.LDAP != nil && s.LDAP.UserDN != "" validAD := s.ProviderType == psession.ProviderTypeActiveDirectory && s.ActiveDirectory != nil && s.ActiveDirectory.UserDN != "" @@ -177,13 +180,28 @@ func upstreamLDAPRefresh(ctx context.Context, s *psession.CustomSessionData, pro return errorsx.WithStack(errMissingUpstreamSessionInternalError) } + var additionalAttributes map[string]string + if s.ProviderType == psession.ProviderTypeLDAP { + additionalAttributes = s.LDAP.ExtraRefreshAttributes + } else { + additionalAttributes = s.ActiveDirectory.ExtraRefreshAttributes + } + // get ldap/ad provider out of cache p, dn, err := findLDAPProviderByNameAndValidateUID(s, providerCache) if err != nil { return err } + if session.IDTokenClaims().AuthTime.IsZero() { + return errorsx.WithStack(errMissingUpstreamSessionInternalError) + } // run PerformRefresh - err = p.PerformRefresh(ctx, dn, username, subject) + err = p.PerformRefresh(ctx, provider.StoredRefreshAttributes{ + Username: username, + Subject: subject, + DN: dn, + AdditionalAttributes: additionalAttributes, + }) if err != nil { return errorsx.WithStack(errUpstreamRefreshError.WithHint( "Upstream refresh failed.").WithWrap(err).WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType)) diff --git a/internal/oidc/token/token_handler_test.go b/internal/oidc/token/token_handler_test.go index f3767bc2a..81207357e 100644 --- a/internal/oidc/token/token_handler_test.go +++ b/internal/oidc/token/token_handler_test.go @@ -2058,6 +2058,219 @@ func TestRefreshGrant(t *testing.T) { }, }, }, + { + name: "upstream ldap idp not found", + idps: oidctestutil.NewUpstreamIDPListerBuilder(), + authcodeExchange: authcodeExchangeInputs{ + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, + customSessionData: happyLDAPCustomSessionData, + want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( + happyLDAPCustomSessionData, + ), + }, + refreshRequest: refreshRequestInputs{ + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusUnauthorized, + wantErrorResponseBody: here.Doc(` + { + "error": "error", + "error_description": "Error during upstream refresh. Provider from upstream session data was not found." + } + `), + }, + }, + }, + { + name: "upstream active directory idp not found", + idps: oidctestutil.NewUpstreamIDPListerBuilder(), + authcodeExchange: authcodeExchangeInputs{ + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, + customSessionData: happyActiveDirectoryCustomSessionData, + want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( + happyActiveDirectoryCustomSessionData, + ), + }, + refreshRequest: refreshRequestInputs{ + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusUnauthorized, + wantErrorResponseBody: here.Doc(` + { + "error": "error", + "error_description": "Error during upstream refresh. Provider from upstream session data was not found." + } + `), + }, + }, + }, + { + name: "fosite session is empty", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{ + Name: ldapUpstreamName, + ResourceUID: ldapUpstreamResourceUID, + URL: ldapUpstreamURL, + }), + authcodeExchange: authcodeExchangeInputs{ + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, + customSessionData: happyLDAPCustomSessionData, + want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( + happyLDAPCustomSessionData, + ), + }, + modifyRefreshTokenStorage: func(t *testing.T, oauthStore *oidc.KubeStorage, refreshToken string) { + refreshTokenSignature := getFositeDataSignature(t, refreshToken) + firstRequester, err := oauthStore.GetRefreshTokenSession(context.Background(), refreshTokenSignature, nil) + require.NoError(t, err) + session := firstRequester.GetSession().(*psession.PinnipedSession) + session.Fosite = &openid.DefaultSession{} + err = oauthStore.DeleteRefreshTokenSession(context.Background(), refreshTokenSignature) + require.NoError(t, err) + err = oauthStore.CreateRefreshTokenSession(context.Background(), refreshTokenSignature, firstRequester) + require.NoError(t, err) + }, + refreshRequest: refreshRequestInputs{ + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusInternalServerError, + wantErrorResponseBody: here.Doc(` + { + "error": "error", + "error_description": "There was an internal server error. Required upstream data not found in session." + } + `), + }, + }, + }, + { + name: "username not found in extra field", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{ + Name: ldapUpstreamName, + ResourceUID: ldapUpstreamResourceUID, + URL: ldapUpstreamURL, + }), + authcodeExchange: authcodeExchangeInputs{ + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, + customSessionData: happyLDAPCustomSessionData, + want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( + happyLDAPCustomSessionData, + ), + }, + modifyRefreshTokenStorage: func(t *testing.T, oauthStore *oidc.KubeStorage, refreshToken string) { + refreshTokenSignature := getFositeDataSignature(t, refreshToken) + firstRequester, err := oauthStore.GetRefreshTokenSession(context.Background(), refreshTokenSignature, nil) + require.NoError(t, err) + session := firstRequester.GetSession().(*psession.PinnipedSession) + session.Fosite = &openid.DefaultSession{ + Claims: &jwt.IDTokenClaims{ + Extra: map[string]interface{}{}, + }, + } + err = oauthStore.DeleteRefreshTokenSession(context.Background(), refreshTokenSignature) + require.NoError(t, err) + err = oauthStore.CreateRefreshTokenSession(context.Background(), refreshTokenSignature, firstRequester) + require.NoError(t, err) + }, + refreshRequest: refreshRequestInputs{ + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusInternalServerError, + wantErrorResponseBody: here.Doc(` + { + "error": "error", + "error_description": "There was an internal server error. Required upstream data not found in session." + } + `), + }, + }, + }, + { + name: "auth time is the zero value", // time.Times can never be nil, but it is possible that it would be the zero value which would mean something's wrong + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{ + Name: ldapUpstreamName, + ResourceUID: ldapUpstreamResourceUID, + URL: ldapUpstreamURL, + }), + authcodeExchange: authcodeExchangeInputs{ + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, + customSessionData: happyLDAPCustomSessionData, + want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( + happyLDAPCustomSessionData, + ), + }, + modifyRefreshTokenStorage: func(t *testing.T, oauthStore *oidc.KubeStorage, refreshToken string) { + refreshTokenSignature := getFositeDataSignature(t, refreshToken) + firstRequester, err := oauthStore.GetRefreshTokenSession(context.Background(), refreshTokenSignature, nil) + require.NoError(t, err) + session := firstRequester.GetSession().(*psession.PinnipedSession) + fositeSessionClaims := session.Fosite.IDTokenClaims() + fositeSessionClaims.AuthTime = time.Time{} + session.Fosite.Claims = fositeSessionClaims + err = oauthStore.DeleteRefreshTokenSession(context.Background(), refreshTokenSignature) + require.NoError(t, err) + err = oauthStore.CreateRefreshTokenSession(context.Background(), refreshTokenSignature, firstRequester) + require.NoError(t, err) + }, + refreshRequest: refreshRequestInputs{ + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusInternalServerError, + wantErrorResponseBody: here.Doc(` + { + "error": "error", + "error_description": "There was an internal server error. Required upstream data not found in session." + } + `), + }, + }, + }, + { + name: "when the ldap provider in the session storage is found but has the wrong resource UID during the refresh request", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{ + Name: ldapUpstreamName, + ResourceUID: "the-wrong-uid", + URL: ldapUpstreamURL, + }), + authcodeExchange: authcodeExchangeInputs{ + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, + customSessionData: happyLDAPCustomSessionData, + want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( + happyLDAPCustomSessionData, + ), + }, + refreshRequest: refreshRequestInputs{ + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusUnauthorized, + wantErrorResponseBody: here.Doc(` + { + "error": "error", + "error_description": "Error during upstream refresh. Provider from upstream session data has changed its resource UID since authentication." + } + `), + }, + }, + }, + { + name: "when the active directory provider in the session storage is found but has the wrong resource UID during the refresh request", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&oidctestutil.TestUpstreamLDAPIdentityProvider{ + Name: activeDirectoryUpstreamName, + ResourceUID: "the-wrong-uid", + URL: ldapUpstreamURL, + }), + authcodeExchange: authcodeExchangeInputs{ + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, + customSessionData: happyActiveDirectoryCustomSessionData, + want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( + happyActiveDirectoryCustomSessionData, + ), + }, + refreshRequest: refreshRequestInputs{ + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusUnauthorized, + wantErrorResponseBody: here.Doc(` + { + "error": "error", + "error_description": "Error during upstream refresh. Provider from upstream session data has changed its resource UID since authentication." + } + `), + }, + }, + }, } for _, test := range tests { test := test diff --git a/internal/psession/pinniped_session.go b/internal/psession/pinniped_session.go index 8009f91d0..d7fd47dff 100644 --- a/internal/psession/pinniped_session.go +++ b/internal/psession/pinniped_session.go @@ -66,12 +66,14 @@ type OIDCSessionData struct { // LDAPSessionData is the additional data needed by Pinniped when the upstream IDP is an LDAP provider. type LDAPSessionData struct { - UserDN string `json:"userDN"` + UserDN string `json:"userDN"` + ExtraRefreshAttributes map[string]string `json:"extraRefreshAttributes,omitempty"` } // ActiveDirectorySessionData is the additional data needed by Pinniped when the upstream IDP is an Active Directory provider. type ActiveDirectorySessionData struct { - UserDN string `json:"userDN"` + UserDN string `json:"userDN"` + ExtraRefreshAttributes map[string]string `json:"extraRefreshAttributes,omitempty"` } // NewPinnipedSession returns a new empty session. diff --git a/internal/testutil/oidctestutil/oidctestutil.go b/internal/testutil/oidctestutil/oidctestutil.go index 03603bdbb..0af6e429c 100644 --- a/internal/testutil/oidctestutil/oidctestutil.go +++ b/internal/testutil/oidctestutil/oidctestutil.go @@ -111,16 +111,16 @@ func (u *TestUpstreamLDAPIdentityProvider) GetURL() *url.URL { return u.URL } -func (u *TestUpstreamLDAPIdentityProvider) PerformRefresh(ctx context.Context, userDN, expectedUsername, expectedSubject string) error { +func (u *TestUpstreamLDAPIdentityProvider) PerformRefresh(ctx context.Context, storedRefreshAttributes provider.StoredRefreshAttributes) error { if u.performRefreshArgs == nil { u.performRefreshArgs = make([]*PerformRefreshArgs, 0) } u.performRefreshCallCount++ u.performRefreshArgs = append(u.performRefreshArgs, &PerformRefreshArgs{ Ctx: ctx, - DN: userDN, - ExpectedUsername: expectedUsername, - ExpectedSubject: expectedSubject, + DN: storedRefreshAttributes.DN, + ExpectedUsername: storedRefreshAttributes.Username, + ExpectedSubject: storedRefreshAttributes.Subject, }) if u.PerformRefreshErr != nil { return u.PerformRefreshErr diff --git a/internal/upstreamldap/upstreamldap.go b/internal/upstreamldap/upstreamldap.go index b69b37354..594aac1f5 100644 --- a/internal/upstreamldap/upstreamldap.go +++ b/internal/upstreamldap/upstreamldap.go @@ -13,13 +13,11 @@ import ( "fmt" "net" "net/url" - "regexp" "sort" "strings" "time" "github.com/go-ldap/ldap/v3" - "github.com/google/uuid" "k8s.io/apimachinery/pkg/types" "k8s.io/apiserver/pkg/authentication/user" "k8s.io/utils/trace" @@ -39,7 +37,6 @@ const ( groupSearchPageSize = uint32(250) defaultLDAPPort = uint16(389) defaultLDAPSPort = uint16(636) - sAMAccountNameAttribute = "sAMAccountName" ) // Conn abstracts the upstream LDAP communication protocol (mostly for testing). @@ -119,6 +116,9 @@ type ProviderConfig struct { // GroupNameMappingOverrides are the mappings between an attribute name and a way to parse it as a group // name when it comes out of LDAP. 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, provider.StoredRefreshAttributes) error } // UserSearchConfig contains information about how to search for users in the upstream LDAP IDP. @@ -170,9 +170,11 @@ func (p *Provider) GetConfig() ProviderConfig { return p.c } -func (p *Provider) PerformRefresh(ctx context.Context, userDN, expectedUsername, expectedSubject string) error { +func (p *Provider) PerformRefresh(ctx context.Context, storedRefreshAttributes provider.StoredRefreshAttributes) 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 + searchResult, err := p.performRefresh(ctx, userDN) if err != nil { p.traceRefreshFailure(t, err) @@ -196,9 +198,9 @@ func (p *Provider) PerformRefresh(ctx context.Context, userDN, expectedUsername, if err != nil { return err } - if newUsername != expectedUsername { + if newUsername != storedRefreshAttributes.Username { return fmt.Errorf(`searching for user "%s" returned a different username than the previous value. expected: "%s", actual: "%s"`, - userDN, expectedUsername, newUsername, + userDN, storedRefreshAttributes.Username, newUsername, ) } @@ -207,10 +209,15 @@ func (p *Provider) PerformRefresh(ctx context.Context, userDN, expectedUsername, return err } newSubject := downstreamsession.DownstreamLDAPSubject(newUID, *p.GetURL()) - if newSubject != expectedSubject { - return fmt.Errorf(`searching for user "%s" produced a different subject than the previous value. expected: "%s", actual: "%s"`, userDN, expectedSubject, newSubject) + if newSubject != storedRefreshAttributes.Subject { + return fmt.Errorf(`searching for user "%s" produced a different subject than the previous value. expected: "%s", actual: "%s"`, userDN, storedRefreshAttributes.Subject, newSubject) + } + for attribute, validateFunc := range p.c.RefreshAttributeChecks { + err = validateFunc(userEntry, storedRefreshAttributes) + if err != nil { + return fmt.Errorf(`validation for attribute "%s" failed during upstream refresh: %w`, attribute, err) + } } - // we checked that the user still exists and their information is the same, so just return. return nil } @@ -569,6 +576,15 @@ func (p *Provider) searchAndBindUser(conn Conn, username string, bindFunc func(c } sort.Strings(mappedGroupNames) + mappedRefreshAttributes := make(map[string]string) + for k := range p.c.RefreshAttributeChecks { + mappedVal, err := p.getSearchResultAttributeRawValueEncoded(k, userEntry, username) + if err != nil { + return nil, err + } + mappedRefreshAttributes[k] = mappedVal + } + // Caution: Note that any other LDAP commands after this bind will be run as this user instead of as the configured BindUsername! err = bindFunc(conn, userEntry.DN) if err != nil { @@ -592,7 +608,8 @@ func (p *Provider) searchAndBindUser(conn Conn, username string, bindFunc func(c UID: mappedUID, Groups: mappedGroupNames, }, - DN: userEntry.DN, + DN: userEntry.DN, + ExtraRefreshAttributes: mappedRefreshAttributes, } return response, nil @@ -665,6 +682,9 @@ func (p *Provider) userSearchRequestedAttributes() []string { if p.c.UserSearch.UIDAttribute != distinguishedNameAttributeName { attributes = append(attributes, p.c.UserSearch.UIDAttribute) } + for k := range p.c.RefreshAttributeChecks { + attributes = append(attributes, k) + } return attributes } @@ -782,57 +802,18 @@ func (p *Provider) traceRefreshFailure(t *trace.Trace, err error) { ) } -func MicrosoftUUIDFromBinary(attributeName string) func(entry *ldap.Entry) (string, error) { - // validation has already been done so we can just get the attribute... - return func(entry *ldap.Entry) (string, error) { - binaryUUID := entry.GetRawAttributeValue(attributeName) - return microsoftUUIDFromBinary(binaryUUID) +func AttributeUnchangedSinceLogin(attribute string) func(*ldap.Entry, provider.StoredRefreshAttributes) error { + return func(entry *ldap.Entry, storedAttributes provider.StoredRefreshAttributes) error { + prevAttributeValue := storedAttributes.AdditionalAttributes[attribute] + newValues := entry.GetAttributeValues(attribute) + + if len(newValues) != 1 { + return fmt.Errorf(`expected to find 1 value for "%s" attribute, but found %d`, attribute, len(newValues)) + } + encodedNewValue := base64.RawURLEncoding.EncodeToString(entry.GetRawAttributeValue(attribute)) + if prevAttributeValue != encodedNewValue { + return fmt.Errorf(`value for attribute "%s" has changed since initial value at login`, attribute) + } + return nil } } - -func microsoftUUIDFromBinary(binaryUUID []byte) (string, error) { - uuidVal, err := uuid.FromBytes(binaryUUID) // start out with the RFC4122 version - if err != nil { - return "", err - } - // then swap it because AD stores the first 3 fields little-endian rather than the expected - // big-endian. - uuidVal[0], uuidVal[1], uuidVal[2], uuidVal[3] = uuidVal[3], uuidVal[2], uuidVal[1], uuidVal[0] - uuidVal[4], uuidVal[5] = uuidVal[5], uuidVal[4] - uuidVal[6], uuidVal[7] = uuidVal[7], uuidVal[6] - return uuidVal.String(), nil -} - -func GroupSAMAccountNameWithDomainSuffix(entry *ldap.Entry) (string, error) { - sAMAccountNameAttributeValues := entry.GetAttributeValues(sAMAccountNameAttribute) - - if len(sAMAccountNameAttributeValues) != 1 { - return "", fmt.Errorf(`found %d values for attribute "%s", but expected 1 result`, - len(sAMAccountNameAttributeValues), sAMAccountNameAttribute, - ) - } - - sAMAccountName := sAMAccountNameAttributeValues[0] - if len(sAMAccountName) == 0 { - return "", fmt.Errorf(`found empty value for attribute "%s", but expected value to be non-empty`, - sAMAccountNameAttribute, - ) - } - - distinguishedName := entry.DN - domain, err := getDomainFromDistinguishedName(distinguishedName) - if err != nil { - return "", err - } - return sAMAccountName + "@" + domain, nil -} - -var domainComponentsRegexp = regexp.MustCompile(",DC=|,dc=") - -func getDomainFromDistinguishedName(distinguishedName string) (string, error) { - domainComponents := domainComponentsRegexp.Split(distinguishedName, -1) - if len(domainComponents) == 1 { - return "", fmt.Errorf("did not find domain components in group dn: %s", distinguishedName) - } - return strings.Join(domainComponents[1:], "."), nil -} diff --git a/internal/upstreamldap/upstreamldap_test.go b/internal/upstreamldap/upstreamldap_test.go index ca2f3e6bb..f7c884fb9 100644 --- a/internal/upstreamldap/upstreamldap_test.go +++ b/internal/upstreamldap/upstreamldap_test.go @@ -26,6 +26,7 @@ import ( "go.pinniped.dev/internal/crypto/ptls" "go.pinniped.dev/internal/endpointaddr" "go.pinniped.dev/internal/mocks/mockldapconn" + "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil/tlsserver" ) @@ -154,16 +155,17 @@ func TestEndUserAuthentication(t *testing.T) { } // The auth response which matches the exampleUserSearchResult and exampleGroupSearchResult. - expectedAuthResponse := func(editFunc func(r *user.DefaultInfo)) *authenticators.Response { + expectedAuthResponse := func(editFunc func(r *authenticators.Response)) *authenticators.Response { u := &user.DefaultInfo{ Name: testUserSearchResultUsernameAttributeValue, UID: base64.RawURLEncoding.EncodeToString([]byte(testUserSearchResultUIDAttributeValue)), Groups: []string{testGroupSearchResultGroupNameAttributeValue1, testGroupSearchResultGroupNameAttributeValue2}, } + response := &authenticators.Response{User: u, DN: testUserSearchResultDNValue, ExtraRefreshAttributes: map[string]string{}} if editFunc != nil { - editFunc(u) + editFunc(response) } - return &authenticators.Response{User: u, DN: testUserSearchResultDNValue} + return response } tests := []struct { @@ -250,8 +252,9 @@ func TestEndUserAuthentication(t *testing.T) { bindEndUserMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) }, - wantAuthResponse: expectedAuthResponse(func(r *user.DefaultInfo) { - r.Groups = []string{} + wantAuthResponse: expectedAuthResponse(func(r *authenticators.Response) { + info := r.User.(*user.DefaultInfo) + info.Groups = []string{} }), }, { @@ -282,8 +285,9 @@ func TestEndUserAuthentication(t *testing.T) { bindEndUserMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) }, - wantAuthResponse: expectedAuthResponse(func(r *user.DefaultInfo) { - r.Name = testUserSearchResultDNValue + wantAuthResponse: expectedAuthResponse(func(r *authenticators.Response) { + info := r.User.(*user.DefaultInfo) + info.Name = testUserSearchResultDNValue }), }, { @@ -314,8 +318,9 @@ func TestEndUserAuthentication(t *testing.T) { bindEndUserMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) }, - wantAuthResponse: expectedAuthResponse(func(r *user.DefaultInfo) { - r.UID = base64.RawURLEncoding.EncodeToString([]byte(testUserSearchResultDNValue)) + wantAuthResponse: expectedAuthResponse(func(r *authenticators.Response) { + info := r.User.(*user.DefaultInfo) + info.UID = base64.RawURLEncoding.EncodeToString([]byte(testUserSearchResultDNValue)) }), }, { @@ -337,8 +342,9 @@ func TestEndUserAuthentication(t *testing.T) { bindEndUserMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) }, - wantAuthResponse: expectedAuthResponse(func(r *user.DefaultInfo) { - r.Groups = []string{testGroupSearchResultDNValue1, testGroupSearchResultDNValue2} + wantAuthResponse: expectedAuthResponse(func(r *authenticators.Response) { + info := r.User.(*user.DefaultInfo) + info.Groups = []string{testGroupSearchResultDNValue1, testGroupSearchResultDNValue2} }), }, { @@ -360,8 +366,9 @@ func TestEndUserAuthentication(t *testing.T) { bindEndUserMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) }, - wantAuthResponse: expectedAuthResponse(func(r *user.DefaultInfo) { - r.Groups = []string{testGroupSearchResultDNValue1, testGroupSearchResultDNValue2} + wantAuthResponse: expectedAuthResponse(func(r *authenticators.Response) { + info := r.User.(*user.DefaultInfo) + info.Groups = []string{testGroupSearchResultDNValue1, testGroupSearchResultDNValue2} }), }, { @@ -507,33 +514,37 @@ func TestEndUserAuthentication(t *testing.T) { UID: base64.RawURLEncoding.EncodeToString([]byte(testUserSearchResultUIDAttributeValue)), Groups: []string{"a", "b", "c"}, }, - DN: testUserSearchResultDNValue, + DN: testUserSearchResultDNValue, + ExtraRefreshAttributes: map[string]string{}, }, }, { - name: "override UID parsing to work with microsoft style objectGUIDs", + name: "requesting additional refresh related attributes", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(func(p *ProviderConfig) { - p.UIDAttributeParsingOverrides = map[string]func(entry *ldap.Entry) (string, error){ - "objectGUID": MicrosoftUUIDFromBinary("objectGUID"), + p.RefreshAttributeChecks = map[string]func(entry *ldap.Entry, attributes provider.StoredRefreshAttributes) error{ + "some-attribute-to-check-during-refresh": func(entry *ldap.Entry, attributes provider.StoredRefreshAttributes) error { + return nil + }, } - p.UserSearch.UIDAttribute = "objectGUID" }), searchMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) conn.EXPECT().Search(expectedUserSearch(func(r *ldap.SearchRequest) { - r.Attributes = []string{testUserSearchUsernameAttribute, "objectGUID"} + r.Attributes = append(r.Attributes, "some-attribute-to-check-during-refresh") })).Return(&ldap.SearchResult{ Entries: []*ldap.Entry{ { DN: testUserSearchResultDNValue, Attributes: []*ldap.EntryAttribute{ ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testUserSearchResultUsernameAttributeValue}), - ldap.NewEntryAttribute("objectGUID", []string{"\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16"}), + ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testUserSearchResultUIDAttributeValue}), + ldap.NewEntryAttribute("some-attribute-to-check-during-refresh", []string{"some-attribute-value"}), }, }, - }}, nil).Times(1) + }, + }, nil).Times(1) conn.EXPECT().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize). Return(exampleGroupSearchResult, nil).Times(1) conn.EXPECT().Close().Times(1) @@ -541,172 +552,31 @@ func TestEndUserAuthentication(t *testing.T) { bindEndUserMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) }, - wantAuthResponse: expectedAuthResponse(func(r *user.DefaultInfo) { - r.UID = "04030201-0605-0807-0910-111213141516" + wantAuthResponse: expectedAuthResponse(func(r *authenticators.Response) { + r.ExtraRefreshAttributes = map[string]string{"some-attribute-to-check-during-refresh": "c29tZS1hdHRyaWJ1dGUtdmFsdWU"} }), }, { - name: "override UID parsing when the attribute name doesn't match what's returned does default parsing", + name: "requesting additional refresh related attributes, but they aren't returned", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(func(p *ProviderConfig) { - p.UIDAttributeParsingOverrides = map[string]func(entry *ldap.Entry) (string, error){ - "objectGUID": MicrosoftUUIDFromBinary("objectGUID"), + p.RefreshAttributeChecks = map[string]func(entry *ldap.Entry, attributes provider.StoredRefreshAttributes) error{ + "some-attribute-to-check-during-refresh": func(entry *ldap.Entry, attributes provider.StoredRefreshAttributes) error { + return nil + }, } }), searchMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(nil)).Return(exampleUserSearchResult, nil).Times(1) + conn.EXPECT().Search(expectedUserSearch(func(r *ldap.SearchRequest) { + r.Attributes = append(r.Attributes, "some-attribute-to-check-during-refresh") + })).Return(exampleUserSearchResult, nil).Times(1) conn.EXPECT().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize). Return(exampleGroupSearchResult, nil).Times(1) conn.EXPECT().Close().Times(1) }, - bindEndUserMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) - }, - wantAuthResponse: expectedAuthResponse(nil), - }, - { - name: "override group parsing to create new group names", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(func(p *ProviderConfig) { - p.GroupSearch.GroupNameAttribute = "sAMAccountName" - p.GroupAttributeParsingOverrides = map[string]func(*ldap.Entry) (string, error){ - "sAMAccountName": GroupSAMAccountNameWithDomainSuffix, - } - }), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(nil)).Return(exampleUserSearchResult, nil).Times(1) - conn.EXPECT().SearchWithPaging(expectedGroupSearch(func(r *ldap.SearchRequest) { - r.Attributes = []string{"sAMAccountName"} - }), expectedGroupSearchPageSize). - Return(&ldap.SearchResult{ - Entries: []*ldap.Entry{ - { - DN: "CN=Mammals,OU=Users,OU=pinniped-ad,DC=activedirectory,DC=mycompany,DC=example,DC=com", - Attributes: []*ldap.EntryAttribute{ - ldap.NewEntryAttribute("sAMAccountName", []string{"Mammals"}), - }, - }, - { - DN: "CN=Animals,OU=Users,OU=pinniped-ad,DC=activedirectory,DC=mycompany,DC=example,DC=com", - Attributes: []*ldap.EntryAttribute{ - ldap.NewEntryAttribute("sAMAccountName", []string{"Animals"}), - }, - }, - }, - Referrals: []string{}, // note that we are not following referrals at this time - Controls: []ldap.Control{}, - }, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - bindEndUserMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) - }, - wantAuthResponse: expectedAuthResponse(func(r *user.DefaultInfo) { - r.Groups = []string{"Animals@activedirectory.mycompany.example.com", "Mammals@activedirectory.mycompany.example.com"} - }), - }, - { - name: "override group parsing when domain can't be determined from dn", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(func(p *ProviderConfig) { - p.GroupSearch.GroupNameAttribute = "sAMAccountName" - p.GroupAttributeParsingOverrides = map[string]func(*ldap.Entry) (string, error){ - "sAMAccountName": GroupSAMAccountNameWithDomainSuffix, - } - }), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(nil)).Return(exampleUserSearchResult, nil).Times(1) - conn.EXPECT().SearchWithPaging(expectedGroupSearch(func(r *ldap.SearchRequest) { - r.Attributes = []string{"sAMAccountName"} - }), expectedGroupSearchPageSize). - Return(&ldap.SearchResult{ - Entries: []*ldap.Entry{ - { - DN: "no-domain-components", - Attributes: []*ldap.EntryAttribute{ - ldap.NewEntryAttribute("sAMAccountName", []string{"Mammals"}), - }, - }, - { - DN: "CN=Animals,OU=Users,OU=pinniped-ad,DC=activedirectory,DC=mycompany,DC=example,DC=com", - Attributes: []*ldap.EntryAttribute{ - ldap.NewEntryAttribute("sAMAccountName", []string{"Animals"}), - }, - }, - }, - Referrals: []string{}, // note that we are not following referrals at this time - Controls: []ldap.Control{}, - }, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - wantError: "error finding groups for user some-upstream-user-dn: did not find domain components in group dn: no-domain-components", - }, - { - name: "override group parsing when entry has multiple values for attribute", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(func(p *ProviderConfig) { - p.GroupSearch.GroupNameAttribute = "sAMAccountName" - p.GroupAttributeParsingOverrides = map[string]func(*ldap.Entry) (string, error){ - "sAMAccountName": GroupSAMAccountNameWithDomainSuffix, - } - }), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(nil)).Return(exampleUserSearchResult, nil).Times(1) - conn.EXPECT().SearchWithPaging(expectedGroupSearch(func(r *ldap.SearchRequest) { - r.Attributes = []string{"sAMAccountName"} - }), expectedGroupSearchPageSize). - Return(&ldap.SearchResult{ - Entries: []*ldap.Entry{ - { - DN: "no-domain-components", - Attributes: []*ldap.EntryAttribute{ - ldap.NewEntryAttribute("sAMAccountName", []string{"Mammals", "Eukaryotes"}), - }, - }, - }, - Referrals: []string{}, // note that we are not following referrals at this time - Controls: []ldap.Control{}, - }, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - wantError: "error finding groups for user some-upstream-user-dn: found 2 values for attribute \"sAMAccountName\", but expected 1 result", - }, { - name: "override group parsing when entry has no values for attribute", - username: testUpstreamUsername, - password: testUpstreamPassword, - providerConfig: providerConfig(func(p *ProviderConfig) { - p.GroupSearch.GroupNameAttribute = "sAMAccountName" - p.GroupAttributeParsingOverrides = map[string]func(*ldap.Entry) (string, error){ - "sAMAccountName": GroupSAMAccountNameWithDomainSuffix, - } - }), - searchMocks: func(conn *mockldapconn.MockConn) { - conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) - conn.EXPECT().Search(expectedUserSearch(nil)).Return(exampleUserSearchResult, nil).Times(1) - conn.EXPECT().SearchWithPaging(expectedGroupSearch(func(r *ldap.SearchRequest) { - r.Attributes = []string{"sAMAccountName"} - }), expectedGroupSearchPageSize). - Return(&ldap.SearchResult{ - Entries: []*ldap.Entry{ - { - DN: "no-domain-components", - Attributes: []*ldap.EntryAttribute{}, - }, - }, - Referrals: []string{}, // note that we are not following referrals at this time - Controls: []ldap.Control{}, - }, nil).Times(1) - conn.EXPECT().Close().Times(1) - }, - wantError: "error finding groups for user some-upstream-user-dn: found 0 values for attribute \"sAMAccountName\", but expected 1 result", + wantError: "found 0 values for attribute \"some-attribute-to-check-during-refresh\" while searching for user \"some-upstream-username\", but expected 1 result", }, { name: "when dial fails", @@ -1162,9 +1032,9 @@ func TestEndUserAuthentication(t *testing.T) { return conn, nil }) - provider := New(*tt.providerConfig) + ldapProvider := New(*tt.providerConfig) - authResponse, authenticated, err := provider.AuthenticateUser(context.Background(), tt.username, tt.password) + authResponse, authenticated, err := ldapProvider.AuthenticateUser(context.Background(), tt.username, tt.password) require.Equal(t, !tt.wantToSkipDial, dialWasAttempted) switch { case tt.wantError != "": @@ -1196,7 +1066,7 @@ func TestEndUserAuthentication(t *testing.T) { } // Skip tt.bindEndUserMocks since DryRunAuthenticateUser() never binds as the end user. - authResponse, authenticated, err = provider.DryRunAuthenticateUser(context.Background(), tt.username) + authResponse, authenticated, err = ldapProvider.DryRunAuthenticateUser(context.Background(), tt.username) require.Equal(t, !tt.wantToSkipDial, dialWasAttempted) switch { case tt.wantError != "": @@ -1217,6 +1087,7 @@ func TestEndUserAuthentication(t *testing.T) { } func TestUpstreamRefresh(t *testing.T) { + pwdLastSetAttribute := "pwdLastSet" expectedUserSearch := &ldap.SearchRequest{ BaseDN: testUserSearchResultDNValue, Scope: ldap.ScopeBaseObject, @@ -1225,7 +1096,7 @@ func TestUpstreamRefresh(t *testing.T) { TimeLimit: 90, TypesOnly: false, Filter: "(objectClass=*)", - Attributes: []string{testUserSearchUsernameAttribute, testUserSearchUIDAttribute}, + Attributes: []string{testUserSearchUsernameAttribute, testUserSearchUIDAttribute, pwdLastSetAttribute}, Controls: nil, // don't need paging because we set the SizeLimit so small } @@ -1242,6 +1113,11 @@ func TestUpstreamRefresh(t *testing.T) { Name: testUserSearchUIDAttribute, ByteValues: [][]byte{[]byte(testUserSearchResultUIDAttributeValue)}, }, + { + Name: pwdLastSetAttribute, + Values: []string{"132801740800000000"}, + ByteValues: [][]byte{[]byte("132801740800000000")}, + }, }, }, }, @@ -1259,6 +1135,9 @@ func TestUpstreamRefresh(t *testing.T) { UIDAttribute: testUserSearchUIDAttribute, UsernameAttribute: testUserSearchUsernameAttribute, }, + RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{ + pwdLastSetAttribute: AttributeUnchangedSinceLogin(pwdLastSetAttribute), + }, } tests := []struct { @@ -1512,6 +1391,36 @@ func TestUpstreamRefresh(t *testing.T) { }, wantErr: "found 2 values for attribute \"some-upstream-uid-attribute\" while searching for user \"some-upstream-user-dn\", but expected 1 result", }, + { + name: "search result has a changed pwdLastSet value", + providerConfig: providerConfig, + setupMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedUserSearch).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: testUserSearchResultDNValue, + Attributes: []*ldap.EntryAttribute{ + { + Name: testUserSearchUsernameAttribute, + Values: []string{testUserSearchResultUsernameAttributeValue}, + }, + { + Name: testUserSearchUIDAttribute, + ByteValues: [][]byte{[]byte(testUserSearchResultUIDAttributeValue)}, + }, + { + Name: pwdLastSetAttribute, + Values: []string{"132801740800000001"}, + }, + }, + }, + }, + }, nil).Times(1) + conn.EXPECT().Close().Times(1) + }, + wantErr: "validation for attribute \"pwdLastSet\" failed during upstream refresh: value for attribute \"pwdLastSet\" has changed since initial value at login", + }, } for _, tt := range tests { @@ -1536,9 +1445,15 @@ func TestUpstreamRefresh(t *testing.T) { return conn, nil }) - provider := New(*providerConfig) + initialPwdLastSetEncoded := base64.RawURLEncoding.EncodeToString([]byte("132801740800000000")) + ldapProvider := New(*providerConfig) subject := "ldaps://ldap.example.com:8443?base=some-upstream-user-base-dn&sub=c29tZS11cHN0cmVhbS11aWQtdmFsdWU" - err := provider.PerformRefresh(context.Background(), testUserSearchResultDNValue, testUserSearchResultUsernameAttributeValue, subject) + err := ldapProvider.PerformRefresh(context.Background(), provider.StoredRefreshAttributes{ + Username: testUserSearchResultUsernameAttributeValue, + Subject: subject, + DN: testUserSearchResultDNValue, + AdditionalAttributes: map[string]string{pwdLastSetAttribute: initialPwdLastSetEncoded}, + }) if tt.wantErr != "" { require.Error(t, err) require.Equal(t, tt.wantErr, err.Error()) @@ -1846,73 +1761,76 @@ func TestRealTLSDialing(t *testing.T) { } } -func TestGetMicrosoftFormattedUUID(t *testing.T) { +func TestAttributeUnchangedSinceLogin(t *testing.T) { + initialVal := "some-attribute-value" + changedVal := "some-different-attribute-value" + attributeName := "some-attribute-name" tests := []struct { name string - binaryUUID []byte - wantString string + entry *ldap.Entry + wantResult bool wantErr string }{ { - name: "happy path", - binaryUUID: []byte("\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11\x12\x13\x14\x15\x16"), - wantString: "04030201-0605-0807-0910-111213141516", + name: "happy path where value has not changed since login", + entry: &ldap.Entry{ + DN: "some-dn", + Attributes: []*ldap.EntryAttribute{ + { + Name: attributeName, + Values: []string{initialVal}, + ByteValues: [][]byte{[]byte(initialVal)}, + }, + }, + }, }, { - name: "not the right length", - binaryUUID: []byte("2\xf8\xb0\xaa\xb6V\xb1D\x8b(\xee"), - wantErr: "invalid UUID (got 11 bytes)", + name: "password has been reset since login", + entry: &ldap.Entry{ + DN: "some-dn", + Attributes: []*ldap.EntryAttribute{ + { + Name: attributeName, + Values: []string{changedVal}, + ByteValues: [][]byte{[]byte(changedVal)}, + }, + }, + }, + wantErr: "value for attribute \"some-attribute-name\" has changed since initial value at login", + }, + { + name: "no value for attribute attribute", + entry: &ldap.Entry{ + DN: "some-dn", + Attributes: []*ldap.EntryAttribute{}, + }, + wantErr: "expected to find 1 value for \"some-attribute-name\" attribute, but found 0", + }, + { + name: "too many values for attribute", + entry: &ldap.Entry{ + DN: "some-dn", + Attributes: []*ldap.EntryAttribute{ + { + Name: attributeName, + Values: []string{"val1", "val2"}, + }, + }, + }, + wantErr: "expected to find 1 value for \"some-attribute-name\" attribute, but found 2", }, } - for _, test := range tests { tt := test t.Run(tt.name, func(t *testing.T) { - actualUUIDString, err := microsoftUUIDFromBinary(tt.binaryUUID) + initialValRawEncoded := base64.RawURLEncoding.EncodeToString([]byte(initialVal)) + err := AttributeUnchangedSinceLogin(attributeName)(tt.entry, provider.StoredRefreshAttributes{AdditionalAttributes: map[string]string{attributeName: initialValRawEncoded}}) if tt.wantErr != "" { - require.EqualError(t, err, tt.wantErr) + require.Error(t, err) + require.Equal(t, tt.wantErr, err.Error()) } else { require.NoError(t, err) } - require.Equal(t, tt.wantString, actualUUIDString) - }) - } -} - -func TestGetDomainFromDistinguishedName(t *testing.T) { - tests := []struct { - name string - distinguishedName string - wantDomain string - wantErr string - }{ - { - name: "happy path", - distinguishedName: "CN=Mammals,OU=Users,OU=pinniped-ad,DC=activedirectory,DC=mycompany,DC=example,DC=com", - wantDomain: "activedirectory.mycompany.example.com", - }, - { - name: "lowercased happy path", - distinguishedName: "cn=Mammals,ou=Users,ou=pinniped-ad,dc=activedirectory,dc=mycompany,dc=example,dc=com", - wantDomain: "activedirectory.mycompany.example.com", - }, - { - name: "no domain components", - distinguishedName: "not-a-dn", - wantErr: "did not find domain components in group dn: not-a-dn", - }, - } - - for _, test := range tests { - tt := test - t.Run(tt.name, func(t *testing.T) { - actualDomain, err := getDomainFromDistinguishedName(tt.distinguishedName) - if tt.wantErr != "" { - require.EqualError(t, err, tt.wantErr) - } else { - require.NoError(t, err) - } - require.Equal(t, tt.wantDomain, actualDomain) }) } } diff --git a/test/integration/ldap_client_test.go b/test/integration/ldap_client_test.go index 1d913f8ac..1897d9241 100644 --- a/test/integration/ldap_client_test.go +++ b/test/integration/ldap_client_test.go @@ -83,7 +83,9 @@ func TestLDAPSearch_Parallel(t *testing.T) { password: pinnyPassword, provider: upstreamldap.New(*providerConfig(nil)), wantAuthResponse: &authenticators.Response{ - User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, + DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + ExtraRefreshAttributes: map[string]string{}, }, }, { @@ -95,7 +97,9 @@ func TestLDAPSearch_Parallel(t *testing.T) { p.ConnectionProtocol = upstreamldap.StartTLS })), wantAuthResponse: &authenticators.Response{ - User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, + DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + ExtraRefreshAttributes: map[string]string{}, }, }, { @@ -104,7 +108,9 @@ func TestLDAPSearch_Parallel(t *testing.T) { password: pinnyPassword, provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.Base = "dc=pinniped,dc=dev" })), wantAuthResponse: &authenticators.Response{ - User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, + DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + ExtraRefreshAttributes: map[string]string{}, }, }, { @@ -113,7 +119,9 @@ func TestLDAPSearch_Parallel(t *testing.T) { password: pinnyPassword, provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.Filter = "(cn={})" })), wantAuthResponse: &authenticators.Response{ - User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, + DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + ExtraRefreshAttributes: map[string]string{}, }, }, { @@ -125,7 +133,9 @@ func TestLDAPSearch_Parallel(t *testing.T) { p.UserSearch.Filter = "cn={}" })), wantAuthResponse: &authenticators.Response{ - User: &user.DefaultInfo{Name: "cn=pinny,ou=users,dc=pinniped,dc=dev", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + User: &user.DefaultInfo{Name: "cn=pinny,ou=users,dc=pinniped,dc=dev", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, + DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + ExtraRefreshAttributes: map[string]string{}, }, }, { @@ -136,7 +146,9 @@ func TestLDAPSearch_Parallel(t *testing.T) { p.UserSearch.Filter = "(|(cn={})(mail={}))" })), wantAuthResponse: &authenticators.Response{ - User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, + DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + ExtraRefreshAttributes: map[string]string{}, }, }, { @@ -147,7 +159,9 @@ func TestLDAPSearch_Parallel(t *testing.T) { p.UserSearch.Filter = "(|(cn={})(mail={}))" })), wantAuthResponse: &authenticators.Response{ - User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, + DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + ExtraRefreshAttributes: map[string]string{}, }, }, { @@ -156,7 +170,9 @@ func TestLDAPSearch_Parallel(t *testing.T) { password: pinnyPassword, provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.UIDAttribute = "dn" })), wantAuthResponse: &authenticators.Response{ - User: &user.DefaultInfo{Name: "pinny", UID: b64("cn=pinny,ou=users,dc=pinniped,dc=dev"), Groups: []string{"ball-game-players", "seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + User: &user.DefaultInfo{Name: "pinny", UID: b64("cn=pinny,ou=users,dc=pinniped,dc=dev"), Groups: []string{"ball-game-players", "seals"}}, + DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + ExtraRefreshAttributes: map[string]string{}, }, }, { @@ -165,7 +181,9 @@ func TestLDAPSearch_Parallel(t *testing.T) { password: pinnyPassword, provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.UIDAttribute = "sn" })), wantAuthResponse: &authenticators.Response{ - User: &user.DefaultInfo{Name: "pinny", UID: b64("Seal"), Groups: []string{"ball-game-players", "seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + User: &user.DefaultInfo{Name: "pinny", UID: b64("Seal"), Groups: []string{"ball-game-players", "seals"}}, + DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + ExtraRefreshAttributes: map[string]string{}, }, }, { @@ -174,7 +192,9 @@ func TestLDAPSearch_Parallel(t *testing.T) { password: pinnyPassword, provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.UsernameAttribute = "sn" })), wantAuthResponse: &authenticators.Response{ - User: &user.DefaultInfo{Name: "Seal", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", // note that the final answer has case preserved from the entry + User: &user.DefaultInfo{Name: "Seal", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, + DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", // note that the final answer has case preserved from the entry + ExtraRefreshAttributes: map[string]string{}, }, }, { @@ -187,7 +207,9 @@ func TestLDAPSearch_Parallel(t *testing.T) { p.UserSearch.UIDAttribute = "givenName" })), wantAuthResponse: &authenticators.Response{ - User: &user.DefaultInfo{Name: "Pinny the 🦭", UID: b64("Pinny the 🦭"), Groups: []string{"ball-game-players", "seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + User: &user.DefaultInfo{Name: "Pinny the 🦭", UID: b64("Pinny the 🦭"), Groups: []string{"ball-game-players", "seals"}}, + DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + ExtraRefreshAttributes: map[string]string{}, }, }, { @@ -199,7 +221,9 @@ func TestLDAPSearch_Parallel(t *testing.T) { p.UserSearch.UsernameAttribute = "cn" })), wantAuthResponse: &authenticators.Response{ - User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, + DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + ExtraRefreshAttributes: map[string]string{}, }, }, { @@ -220,7 +244,9 @@ func TestLDAPSearch_Parallel(t *testing.T) { p.GroupSearch.Base = "" })), wantAuthResponse: &authenticators.Response{ - User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{}}, + DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + ExtraRefreshAttributes: map[string]string{}, }, }, { @@ -231,7 +257,9 @@ func TestLDAPSearch_Parallel(t *testing.T) { p.GroupSearch.Base = "ou=users,dc=pinniped,dc=dev" // there are no groups under this part of the tree })), wantAuthResponse: &authenticators.Response{ - User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{}}, + DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + ExtraRefreshAttributes: map[string]string{}, }, }, { @@ -245,7 +273,9 @@ func TestLDAPSearch_Parallel(t *testing.T) { User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{ "cn=ball-game-players,ou=beach-groups,ou=groups,dc=pinniped,dc=dev", "cn=seals,ou=groups,dc=pinniped,dc=dev", - }}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + }}, + DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + ExtraRefreshAttributes: map[string]string{}, }, }, { @@ -259,7 +289,9 @@ func TestLDAPSearch_Parallel(t *testing.T) { User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{ "cn=ball-game-players,ou=beach-groups,ou=groups,dc=pinniped,dc=dev", "cn=seals,ou=groups,dc=pinniped,dc=dev", - }}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + }}, + DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + ExtraRefreshAttributes: map[string]string{}, }, }, { @@ -270,7 +302,9 @@ func TestLDAPSearch_Parallel(t *testing.T) { p.GroupSearch.GroupNameAttribute = "objectClass" // silly example, but still a meaningful test })), wantAuthResponse: &authenticators.Response{ - User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"groupOfNames", "groupOfNames"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"groupOfNames", "groupOfNames"}}, + DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + ExtraRefreshAttributes: map[string]string{}, }, }, { @@ -281,7 +315,9 @@ func TestLDAPSearch_Parallel(t *testing.T) { p.GroupSearch.Filter = "(&(&(objectClass=groupOfNames)(member={}))(cn=seals))" })), wantAuthResponse: &authenticators.Response{ - User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"seals"}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"seals"}}, + DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + ExtraRefreshAttributes: map[string]string{}, }, }, { @@ -292,7 +328,9 @@ func TestLDAPSearch_Parallel(t *testing.T) { p.GroupSearch.Filter = "foobar={}" // foobar is not a valid attribute name for this LDAP server's schema })), wantAuthResponse: &authenticators.Response{ - User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{}}, DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{}}, + DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + ExtraRefreshAttributes: map[string]string{}, }, }, { @@ -671,8 +709,9 @@ func TestSimultaneousLDAPRequestsOnSingleProvider(t *testing.T) { assert.NoError(t, result.err) assert.True(t, result.authenticated, "expected the user to be authenticated, but they were not") assert.Equal(t, &authenticators.Response{ - User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, - DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, + DN: "cn=pinny,ou=users,dc=pinniped,dc=dev", + ExtraRefreshAttributes: map[string]string{}, }, result.response) } } diff --git a/test/integration/supervisor_login_test.go b/test/integration/supervisor_login_test.go index 43b45e78f..b10f650eb 100644 --- a/test/integration/supervisor_login_test.go +++ b/test/integration/supervisor_login_test.go @@ -5,11 +5,16 @@ package integration import ( "context" + "crypto/rand" "crypto/tls" + "crypto/x509" "encoding/base64" + "encoding/hex" "encoding/json" "fmt" "io/ioutil" + "math/big" + "net" "net/http" "net/http/httptest" "net/url" @@ -19,15 +24,18 @@ import ( "time" coreosoidc "github.com/coreos/go-oidc/v3/oidc" + "github.com/go-ldap/ldap/v3" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/oauth2" + "golang.org/x/text/encoding/unicode" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" idpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" "go.pinniped.dev/internal/certauthority" + "go.pinniped.dev/internal/crypto/ptls" "go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/testutil" @@ -38,24 +46,26 @@ import ( "go.pinniped.dev/test/testlib/browsertest" ) +// nolint:gocyclo func TestSupervisorLogin(t *testing.T) { env := testlib.IntegrationEnv(t) tests := []struct { name string maybeSkip func(t *testing.T) + createTestUser func(t *testing.T) (string, string) + deleteTestUser func(t *testing.T, username string) + requestAuthorization func(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL, username, password string, httpClient *http.Client) createIDP func(t *testing.T) string - requestAuthorization func(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL string, httpClient *http.Client) wantDownstreamIDTokenSubjectToMatch string - wantDownstreamIDTokenUsernameToMatch string + wantDownstreamIDTokenUsernameToMatch func(username string) string wantDownstreamIDTokenGroups []string wantErrorDescription string wantErrorType string - // We don't necessarily have any way to revoke the user's session on the upstream provider, - // so to cause the upstream refresh to fail we can cheat by manipulating the user's session + // 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 string) + breakRefreshSessionData func(t *testing.T, sessionData *psession.PinnipedSession, idpName, username string) }{ { name: "oidc with default username and groups claim settings", @@ -76,7 +86,7 @@ func TestSupervisorLogin(t *testing.T) { return oidcIDP.Name }, requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlow, - breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) { + breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { customSessionData := pinnipedSession.Custom require.Equal(t, psession.ProviderTypeOIDC, customSessionData.ProviderType) require.NotEmpty(t, customSessionData.OIDC.UpstreamRefreshToken) @@ -85,7 +95,7 @@ func TestSupervisorLogin(t *testing.T) { // the ID token Subject should include the upstream user ID after the upstream issuer name wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", // the ID token Username should include the upstream user ID after the upstream issuer name - wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+" }, }, { name: "oidc with custom username and groups claim settings", @@ -113,14 +123,14 @@ func TestSupervisorLogin(t *testing.T) { return oidcIDP.Name }, requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlow, - breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) { + breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { customSessionData := pinnipedSession.Custom require.Equal(t, psession.ProviderTypeOIDC, customSessionData.ProviderType) require.NotEmpty(t, customSessionData.OIDC.UpstreamRefreshToken) customSessionData.OIDC.UpstreamRefreshToken = "invalid-updated-refresh-token" }, wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", - wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Username) + "$", + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Username) + "$" }, wantDownstreamIDTokenGroups: env.SupervisorUpstreamOIDC.ExpectedGroups, }, { @@ -144,7 +154,7 @@ func TestSupervisorLogin(t *testing.T) { }, idpv1alpha1.PhaseReady) return oidcIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamOIDC.Username, // username to present to server during login @@ -153,7 +163,7 @@ func TestSupervisorLogin(t *testing.T) { false, ) }, - breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) { + breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { customSessionData := pinnipedSession.Custom require.Equal(t, psession.ProviderTypeOIDC, customSessionData.ProviderType) require.NotEmpty(t, customSessionData.OIDC.UpstreamRefreshToken) @@ -162,7 +172,7 @@ func TestSupervisorLogin(t *testing.T) { // the ID token Subject should include the upstream user ID after the upstream issuer name wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", // the ID token Username should include the upstream user ID after the upstream issuer name - wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+" }, }, { name: "ldap with email as username and groups names as DNs and using an LDAP provider which supports TLS", @@ -212,7 +222,7 @@ func TestSupervisorLogin(t *testing.T) { requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) return ldapIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login @@ -221,7 +231,7 @@ func TestSupervisorLogin(t *testing.T) { false, ) }, - breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) { + breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { customSessionData := pinnipedSession.Custom require.Equal(t, psession.ProviderTypeLDAP, customSessionData.ProviderType) require.NotEmpty(t, customSessionData.LDAP.UserDN) @@ -235,8 +245,10 @@ func TestSupervisorLogin(t *testing.T) { "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), ) + "$", // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute - wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$", - wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { + return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$" + }, + wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, }, { name: "ldap with CN as username and group names as CNs and using an LDAP provider which only supports StartTLS", // try another variation of configuration options @@ -286,7 +298,7 @@ func TestSupervisorLogin(t *testing.T) { requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) return ldapIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamLDAP.TestUserCN, // username to present to server during login @@ -295,7 +307,7 @@ func TestSupervisorLogin(t *testing.T) { false, ) }, - breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) { + breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { customSessionData := pinnipedSession.Custom require.Equal(t, psession.ProviderTypeLDAP, customSessionData.ProviderType) require.NotEmpty(t, customSessionData.LDAP.UserDN) @@ -309,7 +321,7 @@ func TestSupervisorLogin(t *testing.T) { "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), ) + "$", // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute - wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserDN) + "$", + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserDN) + "$" }, wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsCNs, }, { @@ -360,7 +372,7 @@ func TestSupervisorLogin(t *testing.T) { requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) return ldapIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login @@ -438,7 +450,7 @@ func TestSupervisorLogin(t *testing.T) { }, time.Minute, 500*time.Millisecond) return ldapIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login @@ -447,7 +459,7 @@ func TestSupervisorLogin(t *testing.T) { false, ) }, - breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) { + breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { customSessionData := pinnipedSession.Custom require.Equal(t, psession.ProviderTypeLDAP, customSessionData.ProviderType) require.NotEmpty(t, customSessionData.LDAP.UserDN) @@ -460,8 +472,10 @@ func TestSupervisorLogin(t *testing.T) { "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), ) + "$", // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute - wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$", - wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { + return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$" + }, + wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, }, { name: "ldap login still works after deleting and recreating the bind secret", @@ -543,7 +557,7 @@ func TestSupervisorLogin(t *testing.T) { }, time.Minute, 500*time.Millisecond) return ldapIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login @@ -552,7 +566,7 @@ func TestSupervisorLogin(t *testing.T) { false, ) }, - breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) { + breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { customSessionData := pinnipedSession.Custom require.Equal(t, psession.ProviderTypeLDAP, customSessionData.ProviderType) require.NotEmpty(t, customSessionData.LDAP.UserDN) @@ -565,8 +579,10 @@ func TestSupervisorLogin(t *testing.T) { "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), ) + "$", // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute - wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$", - wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { + return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$" + }, + wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, }, { name: "activedirectory with all default options", @@ -604,7 +620,7 @@ func TestSupervisorLogin(t *testing.T) { requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg) return adIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue, // username to present to server during login @@ -613,7 +629,7 @@ func TestSupervisorLogin(t *testing.T) { false, ) }, - breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) { + breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { customSessionData := pinnipedSession.Custom require.Equal(t, psession.ProviderTypeActiveDirectory, customSessionData.ProviderType) require.NotEmpty(t, customSessionData.ActiveDirectory.UserDN) @@ -627,8 +643,10 @@ func TestSupervisorLogin(t *testing.T) { "&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue, ) + "$", // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute - wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$", - wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames, + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { + return "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$" + }, + wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames, }, { name: "activedirectory with custom options", maybeSkip: func(t *testing.T) { @@ -679,7 +697,7 @@ func TestSupervisorLogin(t *testing.T) { requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg) return adIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamActiveDirectory.TestUserMailAttributeValue, // username to present to server during login @@ -688,7 +706,7 @@ func TestSupervisorLogin(t *testing.T) { false, ) }, - breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) { + breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { customSessionData := pinnipedSession.Custom require.Equal(t, psession.ProviderTypeActiveDirectory, customSessionData.ProviderType) require.NotEmpty(t, customSessionData.ActiveDirectory.UserDN) @@ -702,8 +720,10 @@ func TestSupervisorLogin(t *testing.T) { "&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue, ) + "$", // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute - wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserMailAttributeValue) + "$", - wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserDirectGroupsDNs, + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { + return "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserMailAttributeValue) + "$" + }, + wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserDirectGroupsDNs, }, { name: "active directory login still works after updating bind secret", @@ -759,7 +779,7 @@ func TestSupervisorLogin(t *testing.T) { }, time.Minute, 500*time.Millisecond) return adIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue, // username to present to server during login @@ -768,7 +788,7 @@ func TestSupervisorLogin(t *testing.T) { false, ) }, - breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) { + breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { customSessionData := pinnipedSession.Custom require.Equal(t, psession.ProviderTypeActiveDirectory, customSessionData.ProviderType) require.NotEmpty(t, customSessionData.ActiveDirectory.UserDN) @@ -781,8 +801,10 @@ func TestSupervisorLogin(t *testing.T) { "&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue, ) + "$", // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute - wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$", - wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames, + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { + return "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$" + }, + wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames, }, { name: "active directory login still works after deleting and recreating bind secret", @@ -853,7 +875,7 @@ func TestSupervisorLogin(t *testing.T) { }, time.Minute, 500*time.Millisecond) return adIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue, // username to present to server during login @@ -862,7 +884,7 @@ func TestSupervisorLogin(t *testing.T) { false, ) }, - breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) { + breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { customSessionData := pinnipedSession.Custom require.Equal(t, psession.ProviderTypeActiveDirectory, customSessionData.ProviderType) require.NotEmpty(t, customSessionData.ActiveDirectory.UserDN) @@ -875,8 +897,196 @@ func TestSupervisorLogin(t *testing.T) { "&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue, ) + "$", // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute - wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$", - wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames, + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { + return "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$" + }, + wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames, + }, + { + name: "active directory login fails after the user password is changed", + maybeSkip: func(t *testing.T) { + t.Helper() + if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) { + t.Skip("LDAP integration test requires connectivity to an LDAP server") + } + if env.SupervisorUpstreamActiveDirectory.Host == "" { + t.Skip("Active Directory hostname not specified") + } + }, + createIDP: func(t *testing.T) string { + t.Helper() + secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ad-service-account", v1.SecretTypeBasicAuth, + map[string]string{ + v1.BasicAuthUsernameKey: env.SupervisorUpstreamActiveDirectory.BindUsername, + v1.BasicAuthPasswordKey: env.SupervisorUpstreamActiveDirectory.BindPassword, + }, + ) + adIDP := testlib.CreateTestActiveDirectoryIdentityProvider(t, idpv1alpha1.ActiveDirectoryIdentityProviderSpec{ + Host: env.SupervisorUpstreamActiveDirectory.Host, + TLS: &idpv1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamActiveDirectory.CABundle)), + }, + Bind: idpv1alpha1.ActiveDirectoryIdentityProviderBind{ + SecretName: secret.Name, + }, + }, idpv1alpha1.ActiveDirectoryPhaseReady) + expectedMsg := fmt.Sprintf( + `successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`, + env.SupervisorUpstreamActiveDirectory.Host, env.SupervisorUpstreamActiveDirectory.BindUsername, + secret.Name, secret.ResourceVersion, + ) + requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg) + return adIDP.Name + }, + createTestUser: func(t *testing.T) (string, string) { + return createFreshADTestUser(t, env) + }, + deleteTestUser: func(t *testing.T, username string) { + deleteTestADUser(t, env, username) + }, + requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, testUserName, testUserPassword string, httpClient *http.Client) { + requestAuthorizationUsingCLIPasswordFlow(t, + downstreamAuthorizeURL, + testUserName, // username to present to server during login + testUserPassword, // password to present to server during login + httpClient, + false, + ) + }, + breakRefreshSessionData: func(t *testing.T, sessionData *psession.PinnipedSession, _, username string) { + changeADTestUserPassword(t, env, username) + }, + // we can't know the subject ahead of time because we created a new user and don't know their uid, + // so skip wantDownstreamIDTokenSubjectToMatch + // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute + wantDownstreamIDTokenUsernameToMatch: func(username string) string { + return "^" + regexp.QuoteMeta(username+"@"+env.SupervisorUpstreamActiveDirectory.Domain) + "$" + }, + wantDownstreamIDTokenGroups: []string{}, // none for now. + }, + { + name: "active directory login fails after the user is deactivated", + maybeSkip: func(t *testing.T) { + t.Helper() + if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) { + t.Skip("LDAP integration test requires connectivity to an LDAP server") + } + if env.SupervisorUpstreamActiveDirectory.Host == "" { + t.Skip("Active Directory hostname not specified") + } + }, + createIDP: func(t *testing.T) string { + t.Helper() + secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ad-service-account", v1.SecretTypeBasicAuth, + map[string]string{ + v1.BasicAuthUsernameKey: env.SupervisorUpstreamActiveDirectory.BindUsername, + v1.BasicAuthPasswordKey: env.SupervisorUpstreamActiveDirectory.BindPassword, + }, + ) + adIDP := testlib.CreateTestActiveDirectoryIdentityProvider(t, idpv1alpha1.ActiveDirectoryIdentityProviderSpec{ + Host: env.SupervisorUpstreamActiveDirectory.Host, + TLS: &idpv1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamActiveDirectory.CABundle)), + }, + Bind: idpv1alpha1.ActiveDirectoryIdentityProviderBind{ + SecretName: secret.Name, + }, + }, idpv1alpha1.ActiveDirectoryPhaseReady) + expectedMsg := fmt.Sprintf( + `successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`, + env.SupervisorUpstreamActiveDirectory.Host, env.SupervisorUpstreamActiveDirectory.BindUsername, + secret.Name, secret.ResourceVersion, + ) + requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg) + return adIDP.Name + }, + createTestUser: func(t *testing.T) (string, string) { + return createFreshADTestUser(t, env) + }, + deleteTestUser: func(t *testing.T, username string) { + deleteTestADUser(t, env, username) + }, + requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, testUserName, testUserPassword string, httpClient *http.Client) { + requestAuthorizationUsingCLIPasswordFlow(t, + downstreamAuthorizeURL, + testUserName, // username to present to server during login + testUserPassword, // password to present to server during login + httpClient, + false, + ) + }, + breakRefreshSessionData: func(t *testing.T, sessionData *psession.PinnipedSession, _, username string) { + deactivateADTestUser(t, env, username) + }, + // we can't know the subject ahead of time because we created a new user and don't know their uid, + // so skip wantDownstreamIDTokenSubjectToMatch + // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute + wantDownstreamIDTokenUsernameToMatch: func(username string) string { + return "^" + regexp.QuoteMeta(username+"@"+env.SupervisorUpstreamActiveDirectory.Domain) + "$" + }, + wantDownstreamIDTokenGroups: []string{}, // none for now. + }, + { + name: "active directory login fails after the user is locked", + maybeSkip: func(t *testing.T) { + t.Helper() + if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) { + t.Skip("LDAP integration test requires connectivity to an LDAP server") + } + if env.SupervisorUpstreamActiveDirectory.Host == "" { + t.Skip("Active Directory hostname not specified") + } + }, + createIDP: func(t *testing.T) string { + t.Helper() + secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ad-service-account", v1.SecretTypeBasicAuth, + map[string]string{ + v1.BasicAuthUsernameKey: env.SupervisorUpstreamActiveDirectory.BindUsername, + v1.BasicAuthPasswordKey: env.SupervisorUpstreamActiveDirectory.BindPassword, + }, + ) + adIDP := testlib.CreateTestActiveDirectoryIdentityProvider(t, idpv1alpha1.ActiveDirectoryIdentityProviderSpec{ + Host: env.SupervisorUpstreamActiveDirectory.Host, + TLS: &idpv1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamActiveDirectory.CABundle)), + }, + Bind: idpv1alpha1.ActiveDirectoryIdentityProviderBind{ + SecretName: secret.Name, + }, + }, idpv1alpha1.ActiveDirectoryPhaseReady) + expectedMsg := fmt.Sprintf( + `successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`, + env.SupervisorUpstreamActiveDirectory.Host, env.SupervisorUpstreamActiveDirectory.BindUsername, + secret.Name, secret.ResourceVersion, + ) + requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg) + return adIDP.Name + }, + createTestUser: func(t *testing.T) (string, string) { + return createFreshADTestUser(t, env) + }, + deleteTestUser: func(t *testing.T, username string) { + deleteTestADUser(t, env, username) + }, + requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, testUserName, testUserPassword string, httpClient *http.Client) { + requestAuthorizationUsingCLIPasswordFlow(t, + downstreamAuthorizeURL, + testUserName, // username to present to server during login + testUserPassword, // password to present to server during login + httpClient, + false, + ) + }, + breakRefreshSessionData: func(t *testing.T, sessionData *psession.PinnipedSession, _, username string) { + lockADTestUser(t, env, username) + }, + // we can't know the subject ahead of time because we created a new user and don't know their uid, + // so skip wantDownstreamIDTokenSubjectToMatch + // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute + wantDownstreamIDTokenUsernameToMatch: func(username string) string { + return "^" + regexp.QuoteMeta(username+"@"+env.SupervisorUpstreamActiveDirectory.Domain) + "$" + }, + wantDownstreamIDTokenGroups: []string{}, }, { name: "logging in to activedirectory with a deactivated user fails", @@ -914,7 +1124,7 @@ func TestSupervisorLogin(t *testing.T) { requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg) return adIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamActiveDirectory.TestDeactivatedUserSAMAccountNameValue, // username to present to server during login @@ -975,7 +1185,7 @@ func TestSupervisorLogin(t *testing.T) { requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) return ldapIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login @@ -984,7 +1194,7 @@ func TestSupervisorLogin(t *testing.T) { false, ) }, - breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, idpName string) { + breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, idpName, _ string) { // get the idp, update the config. client := testlib.NewSupervisorClientset(t) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) @@ -1007,8 +1217,10 @@ func TestSupervisorLogin(t *testing.T) { "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), ) + "$", // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute - wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$", - wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { + return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$" + }, + wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, }, } for _, test := range tests { @@ -1020,6 +1232,8 @@ func TestSupervisorLogin(t *testing.T) { tt.createIDP, tt.requestAuthorization, tt.breakRefreshSessionData, + tt.createTestUser, + tt.deleteTestUser, tt.wantDownstreamIDTokenSubjectToMatch, tt.wantDownstreamIDTokenUsernameToMatch, tt.wantDownstreamIDTokenGroups, @@ -1150,10 +1364,15 @@ func requireEventuallySuccessfulActiveDirectoryIdentityProviderConditions(t *tes func testSupervisorLogin( t *testing.T, createIDP func(t *testing.T) string, - requestAuthorization func(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL string, httpClient *http.Client), - breakRefreshSessionData func(t *testing.T, pinnipedSession *psession.PinnipedSession, idpName string), - wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch string, wantDownstreamIDTokenGroups []string, - wantErrorDescription string, wantErrorType string, + requestAuthorization func(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL, username, password string, httpClient *http.Client), + breakRefreshSessionData func(t *testing.T, pinnipedSession *psession.PinnipedSession, idpName, username string), + createTestUser func(t *testing.T) (string, string), + deleteTestUser func(t *testing.T, username string), + wantDownstreamIDTokenSubjectToMatch string, + wantDownstreamIDTokenUsernameToMatch func(username string) string, + wantDownstreamIDTokenGroups []string, + wantErrorDescription string, + wantErrorType string, ) { env := testlib.IntegrationEnv(t) @@ -1241,6 +1460,12 @@ func testSupervisorLogin( // Create upstream IDP and wait for it to become ready. idpName := createIDP(t) + username, password := "", "" + if createTestUser != nil { + username, password = createTestUser(t) + defer deleteTestUser(t, username) + } + // Perform OIDC discovery for our downstream. var discovery *coreosoidc.Provider testlib.RequireEventually(t, func(requireEventually *require.Assertions) { @@ -1276,7 +1501,7 @@ func testSupervisorLogin( ) // Perform parameterized auth code acquisition. - requestAuthorization(t, downstreamAuthorizeURL, localCallbackServer.URL, httpClient) + requestAuthorization(t, downstreamAuthorizeURL, localCallbackServer.URL, username, password, httpClient) // Expect that our callback handler was invoked. callback := localCallbackServer.waitForCallback(10 * time.Second) @@ -1294,7 +1519,7 @@ func testSupervisorLogin( expectedIDTokenClaims := []string{"iss", "exp", "sub", "aud", "auth_time", "iat", "jti", "nonce", "rat", "username", "groups"} verifyTokenResponse(t, tokenResponse, discovery, downstreamOAuth2Config, nonceParam, - expectedIDTokenClaims, wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch, wantDownstreamIDTokenGroups) + expectedIDTokenClaims, wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch(username), wantDownstreamIDTokenGroups) // token exchange on the original token doTokenExchange(t, &downstreamOAuth2Config, tokenResponse, httpClient, discovery) @@ -1308,7 +1533,7 @@ func testSupervisorLogin( expectRefreshedIDTokenClaims := []string{"iss", "exp", "sub", "aud", "auth_time", "iat", "jti", "rat", "username", "groups", "at_hash"} verifyTokenResponse(t, refreshedTokenResponse, discovery, downstreamOAuth2Config, "", - expectRefreshedIDTokenClaims, wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch, wantDownstreamIDTokenGroups) + expectRefreshedIDTokenClaims, wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch(username), wantDownstreamIDTokenGroups) require.NotEqual(t, tokenResponse.AccessToken, refreshedTokenResponse.AccessToken) require.NotEqual(t, tokenResponse.RefreshToken, refreshedTokenResponse.RefreshToken) @@ -1333,7 +1558,7 @@ func testSupervisorLogin( // Next mutate the part of the session that is used during upstream refresh. pinnipedSession, ok := storedRefreshSession.GetSession().(*psession.PinnipedSession) require.True(t, ok, "should have been able to cast session data to PinnipedSession") - breakRefreshSessionData(t, pinnipedSession, idpName) + breakRefreshSessionData(t, pinnipedSession, idpName, username) // Then save the mutated Secret back to Kubernetes. // There is no update function, so delete and create again at the same name. @@ -1423,7 +1648,7 @@ func verifyTokenResponse( require.NotEmpty(t, tokenResponse.RefreshToken) } -func requestAuthorizationUsingBrowserAuthcodeFlow(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL string, httpClient *http.Client) { +func requestAuthorizationUsingBrowserAuthcodeFlow(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL, _, _ string, httpClient *http.Client) { t.Helper() env := testlib.IntegrationEnv(t) @@ -1595,3 +1820,153 @@ func expectSecurityHeaders(t *testing.T, response *http.Response, expectFositeTo assert.Equal(t, "no-cache", h.Get("Pragma")) assert.Equal(t, "0", h.Get("Expires")) } + +// create a fresh test user in AD to use for this test. +func createFreshADTestUser(t *testing.T, env *testlib.TestEnv) (string, string) { + t.Helper() + // dial tls + conn := dialTLS(t, env) + // bind + err := conn.Bind(env.SupervisorUpstreamActiveDirectory.BindUsername, env.SupervisorUpstreamActiveDirectory.BindPassword) + require.NoError(t, err) + + testUserName := "user-" + createRandomHexString(t, 7) // sAMAccountNames are limited to 20 characters, so this is as long as we can make it. + // create + userDN := fmt.Sprintf("CN=%s,OU=test-users,%s", testUserName, env.SupervisorUpstreamActiveDirectory.UserSearchBase) + a := ldap.NewAddRequest(userDN, []ldap.Control{}) + a.Attribute("objectClass", []string{"top", "person", "organizationalPerson", "user"}) + a.Attribute("userPrincipalName", []string{fmt.Sprintf("%s@%s", testUserName, env.SupervisorUpstreamActiveDirectory.Domain)}) + a.Attribute("sAMAccountName", []string{testUserName}) + + err = conn.Add(a) + require.NoError(t, err) + + // modify password and enable account + testUserPassword := createRandomASCIIString(t, 20) + enc := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewEncoder() + encodedTestUserPassword, err := enc.String("\"" + testUserPassword + "\"") + require.NoError(t, err) + + m := ldap.NewModifyRequest(userDN, []ldap.Control{}) + m.Replace("unicodePwd", []string{encodedTestUserPassword}) + m.Replace("userAccountControl", []string{"512"}) + err = conn.Modify(m) + require.NoError(t, err) + + time.Sleep(20 * time.Second) // intrasite domain controller replication can take up to 15 seconds, so wait to ensure the change has propogated. + return testUserName, testUserPassword +} + +// deactivate the test user. +func deactivateADTestUser(t *testing.T, env *testlib.TestEnv, testUserName string) { + conn := dialTLS(t, env) + // bind + err := conn.Bind(env.SupervisorUpstreamActiveDirectory.BindUsername, env.SupervisorUpstreamActiveDirectory.BindPassword) + require.NoError(t, err) + + userDN := fmt.Sprintf("CN=%s,OU=test-users,%s", testUserName, env.SupervisorUpstreamActiveDirectory.UserSearchBase) + m := ldap.NewModifyRequest(userDN, []ldap.Control{}) + m.Replace("userAccountControl", []string{"514"}) // normal user, account disabled + err = conn.Modify(m) + require.NoError(t, err) + + time.Sleep(20 * time.Second) // intrasite domain controller replication can take up to 15 seconds, so wait to ensure the change has propogated. +} + +// lock the test user's account by entering the wrong password a bunch of times. +func lockADTestUser(t *testing.T, env *testlib.TestEnv, testUserName string) { + userDN := fmt.Sprintf("CN=%s,OU=test-users,%s", testUserName, env.SupervisorUpstreamActiveDirectory.UserSearchBase) + conn := dialTLS(t, env) + + // our password policy allows 20 wrong attempts before locking the account, so do 21. + // these wrong password attempts could go to different domain controllers, but account + // lockout changes are urgently replicated, meaning that the domain controllers will be + // synced asap rather than in the usual 15 second interval. + // See https://docs.microsoft.com/en-us/previous-versions/windows/it-pro/windows-2000-server/cc961787(v=technet.10)#urgent-replication-of-account-lockout-changes + for i := 0; i <= 21; i++ { + err := conn.Bind(userDN, "not-the-right-password-"+fmt.Sprint(i)) + require.Error(t, err) // this should be an error + } + + err := conn.Bind(env.SupervisorUpstreamActiveDirectory.BindUsername, env.SupervisorUpstreamActiveDirectory.BindPassword) + require.NoError(t, err) + + time.Sleep(20 * time.Second) // intrasite domain controller replication can take up to 15 seconds, so wait to ensure the change has propogated. +} + +// change the user's password to a new one. +func changeADTestUserPassword(t *testing.T, env *testlib.TestEnv, testUserName string) { + conn := dialTLS(t, env) + // bind + err := conn.Bind(env.SupervisorUpstreamActiveDirectory.BindUsername, env.SupervisorUpstreamActiveDirectory.BindPassword) + require.NoError(t, err) + + newTestUserPassword := createRandomASCIIString(t, 20) + enc := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewEncoder() + encodedTestUserPassword, err := enc.String(`"` + newTestUserPassword + `"`) + require.NoError(t, err) + + userDN := fmt.Sprintf("CN=%s,OU=test-users,%s", testUserName, env.SupervisorUpstreamActiveDirectory.UserSearchBase) + m := ldap.NewModifyRequest(userDN, []ldap.Control{}) + m.Replace("unicodePwd", []string{encodedTestUserPassword}) + err = conn.Modify(m) + require.NoError(t, err) + + time.Sleep(20 * time.Second) // intrasite domain controller replication can take up to 15 seconds, so wait to ensure the change has propogated. + // don't bother to return the new password... we won't be using it, just checking that it's changed. +} + +// delete the test user created for this test. +func deleteTestADUser(t *testing.T, env *testlib.TestEnv, testUserName string) { + t.Helper() + conn := dialTLS(t, env) + // bind + err := conn.Bind(env.SupervisorUpstreamActiveDirectory.BindUsername, env.SupervisorUpstreamActiveDirectory.BindPassword) + require.NoError(t, err) + + userDN := fmt.Sprintf("CN=%s,OU=test-users,%s", testUserName, env.SupervisorUpstreamActiveDirectory.UserSearchBase) + d := ldap.NewDelRequest(userDN, []ldap.Control{}) + err = conn.Del(d) + require.NoError(t, err) +} + +func dialTLS(t *testing.T, env *testlib.TestEnv) *ldap.Conn { + t.Helper() + // dial tls + rootCAs := x509.NewCertPool() + success := rootCAs.AppendCertsFromPEM([]byte(env.SupervisorUpstreamActiveDirectory.CABundle)) + require.True(t, success) + tlsConfig := ptls.DefaultLDAP(rootCAs) + dialer := &tls.Dialer{NetDialer: &net.Dialer{Timeout: time.Minute}, Config: tlsConfig} + c, err := dialer.DialContext(context.Background(), "tcp", env.SupervisorUpstreamActiveDirectory.Host) + require.NoError(t, err) + conn := ldap.NewConn(c, true) + conn.Start() + return conn +} + +func createRandomHexString(t *testing.T, length int) string { + t.Helper() + bytes := make([]byte, length) + _, err := rand.Read(bytes) + require.NoError(t, err) + randomString := hex.EncodeToString(bytes) + return randomString +} + +func createRandomASCIIString(t *testing.T, length int) string { + result := "" + for { + if len(result) >= length { + return result + } + num, err := rand.Int(rand.Reader, big.NewInt(int64(127))) + require.NoError(t, err) + n := num.Int64() + // Make sure that the number/byte/letter is inside + // the range of printable ASCII characters (excluding space and DEL) + if n > 32 && n < 127 { + result += string(rune(n)) + } + } +} diff --git a/test/testlib/env.go b/test/testlib/env.go index f12b01636..7ce6d0bec 100644 --- a/test/testlib/env.go +++ b/test/testlib/env.go @@ -84,6 +84,7 @@ type TestOIDCUpstream struct { type TestLDAPUpstream struct { Host string `json:"host"` + Domain string `json:"domain"` StartTLSOnlyHost string `json:"startTLSOnlyHost"` CABundle string `json:"caBundle"` BindUsername string `json:"bindUsername"` @@ -279,6 +280,7 @@ func loadEnvVars(t *testing.T, result *TestEnv) { result.SupervisorUpstreamActiveDirectory = TestLDAPUpstream{ Host: wantEnv("PINNIPED_TEST_AD_HOST", ""), + Domain: wantEnv("PINNIPED_TEST_AD_DOMAIN", ""), CABundle: base64Decoded(t, os.Getenv("PINNIPED_TEST_AD_LDAPS_CA_BUNDLE")), BindUsername: wantEnv("PINNIPED_TEST_AD_BIND_ACCOUNT_USERNAME", ""), BindPassword: wantEnv("PINNIPED_TEST_AD_BIND_ACCOUNT_PASSWORD", ""),