Compare commits

...

1 Commits

Author SHA1 Message Date
Drew Foehn 1a32885dcf feat: added organizationId/employee id as preferred username
Signed-off-by: Drew Foehn <drew@pixelburn.net>
2025-10-25 13:42:33 -04:00
6 changed files with 170 additions and 10 deletions

View File

@ -8,6 +8,8 @@
## 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
## Release Highlights

View File

@ -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'<br/> | `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`

View File

@ -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{

View File

@ -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"`
// 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 {

View File

@ -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,15 +102,46 @@ 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 {
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
}
// 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 := getAdminService(opts)
// 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
// 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 +246,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
@ -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
var possibleScopesList = [...]string{
var possibleScopesList = []string{
admin.AdminDirectoryGroupMemberReadonlyScope, // least permissive combination
admin.AdminDirectoryGroupMemberReadonlyScope,
admin.AdminDirectoryGroupReadonlyScope,
admin.AdminDirectoryGroupMemberScope,
@ -262,7 +297,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 +399,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

View File

@ -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)
}