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)
- [#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

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 |
| `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

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

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,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

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