git.iko.soy/typeable/oauth2-proxy
on master help sign in
feat: added organizationId/employee id as preferred username (#3237)

Signed-off-by: Drew Foehn <drew@pixelburn.net> Signed-off-by: Jan Larwig <jan@larwig.com>

commit87827435ce895a8d7c6dfa03a879dd3399d36cce authorDrew Foehn <pixeldrew@users.noreply.github.com> dateFri 7 Nov 2025, 13:44 (1 year ago) parent110d51d
7 files changed+185−14
CHANGELOG.md+1 docs/docs/configuration/alpha_config.md+2 docs/docs/configuration/providers/google.md+16−7 pkg/apis/options/legacy_options.go+6 pkg/apis/options/providers.go+4 providers/google.go+76−7 providers/google_test.go+80
CHANGELOG.md+1
@@ -11,6 +11,7 @@
1111- [#3228](https://github.com/oauth2-proxy/oauth2-proxy/pull/3228) fix: use GetSecret() in ticket.go makeCookie to respect cookie-secret-file (@stagswtf)
1212- [#3244](https://github.com/oauth2-proxy/oauth2-proxy/pull/3244) chore(deps): upgrade to latest go1.25.3 (@tuunit)
1313- [#3238](https://github.com/oauth2-proxy/oauth2-proxy/pull/3238) chore: Replace pkg/clock with narrowly targeted stub clocks (@dsymonds)
14+- [#3237](https://github.com/oauth2-proxy/oauth2-proxy/pull/3237) - feat: add option to use organization id for preferred username in Google Provider (@pixeldrew)
1415
1516# V7.12.0
1617
docs/docs/configuration/alpha_config.md+2
@@ -252,6 +252,8 @@ Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h".
252252| `serviceAccountJson` | _string_ | ServiceAccountJSON is the path to the service account json credentials |
253253| `useApplicationDefaultCredentials` | _bool_ | UseApplicationDefaultCredentials is a boolean whether to use Application Default Credentials instead of a ServiceAccountJSON |
254254| `targetPrincipal` | _string_ | TargetPrincipal is the Google Service Account used for Application Default Credentials |
255+| `useOrganizationID` | _bool_ | UseOrganizationId indicates whether to use the organization ID as the UserName claim |
256+| `adminAPIUserScope` | _string_ | admin scope needed for fetching user organization information from admin api, can be one of cloud, user or defaults to readonly |
255257
256258### Header
257259
docs/docs/configuration/providers/google.md+16−7
@@ -5,13 +5,15 @@ title: Google (default)
55
66## Config Options
77
8−| Flag | Toml Field | Type | Description | Default |
9−| ---------------------------------------------- | -------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------ | -------------------------------------------------- |
10−| `--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). If not specified and service account or default credentials are configured, all user groups will be allowed. | |
12−| `--google-service-account-json` | `google_service_account_json` | string | the path to the service account json credentials | |
13−| `--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) | |
14−| `--google-target-principal` | `google_target_principal` | bool | the target principal to impersonate when using ADC | defaults to the service account configured for ADC |
8+| Flag | Toml Field | Type | Description | Default |
9+|-------------------------------------------------|----------------------------------------------| ------ |---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------|
10+| `--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). If not specified and service account or default credentials are configured, all user groups will be allowed. | |
12+| `--google-service-account-json` | `google_service_account_json` | string | the path to the service account json credentials | |
13+| `--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) | |
14+| `--google-target-principal` | `google_target_principal` | bool | the target principal to impersonate when using ADC | defaults to the service account configured for ADC |
15+| `--google-use-organization-id` | `google_use_organization_id` | bool | use organization id as preferred username | false |
16+| `--google-admin-api-user-scope` | `google_admin_api_user_scope` | string | the OAuth scope to use when querying the Google Admin SDK for organization id, can be 'readonly', 'user' or 'cloud'<br/> | `readonly` |
1517
1618## Usage
1719
@@ -73,3 +75,10 @@ can be leveraged through a feature called Workload Identity. Follow Google's [gu
7375to set up Workload Identity.
7476
7577When deployed outside of GCP, [Workload Identity Federation](https://cloud.google.com/docs/authentication/provide-credentials-adc#wlif) might be an option.
78+
79+##### Using Organization ID as Preferred Username (optional)
80+By default, the google provider uses the google id as username. If you would like to use an organization id instead, you can set the `google-use-organization-id` flag to true.
81+This requires that the service account used to query the Google Admin SDK has one of the following scopes granted in step 5 above:
82+- `https://www.googleapis.com/auth/admin.directory.user.readonly`,
83+- `https://www.googleapis.com/auth/admin.directory.user`
84+- `https://www.googleapis.com/auth/cloud-platform`
pkg/apis/options/legacy_options.go+6
@@ -510,6 +510,8 @@ type LegacyProvider struct {
510510 GoogleServiceAccountJSON string `flag:"google-service-account-json" cfg:"google_service_account_json"`
511511 GoogleUseApplicationDefaultCredentials bool `flag:"google-use-application-default-credentials" cfg:"google_use_application_default_credentials"`
512512 GoogleTargetPrincipal string `flag:"google-target-principal" cfg:"google_target_principal"`
513+ GoogleUseOrganizationID bool `flag:"google-use-organization-id" cfg:"google_use_organization_id"`
514+ GoogleAdminAPIUserScope string `flag:"google-admin-api-user-scope" cfg:"google_admin_api_user_scope"`
513515
514516 // These options allow for other providers besides Google, with
515517 // potential overrides.
@@ -623,6 +625,8 @@ func legacyGoogleFlagSet() *pflag.FlagSet {
623625 flagSet.String("google-service-account-json", "", "the path to the service account json credentials")
624626 flagSet.String("google-use-application-default-credentials", "", "use application default credentials instead of service account json (i.e. GKE Workload Identity)")
625627 flagSet.String("google-target-principal", "", "the target principal to impersonate when using ADC")
628+ flagSet.String("google-use-organization-id", "", "use organization id as preferred username")
629+ flagSet.String("google-admin-api-user-scope", "", "authorization scope required to call users.get, can be one of ")
626630
627631 return flagSet
628632}
@@ -770,6 +774,8 @@ func (l *LegacyProvider) convert() (Providers, error) {
770774 ServiceAccountJSON: l.GoogleServiceAccountJSON,
771775 UseApplicationDefaultCredentials: l.GoogleUseApplicationDefaultCredentials,
772776 TargetPrincipal: l.GoogleTargetPrincipal,
777+ UseOrganizationID: l.GoogleUseOrganizationID,
778+ AdminAPIUserScope: l.GoogleAdminAPIUserScope,
773779 }
774780 case "entra-id":
775781 provider.MicrosoftEntraIDConfig = MicrosoftEntraIDOptions{
pkg/apis/options/providers.go+4
@@ -230,6 +230,10 @@ type GoogleOptions struct {
230230 UseApplicationDefaultCredentials bool `json:"useApplicationDefaultCredentials,omitempty"`
231231 // TargetPrincipal is the Google Service Account used for Application Default Credentials
232232 TargetPrincipal string `json:"targetPrincipal,omitempty"`
233+ // UseOrganizationId indicates whether to use the organization ID as the UserName claim
234+ UseOrganizationID bool `json:"useOrganizationID,omitempty"`
235+ // admin scope needed for fetching user organization information from admin api, can be one of cloud, user or defaults to readonly
236+ AdminAPIUserScope string `json:"adminAPIUserScope,omitempty"`
233237}
234238
235239type OIDCOptions struct {
providers/google.go+76−7
@@ -40,6 +40,8 @@ type GoogleProvider struct {
4040 // Refresh. `Authorize` uses the results of this saved in `session.Groups`
4141 // Since it is called on every request.
4242 groupValidator func(*sessions.SessionState) bool
43+
44+ setPreferredUsername func(s *sessions.SessionState) error
4345}
4446
4547var _ Provider = (*GoogleProvider)(nil)
@@ -100,17 +102,59 @@ func NewGoogleProvider(p *ProviderData, opts options.GoogleOptions) (*GoogleProv
100102 groupValidator: func(*sessions.SessionState) bool {
101103 return true
102104 },
103− }
104105
105− if opts.ServiceAccountJSON != "" || opts.UseApplicationDefaultCredentials {
106− provider.configureGroups(opts)
106+ setPreferredUsername: func(_ *sessions.SessionState) error {
107+ return nil
108+ },
107109 }
108110
111+ if opts.UseOrganizationID || opts.ServiceAccountJSON != "" || opts.UseApplicationDefaultCredentials {
112+
113+ // reuse admin service to avoid multiple calls for token
114+ var adminService *admin.Service
115+
116+ if opts.UseOrganizationID {
117+ // add user scopes to admin api
118+ userScope := getAdminAPIUserScope(opts.AdminAPIUserScope)
119+ for index, scope := range possibleScopesList {
120+ possibleScopesList[index] = scope + " " + userScope
121+ }
122+
123+ adminService = getAdminService(opts)
124+
125+ provider.setPreferredUsername = func(s *sessions.SessionState) error {
126+ userName, err := getUserInfo(adminService, s.Email)
127+ if err != nil {
128+ return err
129+ }
130+ s.PreferredUsername = userName
131+ return nil
132+ }
133+ }
134+
135+ if opts.ServiceAccountJSON != "" || opts.UseApplicationDefaultCredentials {
136+ if adminService == nil {
137+ adminService = getAdminService(opts)
138+ }
139+ provider.configureGroups(opts, adminService)
140+ }
141+
142+ }
109143 return provider, nil
110144}
111145
112−func (p *GoogleProvider) configureGroups(opts options.GoogleOptions) {
113− adminService := getAdminService(opts)
146+// by default can be readonly user scope
147+func getAdminAPIUserScope(scope string) string {
148+ switch scope {
149+ case "cloud":
150+ return admin.CloudPlatformScope
151+ case "user":
152+ return admin.AdminDirectoryUserScope
153+ }
154+ return admin.AdminDirectoryUserReadonlyScope
155+}
156+
157+func (p *GoogleProvider) configureGroups(opts options.GoogleOptions, adminService *admin.Service) {
114158 // Backwards compatibility with `--google-group` option
115159 if len(opts.Groups) > 0 {
116160 p.setAllowedGroups(opts.Groups)
@@ -204,6 +248,7 @@ func (p *GoogleProvider) Redeem(ctx context.Context, redirectURL, code, codeVeri
204248
205249// EnrichSession checks the listed Google Groups configured and adds any
206250// that the user is a member of to session.Groups.
251+// if preferred username is configured to be organization ID, it sets that as well.
207252func (p *GoogleProvider) EnrichSession(_ context.Context, s *sessions.SessionState) error {
208253 // TODO (@NickMeves) - Move to pure EnrichSession logic and stop
209254 // reusing legacy `groupValidator`.
@@ -212,7 +257,7 @@ func (p *GoogleProvider) EnrichSession(_ context.Context, s *sessions.SessionSta
212257 // populating logic.
213258 p.groupValidator(s)
214259
215− return nil
260+ return p.setPreferredUsername(s)
216261}
217262
218263// SetGroupRestriction configures the GoogleProvider to restrict access to the
@@ -262,7 +307,7 @@ func getOauth2TokenSource(ctx context.Context, opts options.GoogleOptions, scope
262307 if opts.UseApplicationDefaultCredentials {
263308 ts, err := impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{
264309 TargetPrincipal: getTargetPrincipal(ctx, opts),
265− Scopes: []string{scope},
310+ Scopes: strings.Split(scope, " "),
266311 Subject: opts.AdminEmail,
267312 })
268313 if err != nil {
@@ -364,6 +409,30 @@ func getTargetPrincipal(ctx context.Context, opts options.GoogleOptions) (target
364409 return targetPrincipal
365410}
366411
412+func getUserInfo(service *admin.Service, email string) (string, error) {
413+ req := service.Users.Get(email)
414+ user, err := req.Do()
415+ if err != nil {
416+ return "", fmt.Errorf("failed to get user details for %s: %v", email, err)
417+ }
418+
419+ ext, _ := user.ExternalIds.([]interface{})
420+ for _, v := range ext {
421+ m, _ := v.(map[string]interface{})
422+ if m == nil {
423+ continue
424+ }
425+ if t, _ := m["type"].(string); t != "organization" {
426+ continue
427+ }
428+ if val, _ := m["value"].(string); val != "" {
429+ return val, nil
430+ }
431+ }
432+
433+ return "", fmt.Errorf("failed to get organization id for %s", email)
434+}
435+
367436// getUserGroups retrieves all groups that a user is a member of using the Google Admin Directory API
368437func getUserGroups(service *admin.Service, email string) ([]string, error) {
369438 var allGroups []string
providers/google_test.go+80
@@ -325,3 +325,83 @@ func TestGoogleProvider_getUserGroups(t *testing.T) {
325325 assert.NoError(t, err)
326326 assert.Equal(t, []string{"group1@example.com", "group2@example.com"}, groups)
327327}
328+
329+func TestGoogleProvider_getUserInfo(t *testing.T) {
330+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
331+ if r.URL.Path == "/admin/directory/v1/users/test@example.com" {
332+ response := `{
333+ "kind": "admin#directory#user",
334+ "id": "",
335+ "etag": "\"\"",
336+ "primaryEmail": "test@example.com",
337+ "name": {
338+ "givenName": "Test",
339+ "familyName": "User",
340+ "fullName": "Test User"
341+ },
342+ "isAdmin": false,
343+ "isDelegatedAdmin": false,
344+ "lastLoginTime": "",
345+ "creationTime": "",
346+ "agreedToTerms": true,
347+ "suspended": false,
348+ "archived": false,
349+ "changePasswordAtNextLogin": false,
350+ "ipWhitelisted": false,
351+ "emails": [
352+ {
353+ "address": "test@example.com",
354+ "primary": true
355+ }
356+ ],
357+ "externalIds": [
358+ {
359+ "value": "test.user",
360+ "type": "organization"
361+ }
362+ ],
363+ "organizations": [
364+ ],
365+ "phones": [
366+ ],
367+ "languages": [
368+ {
369+ "languageCode": "en",
370+ "preference": "preferred"
371+ }
372+ ],
373+ "aliases": [
374+ "test.user@example.com"
375+ ],
376+ "nonEditableAliases": [
377+ "test.user@example.com"
378+ ],
379+ "gender": {
380+ "type": "male"
381+ },
382+ "customerId": "",
383+ "orgUnitPath": "/",
384+ "isMailboxSetup": true,
385+ "isEnrolledIn2Sv": true,
386+ "isEnforcedIn2Sv": false,
387+ "includeInGlobalAddressList": true,
388+ "thumbnailPhotoUrl": "",
389+ "thumbnailPhotoEtag": "\"\"",
390+ "recoveryEmail": "test.user@gmail.com",
391+ "recoveryPhone": "+55555555555"
392+ }`
393+ fmt.Fprintln(w, response)
394+ } else {
395+ http.NotFound(w, r)
396+ }
397+ }))
398+ defer ts.Close()
399+
400+ client := &http.Client{}
401+ adminService, err := admin.NewService(context.Background(), option.WithHTTPClient(client), option.WithEndpoint(ts.URL))
402+ assert.NoError(t, err)
403+
404+ info, err := getUserInfo(adminService, "test@example.com")
405+ assert.NoError(t, err)
406+ assert.Equal(t, "test.user", info)
407+}