diff --git a/connector/google/google.go b/connector/google/google.go new file mode 100644 index 00000000..e0cb4999 --- /dev/null +++ b/connector/google/google.go @@ -0,0 +1,190 @@ +// Package google implements logging in through Google's OpenID Connect provider. +package google + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/coreos/go-oidc" + "github.com/sirupsen/logrus" + "golang.org/x/oauth2" + + "github.com/dexidp/dex/connector" +) + +// Config holds configuration options for OpenID Connect logins. +type Config struct { + Issuer string `json:"issuer"` + 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"` +} + +// Open returns a connector which can be used to login users through an upstream +// OpenID Connect provider. +func (c *Config) Open(id string, logger logrus.FieldLogger) (conn connector.Connector, err error) { + ctx, cancel := context.WithCancel(context.Background()) + + provider, err := oidc.NewProvider(ctx, c.Issuer) + 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") + } + + 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, + }, nil +} + +var ( + _ connector.CallbackConnector = (*googleConnector)(nil) + _ connector.RefreshConnector = (*googleConnector)(nil) +) + +type googleConnector struct { + redirectURI string + oauth2Config *oauth2.Config + verifier *oidc.IDTokenVerifier + ctx context.Context + cancel context.CancelFunc + logger logrus.FieldLogger + hostedDomains []string +} + +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, token) +} + +// Refresh is implemented for backwards compatibility, even though it's a no-op. +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, token) +} + +func (c *googleConnector) createIdentity(ctx context.Context, identity connector.Identity, token *oauth2.Token) (connector.Identity, error) { + 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) + } + } + + identity = connector.Identity{ + UserID: idToken.Subject, + Username: claims.Username, + Email: claims.Email, + EmailVerified: claims.EmailVerified, + ConnectorData: []byte(token.RefreshToken), + } + return identity, nil +} diff --git a/server/server.go b/server/server.go index 3b722181..fa860330 100644 --- a/server/server.go +++ b/server/server.go @@ -21,6 +21,7 @@ import ( "github.com/dexidp/dex/connector/bitbucketcloud" "github.com/dexidp/dex/connector/github" "github.com/dexidp/dex/connector/gitlab" + "github.com/dexidp/dex/connector/google" "github.com/dexidp/dex/connector/keystone" "github.com/dexidp/dex/connector/ldap" "github.com/dexidp/dex/connector/linkedin" @@ -453,6 +454,7 @@ var ConnectorsConfig = map[string]func() ConnectorConfig{ "ldap": func() ConnectorConfig { return new(ldap.Config) }, "github": func() ConnectorConfig { return new(github.Config) }, "gitlab": func() ConnectorConfig { return new(gitlab.Config) }, + "google": func() ConnectorConfig { return new(google.Config) }, "oidc": func() ConnectorConfig { return new(oidc.Config) }, "saml": func() ConnectorConfig { return new(saml.Config) }, "authproxy": func() ConnectorConfig { return new(authproxy.Config) },