From 268cd285979292f7019de006739efbb2eec3231f Mon Sep 17 00:00:00 2001 From: Sourav Agrawal Date: Sun, 11 Jan 2026 04:53:44 +0530 Subject: [PATCH 1/3] feat: update the Google provider to use OIDC Signed-off-by: Sourav Agrawal --- CHANGELOG.md | 1 + providers/google.go | 333 +++++++++++------------------ providers/google_test.go | 409 ++++++++++++++++++++++++------------ providers/providers.go | 17 +- providers/providers_test.go | 2 + 5 files changed, 414 insertions(+), 348 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1467837f..03c6b559 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ## Changes since v7.13.0 +- [#3294](https://github.com/oauth2-proxy/oauth2-proxy/pull/3294) feat: update the Google provider to use OIDC (@sourava01) - [#3197](https://github.com/oauth2-proxy/oauth2-proxy/pull/3197) fix: NewRemoteKeySet is not using DefaultHTTPClient (@rsrdesarrollo / @tuunit) - [#3292](https://github.com/oauth2-proxy/oauth2-proxy/pull/3292) chore(deps): upgrade gomod and bump to golang v1.25.5 (@tuunit) - [#3304](https://github.com/oauth2-proxy/oauth2-proxy/pull/3304) fix: added conditional so default is not always set and env vars are honored fixes 3303 (@pixeldrew) diff --git a/providers/google.go b/providers/google.go index d8e4dec8..ac3c90a1 100644 --- a/providers/google.go +++ b/providers/google.go @@ -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 -} diff --git a/providers/google_test.go b/providers/google_test.go index f168e31c..33d8f9ee 100644 --- a/providers/google_test.go +++ b/providers/google_test.go @@ -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) +} diff --git a/providers/providers.go b/providers/providers.go index 6af51ecf..0e78d5e0 100644 --- a/providers/providers.go +++ b/providers/providers.go @@ -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) diff --git a/providers/providers_test.go b/providers/providers_test.go index 8e3b8d77..93768960 100644 --- a/providers/providers_test.go +++ b/providers/providers_test.go @@ -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)) From c851c8671cd9a918bea796121b233f105b34c410 Mon Sep 17 00:00:00 2001 From: Sourav Agrawal Date: Sun, 11 Jan 2026 04:54:40 +0530 Subject: [PATCH 2/3] fix: update google provider docs Signed-off-by: Sourav Agrawal --- docs/docs/configuration/providers/google.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/docs/configuration/providers/google.md b/docs/docs/configuration/providers/google.md index 0de5bb74..2cef7c2c 100644 --- a/docs/docs/configuration/providers/google.md +++ b/docs/docs/configuration/providers/google.md @@ -37,7 +37,10 @@ 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) +### 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 +53,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. From 6c4191b7e05ea2fdcd2173ecf3bf8c9b31da1517 Mon Sep 17 00:00:00 2001 From: Sourav Agrawal Date: Thu, 15 Jan 2026 23:53:13 +0530 Subject: [PATCH 3/3] update docs Signed-off-by: Sourav Agrawal --- docs/docs/configuration/providers/google.md | 23 ++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/docs/configuration/providers/google.md b/docs/docs/configuration/providers/google.md index 2cef7c2c..ecdc8d97 100644 --- a/docs/docs/configuration/providers/google.md +++ b/docs/docs/configuration/providers/google.md @@ -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,6 +39,12 @@ 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. +### 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). @@ -80,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`