diff --git a/constants.go b/constants.go index c3da26766a600..e2521d3981b24 100644 --- a/constants.go +++ b/constants.go @@ -387,6 +387,15 @@ const ( TraitInternalKubeGroupsVariable = "{{internal.kubernetes_groups}}" ) +const ( + // GSuiteIssuerURL is issuer URL used for GSuite provider + GSuiteIssuerURL = "https://accounts.google.com" + // GSuiteGroupsEndpoint is gsuite API endpoint + 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" +) + // SCP is Secure Copy. const SCP = "scp" diff --git a/lib/auth/oidc.go b/lib/auth/oidc.go index f137897d3254b..7611012ee0e52 100644 --- a/lib/auth/oidc.go +++ b/lib/auth/oidc.go @@ -1,5 +1,5 @@ /* -Copyright 2017 Gravitational, Inc. +Copyright 2017-2019 Gravitational, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -19,6 +19,7 @@ package auth import ( "encoding/json" "fmt" + "io/ioutil" "net/http" "net/url" "time" @@ -29,6 +30,7 @@ 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" @@ -175,7 +177,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(), code) + claims, err := a.getClaims(oidcClient, connector.GetIssuerURL(), connector.GetScope(), code) if err != nil { return nil, trace.OAuth2( oauth2.ErrorUnsupportedResponseType, "unable to construct claims", q) @@ -476,6 +478,138 @@ 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) + 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) { + 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, + }, nil +} + +type gsuiteClient struct { + phttp.Client + url url.URL + userEmail string + accessToken string + auditLog events.IAuditLog +} + +// fetchGroups fetches GSuite groups a user belongs to and returns +// "groups" claim with +func (g *gsuiteClient) fetchGroups() (jose.Claims, error) { + count := 0 + var groups []string + var nextPageToken string +collect: + for { + if count > MaxPages { + warningMessage := "Truncating list of teams used to populate claims: " + + "hit maximum number pages that can be fetched from GSuite." + + // Print warning to Teleport logs as well as the Audit Log. + log.Warnf(warningMessage) + g.auditLog.EmitAuditEvent(events.UserLoginEvent, events.EventFields{ + events.LoginMethod: events.LoginMethodOIDC, + events.AuthAttemptMessage: warningMessage, + }) + break collect + } + response, err := g.fetchGroupsPage(nextPageToken) + if err != nil { + return nil, trace.Wrap(err) + } + groups = append(groups, response.groups()...) + if response.NextPageToken == "" { + break collect + } + count++ + nextPageToken = response.NextPageToken + } + return jose.Claims{"groups": groups}, nil +} + +func (g *gsuiteClient) fetchGroupsPage(pageToken string) (*gsuiteGroups, error) { + // copy URL to avoid modifying the same url + // with query parameters + u := g.url + q := u.Query() + q.Set("userKey", g.userEmail) + if pageToken != "" { + q.Set("pageToken", pageToken) + } + u.RawQuery = q.Encode() + endpoint := u.String() + + log.Debugf("Fetching OIDC claims from GSuite groups endpoint: %q.", endpoint) + + req, err := http.NewRequest("GET", endpoint, nil) + if err != nil { + return nil, trace.Wrap(err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", g.accessToken)) + + resp, err := g.Do(req) + if err != nil { + return nil, trace.Wrap(err) + } + defer resp.Body.Close() + + bytes, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, trace.Wrap(err) + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return nil, trace.AccessDenied("bad status code: %v %v", resp.StatusCode, string(bytes)) + } + var response gsuiteGroups + if err := json.Unmarshal(bytes, &response); err != nil { + return nil, trace.BadParameter("failed to parse response: %v", err) + } + return &response, nil +} + +type gsuiteGroups struct { + NextPageToken string `json:"nextPageToken"` + Groups []gsuiteGroup `json:"groups"` +} + +func (g gsuiteGroups) groups() []string { + groups := make([]string, len(g.Groups)) + for i, group := range g.Groups { + groups[i] = group.Email + } + return groups +} + +type gsuiteGroup struct { + Email string `json:"email"` +} + // mergeClaims merges b into a. func mergeClaims(a jose.Claims, b jose.Claims) (jose.Claims, error) { for k, v := range b { @@ -489,7 +623,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, code string) (jose.Claims, error) { +func (a *AuthServer) getClaims(oidcClient *oidc.Client, issuerURL string, scope []string, code string) (jose.Claims, error) { var err error oac, err := oidcClient.OAuthClient() @@ -545,6 +679,30 @@ func (a *AuthServer) getClaims(oidcClient *oidc.Client, issuerURL string, code s return nil, trace.Wrap(err) } + // 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) { + email, _, err := claims.StringClaim("email") + if err != nil { + return nil, trace.Wrap(err) + } + gsuiteClaims, err := a.claimsFromGSuite(oidcClient, issuerURL, email, t.AccessToken) + if err != nil { + if !trace.IsNotFound(err) { + return nil, trace.Wrap(err) + } + log.Debugf("Found no GSuite claims.") + } else { + if gsuiteClaims != nil { + log.Debugf("Got GSuiteclaims: %v.", gsuiteClaims) + } + claims, err = mergeClaims(claims, gsuiteClaims) + if err != nil { + return nil, trace.Wrap(err) + } + } + } + return claims, nil }