Merge 08be51fa5c into 7bf586c898
This commit is contained in:
commit
07e84bab36
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Reference in New Issue