This commit is contained in:
Sourav Agrawal 2026-01-17 22:17:07 +05:30 committed by GitHub
commit 07e84bab36
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 438 additions and 354 deletions

View File

@ -8,6 +8,8 @@
## Changes since v7.14.1
- [#3294](https://github.com/oauth2-proxy/oauth2-proxy/pull/3294) feat: update the Google provider to use OIDC (@sourava01)
# V7.14.1
## Release Highlights

View File

@ -3,6 +3,8 @@ id: google
title: Google (default)
---
The Google provider uses OpenID Connect (OIDC) for authentication via Google's JWKS endpoint.
## Config Options
| Flag | Toml Field | Type | Description | Default |
@ -37,7 +39,16 @@ For Google, the registration steps are:
It's recommended to refresh sessions on a short interval (1h) with `cookie-refresh` setting which validates that the
account is still authorized.
#### Restrict auth to specific Google groups on your domain. (optional)
### Scopes
The Google provider requires the `openid` scope for OIDC ID token verification. If you configure a custom `--scope` without `openid`, it will be automatically appended to the custom scope.
Default scope: `openid email profile`
### Groups Claim
Google does not support a `groups` claim in ID tokens. To include groups information in the session, this provider needs access to the [Google Admin Directory API](https://developers.google.com/admin-sdk/directory).
To configure this:
1. Create a [service account](https://developers.google.com/identity/protocols/oauth2/service-account) and configure it
to use [Application Default Credentials / Workload Identity / Workload Identity Federation (recommended)](#using-application-default-credentials-adc--workload-identity--workload-identity-federation-recommended) or,
@ -50,6 +61,7 @@ account is still authorized.
```
https://www.googleapis.com/auth/admin.directory.group.member.readonly
https://www.googleapis.com/auth/admin.directory.group.readonly
```
6. Follow the steps on https://support.google.com/a/answer/60757 to enable Admin API access.
@ -76,9 +88,14 @@ 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.
### Preferred Username
By default, the Google provider extracts the `name` claim from the ID token as the preferred username.
##### 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`
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

@ -1,24 +1,19 @@
package providers
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"cloud.google.com/go/compute/metadata"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util/ptr"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
@ -28,11 +23,19 @@ import (
"google.golang.org/api/option"
)
// GoogleProvider represents an Google based Identity Provider
// GoogleProvider represents a Google based Identity Provider with OIDC-compliant ID token verification.
// This provider uses proper cryptographic verification of ID tokens per the OIDC spec,
// including signature verification via Google's JWKS, issuer validation, audience validation,
// and expiration checks.
type GoogleProvider struct {
*ProviderData
*OIDCProvider
RedeemRefreshURL *url.URL
// adminService is used to fetch user's groups from Google Admin Directory API if configured.
adminService *admin.Service
// useOrganizationID indicates whether to use the organization ID from Admin API as preferred username.
// If false, the 'name' claim from ID token is used instead.
useOrganizationID bool
// groupValidator is a function that determines if the user in the passed
// session is a member of any of the configured Google groups.
@ -41,106 +44,66 @@ 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)
type claims struct {
Subject string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
const (
googleProviderName = "Google"
googleDefaultIssuerURL = "https://accounts.google.com"
)
// setGoogleDefaults sets Google-specific defaults on the provider config.
// This is called before provider data is created to ensure proper OIDC discovery.
func setGoogleDefaults(providerConfig *options.Provider) {
if providerConfig.OIDCConfig.IssuerURL == "" {
providerConfig.OIDCConfig.IssuerURL = googleDefaultIssuerURL
}
if providerConfig.Scope != "" && !strings.Contains(providerConfig.Scope, "openid") {
// Ensure openid scope is present for OIDC ID token verification
providerConfig.Scope = "openid " + providerConfig.Scope
}
}
const (
googleProviderName = "Google"
googleDefaultScope = "profile email"
)
var (
// Default Login URL for Google.
// Pre-parsed URL of https://accounts.google.com/o/oauth2/auth?access_type=offline.
googleDefaultLoginURL = &url.URL{
Scheme: "https",
Host: "accounts.google.com",
Path: "/o/oauth2/auth",
// to get a refresh token. see https://developers.google.com/identity/protocols/OAuth2WebServer#offline
RawQuery: "access_type=offline",
// NewGoogleProvider initiates a new GoogleProvider with OIDC-compliant ID token verification
func NewGoogleProvider(p *ProviderData, opts options.GoogleOptions, oidcOpts options.OIDCOptions) *GoogleProvider {
// Set Google-specific defaults
if p.ProviderName == "" {
p.ProviderName = googleProviderName
}
// Default Redeem URL for Google.
// Pre-parsed URL of https://www.googleapis.com/oauth2/v3/token.
googleDefaultRedeemURL = &url.URL{
Scheme: "https",
Host: "www.googleapis.com",
Path: "/oauth2/v3/token",
}
// Create the underlying OIDC provider (which sets default scope to "openid email profile")
oidcProvider := NewOIDCProvider(p, oidcOpts)
// Default Validation URL for Google.
// Pre-parsed URL of https://www.googleapis.com/oauth2/v1/tokeninfo.
googleDefaultValidateURL = &url.URL{
Scheme: "https",
Host: "www.googleapis.com",
Path: "/oauth2/v1/tokeninfo",
}
)
// NewGoogleProvider initiates a new GoogleProvider
func NewGoogleProvider(p *ProviderData, opts options.GoogleOptions) (*GoogleProvider, error) {
p.setProviderDefaults(providerDefaults{
name: googleProviderName,
loginURL: googleDefaultLoginURL,
redeemURL: googleDefaultRedeemURL,
profileURL: nil,
validateURL: googleDefaultValidateURL,
scope: googleDefaultScope,
})
provider := &GoogleProvider{
ProviderData: p,
OIDCProvider: oidcProvider,
// Set a default groupValidator to just always return valid (true), it will
// be overwritten if we configured a Google group restriction.
groupValidator: func(*sessions.SessionState) bool {
return true
},
setPreferredUsername: func(_ *sessions.SessionState) error {
return nil
},
useOrganizationID: ptr.Deref(opts.UseOrganizationID, options.DefaultGoogleUseOrganizationID),
}
if ptr.Deref(opts.UseOrganizationID, options.DefaultGoogleUseOrganizationID) || opts.ServiceAccountJSON != "" || ptr.Deref(opts.UseApplicationDefaultCredentials, options.DefaultUseApplicationDefaultCredentials) {
// reuse admin service to avoid multiple calls for token
var adminService *admin.Service
if ptr.Deref(opts.UseOrganizationID, options.DefaultGoogleUseOrganizationID) {
// Set up Google Admin API if configured
if opts.ServiceAccountJSON != "" || ptr.Deref(opts.UseApplicationDefaultCredentials, options.DefaultUseApplicationDefaultCredentials) || provider.useOrganizationID {
if provider.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
}
}
provider.adminService = getAdminService(opts)
// Configure group validation if service account is set up
if opts.ServiceAccountJSON != "" || ptr.Deref(opts.UseApplicationDefaultCredentials, options.DefaultUseApplicationDefaultCredentials) {
if adminService == nil {
adminService = getAdminService(opts)
}
provider.configureGroups(opts, adminService)
provider.configureGroups(opts, provider.adminService)
}
}
return provider, nil
return provider
}
// by default can be readonly user scope
@ -165,91 +128,15 @@ func (p *GoogleProvider) configureGroups(opts options.GoogleOptions, adminServic
p.groupValidator = p.populateAllGroups(adminService)
}
func claimsFromIDToken(idToken string) (*claims, error) {
// id_token is a base64 encode ID token payload
// https://developers.google.com/accounts/docs/OAuth2Login#obtainuserinfo
jwt := strings.Split(idToken, ".")
jwtData := strings.TrimSuffix(jwt[1], "=")
b, err := base64.RawURLEncoding.DecodeString(jwtData)
if err != nil {
return nil, err
}
c := &claims{}
err = json.Unmarshal(b, c)
if err != nil {
return nil, err
}
if c.Email == "" {
return nil, errors.New("missing email")
}
if !c.EmailVerified {
return nil, fmt.Errorf("email %s not listed as verified", c.Email)
}
return c, nil
}
// Redeem exchanges the OAuth2 authentication token for an ID token
func (p *GoogleProvider) Redeem(ctx context.Context, redirectURL, code, codeVerifier string) (*sessions.SessionState, error) {
if code == "" {
return nil, ErrMissingCode
}
clientSecret, err := p.GetClientSecret()
if err != nil {
return nil, err
}
params := url.Values{}
params.Add("redirect_uri", redirectURL)
params.Add("client_id", p.ClientID)
params.Add("client_secret", clientSecret)
params.Add("code", code)
params.Add("grant_type", "authorization_code")
if codeVerifier != "" {
params.Add("code_verifier", codeVerifier)
}
var jsonResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
IDToken string `json:"id_token"`
}
err = requests.New(p.RedeemURL.String()).
WithContext(ctx).
WithMethod("POST").
WithBody(bytes.NewBufferString(params.Encode())).
SetHeader("Content-Type", "application/x-www-form-urlencoded").
Do().
UnmarshalInto(&jsonResponse)
if err != nil {
return nil, err
}
c, err := claimsFromIDToken(jsonResponse.IDToken)
if err != nil {
return nil, err
}
ss := &sessions.SessionState{
AccessToken: jsonResponse.AccessToken,
IDToken: jsonResponse.IDToken,
RefreshToken: jsonResponse.RefreshToken,
Email: c.Email,
User: c.Subject,
}
ss.CreatedAtNow()
ss.ExpiresIn(time.Duration(jsonResponse.ExpiresIn) * time.Second)
return ss, nil
}
// 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 {
func (p *GoogleProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error {
// First, call the parent OIDC EnrichSession
if err := p.OIDCProvider.EnrichSession(ctx, s); err != nil {
return err
}
// TODO (@NickMeves) - Move to pure EnrichSession logic and stop
// reusing legacy `groupValidator`.
//
@ -257,7 +144,12 @@ func (p *GoogleProvider) EnrichSession(_ context.Context, s *sessions.SessionSta
// populating logic.
p.groupValidator(s)
return p.setPreferredUsername(s)
// Set preferredUsername
if err := p.setPreferredUsername(s); err != nil {
logger.Errorf("failed to set preferred username: %v", err)
}
return nil
}
// SetGroupRestriction configures the GoogleProvider to restrict access to the
@ -295,6 +187,65 @@ func (p *GoogleProvider) populateAllGroups(adminService *admin.Service) func(s *
}
}
// setPreferredUsername sets the preferred username on the session.
// If useOrganizationID is true, it fetches the organization ID from Admin API.
// Otherwise, it extracts the 'name' claim from the ID token.
func (p *GoogleProvider) setPreferredUsername(s *sessions.SessionState) error {
if p.useOrganizationID && p.adminService != nil {
userName, err := getUserInfo(p.adminService, s.Email)
if err != nil {
return err
}
s.PreferredUsername = userName
return nil
}
extractor, err := p.getClaimExtractor(s.IDToken, s.AccessToken)
if err != nil {
return fmt.Errorf("could not get claim extractor: %v", err)
}
var name string
if exists, err := extractor.GetClaimInto("name", &name); err != nil || !exists {
return nil
}
s.PreferredUsername = name
return nil
}
// CreateSessionFromToken converts Bearer IDTokens into sessions
func (p *GoogleProvider) CreateSessionFromToken(ctx context.Context, token string) (*sessions.SessionState, error) {
ss, err := p.OIDCProvider.CreateSessionFromToken(ctx, token)
if err != nil {
return nil, fmt.Errorf("could not create session from token: %v", err)
}
// Populate groups via groupValidator
if !p.groupValidator(ss) {
return nil, fmt.Errorf("%s is not in the required group(s)", ss.Email)
}
// Set preferredUsername
if err := p.setPreferredUsername(ss); err != nil {
logger.Errorf("failed to set preferred username from bearer token: %v", err)
}
return ss, nil
}
// GetLoginURL makes the LoginURL with optional nonce support
func (p *GoogleProvider) GetLoginURL(redirectURI, state, nonce string, extraParams url.Values) string {
// Add Google-specific parameters for offline access (refresh tokens)
if extraParams == nil {
extraParams = url.Values{}
}
if extraParams.Get("access_type") == "" {
extraParams.Set("access_type", "offline")
}
return p.OIDCProvider.GetLoginURL(redirectURI, state, nonce, extraParams)
}
// https://developers.google.com/admin-sdk/directory/reference/rest/v1/members/hasMember#authorization-scopes
var possibleScopesList = [...]string{
admin.AdminDirectoryGroupMemberReadonlyScope,
@ -502,13 +453,9 @@ func userInGroup(service *admin.Service, group string, email string) bool {
// RefreshSession uses the RefreshToken to fetch new Access and ID Tokens
func (p *GoogleProvider) RefreshSession(ctx context.Context, s *sessions.SessionState) (bool, error) {
if s == nil || s.RefreshToken == "" {
return false, nil
}
err := p.redeemRefreshToken(ctx, s)
if err != nil {
return false, err
refreshed, err := p.OIDCProvider.RefreshSession(ctx, s)
if err != nil || !refreshed {
return refreshed, err
}
// TODO (@NickMeves) - Align Group authorization needs with other providers'
@ -519,44 +466,10 @@ func (p *GoogleProvider) RefreshSession(ctx context.Context, s *sessions.Session
return false, fmt.Errorf("%s is no longer in the group(s)", s.Email)
}
// Update PreferredUsername
if err := p.setPreferredUsername(s); err != nil {
logger.Errorf("failed to set preferred username on refresh: %v", err)
}
return true, nil
}
func (p *GoogleProvider) redeemRefreshToken(ctx context.Context, s *sessions.SessionState) error {
// https://developers.google.com/identity/protocols/OAuth2WebServer#refresh
clientSecret, err := p.GetClientSecret()
if err != nil {
return err
}
params := url.Values{}
params.Add("client_id", p.ClientID)
params.Add("client_secret", clientSecret)
params.Add("refresh_token", s.RefreshToken)
params.Add("grant_type", "refresh_token")
var data struct {
AccessToken string `json:"access_token"`
ExpiresIn int64 `json:"expires_in"`
IDToken string `json:"id_token"`
}
err = requests.New(p.RedeemURL.String()).
WithContext(ctx).
WithMethod("POST").
WithBody(bytes.NewBufferString(params.Encode())).
SetHeader("Content-Type", "application/x-www-form-urlencoded").
Do().
UnmarshalInto(&data)
if err != nil {
return err
}
s.AccessToken = data.AccessToken
s.IDToken = data.IDToken
s.CreatedAtNow()
s.ExpiresIn(time.Duration(data.ExpiresIn) * time.Second)
return nil
}

View File

@ -2,24 +2,40 @@ package providers
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/golang-jwt/jwt/v5"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
internaloidc "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/providers/oidc"
. "github.com/onsi/gomega"
"github.com/stretchr/testify/assert"
admin "google.golang.org/api/admin/directory/v1"
option "google.golang.org/api/option"
)
func newRedeemServer(body []byte) (*url.URL, *httptest.Server) {
// googleTestRegisteredClaims creates standard JWT claims for Google provider tests
func googleTestRegisteredClaims() jwt.RegisteredClaims {
return jwt.RegisteredClaims{
Audience: jwt.ClaimStrings{oidcClientID},
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(5) * time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: oidcIssuer,
NotBefore: jwt.NewNumericDate(time.Time{}),
Subject: "123456789",
}
}
func newGoogleRedeemServer(body []byte) (*url.URL, *httptest.Server) {
s := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
rw.Header().Add("content-type", "application/json")
rw.Write(body)
}))
u, _ := url.Parse(s.URL)
@ -28,96 +44,198 @@ func newRedeemServer(body []byte) (*url.URL, *httptest.Server) {
func newGoogleProvider(t *testing.T) *GoogleProvider {
g := NewWithT(t)
p, err := NewGoogleProvider(
&ProviderData{
ProviderName: "",
LoginURL: &url.URL{},
RedeemURL: &url.URL{},
ProfileURL: &url.URL{},
ValidateURL: &url.URL{},
Scope: ""},
options.GoogleOptions{})
g.Expect(err).ToNot(HaveOccurred())
verificationOptions := internaloidc.IDTokenVerificationOptions{
AudienceClaims: []string{"aud"},
ClientID: oidcClientID,
}
providerData := &ProviderData{
ProviderName: "",
LoginURL: &url.URL{},
RedeemURL: &url.URL{},
ProfileURL: &url.URL{},
ValidateURL: &url.URL{},
Scope: "",
EmailClaim: "email",
UserClaim: "sub",
Verifier: internaloidc.NewVerifier(oidc.NewVerifier(
oidcIssuer,
mockJWKS{},
&oidc.Config{ClientID: oidcClientID},
), verificationOptions),
}
p := NewGoogleProvider(providerData, options.GoogleOptions{}, options.OIDCOptions{
InsecureSkipNonce: func() *bool { b := true; return &b }(),
})
g.Expect(p).ToNot(BeNil())
return p
}
func TestNewGoogleProvider(t *testing.T) {
g := NewWithT(t)
verificationOptions := internaloidc.IDTokenVerificationOptions{
AudienceClaims: []string{"aud"},
ClientID: oidcClientID,
}
providerData := &ProviderData{
Verifier: internaloidc.NewVerifier(oidc.NewVerifier(
oidcIssuer,
mockJWKS{},
&oidc.Config{ClientID: oidcClientID},
), verificationOptions),
}
// Test that defaults are set when calling for a new provider with nothing set
provider, err := NewGoogleProvider(&ProviderData{}, options.GoogleOptions{})
g.Expect(err).ToNot(HaveOccurred())
providerData := provider.Data()
g.Expect(providerData.ProviderName).To(Equal("Google"))
g.Expect(providerData.LoginURL.String()).To(Equal("https://accounts.google.com/o/oauth2/auth?access_type=offline"))
g.Expect(providerData.RedeemURL.String()).To(Equal("https://www.googleapis.com/oauth2/v3/token"))
g.Expect(providerData.ProfileURL.String()).To(Equal(""))
g.Expect(providerData.ValidateURL.String()).To(Equal("https://www.googleapis.com/oauth2/v1/tokeninfo"))
g.Expect(providerData.Scope).To(Equal("profile email"))
provider := NewGoogleProvider(providerData, options.GoogleOptions{}, options.OIDCOptions{})
g.Expect(provider).ToNot(BeNil())
g.Expect(provider.Data().ProviderName).To(Equal("Google"))
g.Expect(provider.Data().Scope).To(Equal("openid email profile"))
}
func TestGoogleProviderOverrides(t *testing.T) {
p, err := NewGoogleProvider(
&ProviderData{
LoginURL: &url.URL{
Scheme: "https",
Host: "example.com",
Path: "/oauth/auth"},
RedeemURL: &url.URL{
Scheme: "https",
Host: "example.com",
Path: "/oauth/token"},
ProfileURL: &url.URL{
Scheme: "https",
Host: "example.com",
Path: "/oauth/profile"},
ValidateURL: &url.URL{
Scheme: "https",
Host: "example.com",
Path: "/oauth/tokeninfo"},
Scope: "profile"},
options.GoogleOptions{})
assert.NoError(t, err)
assert.NotEqual(t, nil, p)
assert.Equal(t, "Google", p.Data().ProviderName)
assert.Equal(t, "https://example.com/oauth/auth",
p.Data().LoginURL.String())
assert.Equal(t, "https://example.com/oauth/token",
p.Data().RedeemURL.String())
assert.Equal(t, "https://example.com/oauth/profile",
p.Data().ProfileURL.String())
assert.Equal(t, "https://example.com/oauth/tokeninfo",
p.Data().ValidateURL.String())
assert.Equal(t, "profile", p.Data().Scope)
}
type redeemResponse struct {
type googleRedeemResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
TokenType string `json:"token_type"`
IDToken string `json:"id_token"`
}
func TestGoogleProviderGetEmailAddress(t *testing.T) {
func TestGoogleProviderRedeem(t *testing.T) {
p := newGoogleProvider(t)
body, err := json.Marshal(redeemResponse{
idToken, err := newSignedTestIDToken(idTokenClaims{
Email: "michael.bland@gsa.gov",
Verified: func() *bool { b := true; return &b }(),
RegisteredClaims: googleTestRegisteredClaims(),
})
assert.NoError(t, err)
body, err := json.Marshal(googleRedeemResponse{
AccessToken: "a1234",
ExpiresIn: 10,
TokenType: "Bearer",
RefreshToken: "refresh12345",
IDToken: "ignored prefix." + base64.URLEncoding.EncodeToString([]byte(`{"email": "michael.bland@gsa.gov", "email_verified":true}`)),
IDToken: idToken,
})
assert.Equal(t, nil, err)
assert.NoError(t, err)
var server *httptest.Server
p.RedeemURL, server = newRedeemServer(body)
p.RedeemURL, server = newGoogleRedeemServer(body)
defer server.Close()
session, err := p.Redeem(context.Background(), "http://redirect/", "code1234", "123")
assert.Equal(t, nil, err)
assert.NotEqual(t, session, nil)
session, err := p.Redeem(context.Background(), "http://redirect/", "code1234", "")
assert.NoError(t, err)
assert.NotNil(t, session)
assert.Equal(t, "michael.bland@gsa.gov", session.Email)
assert.Equal(t, "a1234", session.AccessToken)
assert.Equal(t, "refresh12345", session.RefreshToken)
assert.Equal(t, idToken, session.IDToken)
}
func TestGoogleProviderRedeemWithInvalidToken(t *testing.T) {
p := newGoogleProvider(t)
body, err := json.Marshal(googleRedeemResponse{
AccessToken: "a1234",
ExpiresIn: 10,
TokenType: "Bearer",
RefreshToken: "refresh12345",
IDToken: "invalid.token.format",
})
assert.NoError(t, err)
var server *httptest.Server
p.RedeemURL, server = newGoogleRedeemServer(body)
defer server.Close()
session, err := p.Redeem(context.Background(), "http://redirect/", "code1234", "")
assert.Error(t, err)
assert.Nil(t, session)
}
func TestGoogleProviderRedeemWithMissingIDToken(t *testing.T) {
p := newGoogleProvider(t)
body, err := json.Marshal(googleRedeemResponse{
AccessToken: "a1234",
ExpiresIn: 10,
TokenType: "Bearer",
RefreshToken: "refresh12345",
// No IDToken
})
assert.NoError(t, err)
var server *httptest.Server
p.RedeemURL, server = newGoogleRedeemServer(body)
defer server.Close()
session, err := p.Redeem(context.Background(), "http://redirect/", "code1234", "")
assert.Error(t, err)
assert.Nil(t, session)
}
func TestGoogleProviderValidateSession(t *testing.T) {
p := newGoogleProvider(t)
// Create a valid signed ID token
idToken, err := newSignedTestIDToken(idTokenClaims{
Email: "test@example.com",
Verified: func() *bool { b := true; return &b }(),
RegisteredClaims: googleTestRegisteredClaims(),
})
assert.NoError(t, err)
testCases := map[string]struct {
session *sessions.SessionState
expected bool
}{
"Valid session with ID token": {
session: &sessions.SessionState{
IDToken: idToken,
Email: "test@example.com",
},
expected: true,
},
"Invalid session without ID token": {
session: &sessions.SessionState{
Email: "test@example.com",
},
expected: false,
},
"Invalid session with malformed ID token": {
session: &sessions.SessionState{
IDToken: "invalid.token",
Email: "test@example.com",
},
expected: false,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
result := p.ValidateSession(context.Background(), tc.session)
assert.Equal(t, tc.expected, result)
})
}
}
func TestGoogleProviderGetLoginURL(t *testing.T) {
p := newGoogleProvider(t)
p.LoginURL = &url.URL{
Scheme: "https",
Host: "accounts.google.com",
Path: "/o/oauth2/v2/auth",
}
loginURL := p.GetLoginURL("http://redirect/", "state123", "nonce456", url.Values{})
// Verify access_type=offline is added for refresh tokens
assert.Contains(t, loginURL, "access_type=offline")
assert.Contains(t, loginURL, "state=state123")
}
func TestGoogleProviderGroupValidator(t *testing.T) {
@ -166,75 +284,6 @@ func TestGoogleProviderGroupValidator(t *testing.T) {
}
}
func TestGoogleProviderGetEmailAddressInvalidEncoding(t *testing.T) {
p := newGoogleProvider(t)
body, err := json.Marshal(redeemResponse{
AccessToken: "a1234",
IDToken: "ignored prefix." + `{"email": "michael.bland@gsa.gov"}`,
})
assert.Equal(t, nil, err)
var server *httptest.Server
p.RedeemURL, server = newRedeemServer(body)
defer server.Close()
session, err := p.Redeem(context.Background(), "http://redirect/", "code1234", "123")
assert.NotEqual(t, nil, err)
if session != nil {
t.Errorf("expect nill session %#v", session)
}
}
func TestGoogleProviderRedeemFailsNoCLientSecret(t *testing.T) {
p := newGoogleProvider(t)
p.ProviderData.ClientSecretFile = "srvnoerre"
session, err := p.Redeem(context.Background(), "http://redirect/", "code1234", "123")
assert.NotEqual(t, nil, err)
if session != nil {
t.Errorf("expect nill session %#v", session)
}
assert.Equal(t, "could not read client secret file", err.Error())
}
func TestGoogleProviderGetEmailAddressInvalidJson(t *testing.T) {
p := newGoogleProvider(t)
body, err := json.Marshal(redeemResponse{
AccessToken: "a1234",
IDToken: "ignored prefix." + base64.URLEncoding.EncodeToString([]byte(`{"email": michael.bland@gsa.gov}`)),
})
assert.Equal(t, nil, err)
var server *httptest.Server
p.RedeemURL, server = newRedeemServer(body)
defer server.Close()
session, err := p.Redeem(context.Background(), "http://redirect/", "code1234", "123")
assert.NotEqual(t, nil, err)
if session != nil {
t.Errorf("expect nill session %#v", session)
}
}
func TestGoogleProviderGetEmailAddressEmailMissing(t *testing.T) {
p := newGoogleProvider(t)
body, err := json.Marshal(redeemResponse{
AccessToken: "a1234",
IDToken: "ignored prefix." + base64.URLEncoding.EncodeToString([]byte(`{"not_email": "missing"}`)),
})
assert.Equal(t, nil, err)
var server *httptest.Server
p.RedeemURL, server = newRedeemServer(body)
defer server.Close()
session, err := p.Redeem(context.Background(), "http://redirect/", "code1234", "123")
assert.NotEqual(t, nil, err)
if session != nil {
t.Errorf("expect nill session %#v", session)
}
}
func TestGoogleProvider_userInGroup(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
@ -405,3 +454,93 @@ func TestGoogleProvider_getUserInfo(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, "test.user", info)
}
func TestGoogleProvider_EnrichSessionWithoutAdminService(t *testing.T) {
const sessionEmail = "test@example.com"
p := newGoogleProvider(t)
// No adminService configured - groups should not be populated
idToken, err := newSignedTestIDToken(idTokenClaims{
Email: sessionEmail,
Verified: func() *bool { b := true; return &b }(),
RegisteredClaims: googleTestRegisteredClaims(),
})
assert.NoError(t, err)
session := &sessions.SessionState{
Email: sessionEmail,
IDToken: idToken,
}
err = p.EnrichSession(context.Background(), session)
assert.NoError(t, err)
assert.Nil(t, session.Groups) // No groups populated without adminService
}
func TestGoogleProvider_RefreshSessionWithoutAdminService(t *testing.T) {
const sessionEmail = "test@example.com"
p := newGoogleProvider(t)
// No adminService configured
idToken, err := newSignedTestIDToken(idTokenClaims{
Email: sessionEmail,
Verified: func() *bool { b := true; return &b }(),
RegisteredClaims: googleTestRegisteredClaims(),
})
assert.NoError(t, err)
// Create mock redeem server for refresh
body, err := json.Marshal(googleRedeemResponse{
AccessToken: "new_access_token",
ExpiresIn: 3600,
TokenType: "Bearer",
RefreshToken: "new_refresh_token",
IDToken: idToken,
})
assert.NoError(t, err)
var server *httptest.Server
p.RedeemURL, server = newGoogleRedeemServer(body)
defer server.Close()
session := &sessions.SessionState{
Email: sessionEmail,
IDToken: idToken,
RefreshToken: "old_refresh_token",
}
refreshed, err := p.RefreshSession(context.Background(), session)
assert.NoError(t, err)
assert.True(t, refreshed)
}
func TestGoogleProvider_CreateSessionFromToken(t *testing.T) {
const sessionEmail = "test@example.com"
p := newGoogleProvider(t)
idToken, err := newSignedTestIDToken(idTokenClaims{
Email: sessionEmail,
Verified: func() *bool { b := true; return &b }(),
RegisteredClaims: googleTestRegisteredClaims(),
})
assert.NoError(t, err)
session, err := p.CreateSessionFromToken(context.Background(), idToken)
assert.NoError(t, err)
assert.NotNil(t, session)
assert.Equal(t, sessionEmail, session.Email)
assert.Equal(t, idToken, session.IDToken)
// No adminService configured, so groups should be nil
assert.Nil(t, session.Groups)
}
func TestGoogleProvider_CreateSessionFromTokenWithInvalidToken(t *testing.T) {
p := newGoogleProvider(t)
session, err := p.CreateSessionFromToken(context.Background(), "invalid.token")
assert.Error(t, err)
assert.Nil(t, session)
}

View File

@ -33,6 +33,9 @@ type Provider interface {
}
func NewProvider(providerConfig options.Provider) (Provider, error) {
// Allow providers to set their defaults before provider data is created
setProviderDefaults(&providerConfig)
providerData, err := newProviderDataFromConfig(providerConfig)
if err != nil {
return nil, fmt.Errorf("could not create provider data: %v", err)
@ -57,7 +60,7 @@ func NewProvider(providerConfig options.Provider) (Provider, error) {
case options.GitLabProvider:
return NewGitLabProvider(providerData, providerConfig)
case options.GoogleProvider:
return NewGoogleProvider(providerData, providerConfig.GoogleConfig)
return NewGoogleProvider(providerData, providerConfig.GoogleConfig, providerConfig.OIDCConfig), nil
case options.KeycloakProvider:
return NewKeycloakProvider(providerData, providerConfig.KeycloakConfig), nil
case options.KeycloakOIDCProvider:
@ -77,6 +80,14 @@ func NewProvider(providerConfig options.Provider) (Provider, error) {
}
}
// setProviderDefaults allows providers to set their defaults before provider data is created.
// Each provider can implement its own defaults function.
func setProviderDefaults(providerConfig *options.Provider) {
if providerConfig.Type == options.GoogleProvider {
setGoogleDefaults(providerConfig)
}
}
func newProviderDataFromConfig(providerConfig options.Provider) (*ProviderData, error) {
p := &ProviderData{
Scope: providerConfig.Scope,
@ -188,11 +199,11 @@ func parseCodeChallengeMethod(providerConfig options.Provider) string {
func providerRequiresOIDCProviderVerifier(providerType options.ProviderType) (bool, error) {
switch providerType {
case options.BitbucketProvider, options.DigitalOceanProvider, options.FacebookProvider, options.GitHubProvider,
options.GoogleProvider, options.KeycloakProvider, options.LinkedInProvider, options.LoginGovProvider,
options.KeycloakProvider, options.LinkedInProvider, options.LoginGovProvider,
options.NextCloudProvider, options.SourceHutProvider:
return false, nil
case options.OIDCProvider, options.ADFSProvider, options.AzureProvider, options.CidaasProvider,
options.GitLabProvider, options.KeycloakOIDCProvider, options.MicrosoftEntraIDProvider:
options.GitLabProvider, options.GoogleProvider, options.KeycloakOIDCProvider, options.MicrosoftEntraIDProvider:
return true, nil
default:
return false, fmt.Errorf("unknown provider type: %s", providerType)

View File

@ -30,6 +30,7 @@ func TestClientSecretFileOptionFails(t *testing.T) {
ClientSecretFile: clientSecret,
}
setProviderDefaults(&providerConfig)
p, err := newProviderDataFromConfig(providerConfig)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(p.ClientSecretFile).To(Equal(clientSecret))
@ -63,6 +64,7 @@ func TestClientSecretFileOption(t *testing.T) {
ClientSecretFile: clientSecretFileName,
}
setProviderDefaults(&providerConfig)
p, err := newProviderDataFromConfig(providerConfig)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(p.ClientSecretFile).To(Equal(clientSecretFileName))