Newer
Older
// Package google implements logging in through Google's OpenID Connect provider.
package google
import (
"context"
"errors"
"fmt"
"net/http"
"golang.org/x/oauth2/google"
admin "google.golang.org/api/admin/directory/v1"
"google.golang.org/api/option"
pkg_groups "github.com/dexidp/dex/pkg/groups"
const (
issuerURL = "https://accounts.google.com"
)
type Config struct {
ClientID string `json:"clientID"`
ClientSecret string `json:"clientSecret"`
RedirectURI string `json:"redirectURI"`
Scopes []string `json:"scopes"` // defaults to "profile" and "email"
// Optional list of whitelisted domains
// If this field is nonempty, only users from a listed domain will be allowed to log in
HostedDomains []string `json:"hostedDomains"`
// Optional list of whitelisted groups
// If this field is nonempty, only users from a listed group will be allowed to log in
Groups []string `json:"groups"`
// Optional path to service account json
// If nonempty, and groups claim is made, will use authentication from file to
// check groups with the admin directory api
ServiceAccountFilePath string `json:"serviceAccountFilePath"`
// Required if ServiceAccountFilePath
// The email of a GSuite super user which the service account will impersonate
// when listing groups
AdminEmail string
// If this field is true, fetch direct group membership and transitive group membership
FetchTransitiveGroupMembership bool `json:"fetchTransitiveGroupMembership"`
// Open returns a connector which can be used to login users through Google.
func (c *Config) Open(id string, logger log.Logger) (conn connector.Connector, err error) {
ctx, cancel := context.WithCancel(context.Background())
provider, err := oidc.NewProvider(ctx, issuerURL)
if err != nil {
cancel()
return nil, fmt.Errorf("failed to get provider: %v", err)
}
scopes := []string{oidc.ScopeOpenID}
if len(c.Scopes) > 0 {
scopes = append(scopes, c.Scopes...)
} else {
scopes = append(scopes, "profile", "email")
}
srv, err := createDirectoryService(c.ServiceAccountFilePath, c.AdminEmail, logger)
if err != nil {
cancel()
return nil, fmt.Errorf("could not create directory service: %v", err)
clientID := c.ClientID
return &googleConnector{
redirectURI: c.RedirectURI,
oauth2Config: &oauth2.Config{
ClientID: clientID,
ClientSecret: c.ClientSecret,
Endpoint: provider.Endpoint(),
Scopes: scopes,
RedirectURL: c.RedirectURI,
},
verifier: provider.Verifier(
&oidc.Config{ClientID: clientID},
),
logger: logger,
cancel: cancel,
hostedDomains: c.HostedDomains,
groups: c.Groups,
serviceAccountFilePath: c.ServiceAccountFilePath,
adminEmail: c.AdminEmail,
fetchTransitiveGroupMembership: c.FetchTransitiveGroupMembership,
adminSrv: srv,
}, nil
}
var (
_ connector.CallbackConnector = (*googleConnector)(nil)
_ connector.RefreshConnector = (*googleConnector)(nil)
)
type googleConnector struct {
redirectURI string
oauth2Config *oauth2.Config
verifier *oidc.IDTokenVerifier
cancel context.CancelFunc
logger log.Logger
hostedDomains []string
groups []string
serviceAccountFilePath string
adminEmail string
fetchTransitiveGroupMembership bool
adminSrv *admin.Service
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
}
func (c *googleConnector) Close() error {
c.cancel()
return nil
}
func (c *googleConnector) LoginURL(s connector.Scopes, callbackURL, state string) (string, error) {
if c.redirectURI != callbackURL {
return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, c.redirectURI)
}
var opts []oauth2.AuthCodeOption
if len(c.hostedDomains) > 0 {
preferredDomain := c.hostedDomains[0]
if len(c.hostedDomains) > 1 {
preferredDomain = "*"
}
opts = append(opts, oauth2.SetAuthURLParam("hd", preferredDomain))
}
if s.OfflineAccess {
opts = append(opts, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "consent"))
}
return c.oauth2Config.AuthCodeURL(state, opts...), nil
}
type oauth2Error struct {
error string
errorDescription string
}
func (e *oauth2Error) Error() string {
if e.errorDescription == "" {
return e.error
}
return e.error + ": " + e.errorDescription
}
func (c *googleConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) {
q := r.URL.Query()
if errType := q.Get("error"); errType != "" {
return identity, &oauth2Error{errType, q.Get("error_description")}
}
token, err := c.oauth2Config.Exchange(r.Context(), q.Get("code"))
if err != nil {
return identity, fmt.Errorf("google: failed to get token: %v", err)
}
return c.createIdentity(r.Context(), identity, s, token)
}
func (c *googleConnector) Refresh(ctx context.Context, s connector.Scopes, identity connector.Identity) (connector.Identity, error) {
t := &oauth2.Token{
RefreshToken: string(identity.ConnectorData),
Expiry: time.Now().Add(-time.Hour),
}
token, err := c.oauth2Config.TokenSource(ctx, t).Token()
if err != nil {
return identity, fmt.Errorf("google: failed to get token: %v", err)
}
return c.createIdentity(ctx, identity, s, token)
func (c *googleConnector) createIdentity(ctx context.Context, identity connector.Identity, s connector.Scopes, token *oauth2.Token) (connector.Identity, error) {
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return identity, errors.New("google: no id_token in token response")
}
idToken, err := c.verifier.Verify(ctx, rawIDToken)
if err != nil {
return identity, fmt.Errorf("google: failed to verify ID Token: %v", err)
}
var claims struct {
Username string `json:"name"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
HostedDomain string `json:"hd"`
}
if err := idToken.Claims(&claims); err != nil {
return identity, fmt.Errorf("oidc: failed to decode claims: %v", err)
}
if len(c.hostedDomains) > 0 {
found := false
for _, domain := range c.hostedDomains {
if claims.HostedDomain == domain {
found = true
break
}
}
if !found {
return identity, fmt.Errorf("oidc: unexpected hd claim %v", claims.HostedDomain)
}
}
if s.Groups && c.adminSrv != nil {
groups, err = c.getGroups(claims.Email, c.fetchTransitiveGroupMembership)
if err != nil {
return identity, fmt.Errorf("google: could not retrieve groups: %v", err)
}
if len(c.groups) > 0 {
groups = pkg_groups.Filter(groups, c.groups)
if len(groups) == 0 {
return identity, fmt.Errorf("google: user %q is not in any of the required groups", claims.Username)
}
}
identity = connector.Identity{
UserID: idToken.Subject,
Username: claims.Username,
Email: claims.Email,
EmailVerified: claims.EmailVerified,
ConnectorData: []byte(token.RefreshToken),
// getGroups creates a connection to the admin directory service and lists
// all groups the user is a member of
func (c *googleConnector) getGroups(email string, fetchTransitiveGroupMembership bool) ([]string, error) {
var err error
groupsList := &admin.Groups{}
for {
groupsList, err = c.adminSrv.Groups.List().
UserKey(email).PageToken(groupsList.NextPageToken).Do()
if err != nil {
return nil, fmt.Errorf("could not list groups: %v", err)
}
for _, group := range groupsList.Groups {
// TODO (joelspeed): Make desired group key configurable
userGroups = append(userGroups, group.Email)
// getGroups takes a user's email/alias as well as a group's email/alias
if fetchTransitiveGroupMembership {
transitiveGroups, err := c.getGroups(group.Email, fetchTransitiveGroupMembership)
if err != nil {
return nil, fmt.Errorf("could not list transitive groups: %v", err)
}
userGroups = append(userGroups, transitiveGroups...)
}
}
if groupsList.NextPageToken == "" {
break
}
return uniqueGroups(userGroups), nil
Hoang Quoc Trung
committed
// createDirectoryService sets up super user impersonation and creates an admin client for calling
// the google admin api. If no serviceAccountFilePath is defined, the application default credential
// is used.
func createDirectoryService(serviceAccountFilePath, email string, logger log.Logger) (*admin.Service, error) {
// We know impersonation is required when using a service account credential
// TODO: or is it?
if email == "" && serviceAccountFilePath != "" {
Hoang Quoc Trung
committed
return nil, fmt.Errorf("directory service requires adminEmail")
Hoang Quoc Trung
committed
var jsonCredentials []byte
var err error
Hoang Quoc Trung
committed
if serviceAccountFilePath == "" {
logger.Warn("the application default credential is used since the service account file path is not used")
credential, err := google.FindDefaultCredentials(ctx)
if err != nil {
return nil, fmt.Errorf("failed to fetch application default credentials: %w", err)
}
jsonCredentials = credential.JSON
} else {
jsonCredentials, err = os.ReadFile(serviceAccountFilePath)
if err != nil {
return nil, fmt.Errorf("error reading credentials from file: %v", err)
}
}
config, err := google.JWTConfigFromJSON(jsonCredentials, admin.AdminDirectoryGroupReadonlyScope)
Hoang Quoc Trung
committed
return nil, fmt.Errorf("unable to parse credentials to config: %v", err)
// Only attempt impersonation when there is a user configured
if email != "" {
config.Subject = email
}
Hoang Quoc Trung
committed
return admin.NewService(ctx, option.WithHTTPClient(config.Client(ctx)))
// uniqueGroups returns the unique groups of a slice
func uniqueGroups(groups []string) []string {
keys := make(map[string]struct{})
unique := []string{}
for _, group := range groups {
if _, exists := keys[group]; !exists {
keys[group] = struct{}{}
unique = append(unique, group)
}
}
return unique
}