// Copyright 2021 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package upstreamldap import ( "context" "crypto/tls" "encoding/base64" "errors" "fmt" "net" "net/http" "net/url" "testing" "time" "github.com/go-ldap/ldap/v3" "github.com/golang/mock/gomock" "github.com/stretchr/testify/require" "k8s.io/apiserver/pkg/authentication/user" "go.pinniped.dev/internal/authenticators" "go.pinniped.dev/internal/certauthority" "go.pinniped.dev/internal/endpointaddr" "go.pinniped.dev/internal/mocks/mockldapconn" "go.pinniped.dev/internal/testutil" ) const ( testHost = "ldap.example.com:8443" testBindUsername = "cn=some-bind-username,dc=pinniped,dc=dev" testBindPassword = "some-bind-password" testUpstreamUsername = "some-upstream-username" testUpstreamPassword = "some-upstream-password" testUserSearchBase = "some-upstream-user-base-dn" testGroupSearchBase = "some-upstream-group-base-dn" testUserSearchFilter = "some-user-filter={}-and-more-filter={}" testGroupSearchFilter = "some-group-filter={}-and-more-filter={}" testUserSearchUsernameAttribute = "some-upstream-username-attribute" testUserSearchUIDAttribute = "some-upstream-uid-attribute" testGroupSearchGroupNameAttribute = "some-upstream-group-name-attribute" testUserSearchResultDNValue = "some-upstream-user-dn" testGroupSearchResultDNValue1 = "some-upstream-group-dn1" testGroupSearchResultDNValue2 = "some-upstream-group-dn2" testUserSearchResultUsernameAttributeValue = "some-upstream-username-value" testUserSearchResultUIDAttributeValue = "some-upstream-uid-value" testGroupSearchResultGroupNameAttributeValue1 = "some-upstream-group-name-value1" testGroupSearchResultGroupNameAttributeValue2 = "some-upstream-group-name-value2" expectedGroupSearchPageSize = uint32(250) ) var ( testUserSearchFilterInterpolated = fmt.Sprintf("(some-user-filter=%s-and-more-filter=%s)", testUpstreamUsername, testUpstreamUsername) testGroupSearchFilterInterpolated = fmt.Sprintf("(some-group-filter=%s-and-more-filter=%s)", testUserSearchResultDNValue, testUserSearchResultDNValue) ) func TestEndUserAuthentication(t *testing.T) { providerConfig := func(editFunc func(p *ProviderConfig)) *ProviderConfig { config := &ProviderConfig{ Name: "some-provider-name", Host: testHost, CABundle: nil, // this field is only used by the production dialer, which is replaced by a mock for this test ConnectionProtocol: TLS, BindUsername: testBindUsername, BindPassword: testBindPassword, UserSearch: UserSearchConfig{ Base: testUserSearchBase, Filter: testUserSearchFilter, UsernameAttribute: testUserSearchUsernameAttribute, UIDAttribute: testUserSearchUIDAttribute, }, GroupSearch: GroupSearchConfig{ Base: testGroupSearchBase, Filter: testGroupSearchFilter, GroupNameAttribute: testGroupSearchGroupNameAttribute, }, } if editFunc != nil { editFunc(config) } return config } expectedUserSearch := func(editFunc func(r *ldap.SearchRequest)) *ldap.SearchRequest { request := &ldap.SearchRequest{ BaseDN: testUserSearchBase, Scope: ldap.ScopeWholeSubtree, DerefAliases: ldap.NeverDerefAliases, SizeLimit: 2, TimeLimit: 90, TypesOnly: false, Filter: testUserSearchFilterInterpolated, Attributes: []string{testUserSearchUsernameAttribute, testUserSearchUIDAttribute}, Controls: nil, // don't need paging because we set the SizeLimit so small } if editFunc != nil { editFunc(request) } return request } expectedGroupSearch := func(editFunc func(r *ldap.SearchRequest)) *ldap.SearchRequest { request := &ldap.SearchRequest{ BaseDN: testGroupSearchBase, Scope: ldap.ScopeWholeSubtree, DerefAliases: ldap.NeverDerefAliases, SizeLimit: 0, // unlimited size because we will search with paging TimeLimit: 90, TypesOnly: false, Filter: testGroupSearchFilterInterpolated, Attributes: []string{testGroupSearchGroupNameAttribute}, Controls: nil, // nil because ldap.SearchWithPaging() will set the appropriate controls for us } if editFunc != nil { editFunc(request) } return request } exampleUserSearchResult := &ldap.SearchResult{ Entries: []*ldap.Entry{ { DN: testUserSearchResultDNValue, Attributes: []*ldap.EntryAttribute{ ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testUserSearchResultUsernameAttributeValue}), ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testUserSearchResultUIDAttributeValue}), }, }, }, } exampleGroupSearchResult := &ldap.SearchResult{ Entries: []*ldap.Entry{ { DN: testGroupSearchResultDNValue1, Attributes: []*ldap.EntryAttribute{ ldap.NewEntryAttribute(testGroupSearchGroupNameAttribute, []string{testGroupSearchResultGroupNameAttributeValue1}), }, }, { DN: testGroupSearchResultDNValue2, Attributes: []*ldap.EntryAttribute{ ldap.NewEntryAttribute(testGroupSearchGroupNameAttribute, []string{testGroupSearchResultGroupNameAttributeValue2}), }, }, }, Referrals: []string{}, // note that we are not following referrals at this time Controls: []ldap.Control{}, } // The auth response which matches the exampleUserSearchResult and exampleGroupSearchResult. expectedAuthResponse := func(editFunc func(r *user.DefaultInfo)) *authenticators.Response { u := &user.DefaultInfo{ Name: testUserSearchResultUsernameAttributeValue, UID: base64.RawURLEncoding.EncodeToString([]byte(testUserSearchResultUIDAttributeValue)), Groups: []string{testGroupSearchResultGroupNameAttributeValue1, testGroupSearchResultGroupNameAttributeValue2}, } if editFunc != nil { editFunc(u) } return &authenticators.Response{User: u, DN: testUserSearchResultDNValue} } tests := []struct { name string username string password string providerConfig *ProviderConfig searchMocks func(conn *mockldapconn.MockConn) bindEndUserMocks func(conn *mockldapconn.MockConn) dialError error wantError string wantToSkipDial bool wantAuthResponse *authenticators.Response wantUnauthenticated bool skipDryRunAuthenticateUser bool // tests about when the end user bind fails don't make sense for DryRunAuthenticateUser() }{ { name: "happy path", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(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().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: "when the user search filter is already wrapped by parenthesis then it is not wrapped again", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(func(p *ProviderConfig) { p.UserSearch.Filter = "(" + testUserSearchFilter + ")" }), 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(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: "when the group search filter is already wrapped by parenthesis then it is not wrapped again", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(func(p *ProviderConfig) { p.GroupSearch.Filter = "(" + testGroupSearchFilter + ")" }), 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(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: "when the group search base is empty then skip the group search entirely", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(func(p *ProviderConfig) { p.GroupSearch.Base = "" // this configuration means that the user does not want group search to happen }), searchMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) conn.EXPECT().Search(expectedUserSearch(nil)).Return(exampleUserSearchResult, 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{} }), }, { name: "when the UsernameAttribute is dn and there is a user search filter provided", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(func(p *ProviderConfig) { p.UserSearch.UsernameAttribute = "dn" }), searchMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) conn.EXPECT().Search(expectedUserSearch(func(r *ldap.SearchRequest) { r.Attributes = []string{testUserSearchUIDAttribute} })).Return(&ldap.SearchResult{ Entries: []*ldap.Entry{ { DN: testUserSearchResultDNValue, Attributes: []*ldap.EntryAttribute{ ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testUserSearchResultUIDAttributeValue}), }, }, }, }, 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(func(r *user.DefaultInfo) { r.Name = testUserSearchResultDNValue }), }, { name: "when the UIDAttribute is dn", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(func(p *ProviderConfig) { p.UserSearch.UIDAttribute = "dn" }), searchMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) conn.EXPECT().Search(expectedUserSearch(func(r *ldap.SearchRequest) { r.Attributes = []string{testUserSearchUsernameAttribute} })).Return(&ldap.SearchResult{ Entries: []*ldap.Entry{ { DN: testUserSearchResultDNValue, Attributes: []*ldap.EntryAttribute{ ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testUserSearchResultUsernameAttributeValue}), }, }, }, }, 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(func(r *user.DefaultInfo) { r.UID = base64.RawURLEncoding.EncodeToString([]byte(testUserSearchResultDNValue)) }), }, { name: "when the GroupNameAttribute is empty then it defaults to dn", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(func(p *ProviderConfig) { p.GroupSearch.GroupNameAttribute = "" // blank means to use dn }), 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{} }), 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(func(r *user.DefaultInfo) { r.Groups = []string{testGroupSearchResultDNValue1, testGroupSearchResultDNValue2} }), }, { name: "when the GroupNameAttribute is dn", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(func(p *ProviderConfig) { p.GroupSearch.GroupNameAttribute = "dn" }), 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{} }), 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(func(r *user.DefaultInfo) { r.Groups = []string{testGroupSearchResultDNValue1, testGroupSearchResultDNValue2} }), }, { name: "when the GroupNameAttribute is cn", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(func(p *ProviderConfig) { p.GroupSearch.GroupNameAttribute = "cn" }), 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{"cn"} }), expectedGroupSearchPageSize). Return(&ldap.SearchResult{ Entries: []*ldap.Entry{ { DN: testGroupSearchResultDNValue1, Attributes: []*ldap.EntryAttribute{ ldap.NewEntryAttribute("cn", []string{testGroupSearchResultGroupNameAttributeValue1}), }, }, { DN: testGroupSearchResultDNValue2, Attributes: []*ldap.EntryAttribute{ ldap.NewEntryAttribute("cn", []string{testGroupSearchResultGroupNameAttributeValue2}), }, }, }, 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(nil), }, { name: "when user search Filter is blank it derives a search filter from the UsernameAttribute", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(func(p *ProviderConfig) { p.UserSearch.Filter = "" }), searchMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) conn.EXPECT().Search(expectedUserSearch(func(r *ldap.SearchRequest) { r.Filter = "(" + testUserSearchUsernameAttribute + "=" + testUpstreamUsername + ")" })).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: "when group search Filter is blank it uses a default search filter of member={}", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(func(p *ProviderConfig) { p.GroupSearch.Filter = "" }), 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.Filter = "(member=" + testUserSearchResultDNValue + ")" }), 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: "when the username has special LDAP search filter characters then they must be properly escaped in the search filter, because the username is end-user input", username: `a&b|c(d)e\f*g`, password: testUpstreamPassword, providerConfig: providerConfig(nil), searchMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) conn.EXPECT().Search(expectedUserSearch(func(r *ldap.SearchRequest) { r.Filter = fmt.Sprintf("(some-user-filter=%s-and-more-filter=%s)", `a&b|c\28d\29e\5cf\2ag`, `a&b|c\28d\29e\5cf\2ag`) })).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: "group names are sorted to make the result more stable/predictable", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(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().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize). Return(&ldap.SearchResult{ Entries: []*ldap.Entry{ { DN: testGroupSearchResultDNValue1, Attributes: []*ldap.EntryAttribute{ ldap.NewEntryAttribute(testGroupSearchGroupNameAttribute, []string{"c"}), }, }, { DN: testGroupSearchResultDNValue2, Attributes: []*ldap.EntryAttribute{ ldap.NewEntryAttribute(testGroupSearchGroupNameAttribute, []string{"a"}), }, }, { DN: testGroupSearchResultDNValue2, Attributes: []*ldap.EntryAttribute{ ldap.NewEntryAttribute(testGroupSearchGroupNameAttribute, []string{"b"}), }, }, }, 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: &authenticators.Response{ User: &user.DefaultInfo{ Name: testUserSearchResultUsernameAttributeValue, UID: base64.RawURLEncoding.EncodeToString([]byte(testUserSearchResultUIDAttributeValue)), Groups: []string{"a", "b", "c"}, }, DN: testUserSearchResultDNValue, }, }, { name: "override UID parsing to work with microsoft style objectGUIDs", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(func(p *ProviderConfig) { p.UIDAttributeParsingOverrides = map[string]func(entry *ldap.Entry) (string, error){ "objectGUID": MicrosoftUUIDFromBinary("objectGUID"), } 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"} })).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"}), }, }, }}, 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(func(r *user.DefaultInfo) { r.UID = "04030201-0605-0807-0910-111213141516" }), }, { name: "override UID parsing when the attribute name doesn't match what's returned does default parsing", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(func(p *ProviderConfig) { p.UIDAttributeParsingOverrides = map[string]func(entry *ldap.Entry) (string, error){ "objectGUID": MicrosoftUUIDFromBinary("objectGUID"), } }), 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(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", }, { name: "when dial fails", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(nil), dialError: errors.New("some dial error"), wantError: fmt.Sprintf(`error dialing host "%s": some dial error`, testHost), }, { name: "when the UsernameAttribute is dn and there is not a user search filter provided", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(func(p *ProviderConfig) { p.UserSearch.UsernameAttribute = "dn" p.UserSearch.Filter = "" }), wantToSkipDial: true, wantError: `must specify UserSearch Filter when UserSearch UsernameAttribute is "dn"`, }, { name: "when binding as the bind user returns an error", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(nil), searchMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testBindUsername, testBindPassword).Return(errors.New("some bind error")).Times(1) conn.EXPECT().Close().Times(1) }, wantError: fmt.Sprintf(`error binding as "%s" before user search: some bind error`, testBindUsername), }, { name: "when searching for the user returns an error", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(nil), searchMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) conn.EXPECT().Search(expectedUserSearch(nil)).Return(nil, errors.New("some user search error")).Times(1) conn.EXPECT().Close().Times(1) }, wantError: `error searching for user: some user search error`, }, { name: "when searching for the user's groups returns an error", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(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().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize). Return(nil, errors.New("some group search error")).Times(1) conn.EXPECT().Close().Times(1) }, wantError: fmt.Sprintf(`error searching for group memberships for user with DN "%s": some group search error`, testUserSearchResultDNValue), }, { name: "when searching for the user returns no results", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(nil), searchMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) conn.EXPECT().Search(expectedUserSearch(nil)).Return(&ldap.SearchResult{ Entries: []*ldap.Entry{}, }, nil).Times(1) conn.EXPECT().Close().Times(1) }, wantUnauthenticated: true, }, { name: "when searching for the user returns multiple results", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(nil), searchMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) conn.EXPECT().Search(expectedUserSearch(nil)).Return(&ldap.SearchResult{ Entries: []*ldap.Entry{ {DN: testUserSearchResultDNValue}, {DN: "some-other-dn"}, }, }, nil).Times(1) conn.EXPECT().Close().Times(1) }, wantError: fmt.Sprintf(`searching for user "%s" resulted in 2 search results, but expected 1 result`, testUpstreamUsername), }, { name: "when searching for the user returns a user without a DN", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(nil), searchMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) conn.EXPECT().Search(expectedUserSearch(nil)).Return(&ldap.SearchResult{ Entries: []*ldap.Entry{ {DN: ""}, }, }, nil).Times(1) conn.EXPECT().Close().Times(1) }, wantError: fmt.Sprintf(`searching for user "%s" resulted in search result without DN`, testUpstreamUsername), }, { name: "when searching for the user's groups returns a group without a DN", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(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().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize). Return(&ldap.SearchResult{ Entries: []*ldap.Entry{ { DN: testGroupSearchResultDNValue1, Attributes: []*ldap.EntryAttribute{ ldap.NewEntryAttribute(testGroupSearchGroupNameAttribute, []string{testGroupSearchResultGroupNameAttributeValue1}), }, }, { DN: "", Attributes: []*ldap.EntryAttribute{ ldap.NewEntryAttribute(testGroupSearchGroupNameAttribute, []string{testGroupSearchResultGroupNameAttributeValue2}), }, }, }, Referrals: []string{}, // note that we are not following referrals at this time Controls: []ldap.Control{}, }, nil).Times(1) conn.EXPECT().Close().Times(1) }, wantError: fmt.Sprintf( `searching for group memberships for user with DN "%s" resulted in search result without DN`, testUserSearchResultDNValue), }, { name: "when searching for the user returns a user without an expected username attribute", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(nil), searchMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) conn.EXPECT().Search(expectedUserSearch(nil)).Return(&ldap.SearchResult{ Entries: []*ldap.Entry{ { DN: testUserSearchResultDNValue, Attributes: []*ldap.EntryAttribute{ ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testUserSearchResultUIDAttributeValue}), }, }, }, }, nil).Times(1) conn.EXPECT().Close().Times(1) }, wantError: fmt.Sprintf( `found 0 values for attribute "%s" while searching for user "%s", but expected 1 result`, testUserSearchUsernameAttribute, testUpstreamUsername), }, { name: "when searching for the group memberships returns a group without an expected group name attribute", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(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().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize). Return(&ldap.SearchResult{ Entries: []*ldap.Entry{ { DN: testGroupSearchResultDNValue1, Attributes: []*ldap.EntryAttribute{ ldap.NewEntryAttribute(testGroupSearchGroupNameAttribute, []string{testGroupSearchResultGroupNameAttributeValue1}), }, }, { DN: testGroupSearchResultDNValue1, Attributes: []*ldap.EntryAttribute{ ldap.NewEntryAttribute("unrelated attribute", []string{"anything"}), }, }, }, Referrals: []string{}, // note that we are not following referrals at this time Controls: []ldap.Control{}, }, nil).Times(1) conn.EXPECT().Close().Times(1) }, wantError: fmt.Sprintf( `error searching for group memberships for user with DN "%s": found 0 values for attribute "%s" while searching for user "%s", but expected 1 result`, testUserSearchResultDNValue, testGroupSearchGroupNameAttribute, testUserSearchResultDNValue), }, { name: "when searching for the user returns a user with too many values for the expected username attribute", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(nil), searchMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) conn.EXPECT().Search(expectedUserSearch(nil)).Return(&ldap.SearchResult{ Entries: []*ldap.Entry{ { DN: testUserSearchResultDNValue, Attributes: []*ldap.EntryAttribute{ ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{ testUserSearchResultUsernameAttributeValue, "unexpected-additional-value", }), ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testUserSearchResultUIDAttributeValue}), }, }, }, }, nil).Times(1) conn.EXPECT().Close().Times(1) }, wantError: fmt.Sprintf( `found 2 values for attribute "%s" while searching for user "%s", but expected 1 result`, testUserSearchUsernameAttribute, testUpstreamUsername), }, { name: "when searching for the group memberships returns a group with too many values for the expected group name attribute", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(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().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize). Return(&ldap.SearchResult{ Entries: []*ldap.Entry{ { DN: testGroupSearchResultDNValue1, Attributes: []*ldap.EntryAttribute{ ldap.NewEntryAttribute(testGroupSearchGroupNameAttribute, []string{testGroupSearchResultGroupNameAttributeValue1}), }, }, { DN: testGroupSearchResultDNValue1, Attributes: []*ldap.EntryAttribute{ ldap.NewEntryAttribute(testGroupSearchGroupNameAttribute, []string{ testGroupSearchResultGroupNameAttributeValue1, "unexpected-additional-value", }), }, }, }, Referrals: []string{}, // note that we are not following referrals at this time Controls: []ldap.Control{}, }, nil).Times(1) conn.EXPECT().Close().Times(1) }, wantError: fmt.Sprintf( `error searching for group memberships for user with DN "%s": found 2 values for attribute "%s" while searching for user "%s", but expected 1 result`, testUserSearchResultDNValue, testGroupSearchGroupNameAttribute, testUserSearchResultDNValue), }, { name: "when searching for the user returns a user with an empty value for the expected username attribute", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(nil), searchMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) conn.EXPECT().Search(expectedUserSearch(nil)).Return(&ldap.SearchResult{ Entries: []*ldap.Entry{ { DN: testUserSearchResultDNValue, Attributes: []*ldap.EntryAttribute{ ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{""}), ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testUserSearchResultUIDAttributeValue}), }, }, }, }, nil).Times(1) conn.EXPECT().Close().Times(1) }, wantError: fmt.Sprintf( `found empty value for attribute "%s" while searching for user "%s", but expected value to be non-empty`, testUserSearchUsernameAttribute, testUpstreamUsername), }, { name: "when searching for the group memberships returns a group with an empty value for for the expected group name attribute", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(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().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize). Return(&ldap.SearchResult{ Entries: []*ldap.Entry{ { DN: testGroupSearchResultDNValue1, Attributes: []*ldap.EntryAttribute{ ldap.NewEntryAttribute(testGroupSearchGroupNameAttribute, []string{testGroupSearchResultGroupNameAttributeValue1}), }, }, { DN: testGroupSearchResultDNValue1, Attributes: []*ldap.EntryAttribute{ ldap.NewEntryAttribute(testGroupSearchGroupNameAttribute, []string{""}), }, }, }, Referrals: []string{}, // note that we are not following referrals at this time Controls: []ldap.Control{}, }, nil).Times(1) conn.EXPECT().Close().Times(1) }, wantError: fmt.Sprintf( `error searching for group memberships for user with DN "%s": found empty value for attribute "%s" while searching for user "%s", but expected value to be non-empty`, testUserSearchResultDNValue, testGroupSearchGroupNameAttribute, testUserSearchResultDNValue), }, { name: "when searching for the user returns a user without an expected UID attribute", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(nil), searchMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) conn.EXPECT().Search(expectedUserSearch(nil)).Return(&ldap.SearchResult{ Entries: []*ldap.Entry{ { DN: testUserSearchResultDNValue, Attributes: []*ldap.EntryAttribute{ ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testUserSearchResultUsernameAttributeValue}), }, }, }, }, nil).Times(1) conn.EXPECT().Close().Times(1) }, wantError: fmt.Sprintf(`found 0 values for attribute "%s" while searching for user "%s", but expected 1 result`, testUserSearchUIDAttribute, testUpstreamUsername), }, { name: "when searching for the user returns a user with too many values for the expected UID attribute", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(nil), searchMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) conn.EXPECT().Search(expectedUserSearch(nil)).Return(&ldap.SearchResult{ Entries: []*ldap.Entry{ { DN: testUserSearchResultDNValue, Attributes: []*ldap.EntryAttribute{ ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testUserSearchResultUsernameAttributeValue}), ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{ testUserSearchResultUIDAttributeValue, "unexpected-additional-value", }), }, }, }, }, nil).Times(1) conn.EXPECT().Close().Times(1) }, wantError: fmt.Sprintf(`found 2 values for attribute "%s" while searching for user "%s", but expected 1 result`, testUserSearchUIDAttribute, testUpstreamUsername), }, { name: "when searching for the user returns a user with an empty value for the expected UID attribute", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(nil), searchMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) conn.EXPECT().Search(expectedUserSearch(nil)).Return(&ldap.SearchResult{ Entries: []*ldap.Entry{ { DN: testUserSearchResultDNValue, Attributes: []*ldap.EntryAttribute{ ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testUserSearchResultUsernameAttributeValue}), ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{""}), }, }, }, }, nil).Times(1) conn.EXPECT().Close().Times(1) }, wantError: fmt.Sprintf(`found empty value for attribute "%s" while searching for user "%s", but expected value to be non-empty`, testUserSearchUIDAttribute, testUpstreamUsername), }, { name: "when binding as the found user returns an error", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(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().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize). Return(exampleGroupSearchResult, nil).Times(1) conn.EXPECT().Close().Times(1) }, bindEndUserMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Return(errors.New("some bind error")).Times(1) }, skipDryRunAuthenticateUser: true, wantError: fmt.Sprintf(`error binding for user "%s" using provided password against DN "%s": some bind error`, testUpstreamUsername, testUserSearchResultDNValue), }, { name: "when binding as the found user returns a specific invalid credentials error", username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(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().SearchWithPaging(expectedGroupSearch(nil), expectedGroupSearchPageSize). Return(exampleGroupSearchResult, nil).Times(1) conn.EXPECT().Close().Times(1) }, wantUnauthenticated: true, skipDryRunAuthenticateUser: true, bindEndUserMocks: func(conn *mockldapconn.MockConn) { err := &ldap.Error{ Err: errors.New("some bind error"), ResultCode: ldap.LDAPResultInvalidCredentials, } conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Return(err).Times(1) }, }, { name: "when no username is specified", username: "", password: testUpstreamPassword, providerConfig: providerConfig(nil), wantToSkipDial: true, wantUnauthenticated: true, }, } for _, test := range tests { tt := test t.Run(tt.name, func(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) conn := mockldapconn.NewMockConn(ctrl) if tt.searchMocks != nil { tt.searchMocks(conn) } if tt.bindEndUserMocks != nil { tt.bindEndUserMocks(conn) } dialWasAttempted := false tt.providerConfig.Dialer = LDAPDialerFunc(func(ctx context.Context, addr endpointaddr.HostPort) (Conn, error) { dialWasAttempted = true require.Equal(t, tt.providerConfig.Host, addr.Endpoint()) if tt.dialError != nil { return nil, tt.dialError } return conn, nil }) provider := New(*tt.providerConfig) authResponse, authenticated, err := provider.AuthenticateUser(context.Background(), tt.username, tt.password) require.Equal(t, !tt.wantToSkipDial, dialWasAttempted) switch { case tt.wantError != "": require.EqualError(t, err, tt.wantError) require.False(t, authenticated) require.Nil(t, authResponse) case tt.wantUnauthenticated: require.NoError(t, err) require.False(t, authenticated) require.Nil(t, authResponse) default: require.NoError(t, err) require.True(t, authenticated) require.Equal(t, tt.wantAuthResponse, authResponse) } // DryRunAuthenticateUser() should have the same behavior as AuthenticateUser() except that it does not bind // as the end user to confirm their password. Since it should behave the same, all of the same test cases // apply, except for those which are specifically testing what happens when the end user bind fails. if tt.skipDryRunAuthenticateUser { return // move on to the next test } // Reset some variables to get ready to call DryRunAuthenticateUser(). dialWasAttempted = false conn = mockldapconn.NewMockConn(ctrl) if tt.searchMocks != nil { tt.searchMocks(conn) } // Skip tt.bindEndUserMocks since DryRunAuthenticateUser() never binds as the end user. authResponse, authenticated, err = provider.DryRunAuthenticateUser(context.Background(), tt.username) require.Equal(t, !tt.wantToSkipDial, dialWasAttempted) switch { case tt.wantError != "": require.EqualError(t, err, tt.wantError) require.False(t, authenticated) require.Nil(t, authResponse) case tt.wantUnauthenticated: require.NoError(t, err) require.False(t, authenticated) require.Nil(t, authResponse) default: require.NoError(t, err) require.True(t, authenticated) require.Equal(t, tt.wantAuthResponse, authResponse) } }) } } func TestUpstreamRefresh(t *testing.T) { expectedUserSearch := &ldap.SearchRequest{ BaseDN: testUserSearchResultDNValue, Scope: ldap.ScopeBaseObject, DerefAliases: ldap.NeverDerefAliases, SizeLimit: 2, TimeLimit: 90, TypesOnly: false, Filter: "(objectClass=*)", Attributes: []string{testUserSearchUsernameAttribute, testUserSearchUIDAttribute}, Controls: nil, // don't need paging because we set the SizeLimit so small } happyPathUserSearchResult := &ldap.SearchResult{ Entries: []*ldap.Entry{ { DN: testUserSearchResultDNValue, Attributes: []*ldap.EntryAttribute{ { Name: testUserSearchUsernameAttribute, Values: []string{testUserSearchResultUsernameAttributeValue}, }, { Name: testUserSearchUIDAttribute, ByteValues: [][]byte{[]byte(testUserSearchResultUIDAttributeValue)}, }, }, }, }, } providerConfig := &ProviderConfig{ Name: "some-provider-name", Host: testHost, CABundle: nil, // this field is only used by the production dialer, which is replaced by a mock for this test ConnectionProtocol: TLS, BindUsername: testBindUsername, BindPassword: testBindPassword, UserSearch: UserSearchConfig{ Base: testUserSearchBase, UIDAttribute: testUserSearchUIDAttribute, UsernameAttribute: testUserSearchUsernameAttribute, }, } tests := []struct { name string providerConfig *ProviderConfig setupMocks func(conn *mockldapconn.MockConn) dialError error wantErr string }{ { name: "happy path where searching the dn returns a single entry", providerConfig: providerConfig, setupMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) conn.EXPECT().Search(expectedUserSearch).Return(happyPathUserSearchResult, nil).Times(1) conn.EXPECT().Close().Times(1) }, }, { name: "error where dial fails", providerConfig: providerConfig, dialError: errors.New("some dial error"), wantErr: "error dialing host \"ldap.example.com:8443\": some dial error", }, { name: "error binding", providerConfig: providerConfig, setupMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testBindUsername, testBindPassword).Return(errors.New("some bind error")).Times(1) conn.EXPECT().Close().Times(1) }, wantErr: "error binding as \"cn=some-bind-username,dc=pinniped,dc=dev\" before user search: some bind error", }, { name: "search result returns no entries", providerConfig: providerConfig, setupMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) conn.EXPECT().Search(expectedUserSearch).Return(&ldap.SearchResult{ Entries: []*ldap.Entry{}, }, nil).Times(1) conn.EXPECT().Close().Times(1) }, wantErr: "searching for user \"some-upstream-user-dn\" resulted in 0 search results, but expected 1 result", }, { name: "error searching", providerConfig: providerConfig, setupMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) conn.EXPECT().Search(expectedUserSearch).Return(nil, errors.New("some search error")) conn.EXPECT().Close().Times(1) }, wantErr: "error searching for user \"some-upstream-user-dn\": some search error", }, { name: "search result returns more than one entry", 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{}, }, { DN: "doesn't-matter", Attributes: []*ldap.EntryAttribute{}, }, }, }, nil).Times(1) conn.EXPECT().Close().Times(1) }, wantErr: "searching for user \"some-upstream-user-dn\" resulted in 2 search results, but expected 1 result", }, { name: "search result has wrong uid", 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("wrong-uid")}, }, }, }, }, }, nil).Times(1) conn.EXPECT().Close().Times(1) }, wantErr: "searching for user \"some-upstream-user-dn\" produced a different subject than the previous value. expected: \"ldaps://ldap.example.com:8443?base=some-upstream-user-base-dn&sub=c29tZS11cHN0cmVhbS11aWQtdmFsdWU\", actual: \"ldaps://ldap.example.com:8443?base=some-upstream-user-base-dn&sub=d3JvbmctdWlk\"", }, { name: "search result has wrong username", 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{"wrong-username"}, }, }, }, }, }, nil).Times(1) conn.EXPECT().Close().Times(1) }, wantErr: "searching for user \"some-upstream-user-dn\" returned a different username than the previous value. expected: \"some-upstream-username-value\", actual: \"wrong-username\"", }, { name: "search result has no dn", providerConfig: providerConfig, setupMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) conn.EXPECT().Search(expectedUserSearch).Return(&ldap.SearchResult{ Entries: []*ldap.Entry{ { Attributes: []*ldap.EntryAttribute{ { Name: testUserSearchUsernameAttribute, Values: []string{testUserSearchResultUsernameAttributeValue}, }, { Name: testUserSearchUIDAttribute, ByteValues: [][]byte{[]byte(testUserSearchResultUIDAttributeValue)}, }, }, }, }, }, nil).Times(1) conn.EXPECT().Close().Times(1) }, wantErr: "searching for user with original DN \"some-upstream-user-dn\" resulted in search result without DN", }, { name: "search result has 0 values for username attribute", 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{}, }, { Name: testUserSearchUIDAttribute, ByteValues: [][]byte{[]byte(testUserSearchResultUIDAttributeValue)}, }, }, }, }, }, nil).Times(1) conn.EXPECT().Close().Times(1) }, wantErr: "found 0 values for attribute \"some-upstream-username-attribute\" while searching for user \"some-upstream-user-dn\", but expected 1 result", }, { name: "search result has more than one value for username attribute", 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, "something-else"}, }, { Name: testUserSearchUIDAttribute, ByteValues: [][]byte{[]byte(testUserSearchResultUIDAttributeValue)}, }, }, }, }, }, nil).Times(1) conn.EXPECT().Close().Times(1) }, wantErr: "found 2 values for attribute \"some-upstream-username-attribute\" while searching for user \"some-upstream-user-dn\", but expected 1 result", }, { name: "search result has 0 values for uid attribute", 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{}, }, }, }, }, }, nil).Times(1) conn.EXPECT().Close().Times(1) }, wantErr: "found 0 values for attribute \"some-upstream-uid-attribute\" while searching for user \"some-upstream-user-dn\", but expected 1 result", }, { name: "search result has 2 values for uid attribute", 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), []byte("other-uid-value")}, }, }, }, }, }, nil).Times(1) conn.EXPECT().Close().Times(1) }, wantErr: "found 2 values for attribute \"some-upstream-uid-attribute\" while searching for user \"some-upstream-user-dn\", but expected 1 result", }, } for _, test := range tests { tt := test t.Run(tt.name, func(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) conn := mockldapconn.NewMockConn(ctrl) if tt.setupMocks != nil { tt.setupMocks(conn) } dialWasAttempted := false providerConfig.Dialer = LDAPDialerFunc(func(ctx context.Context, addr endpointaddr.HostPort) (Conn, error) { dialWasAttempted = true require.Equal(t, providerConfig.Host, addr.Endpoint()) if tt.dialError != nil { return nil, tt.dialError } return conn, nil }) provider := New(*providerConfig) subject := "ldaps://ldap.example.com:8443?base=some-upstream-user-base-dn&sub=c29tZS11cHN0cmVhbS11aWQtdmFsdWU" err := provider.PerformRefresh(context.Background(), testUserSearchResultDNValue, testUserSearchResultUsernameAttributeValue, subject) if tt.wantErr != "" { require.Error(t, err) require.Equal(t, tt.wantErr, err.Error()) } else { require.NoError(t, err) } require.Equal(t, true, dialWasAttempted) }) } } func TestTestConnection(t *testing.T) { providerConfig := func(editFunc func(p *ProviderConfig)) *ProviderConfig { config := &ProviderConfig{ Name: "some-provider-name", Host: testHost, CABundle: nil, // this field is only used by the production dialer, which is replaced by a mock for this test ConnectionProtocol: TLS, BindUsername: testBindUsername, BindPassword: testBindPassword, UserSearch: UserSearchConfig{}, // not used by TestConnection } if editFunc != nil { editFunc(config) } return config } tests := []struct { name string providerConfig *ProviderConfig setupMocks func(conn *mockldapconn.MockConn) dialError error wantError string wantToSkipDial bool }{ { name: "happy path", providerConfig: providerConfig(nil), setupMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) conn.EXPECT().Close().Times(1) }, }, { name: "when dial fails", providerConfig: providerConfig(nil), dialError: errors.New("some dial error"), wantError: fmt.Sprintf(`error dialing host "%s": some dial error`, testHost), }, { name: "when binding as the bind user returns an error", providerConfig: providerConfig(nil), setupMocks: func(conn *mockldapconn.MockConn) { conn.EXPECT().Bind(testBindUsername, testBindPassword).Return(errors.New("some bind error")).Times(1) conn.EXPECT().Close().Times(1) }, wantError: fmt.Sprintf(`error binding as "%s": some bind error`, testBindUsername), }, { name: "when the config is invalid", providerConfig: providerConfig(func(p *ProviderConfig) { // This particular combination of options is not allowed. p.UserSearch.UsernameAttribute = "dn" p.UserSearch.Filter = "" }), wantToSkipDial: true, wantError: `must specify UserSearch Filter when UserSearch UsernameAttribute is "dn"`, }, } for _, test := range tests { tt := test t.Run(tt.name, func(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) conn := mockldapconn.NewMockConn(ctrl) if tt.setupMocks != nil { tt.setupMocks(conn) } dialWasAttempted := false tt.providerConfig.Dialer = LDAPDialerFunc(func(ctx context.Context, addr endpointaddr.HostPort) (Conn, error) { dialWasAttempted = true require.Equal(t, tt.providerConfig.Host, addr.Endpoint()) if tt.dialError != nil { return nil, tt.dialError } return conn, nil }) provider := New(*tt.providerConfig) err := provider.TestConnection(context.Background()) require.Equal(t, !tt.wantToSkipDial, dialWasAttempted) switch { case tt.wantError != "": require.EqualError(t, err, tt.wantError) default: require.NoError(t, err) } }) } } func TestGetConfig(t *testing.T) { c := ProviderConfig{ Name: "original-provider-name", Host: testHost, CABundle: []byte("some-ca-bundle"), BindUsername: testBindUsername, BindPassword: testBindPassword, UserSearch: UserSearchConfig{ Base: testUserSearchBase, Filter: testUserSearchFilter, UsernameAttribute: testUserSearchUsernameAttribute, UIDAttribute: testUserSearchUIDAttribute, }, } p := New(c) require.Equal(t, c, p.c) require.Equal(t, c, p.GetConfig()) // The original config can be changed without impacting the provider, since the provider made a copy of the config. c.Name = "changed-name" require.Equal(t, "original-provider-name", p.c.Name) // The return value of GetConfig can be modified without impacting the provider, since it is a copy of the config. returnedConfig := p.GetConfig() returnedConfig.Name = "changed-name" require.Equal(t, "original-provider-name", p.c.Name) } func TestGetURL(t *testing.T) { require.Equal(t, "ldaps://ldap.example.com:1234?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev", New(ProviderConfig{ Host: "ldap.example.com:1234", UserSearch: UserSearchConfig{Base: "ou=users,dc=pinniped,dc=dev"}, }).GetURL().String()) require.Equal(t, "ldaps://ldap.example.com?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev", New(ProviderConfig{ Host: "ldap.example.com", UserSearch: UserSearchConfig{Base: "ou=users,dc=pinniped,dc=dev"}, }).GetURL().String()) } // Testing of host parsing, TLS negotiation, and CA bundle, etc. for the production code's dialer. func TestRealTLSDialing(t *testing.T) { testServerCABundle, testServerURL := testutil.TLSTestServer(t, func(w http.ResponseWriter, r *http.Request) {}) parsedURL, err := url.Parse(testServerURL) require.NoError(t, err) testServerHostAndPort := parsedURL.Host caForTestServerWithBadCertName, err := certauthority.New("Test CA", time.Hour) require.NoError(t, err) wrongIP := net.ParseIP("10.2.3.4") cert, err := caForTestServerWithBadCertName.IssueServerCert([]string{"wrong-dns-name"}, []net.IP{wrongIP}, time.Hour) require.NoError(t, err) testServerWithBadCertNameAddr := testutil.TLSTestServerWithCert(t, func(w http.ResponseWriter, r *http.Request) {}, cert) unusedPortGrabbingListener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) recentlyClaimedHostAndPort := unusedPortGrabbingListener.Addr().String() require.NoError(t, unusedPortGrabbingListener.Close()) alreadyCancelledContext, cancelFunc := context.WithCancel(context.Background()) cancelFunc() // cancel it immediately tests := []struct { name string host string connProto LDAPConnectionProtocol caBundle []byte context context.Context wantError string }{ { name: "happy path", host: testServerHostAndPort, caBundle: []byte(testServerCABundle), connProto: TLS, context: context.Background(), }, { name: "server cert name does not match the address to which the client connected", host: testServerWithBadCertNameAddr, caBundle: caForTestServerWithBadCertName.Bundle(), connProto: TLS, context: context.Background(), wantError: `LDAP Result Code 200 "Network Error": x509: certificate is valid for 10.2.3.4, not 127.0.0.1`, }, { name: "invalid CA bundle with TLS", host: testServerHostAndPort, caBundle: []byte("not a ca bundle"), connProto: TLS, context: context.Background(), wantError: `LDAP Result Code 200 "Network Error": could not parse CA bundle`, }, { name: "invalid CA bundle with StartTLS", host: testServerHostAndPort, caBundle: []byte("not a ca bundle"), connProto: StartTLS, context: context.Background(), wantError: `LDAP Result Code 200 "Network Error": could not parse CA bundle`, }, { name: "invalid host with TLS", host: "this:is:not:a:valid:hostname", caBundle: []byte(testServerCABundle), connProto: TLS, context: context.Background(), wantError: `LDAP Result Code 200 "Network Error": host "this:is:not:a:valid:hostname" is not a valid hostname or IP address`, }, { name: "invalid host with StartTLS", host: "this:is:not:a:valid:hostname", caBundle: []byte(testServerCABundle), connProto: StartTLS, context: context.Background(), wantError: `LDAP Result Code 200 "Network Error": host "this:is:not:a:valid:hostname" is not a valid hostname or IP address`, }, { name: "missing CA bundle when it is required because the host is not using a trusted CA", host: testServerHostAndPort, caBundle: nil, connProto: TLS, context: context.Background(), wantError: `LDAP Result Code 200 "Network Error": x509: certificate signed by unknown authority`, }, { name: "cannot connect to host", // This is assuming that this port was not reclaimed by another app since the test setup ran. Seems safe enough. host: recentlyClaimedHostAndPort, caBundle: []byte(testServerCABundle), connProto: TLS, context: context.Background(), wantError: fmt.Sprintf(`LDAP Result Code 200 "Network Error": dial tcp %s: connect: connection refused`, recentlyClaimedHostAndPort), }, { name: "pays attention to the passed context", host: testServerHostAndPort, caBundle: []byte(testServerCABundle), connProto: TLS, context: alreadyCancelledContext, wantError: fmt.Sprintf(`LDAP Result Code 200 "Network Error": dial tcp %s: operation was canceled`, testServerHostAndPort), }, { name: "unsupported connection protocol", host: testServerHostAndPort, caBundle: []byte(testServerCABundle), connProto: "bad usage of this type", context: alreadyCancelledContext, wantError: `LDAP Result Code 200 "Network Error": did not specify valid ConnectionProtocol`, }, } for _, test := range tests { tt := test t.Run(tt.name, func(t *testing.T) { provider := New(ProviderConfig{ Host: tt.host, CABundle: tt.caBundle, ConnectionProtocol: tt.connProto, Dialer: nil, // this test is for the default (production) TLS dialer }) conn, err := provider.dial(tt.context) if conn != nil { defer conn.Close() } if tt.wantError != "" { require.Nil(t, conn) require.EqualError(t, err, tt.wantError) } else { require.NoError(t, err) require.NotNil(t, conn) // Should be an instance of the real production LDAP client type. // Can't test its methods here because we are not dialed to a real LDAP server. require.IsType(t, &ldap.Conn{}, conn) // Indirectly checking that the Dialer method constructed the ldap.Conn with isTLS set to true, // since this is always the correct behavior unless/until we want to support StartTLS. err := conn.(*ldap.Conn).StartTLS(&tls.Config{}) require.EqualError(t, err, `LDAP Result Code 200 "Network Error": ldap: already encrypted`) } }) } } 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) }) } }