feat: update the Google provider to use OIDC

Signed-off-by: Sourav Agrawal <souravagr01@gmail.com>
This commit is contained in:
Sourav Agrawal 2026-01-11 04:53:44 +05:30 committed by Sourav Agrawal
parent 49536035a2
commit 268cd28597
5 changed files with 414 additions and 348 deletions

View File

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

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