git.iko.soy/typeable/oauth2-proxy
on master help sign in
feat: make google-groups argument optional (#3138)

add test cases update documentation refactor code and some cleanup update changelog Signed-off-by: Jan Larwig <jan@larwig.com>

commite75a258299ec3db633450dd48a6df54b38988916 authorSourav Agrawal <146818014+sourava01@users.noreply.github.com> dateThu 24 Jul 2025, 11:25 (1 year ago) parentb905f2c
6 files changed+135−25
CHANGELOG.md+1 docs/docs/configuration/providers/google.md+1−1 pkg/validation/options_test.go+23−3 pkg/validation/providers.go+1−5 providers/google.go+73−16 providers/google_test.go+36
CHANGELOG.md+1
@@ -15,6 +15,7 @@
1515- [#2524](https://github.com/oauth2-proxy/oauth2-proxy/pull/2524) fix: regex substitution for $ signs in upstream path handling before running envsubst (@dashkan / @tuunit)
1616- [#3104](https://github.com/oauth2-proxy/oauth2-proxy/pull/3104) feat(cookie): add feature support for cookie-secret-file (@sandy2008)
1717- [#3055](https://github.com/oauth2-proxy/oauth2-proxy/pull/3055) feat: support non-default authorization request response mode also for OIDC providers (@stieler-it)
18+- [#3138](https://github.com/oauth2-proxy/oauth2-proxy/pull/3138) feat: make google_groups argument optional when using google provider (@sourava01)
1819
1920# V7.10.0
2021
docs/docs/configuration/providers/google.md+1−1
@@ -8,7 +8,7 @@ title: Google (default)
88| Flag | Toml Field | Type | Description | Default |
99| ---------------------------------------------- | -------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------ | -------------------------------------------------- |
1010| `--google-admin-email` | `google_admin_email` | string | the google admin to impersonate for api calls | |
11−| `--google-group` | `google_groups` | string | restrict logins to members of this google group (may be given multiple times). | |
11+| `--google-group` | `google_groups` | string | restrict logins to members of this google group (may be given multiple times). If not specified and service account or default credentials are configured, all user groups will be allowed. | |
1212| `--google-service-account-json` | `google_service_account_json` | string | the path to the service account json credentials | |
1313| `--google-use-application-default-credentials` | `google_use_application_default_credentials` | bool | use application default credentials instead of service account json (i.e. GKE Workload Identity) | |
1414| `--google-target-principal` | `google_target_principal` | bool | the target principal to impersonate when using ADC | defaults to the service account configured for ADC |
pkg/validation/options_test.go+23−3
@@ -55,18 +55,38 @@ func TestNewOptions(t *testing.T) {
5555 assert.Equal(t, expected, err.Error())
5656}
5757
58−func TestGoogleGroupOptions(t *testing.T) {
58+func TestGoogleGroupOptionsWithoutServiceAccountJSON(t *testing.T) {
5959 o := testOptions()
60− o.Providers[0].GoogleConfig.Groups = []string{"googlegroup"}
60+ o.Providers[0].GoogleConfig.AdminEmail = "admin@example.com"
6161 err := Validate(o)
6262 assert.NotEqual(t, nil, err)
6363
6464 expected := errorMsg([]string{
65− "missing setting: google-admin-email",
6665 "missing setting: google-service-account-json or google-use-application-default-credentials"})
6766 assert.Equal(t, expected, err.Error())
6867}
6968
69+func TestGoogleGroupOptionsWithoutAdminEmail(t *testing.T) {
70+ o := testOptions()
71+ o.Providers[0].GoogleConfig.UseApplicationDefaultCredentials = true
72+ err := Validate(o)
73+ assert.NotEqual(t, nil, err)
74+
75+ expected := errorMsg([]string{
76+ "missing setting: google-admin-email"})
77+ assert.Equal(t, expected, err.Error())
78+}
79+
80+func TestGoogleGroupOptionsWithoutGroups(t *testing.T) {
81+ o := testOptions()
82+ // Set admin email and application default credentials but no groups - should still require them
83+ o.Providers[0].GoogleConfig.AdminEmail = "admin@example.com"
84+ o.Providers[0].GoogleConfig.UseApplicationDefaultCredentials = true
85+ err := Validate(o)
86+ // Should pass validation since google-group is now optional
87+ assert.Equal(t, nil, err)
88+}
89+
7090func TestGoogleGroupInvalidFile(t *testing.T) {
7191 o := testOptions()
7292 o.Providers[0].GoogleConfig.Groups = []string{"test_group"}
pkg/validation/providers.go+1−5
@@ -94,18 +94,14 @@ func validateClientSecret(provider options.Provider) []string {
9494func validateGoogleConfig(provider options.Provider) []string {
9595 msgs := []string{}
9696
97− hasGoogleGroups := len(provider.GoogleConfig.Groups) >= 1
9897 hasAdminEmail := provider.GoogleConfig.AdminEmail != ""
9998 hasSAJSON := provider.GoogleConfig.ServiceAccountJSON != ""
10099 useADC := provider.GoogleConfig.UseApplicationDefaultCredentials
101100
102− if !hasGoogleGroups && !hasAdminEmail && !hasSAJSON && !useADC {
101+ if !hasAdminEmail && !hasSAJSON && !useADC {
103102 return msgs
104103 }
105104
106− if !hasGoogleGroups {
107− msgs = append(msgs, "missing setting: google-group")
108− }
109105 if !hasAdminEmail {
110106 msgs = append(msgs, "missing setting: google-admin-email")
111107 }
providers/google.go+73−16
@@ -103,17 +103,24 @@ func NewGoogleProvider(p *ProviderData, opts options.GoogleOptions) (*GoogleProv
103103 }
104104
105105 if opts.ServiceAccountJSON != "" || opts.UseApplicationDefaultCredentials {
106− // Backwards compatibility with `--google-group` option
107− if len(opts.Groups) > 0 {
108− provider.setAllowedGroups(opts.Groups)
109− }
110−
111− provider.setGroupRestriction(opts)
106+ provider.configureGroups(opts)
112107 }
113108
114109 return provider, nil
115110}
116111
112+func (p *GoogleProvider) configureGroups(opts options.GoogleOptions) {
113+ adminService := getAdminService(opts)
114+ // Backwards compatibility with `--google-group` option
115+ if len(opts.Groups) > 0 {
116+ p.setAllowedGroups(opts.Groups)
117+ p.groupValidator = p.setGroupRestriction(opts.Groups, adminService)
118+ return
119+ }
120+
121+ p.groupValidator = p.populateAllGroups(adminService)
122+}
123+
117124func claimsFromIDToken(idToken string) (*claims, error) {
118125
119126 // id_token is a base64 encode ID token payload
@@ -209,18 +216,13 @@ func (p *GoogleProvider) EnrichSession(_ context.Context, s *sessions.SessionSta
209216}
210217
211218// SetGroupRestriction configures the GoogleProvider to restrict access to the
212−// specified group(s). AdminEmail has to be an administrative email on the domain that is
213−// checked. CredentialsFile is the path to a json file containing a Google service
214−// account credentials.
215−//
216−// TODO (@NickMeves) - Unit Test this OR refactor away from groupValidator func
217−func (p *GoogleProvider) setGroupRestriction(opts options.GoogleOptions) {
218− adminService := getAdminService(opts)
219− p.groupValidator = func(s *sessions.SessionState) bool {
219+// specified group(s).
220+func (p *GoogleProvider) setGroupRestriction(groups []string, adminService *admin.Service) func(*sessions.SessionState) bool {
221+ return func(s *sessions.SessionState) bool {
220222 // Reset our saved Groups in case membership changed
221223 // This is used by `Authorize` on every request
222− s.Groups = make([]string, 0, len(opts.Groups))
223− for _, group := range opts.Groups {
224+ s.Groups = make([]string, 0, len(groups))
225+ for _, group := range groups {
224226 if userInGroup(adminService, group, s.Email) {
225227 s.Groups = append(s.Groups, group)
226228 }
@@ -229,6 +231,25 @@ func (p *GoogleProvider) setGroupRestriction(opts options.GoogleOptions) {
229231 }
230232}
231233
234+// populateAllGroups configures the GoogleProvider to allow access with all
235+// groups and populate session with all groups of the user when no specific
236+// groups are configured.
237+func (p *GoogleProvider) populateAllGroups(adminService *admin.Service) func(s *sessions.SessionState) bool {
238+ return func(s *sessions.SessionState) bool {
239+ // Get all groups of the user
240+ groups, err := getUserGroups(adminService, s.Email)
241+ if err != nil {
242+ logger.Errorf("Failed to get user groups for %s: %v", s.Email, err)
243+ s.Groups = []string{}
244+ return true // Allow access even if we can't get groups
245+ }
246+
247+ // Populate session with all user groups
248+ s.Groups = groups
249+ return true // Always allow access when no specific groups are configured
250+ }
251+}
252+
232253// https://developers.google.com/admin-sdk/directory/reference/rest/v1/members/hasMember#authorization-scopes
233254var possibleScopesList = [...]string{
234255 admin.AdminDirectoryGroupMemberReadonlyScope,
@@ -269,6 +290,10 @@ func getOauth2TokenSource(ctx context.Context, opts options.GoogleOptions, scope
269290 return conf.TokenSource(ctx)
270291}
271292
293+// getAdminService retrieves an oauth token for the admin api of Google
294+// AdminEmail has to be an administrative email on the domain that is
295+// checked. CredentialsFile is the path to a json file containing a Google service
296+// account credentials.
272297func getAdminService(opts options.GoogleOptions) *admin.Service {
273298 ctx := context.Background()
274299 var client *http.Client
@@ -339,6 +364,38 @@ func getTargetPrincipal(ctx context.Context, opts options.GoogleOptions) (target
339364 return targetPrincipal
340365}
341366
367+// getUserGroups retrieves all groups that a user is a member of using the Google Admin Directory API
368+func getUserGroups(service *admin.Service, email string) ([]string, error) {
369+ var allGroups []string
370+ var pageToken string
371+
372+ for {
373+ req := service.Groups.List().UserKey(email).MaxResults(200)
374+ if pageToken != "" {
375+ req = req.PageToken(pageToken)
376+ }
377+
378+ groupsResp, err := req.Do()
379+ if err != nil {
380+ return nil, fmt.Errorf("failed to list groups for user %s: %v", email, err)
381+ }
382+
383+ for _, group := range groupsResp.Groups {
384+ if group.Email != "" {
385+ allGroups = append(allGroups, group.Email)
386+ }
387+ }
388+
389+ // Check if there are more pages
390+ if groupsResp.NextPageToken == "" {
391+ break
392+ }
393+ pageToken = groupsResp.NextPageToken
394+ }
395+
396+ return allGroups, nil
397+}
398+
342399func userInGroup(service *admin.Service, group string, email string) bool {
343400 // Use the HasMember API to checking for the user's presence in each group or nested subgroups
344401 req := service.Members.HasMember(group, email)
providers/google_test.go+36
@@ -289,3 +289,39 @@ func TestGoogleProvider_userInGroup(t *testing.T) {
289289 result = userInGroup(service, "group@example.com", "non-member-out-of-domain@otherexample.com")
290290 assert.False(t, result)
291291}
292+
293+func TestGoogleProvider_getUserGroups(t *testing.T) {
294+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
295+ if r.URL.Path == "/admin/directory/v1/groups" && r.URL.Query().Get("userKey") == "test@example.com" {
296+ response := `{
297+ "kind": "admin#directory#groups",
298+ "groups": [
299+ {
300+ "kind": "admin#directory#group",
301+ "id": "1",
302+ "email": "group1@example.com",
303+ "name": "Group 1"
304+ },
305+ {
306+ "kind": "admin#directory#group",
307+ "id": "2",
308+ "email": "group2@example.com",
309+ "name": "Group 2"
310+ }
311+ ]
312+ }`
313+ fmt.Fprintln(w, response)
314+ } else {
315+ http.NotFound(w, r)
316+ }
317+ }))
318+ defer ts.Close()
319+
320+ client := &http.Client{}
321+ adminService, err := admin.NewService(context.Background(), option.WithHTTPClient(client), option.WithEndpoint(ts.URL))
322+ assert.NoError(t, err)
323+
324+ groups, err := getUserGroups(adminService, "test@example.com")
325+ assert.NoError(t, err)
326+ assert.Equal(t, []string{"group1@example.com", "group2@example.com"}, groups)
327+}