This commit is contained in:
Drew Foehn 2025-10-28 14:24:41 +00:00 committed by GitHub
commit bc089d1691
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 185 additions and 14 deletions

View File

@ -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) - [#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) - [#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) - [#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 # V7.12.0

View File

@ -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 | | `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 | | `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 | | `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 ### Header

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"`
// 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 { 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,17 +102,59 @@ 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.UseOrganizationId || opts.ServiceAccountJSON != "" || opts.UseApplicationDefaultCredentials {
provider.configureGroups(opts)
}
// 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 return provider, nil
} }
func (p *GoogleProvider) configureGroups(opts options.GoogleOptions) { // by default can be readonly user scope
adminService := getAdminService(opts) 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 // Backwards compatibility with `--google-group` option
if len(opts.Groups) > 0 { if len(opts.Groups) > 0 {
p.setAllowedGroups(opts.Groups) 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 // 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 +257,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
@ -262,7 +307,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 +409,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)
}