From 4b6d7a2428cd5d69207865a8025bd51db85d66fe Mon Sep 17 00:00:00 2001 From: Drew Foehn Date: Sun, 19 Oct 2025 21:27:09 -0400 Subject: [PATCH] feat: added organizationId/employee id as preferred username Signed-off-by: Drew Foehn --- CHANGELOG.md | 1 + docs/docs/configuration/alpha_config.md | 2 + docs/docs/configuration/providers/google.md | 23 ++++-- pkg/apis/options/legacy_options.go | 6 ++ pkg/apis/options/providers.go | 4 + providers/google.go | 83 +++++++++++++++++++-- providers/google_test.go | 80 ++++++++++++++++++++ 7 files changed, 185 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e014aee3..b07f0182 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - [#3228](https://github.com/oauth2-proxy/oauth2-proxy/pull/3228) fix: use GetSecret() in ticket.go makeCookie to respect cookie-secret-file (@stagswtf) - [#3244](https://github.com/oauth2-proxy/oauth2-proxy/pull/3244) chore(deps): upgrade to latest go1.25.3 (@tuunit) - [#3238](https://github.com/oauth2-proxy/oauth2-proxy/pull/3238) chore: Replace pkg/clock with narrowly targeted stub clocks (@dsymonds) +- [3237](https://github.com/oauth2-proxy/oauth2-proxy/pull/3237) - Option to use organization id for preferred username in Google Provider (@pixeldrew) # V7.12.0 diff --git a/docs/docs/configuration/alpha_config.md b/docs/docs/configuration/alpha_config.md index 28645ceb..d30dd951 100644 --- a/docs/docs/configuration/alpha_config.md +++ b/docs/docs/configuration/alpha_config.md @@ -252,6 +252,8 @@ Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h". | `serviceAccountJson` | _string_ | ServiceAccountJSON is the path to the service account json credentials | | `useApplicationDefaultCredentials` | _bool_ | UseApplicationDefaultCredentials is a boolean whether to use Application Default Credentials instead of a ServiceAccountJSON | | `targetPrincipal` | _string_ | TargetPrincipal is the Google Service Account used for Application Default Credentials | +| `useOrganizationId` | _bool_ | UseOrganizationId indicates whether to use the organization ID as the UserName claim | +| `adminApiUserScope` | _string_ | user scope needed for fetching user organization information from admin api, can be one of cloud, user or readonly | ### Header diff --git a/docs/docs/configuration/providers/google.md b/docs/docs/configuration/providers/google.md index ac2a7dfa..0de5bb74 100644 --- a/docs/docs/configuration/providers/google.md +++ b/docs/docs/configuration/providers/google.md @@ -5,13 +5,15 @@ title: Google (default) ## Config Options -| Flag | Toml Field | Type | Description | Default | -| ---------------------------------------------- | -------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------ | -------------------------------------------------- | -| `--google-admin-email` | `google_admin_email` | string | the google admin to impersonate for api calls | | -| `--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. | | -| `--google-service-account-json` | `google_service_account_json` | string | the path to the service account json credentials | | -| `--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) | | -| `--google-target-principal` | `google_target_principal` | bool | the target principal to impersonate when using ADC | defaults to the service account configured for ADC | +| Flag | Toml Field | Type | Description | Default | +|-------------------------------------------------|----------------------------------------------| ------ |---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------| +| `--google-admin-email` | `google_admin_email` | string | the google admin to impersonate for api calls | | +| `--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. | | +| `--google-service-account-json` | `google_service_account_json` | string | the path to the service account json credentials | | +| `--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) | | +| `--google-target-principal` | `google_target_principal` | bool | the target principal to impersonate when using ADC | defaults to the service account configured for ADC | +| `--google-use-organization-id` | `google_use_organization_id` | bool | use organization id as preferred username | false | +| `--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'
| `readonly` | ## Usage @@ -73,3 +75,10 @@ can be leveraged through a feature called Workload Identity. Follow Google's [gu to set up Workload Identity. When deployed outside of GCP, [Workload Identity Federation](https://cloud.google.com/docs/authentication/provide-credentials-adc#wlif) might be an option. + +##### Using Organization ID as Preferred Username (optional) +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. +This requires that the service account used to query the Google Admin SDK has one of the following scopes granted in step 5 above: +- `https://www.googleapis.com/auth/admin.directory.user.readonly`, +- `https://www.googleapis.com/auth/admin.directory.user` +- `https://www.googleapis.com/auth/cloud-platform` diff --git a/pkg/apis/options/legacy_options.go b/pkg/apis/options/legacy_options.go index 12975225..f5e26489 100644 --- a/pkg/apis/options/legacy_options.go +++ b/pkg/apis/options/legacy_options.go @@ -510,6 +510,8 @@ type LegacyProvider struct { GoogleServiceAccountJSON string `flag:"google-service-account-json" cfg:"google_service_account_json"` GoogleUseApplicationDefaultCredentials bool `flag:"google-use-application-default-credentials" cfg:"google_use_application_default_credentials"` GoogleTargetPrincipal string `flag:"google-target-principal" cfg:"google_target_principal"` + GoogleUseOrganizationId bool `flag:"google-use-organization-id" cfg:"google_use_organization_id"` + GoogleAdminApiUserScope string `flag:"google-admin-api-user-scope" cfg:"google_admin_api_user_scope"` // These options allow for other providers besides Google, with // potential overrides. @@ -623,6 +625,8 @@ func legacyGoogleFlagSet() *pflag.FlagSet { flagSet.String("google-service-account-json", "", "the path to the service account json credentials") flagSet.String("google-use-application-default-credentials", "", "use application default credentials instead of service account json (i.e. GKE Workload Identity)") flagSet.String("google-target-principal", "", "the target principal to impersonate when using ADC") + flagSet.String("google-use-organization-id", "", "use organization id as preferred username") + flagSet.String("google-admin-api-user-scope", "", "authorization scope required to call users.get, can be one of ") return flagSet } @@ -770,6 +774,8 @@ func (l *LegacyProvider) convert() (Providers, error) { ServiceAccountJSON: l.GoogleServiceAccountJSON, UseApplicationDefaultCredentials: l.GoogleUseApplicationDefaultCredentials, TargetPrincipal: l.GoogleTargetPrincipal, + UseOrganizationId: l.GoogleUseOrganizationId, + AdminApiUserScope: l.GoogleAdminApiUserScope, } case "entra-id": provider.MicrosoftEntraIDConfig = MicrosoftEntraIDOptions{ diff --git a/pkg/apis/options/providers.go b/pkg/apis/options/providers.go index 0f254575..dbffc00d 100644 --- a/pkg/apis/options/providers.go +++ b/pkg/apis/options/providers.go @@ -230,6 +230,10 @@ type GoogleOptions struct { UseApplicationDefaultCredentials bool `json:"useApplicationDefaultCredentials,omitempty"` // TargetPrincipal is the Google Service Account used for Application Default Credentials TargetPrincipal string `json:"targetPrincipal,omitempty"` + // UseOrganizationId indicates whether to use the organization ID as the UserName claim + UseOrganizationId bool `json:"useOrganizationId,omitempty"` + // admin scope needed for fetching user organization information from admin api, can be one of cloud, user or defaults to readonly + AdminApiUserScope string `json:"adminApiUserScope,omitempty"` } type OIDCOptions struct { diff --git a/providers/google.go b/providers/google.go index 097e3567..00580713 100644 --- a/providers/google.go +++ b/providers/google.go @@ -40,6 +40,8 @@ type GoogleProvider struct { // Refresh. `Authorize` uses the results of this saved in `session.Groups` // Since it is called on every request. groupValidator func(*sessions.SessionState) bool + + setPreferredUsername func(s *sessions.SessionState) error } var _ Provider = (*GoogleProvider)(nil) @@ -100,17 +102,59 @@ func NewGoogleProvider(p *ProviderData, opts options.GoogleOptions) (*GoogleProv groupValidator: func(*sessions.SessionState) bool { return true }, + + setPreferredUsername: func(state *sessions.SessionState) error { + return nil + }, } - if opts.ServiceAccountJSON != "" || opts.UseApplicationDefaultCredentials { - provider.configureGroups(opts) - } + if opts.UseOrganizationId || opts.ServiceAccountJSON != "" || opts.UseApplicationDefaultCredentials { + // reuse admin service to avoid multiple calls for token + var adminService *admin.Service + + if opts.UseOrganizationId { + // add user scopes to admin api + userScope := getAdminApiUserScope(opts.AdminApiUserScope) + for index, scope := range possibleScopesList { + possibleScopesList[index] = scope + " " + userScope + } + + adminService = getAdminService(opts) + + provider.setPreferredUsername = func(s *sessions.SessionState) error { + userName, err := getUserInfo(adminService, s.Email) + if err != nil { + return err + } + s.PreferredUsername = userName + return nil + } + } + + if opts.ServiceAccountJSON != "" || opts.UseApplicationDefaultCredentials { + if adminService == nil { + adminService = getAdminService(opts) + } + provider.configureGroups(opts, adminService) + } + + } return provider, nil } -func (p *GoogleProvider) configureGroups(opts options.GoogleOptions) { - adminService := getAdminService(opts) +// by default can be readonly user scope +func getAdminApiUserScope(adminApiUserScope string) string { + switch adminApiUserScope { + case "cloud": + return admin.CloudPlatformScope + case "user": + return admin.AdminDirectoryUserScope + } + return admin.AdminDirectoryUserReadonlyScope +} + +func (p *GoogleProvider) configureGroups(opts options.GoogleOptions, adminService *admin.Service) { // Backwards compatibility with `--google-group` option if len(opts.Groups) > 0 { p.setAllowedGroups(opts.Groups) @@ -204,6 +248,7 @@ func (p *GoogleProvider) Redeem(ctx context.Context, redirectURL, code, codeVeri // EnrichSession checks the listed Google Groups configured and adds any // that the user is a member of to session.Groups. +// if preferred username is configured to be organization ID, it sets that as well. func (p *GoogleProvider) EnrichSession(_ context.Context, s *sessions.SessionState) error { // TODO (@NickMeves) - Move to pure EnrichSession logic and stop // reusing legacy `groupValidator`. @@ -212,7 +257,7 @@ func (p *GoogleProvider) EnrichSession(_ context.Context, s *sessions.SessionSta // populating logic. p.groupValidator(s) - return nil + return p.setPreferredUsername(s) } // SetGroupRestriction configures the GoogleProvider to restrict access to the @@ -262,7 +307,7 @@ func getOauth2TokenSource(ctx context.Context, opts options.GoogleOptions, scope if opts.UseApplicationDefaultCredentials { ts, err := impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{ TargetPrincipal: getTargetPrincipal(ctx, opts), - Scopes: []string{scope}, + Scopes: strings.Split(scope, " "), Subject: opts.AdminEmail, }) if err != nil { @@ -364,6 +409,30 @@ func getTargetPrincipal(ctx context.Context, opts options.GoogleOptions) (target return targetPrincipal } +func getUserInfo(service *admin.Service, email string) (string, error) { + req := service.Users.Get(email) + user, err := req.Do() + if err != nil { + return "", fmt.Errorf("failed to get user details for %s: %v", email, err) + } + + ext, _ := user.ExternalIds.([]interface{}) + for _, v := range ext { + m, _ := v.(map[string]interface{}) + if m == nil { + continue + } + if t, _ := m["type"].(string); t != "organization" { + continue + } + if val, _ := m["value"].(string); val != "" { + return val, nil + } + } + + return "", fmt.Errorf("failed to get organization id for %s", email) +} + // getUserGroups retrieves all groups that a user is a member of using the Google Admin Directory API func getUserGroups(service *admin.Service, email string) ([]string, error) { var allGroups []string diff --git a/providers/google_test.go b/providers/google_test.go index dc061203..f168e31c 100644 --- a/providers/google_test.go +++ b/providers/google_test.go @@ -325,3 +325,83 @@ func TestGoogleProvider_getUserGroups(t *testing.T) { assert.NoError(t, err) assert.Equal(t, []string{"group1@example.com", "group2@example.com"}, groups) } + +func TestGoogleProvider_getUserInfo(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/admin/directory/v1/users/test@example.com" { + response := `{ + "kind": "admin#directory#user", + "id": "", + "etag": "\"\"", + "primaryEmail": "test@example.com", + "name": { + "givenName": "Test", + "familyName": "User", + "fullName": "Test User" + }, + "isAdmin": false, + "isDelegatedAdmin": false, + "lastLoginTime": "", + "creationTime": "", + "agreedToTerms": true, + "suspended": false, + "archived": false, + "changePasswordAtNextLogin": false, + "ipWhitelisted": false, + "emails": [ + { + "address": "test@example.com", + "primary": true + } + ], + "externalIds": [ + { + "value": "test.user", + "type": "organization" + } + ], + "organizations": [ + ], + "phones": [ + ], + "languages": [ + { + "languageCode": "en", + "preference": "preferred" + } + ], + "aliases": [ + "test.user@example.com" + ], + "nonEditableAliases": [ + "test.user@example.com" + ], + "gender": { + "type": "male" + }, + "customerId": "", + "orgUnitPath": "/", + "isMailboxSetup": true, + "isEnrolledIn2Sv": true, + "isEnforcedIn2Sv": false, + "includeInGlobalAddressList": true, + "thumbnailPhotoUrl": "", + "thumbnailPhotoEtag": "\"\"", + "recoveryEmail": "test.user@gmail.com", + "recoveryPhone": "+55555555555" + }` + fmt.Fprintln(w, response) + } else { + http.NotFound(w, r) + } + })) + defer ts.Close() + + client := &http.Client{} + adminService, err := admin.NewService(context.Background(), option.WithHTTPClient(client), option.WithEndpoint(ts.URL)) + assert.NoError(t, err) + + info, err := getUserInfo(adminService, "test@example.com") + assert.NoError(t, err) + assert.Equal(t, "test.user", info) +}