Fix support for GSuite logins

This commit fixes support for GSuite logins
by using service accounts for access purposes.

The resulting connector now looks like:

```yaml
kind: oidc
version: v2
metadata:
  name: gsuite
spec:
  redirect_url: https://example.com/v1/webapi/oidc/callback
  client_id: exampleclientid.apps.googleusercontent.com
  client_secret: exampleclientsecret
  issuer_url: https://accounts.google.com
  # Notice that scope here is not requiested from OIDC exchange anymore, this scope
  #
  # https://www.googleapis.com/auth/admin.directory.group.readonly
  #
  # is now implicitly requested by the client
  #
  scope: ['openid', 'email']
  # The setup below is involved and requires careful following of the guides:
  #
  # https://developers.google.com/admin-sdk/directory/v1/guides/delegation
  # https://developers.google.com/identity/protocols/OAuth2ServiceAccount#delegatingauthority
  #
  # The service account scopes have to be set to
  #
  # https://www.googleapis.com/auth/admin.directory.group.readonly
  # https://www.googleapis.com/auth/admin.directory.group.member.readonly
  #
  # the following paths are supported:
  # 1. plain path
  # /var/lib/secrets/gsuite-creds.json
  #
  # 2. explicit scheme file://
  # file:///var/lib/secrets/gsuite-creds.json
  #
  # other schemes are not supported at the moment
  #
  google_service_account_file: "/var/lib/secrets/gsuite-creds.json"
  google_admin_email: "admin@example.com"
  claims_to_roles:
    - {claim: "groups", value: "admin@example.com", roles: ["clusteradmin"]}
```
This commit is contained in:
Sasha Klizhentas 2019-11-03 12:55:03 -08:00 committed by Alexander Klizhentas
parent 6b0bc77ce6
commit 21e0342021
4 changed files with 102 additions and 27 deletions

3
Gopkg.lock generated
View file

@ -1398,7 +1398,6 @@
"github.com/aws/aws-sdk-go/service/s3/s3manager",
"github.com/beevik/etree",
"github.com/codahale/hdrhistogram",
"github.com/coreos/go-oidc/http",
"github.com/coreos/go-oidc/jose",
"github.com/coreos/go-oidc/oauth2",
"github.com/coreos/go-oidc/oidc",
@ -1470,6 +1469,8 @@
"golang.org/x/net/http2",
"golang.org/x/net/proxy",
"golang.org/x/net/websocket",
"golang.org/x/oauth2/google",
"golang.org/x/oauth2/jwt",
"golang.org/x/text/encoding",
"golang.org/x/text/encoding/unicode",
"google.golang.org/api/iterator",

View file

@ -439,6 +439,8 @@ const (
GSuiteGroupsEndpoint = "https://www.googleapis.com/admin/directory/v1/groups"
// GSuiteGroupsScope is a scope to get access to admin groups API
GSuiteGroupsScope = "https://www.googleapis.com/auth/admin.directory.group.readonly"
// GSuiteDomainClaim is the domain name claim for GSuite
GSuiteDomainClaim = "hd"
)
// SCP is Secure Copy.

View file

