feat: added organizationId/employee id as preferred username

Signed-off-by: Drew Foehn <drew@pixelburn.net>
This commit is contained in:
Drew Foehn 2025-10-19 21:27:09 -04:00
parent 9168731c7a
commit 1a32885dcf
6 changed files with 170 additions and 10 deletions

View File

@ -8,6 +8,8 @@
## Changes since v7.12.0 ## Changes since v7.12.0
- [3237](https://github.com/oauth2-proxy/oauth2-proxy/pull/3237) - Option to use organization id for preferred username in Google Provider
# V7.12.0 # V7.12.0
## Release Highlights ## Release Highlights

View File

@ -5,13 +5,15 @@ title: Google (default)
## Config Options ## Config Options
| Flag | Toml Field | Type | Description | Default | | Flag | Toml Field | Type | Description | Default |
| ---------------------------------------------- | -------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------ | -------------------------------------------------- | |-------------------------------------------------|----------------------------------------------| ------ |---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------|
| `--google-admin-email` | `google_admin_email` | string | the google admin to impersonate for api calls | | | `--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-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-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-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-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'<br/> | `readonly` |
## Usage ## Usage
@ -73,3 +75,10 @@ can be leveraged through a feature called Workload Identity. Follow Google's [gu
to set up Workload Identity. 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. 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`

View File

@ -510,6 +510,8 @@ type LegacyProvider struct {
GoogleServiceAccountJSON string `flag:"google-service-account-json" cfg:"google_service_account_json"` 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"` GoogleUseApplicationDefaultCredentials bool `flag:"google-use-application-default-credentials" cfg:"google_use_application_default_credentials"`
GoogleTargetPrincipal string `flag:"google-target-principal" cfg:"google_target_principal"` 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 // These options allow for other providers besides Google, with
// potential overrides. // 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-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-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-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 return flagSet
} }
@ -770,6 +774,8 @@ func (l *LegacyProvider) convert() (Providers, error) {
ServiceAccountJSON: l.GoogleServiceAccountJSON, ServiceAccountJSON: l.GoogleServiceAccountJSON,
UseApplicationDefaultCredentials: l.GoogleUseApplicationDefaultCredentials, UseApplicationDefaultCredentials: l.GoogleUseApplicationDefaultCredentials,
TargetPrincipal: l.GoogleTargetPrincipal, TargetPrincipal: l.GoogleTargetPrincipal,
UseOrganizationId: l.GoogleUseOrganizationId,
AdminApiUserScope: l.GoogleAdminApiUserScope,
} }
case "entra-id": case "entra-id":
provider.MicrosoftEntraIDConfig = MicrosoftEntraIDOptions{ provider.MicrosoftEntraIDConfig = MicrosoftEntraIDOptions{

View File

@ -230,6 +230,10 @@ type GoogleOptions struct {
UseApplicationDefaultCredentials bool `json:"useApplicationDefaultCredentials,omitempty"` UseApplicationDefaultCredentials bool `json:"useApplicationDefaultCredentials,omitempty"`
// TargetPrincipal is the Google Service Account used for Application Default Credentials // TargetPrincipal is the Google Service Account used for Application Default Credentials
TargetPrincipal string `json:"targetPrincipal,omitempty"` TargetPrincipal string `json:"targetPrincipal,omitempty"`
// UseOrganizationId indicates whether to use the organization ID as the UserName claim
UseOrganizationId bool `json:"useOrganizationId,omitempty"`
// user scope needed for fetching user organization information from admin api, can be one of cloud user or readonly
AdminApiUserScope string `json:"adminApiUserScope,omitempty"`
} }
type OIDCOptions struct { type OIDCOptions struct {

View File

@ -40,6 +40,8 @@ type GoogleProvider struct {
// Refresh. `Authorize` uses the results of this saved in `session.Groups` // Refresh. `Authorize` uses the results of this saved in `session.Groups`
// Since it is called on every request. // Since it is called on every request.
groupValidator func(*sessions.SessionState) bool groupValidator func(*sessions.SessionState) bool
setPreferredUsername func(s *sessions.SessionState) error
} }
var _ Provider = (*GoogleProvider)(nil) var _ Provider = (*GoogleProvider)(nil)
@ -100,15 +102,46 @@ func NewGoogleProvider(p *ProviderData, opts options.GoogleOptions) (*GoogleProv
groupValidator: func(*sessions.SessionState) bool { groupValidator: func(*sessions.SessionState) bool {
return true return true
}, },
setPreferredUsername: func(state *sessions.SessionState) error {
return nil
},
} }
if opts.ServiceAccountJSON != "" || opts.UseApplicationDefaultCredentials { if opts.ServiceAccountJSON != "" || opts.UseApplicationDefaultCredentials {
provider.configureGroups(opts) provider.configureGroups(opts)
} }
if opts.UseOrganizationId {
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
}
}
return provider, nil return provider, nil
} }
// 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) { func (p *GoogleProvider) configureGroups(opts options.GoogleOptions) {
adminService := getAdminService(opts) adminService := getAdminService(opts)
// Backwards compatibility with `--google-group` option // Backwards compatibility with `--google-group` option
@ -204,6 +237,7 @@ func (p *GoogleProvider) Redeem(ctx context.Context, redirectURL, code, codeVeri
// EnrichSession checks the listed Google Groups configured and adds any // EnrichSession checks the listed Google Groups configured and adds any
// that the user is a member of to session.Groups. // 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 { func (p *GoogleProvider) EnrichSession(_ context.Context, s *sessions.SessionState) error {
// TODO (@NickMeves) - Move to pure EnrichSession logic and stop // TODO (@NickMeves) - Move to pure EnrichSession logic and stop
// reusing legacy `groupValidator`. // reusing legacy `groupValidator`.
@ -212,7 +246,7 @@ func (p *GoogleProvider) EnrichSession(_ context.Context, s *sessions.SessionSta
// populating logic. // populating logic.
p.groupValidator(s) p.groupValidator(s)
return nil return p.setPreferredUsername(s)
} }
// SetGroupRestriction configures the GoogleProvider to restrict access to the // SetGroupRestriction configures the GoogleProvider to restrict access to the
@ -251,7 +285,8 @@ func (p *GoogleProvider) populateAllGroups(adminService *admin.Service) func(s *
} }
// https://developers.google.com/admin-sdk/directory/reference/rest/v1/members/hasMember#authorization-scopes // https://developers.google.com/admin-sdk/directory/reference/rest/v1/members/hasMember#authorization-scopes
var possibleScopesList = [...]string{ var possibleScopesList = []string{
admin.AdminDirectoryGroupMemberReadonlyScope, // least permissive combination
admin.AdminDirectoryGroupMemberReadonlyScope, admin.AdminDirectoryGroupMemberReadonlyScope,
admin.AdminDirectoryGroupReadonlyScope, admin.AdminDirectoryGroupReadonlyScope,
admin.AdminDirectoryGroupMemberScope, admin.AdminDirectoryGroupMemberScope,
@ -262,7 +297,7 @@ func getOauth2TokenSource(ctx context.Context, opts options.GoogleOptions, scope
if opts.UseApplicationDefaultCredentials { if opts.UseApplicationDefaultCredentials {
ts, err := impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{ ts, err := impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{
TargetPrincipal: getTargetPrincipal(ctx, opts), TargetPrincipal: getTargetPrincipal(ctx, opts),
Scopes: []string{scope}, Scopes: strings.Split(scope, " "),
Subject: opts.AdminEmail, Subject: opts.AdminEmail,
}) })
if err != nil { if err != nil {
@ -364,6 +399,30 @@ func getTargetPrincipal(ctx context.Context, opts options.GoogleOptions) (target
return targetPrincipal 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 // 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) { func getUserGroups(service *admin.Service, email string) ([]string, error) {
var allGroups []string var allGroups []string

View File

@ -325,3 +325,83 @@ func TestGoogleProvider_getUserGroups(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, []string{"group1@example.com", "group2@example.com"}, groups) 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)
}