git.iko.soy/typeable/oauth2-proxy
on master help sign in
Check Google group membership with hasMember and get. (#224)

* Check Google group membership with hasMember and get. This PR is an enhancement built on https://github.com/pusher/oauth2_proxy/pull/160. That PR reduces the number of calls to the Google Admin API and simplifies the code by using the hasMember method. It also supports checking membership in nested groups. However, the above message doesn't handle members who are not a part of the domain. The hasMember API returns a 400 for that case. As a fallback, when the API returns a 400, this change will try using the `get` API which works as expected for members who aren't a part of the domain. Supporting members who belong to the Google group but aren't part of the domain is a requested feature from https://github.com/pusher/oauth2_proxy/issues/95. https://developers.google.com/admin-sdk/directory/v1/reference/members/get Note that nested members who are not a part of the domain will not be correctly detected with this change. * Update CHANGELOG. * Fix incorrect JSON and stop escaping strings. * Add comments for each scenario.

commit7d910c0ae8eef5620fd6524a38521e953d1adb8e authorJustin Palpant <justin@palpant.us> dateTue 6 Aug 2019, 02:38 (7 years ago) parent69c723a
3 files changed+65−72
CHANGELOG.md+3−2 providers/google.go+26−55 providers/google_test.go+36−15
CHANGELOG.md+3−2
@@ -31,8 +31,9 @@
3131
3232## Changes since v3.2.0
3333
34−- [#178](https://github.com/pusher/oauth2_proxy/pull/178) Add Silence Ping Logging and Exclude Logging Paths flags (@kskewes)
35−- [#209](https://github.com/pusher/oauth2_proxy/pull/209) Improve docker build caching of layers (@dekimsey)
34+- [#224](https://github.com/pusher/oauth2_proxy/pull/224) Check Google group membership using hasMember to support nested groups and external users (@jpalpant)
35+- [#178](https://github.com/pusher/outh2_proxy/pull/178) Add Silence Ping Logging and Exclude Logging Paths flags (@kskewes)
36+- [#209](https://github.com/pusher/outh2_proxy/pull/209) Improve docker build caching of layers (@dekimsey)
3637- [#186](https://github.com/pusher/oauth2_proxy/pull/186) Make config consistent (@JoelSpeed)
3738- [#187](https://github.com/pusher/oauth2_proxy/pull/187) Move root packages to pkg folder (@JoelSpeed)
3839- [#65](https://github.com/pusher/oauth2_proxy/pull/65) Improvements to authenticate requests with a JWT bearer token in the `Authorization` header via
providers/google.go+26−55
@@ -189,71 +189,42 @@ func getAdminService(adminEmail string, credentialsReader io.Reader) *admin.Serv
189189}
190190
191191func userInGroup(service *admin.Service, groups []string, email string) bool {
192− user, err := fetchUser(service, email)
193− if err != nil {
194− logger.Printf("Warning: unable to fetch user: %v", err)
195− user = nil
196− }
197−
198192 for _, group := range groups {
199− members, err := fetchGroupMembers(service, group)
193+ // Use the HasMember API to checking for the user's presence in each group or nested subgroups
194+ req := service.Members.HasMember(group, email)
195+ r, err := req.Do()
200196 if err != nil {
201− if err, ok := err.(*googleapi.Error); ok && err.Code == 404 {
202− logger.Printf("error fetching members for group %s: group does not exist", group)
203− } else {
204− logger.Printf("error fetching group members: %v", err)
205− return false
206− }
207− }
197+ err, ok := err.(*googleapi.Error)
198+ if ok && err.Code == 404 {
199+ logger.Printf("error checking membership in group %s: group does not exist", group)
200+ } else if ok && err.Code == 400 {
201+ // It is possible for Members.HasMember to return false even if the email is a group member.
202+ // One case that can cause this is if the user email is from a different domain than the group,
203+ // e.g. "member@otherdomain.com" in the group "group@mydomain.com" will result in a 400 error
204+ // from the HasMember API. In that case, attempt to query the member object directly from the group.
205+ req := service.Members.Get(group, email)
206+ r, err := req.Do()
208207
209− for _, member := range members {
210− if member.Email == email {
211− return true
212− }
213− if user == nil {
214− continue
215− }
216− switch member.Type {
217− case "CUSTOMER":
218− if member.Id == user.CustomerId {
219− return true
208+ if err != nil {
209+ logger.Printf("error using get API to check member %s of google group %s: user not in the group", email, group)
210+ continue
220211 }
221− case "USER":
222− if member.Id == user.Id {
212+
213+ // If the non-domain user is found within the group, still verify that they are "ACTIVE".
214+ // Do not count the user as belonging to a group if they have another status ("ARCHIVED", "SUSPENDED", or "UNKNOWN").
215+ if r.Status == "ACTIVE" {
223216 return true
224217 }
218+ } else {
219+ logger.Printf("error checking group membership: %v", err)
225220 }
221+ continue
226222 }
227− }
228− return false
229−}
230−
231−func fetchUser(service *admin.Service, email string) (*admin.User, error) {
232− user, err := service.Users.Get(email).Do()
233− return user, err
234−}
235−
236−func fetchGroupMembers(service *admin.Service, group string) ([]*admin.Member, error) {
237− members := []*admin.Member{}
238− pageToken := ""
239− for {
240− req := service.Members.List(group)
241− if pageToken != "" {
242− req.PageToken(pageToken)
243− }
244− r, err := req.Do()
245− if err != nil {
246− return nil, err
247− }
248− for _, member := range r.Members {
249− members = append(members, member)
250− }
251− if r.NextPageToken == "" {
252− break
223+ if r.IsMember {
224+ return true
253225 }
254− pageToken = r.NextPageToken
255226 }
256− return members, nil
227+ return false
257228}
258229
259230// ValidateGroup validates that the provided email exists in the configured Google
providers/google_test.go+36−15
@@ -1,6 +1,7 @@
11package providers
22
33import (
4+ "context"
45 "encoding/base64"
56 "encoding/json"
67 "fmt"
@@ -12,6 +13,7 @@ import (
1213 "github.com/stretchr/testify/assert"
1314
1415 admin "google.golang.org/api/admin/directory/v1"
16+ option "google.golang.org/api/option"
1517)
1618
1719func newRedeemServer(body []byte) (*url.URL, *httptest.Server) {
@@ -185,34 +187,53 @@ func TestGoogleProviderGetEmailAddressEmailMissing(t *testing.T) {
185187
186188func TestGoogleProviderUserInGroup(t *testing.T) {
187189 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
188− if r.URL.Path == "/users/member-by-email@example.com" {
189− fmt.Fprintln(w, "{}")
190− } else if r.URL.Path == "/users/non-member-by-email@example.com" {
191− fmt.Fprintln(w, "{}")
192− } else if r.URL.Path == "/users/member-by-id@example.com" {
193− fmt.Fprintln(w, "{\"id\": \"member-id\"}")
194− } else if r.URL.Path == "/users/non-member-by-id@example.com" {
195− fmt.Fprintln(w, "{\"id\": \"non-member-id\"}")
196− } else if r.URL.Path == "/groups/group@example.com/members" {
197− fmt.Fprintln(w, "{\"members\": [{\"email\": \"member-by-email@example.com\"}, {\"id\": \"member-id\", \"type\": \"USER\"}]}")
190+ if r.URL.Path == "/groups/group@example.com/hasMember/member-in-domain@example.com" {
191+ fmt.Fprintln(w, `{"isMember": true}`)
192+ } else if r.URL.Path == "/groups/group@example.com/hasMember/non-member-in-domain@example.com" {
193+ fmt.Fprintln(w, `{"isMember": false}`)
194+ } else if r.URL.Path == "/groups/group@example.com/hasMember/member-out-of-domain@otherexample.com" {
195+ http.Error(
196+ w,
197+ `{"error": {"errors": [{"domain": "global","reason": "invalid","message": "Invalid Input: memberKey"}],"code": 400,"message": "Invalid Input: memberKey"}}`,
198+ http.StatusBadRequest,
199+ )
200+ } else if r.URL.Path == "/groups/group@example.com/hasMember/non-member-out-of-domain@otherexample.com" {
201+ http.Error(
202+ w,
203+ `{"error": {"errors": [{"domain": "global","reason": "invalid","message": "Invalid Input: memberKey"}],"code": 400,"message": "Invalid Input: memberKey"}}`,
204+ http.StatusBadRequest,
205+ )
206+ } else if r.URL.Path == "/groups/group@example.com/members/non-member-out-of-domain@otherexample.com" {
207+ // note that the client currently doesn't care what this response text or code is - any error here results in failure to match the group
208+ http.Error(
209+ w,
210+ `{"error": {"errors": [{"domain": "global","reason": "notFound","message": "Resource Not Found: memberKey"}],"code": 404,"message": "Resource Not Found: memberKey"}}`,
211+ http.StatusNotFound,
212+ )
213+ } else if r.URL.Path == "/groups/group@example.com/members/member-out-of-domain@otherexample.com" {
214+ fmt.Fprintln(w,
215+ `{"kind": "admin#directory#member","etag":"12345","id":"1234567890","email": "member-out-of-domain@otherexample.com","role": "MEMBER","type": "USER","status": "ACTIVE","delivery_settings": "ALL_MAIL"}}`,
216+ )
198217 }
199218 }))
200219 defer ts.Close()
201220
202221 client := ts.Client()
203− service, err := admin.New(client)
222+ ctx := context.Background()
223+
224+ service, err := admin.NewService(ctx, option.WithHTTPClient(client))
204225 service.BasePath = ts.URL
205226 assert.Equal(t, nil, err)
206227
207− result := userInGroup(service, []string{"group@example.com"}, "member-by-email@example.com")
228+ result := userInGroup(service, []string{"group@example.com"}, "member-in-domain@example.com")
208229 assert.True(t, result)
209230
210− result = userInGroup(service, []string{"group@example.com"}, "member-by-id@example.com")
231+ result = userInGroup(service, []string{"group@example.com"}, "member-out-of-domain@otherexample.com")
211232 assert.True(t, result)
212233
213− result = userInGroup(service, []string{"group@example.com"}, "non-member-by-id@example.com")
234+ result = userInGroup(service, []string{"group@example.com"}, "non-member-in-domain@example.com")
214235 assert.False(t, result)
215236
216− result = userInGroup(service, []string{"group@example.com"}, "non-member-by-email@example.com")
237+ result = userInGroup(service, []string{"group@example.com"}, "non-member-out-of-domain@otherexample.com")
217238 assert.False(t, result)
218239}