@ -30,11 +30,12 @@ import (
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/utils"
phttp "github.com/coreos/go-oidc/http"
"github.com/coreos/go-oidc/jose"
"github.com/coreos/go-oidc/oauth2"
"github.com/coreos/go-oidc/oidc"
"github.com/gravitational/trace"
"golang.org/x/oauth2/google"
"golang.org/x/oauth2/jwt"
)
func (s *AuthServer) getOrCreateOIDCClient(conn services.OIDCConnector) (*oidc.Client, error) {
@ -261,7 +262,7 @@ func (a *AuthServer) validateOIDCAuthCallback(q url.Values) (*oidcAuthResponse,
}
// extract claims from both the id token and the userinfo endpoint and merge them
claims, err := a.getClaims(oidcClient, connector.GetIssuerURL(), connector.GetScope(), code)
claims, err := a.getClaims(oidcClient, connector, code)
if err != nil {
return nil, trace.WrapWithMessage(
// preserve the original error message, to avoid leaking
@ -580,45 +581,42 @@ func claimsFromUserInfo(oidcClient *oidc.Client, issuerURL string, accessToken s
return claims, nil
}
func (a *AuthServer) claimsFromGSuite(oidcClient *oidc.Client, issuerURL string, userEmail string, accessToken string) (jose.Claims, error) {
client, err := a.newGsuiteClient(oidcClient, issuerURL, userEmail, accessToken)
func (a *AuthServer) claimsFromGSuite(config *jwt.Config, issuerURL string, userEmail string, domain string) (jose.Claims, error) {
client, err := a.newGsuiteClient(config, issuerURL, userEmail, domain)
if err != nil {
return nil, trace.Wrap(err)
}
return client.fetchGroups()
}
func (a *AuthServer) newGsuiteClient(oidcClient *oidc.Client, issuerURL string, userEmail string, accessToken string) (*gsuiteClient, error) {
func (a *AuthServer) newGsuiteClient(config *jwt.Config, issuerURL string, userEmail string, domain string) (*gsuiteClient, error) {
err := isHTTPS(issuerURL)
if err != nil {
return nil, trace.Wrap(err)
}
oac, err := oidcClient.OAuthClient()
if err != nil {
return nil, trace.Wrap(err)
}
u, err := url.Parse(teleport.GSuiteGroupsEndpoint)
if err != nil {
return nil, trace.Wrap(err)
}
return &gsuiteClient{
Client: oac.HttpClient(),
url: *u,
userEmail: userEmail,
accessToken: accessToken,
auditLog: a,
domain: domain,
client: config.Client(context.TODO()),
url: *u,
userEmail: userEmail,
config: config,
auditLog: a,
}, nil
}
type gsuiteClient struct {
phttp.Client
url url.URL
userEmail string
accessToken string
auditLog events.IAuditLog
client *http.Client
url url.URL
userEmail string
domain string
config *jwt.Config
auditLog events.IAuditLog
}
// fetchGroups fetches GSuite groups a user belongs to and returns
@ -661,6 +659,7 @@ func (g *gsuiteClient) fetchGroupsPage(pageToken string) (*gsuiteGroups, error)
u := g.url
q := u.Query()
q.Set("userKey", g.userEmail)
q.Set("domain", g.domain)
if pageToken != "" {
q.Set("pageToken", pageToken)
}
@ -673,9 +672,8 @@ func (g *gsuiteClient) fetchGroupsPage(pageToken string) (*gsuiteGroups, error)
if err != nil {
return nil, trace.Wrap(err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", g.accessToken))
resp, err := g.Do(req)
resp, err := g.client.Do(req)
if err != nil {
return nil, trace.Wrap(err)
}
@ -725,7 +723,7 @@ func mergeClaims(a jose.Claims, b jose.Claims) (jose.Claims, error) {
}
// getClaims gets claims from ID token and UserInfo and returns UserInfo claims merged into ID token claims.
func (a *AuthServer) getClaims(oidcClient *oidc.Client, issuerURL string, scope []string, code string) (jose.Claims, error) {
func (a *AuthServer) getClaims(oidcClient *oidc.Client, connector services.OIDCConnector, code string) (jose.Claims, error) {
var err error
oac, err := oidcClient.OAuthClient()
@ -745,7 +743,7 @@ func (a *AuthServer) getClaims(oidcClient *oidc.Client, issuerURL string, scope
}
log.Debugf("OIDC ID Token claims: %v.", idTokenClaims)
userInfoClaims, err := claimsFromUserInfo(oidcClient, issuerURL, t.AccessToken)
userInfoClaims, err := claimsFromUserInfo(oidcClient, connector.GetIssuerURL(), t.AccessToken)
if err != nil {
if trace.IsNotFound(err) {
log.Debugf("OIDC provider doesn't offer UserInfo endpoint. Returning token claims: %v.", idTokenClaims)
@ -783,12 +781,51 @@ func (a *AuthServer) getClaims(oidcClient *oidc.Client, issuerURL string, scope
// for GSuite users, fetch extra data from the proprietary google API
// only if scope includes admin groups readonly scope
if issuerURL == teleport.GSuiteIssuerURL && utils.SliceContainsStr(scope, teleport.GSuiteGroupsScope) {
if connector.GetIssuerURL() == teleport.GSuiteIssuerURL {
email, _, err := claims.StringClaim("email")
if err != nil {
return nil, trace.Wrap(err)
}
gsuiteClaims, err := a.claimsFromGSuite(oidcClient, issuerURL, email, t.AccessToken)
serviceAccountURI := connector.GetGoogleServiceAccountURI()
if serviceAccountURI == "" {
return nil, trace.NotFound(
"the gsuite connector requires google_service_account_uri parameter to be specified and pointing to a valid google service account file with credentials, read this article for more details https://developers.google.com/admin-sdk/directory/v1/guides/delegation")
}
uri, err := utils.ParseSessionsURI(serviceAccountURI)
if err != nil {
return nil, trace.BadParameter("failed to parse google_service_account_uri: %v", err)
}
impersonateAdmin := connector.GetGoogleAdminEmail()
if impersonateAdmin == "" {
return nil, trace.NotFound(
"the gsuite connector requires google_admin_email user to impersonate, as service accounts can not be used directly https://developers.google.com/identity/protocols/OAuth2ServiceAccount#delegatingauthority")
}
jsonCredentials, err := ioutil.ReadFile(uri.Path)
if err != nil {
return nil, trace.Wrap(err)
}
config, err := google.JWTConfigFromJSON(jsonCredentials, teleport.GSuiteGroupsScope)
if err != nil {
return nil, trace.BadParameter("unable to parse client secret file to config: %v", err)
}
// User should impersonate admin user, otherwise it won't work:
//
// https://developers.google.com/admin-sdk/directory/v1/guides/delegation
//
// "Note: Only users with access to the Admin APIs can access the Admin SDK Directory API, therefore your service account needs to impersonate one of those users to access the Admin SDK Directory API. Additionally, the user must have logged in at least once and accepted the G Suite Terms of Service."
//
domain, exists, err := userInfoClaims.StringClaim(teleport.GSuiteDomainClaim)
if err != nil || !exists {
return nil, trace.BadParameter("hd is the required claim for GSuite")
}
config.Subject = impersonateAdmin
gsuiteClaims, err := a.claimsFromGSuite(config, connector.GetIssuerURL(), email, domain)
if err != nil {
if !trace.IsNotFound(err) {
return nil, trace.Wrap(err)

View file

@ -86,6 +86,12 @@ type OIDCConnector interface {
SetClaimsToRoles([]ClaimMapping)
// SetDisplay sets friendly name for this provider.
SetDisplay(string)
// GetGoogleServiceAccountURI returns path to google service account URI
GetGoogleServiceAccountURI() string
// GetGoogleAdminEmail returns a google admin user email
// https://developers.google.com/identity/protocols/OAuth2ServiceAccount#delegatingauthority
// "Note: Although you can use service accounts in applications that run from a G Suite domain, service accounts are not members of your G Suite account and arent subject to domain policies set by G Suite administrators. For example, a policy set in the G Suite admin console to restrict the ability of G Suite end users to share documents outside of the domain would not apply to service accounts."
GetGoogleAdminEmail() string
}
// NewOIDCConnector returns a new OIDCConnector based off a name and OIDCConnectorSpecV2.
@ -233,6 +239,16 @@ type OIDCConnectorV2 struct {
Spec OIDCConnectorSpecV2 `json:"spec"`
}
// GetGoogleServiceAccountFile returns an optional path to google service account file
func (o *OIDCConnectorV2) GetGoogleServiceAccountURI() string {
return o.Spec.GoogleServiceAccountURI
}
// GetGoogleAdminEmail returns a google admin user email
func (o *OIDCConnectorV2) GetGoogleAdminEmail() string {
return o.Spec.GoogleAdminEmail
}
// GetVersion returns resource version
func (o *OIDCConnectorV2) GetVersion() string {
return o.Version
@ -525,6 +541,19 @@ func (o *OIDCConnectorV2) Check() error {
}
}
if o.Spec.GoogleServiceAccountURI != "" {
uri, err := utils.ParseSessionsURI(o.Spec.GoogleServiceAccountURI)
if err != nil {
return trace.Wrap(err)
}
if uri.Scheme != teleport.SchemeFile {
return trace.BadParameter("only %v:// scheme is supported for google_service_account_uri", teleport.SchemeFile)
}
if o.Spec.GoogleAdminEmail == "" {
return trace.BadParameter("whenever google_service_account_uri is specified, google_admin_email should be set as well, read https://developers.google.com/identity/protocols/OAuth2ServiceAccount#delegatingauthority for more details")
}
}
return nil
}
@ -581,6 +610,10 @@ type OIDCConnectorSpecV2 struct {
Scope []string `json:"scope,omitempty"`
// ClaimsToRoles specifies dynamic mapping from claims to roles
ClaimsToRoles []ClaimMapping `json:"claims_to_roles,omitempty"`
// GoogleServiceAccountURI is a path to google service account uri
GoogleServiceAccountURI string `json:"google_service_account_uri,omitempty"`
// GoogleAdminEmail is email of google admin to impersonate
GoogleAdminEmail string `json:"google_admin_email,omitempty"`
}
// OIDCConnectorSpecV2Schema is a JSON Schema for OIDC Connector
@ -596,6 +629,8 @@ var OIDCConnectorSpecV2Schema = fmt.Sprintf(`{
"acr_values": {"type": "string"},
"provider": {"type": "string"},
"display": {"type": "string"},
"google_service_account_uri": {"type": "string"},
"google_admin_email": {"type": "string"},
"scope": {
"type": "array",
"items": {