oauth2_proxy/providers/google.go
Justin Palpant 7d910c0ae8 Check Google group membership with hasMember and get. (#224)
* Check Google group membership with hasMember and get.

This PR is an enhancement built on
https://github.com/pusher/oauth2_proxy/pull/160. That PR reduces the
number of calls to the Google Admin API and simplifies the code by
using the hasMember method. It also supports checking membership in
nested groups.

However, the above message doesn't handle members who are not a part
of the domain. The hasMember API returns a 400 for that case. As a
fallback, when the API returns a 400, this change will try using the
`get` API which works as expected for members who aren't a part of the
domain. Supporting members who belong to the Google group but aren't
part of the domain is a requested feature from
https://github.com/pusher/oauth2_proxy/issues/95.

https://developers.google.com/admin-sdk/directory/v1/reference/members/get

Note that nested members who are not a part of the domain will not be
correctly detected with this change.

* Update CHANGELOG.

* Fix incorrect JSON and stop escaping strings.

* Add comments for each scenario.
2019-08-06 10:38:24 +01:00

305 lines
8.9 KiB
Go

package providers
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/url"
"strings"
"time"
"github.com/pusher/oauth2_proxy/pkg/apis/sessions"
"github.com/pusher/oauth2_proxy/pkg/logger"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
admin "google.golang.org/api/admin/directory/v1"
"google.golang.org/api/googleapi"
)
// GoogleProvider represents an Google based Identity Provider
type GoogleProvider struct {
*ProviderData
RedeemRefreshURL *url.URL
// GroupValidator is a function that determines if the passed email is in
// the configured Google group.
GroupValidator func(string) bool
}
type claims struct {
Subject string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
}
// NewGoogleProvider initiates a new GoogleProvider
func NewGoogleProvider(p *ProviderData) *GoogleProvider {
p.ProviderName = "Google"
if p.LoginURL.String() == "" {
p.LoginURL = &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",
}
}
if p.RedeemURL.String() == "" {
p.RedeemURL = &url.URL{Scheme: "https",
Host: "www.googleapis.com",
Path: "/oauth2/v3/token"}
}
if p.ValidateURL.String() == "" {
p.ValidateURL = &url.URL{Scheme: "https",
Host: "www.googleapis.com",
Path: "/oauth2/v1/tokeninfo"}
}
if p.Scope == "" {
p.Scope = "profile email"
}
return &GoogleProvider{
ProviderData: p,
// Set a default GroupValidator to just always return valid (true), it will
// be overwritten if we configured a Google group restriction.
GroupValidator: func(email string) bool {
return true
},
}
}
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(redirectURL, code string) (s *sessions.SessionState, err error) {
if code == "" {
err = errors.New("missing code")
return
}
params := url.Values{}
params.Add("redirect_uri", redirectURL)
params.Add("client_id", p.ClientID)
params.Add("client_secret", p.ClientSecret)
params.Add("code", code)
params.Add("grant_type", "authorization_code")
var req *http.Request
req, err = http.NewRequest("POST", p.RedeemURL.String(), bytes.NewBufferString(params.Encode()))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
var body []byte
body, err = ioutil.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return
}
if resp.StatusCode != 200 {
err = fmt.Errorf("got %d from %q %s", resp.StatusCode, p.RedeemURL.String(), body)
return
}
var jsonResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
IDToken string `json:"id_token"`
}
err = json.Unmarshal(body, &jsonResponse)
if err != nil {
return
}
c, err := claimsFromIDToken(jsonResponse.IDToken)
if err != nil {
return
}
s = &sessions.SessionState{
AccessToken: jsonResponse.AccessToken,
IDToken: jsonResponse.IDToken,
CreatedAt: time.Now(),
ExpiresOn: time.Now().Add(time.Duration(jsonResponse.ExpiresIn) * time.Second).Truncate(time.Second),
RefreshToken: jsonResponse.RefreshToken,
Email: c.Email,
User: c.Subject,
}
return
}
// SetGroupRestriction configures the GoogleProvider to restrict access to the
// specified group(s). AdminEmail has to be an administrative email on the domain that is
// checked. CredentialsFile is the path to a json file containing a Google service
// account credentials.
func (p *GoogleProvider) SetGroupRestriction(groups []string, adminEmail string, credentialsReader io.Reader) {
adminService := getAdminService(adminEmail, credentialsReader)
p.GroupValidator = func(email string) bool {
return userInGroup(adminService, groups, email)
}
}
func getAdminService(adminEmail string, credentialsReader io.Reader) *admin.Service {
data, err := ioutil.ReadAll(credentialsReader)
if err != nil {
logger.Fatal("can't read Google credentials file:", err)
}
conf, err := google.JWTConfigFromJSON(data, admin.AdminDirectoryUserReadonlyScope, admin.AdminDirectoryGroupReadonlyScope)
if err != nil {
logger.Fatal("can't load Google credentials file:", err)
}
conf.Subject = adminEmail
client := conf.Client(oauth2.NoContext)
adminService, err := admin.New(client)
if err != nil {
logger.Fatal(err)
}
return adminService
}
func userInGroup(service *admin.Service, groups []string, email string) bool {
for _, group := range groups {
// Use the HasMember API to checking for the user's presence in each group or nested subgroups
req := service.Members.HasMember(group, email)
r, err := req.Do()
if err != nil {
err, ok := err.(*googleapi.Error)
if ok && err.Code == 404 {
logger.Printf("error checking membership in group %s: group does not exist", group)
} else if ok && err.Code == 400 {
// It is possible for Members.HasMember to return false even if the email is a group member.
// One case that can cause this is if the user email is from a different domain than the group,
// e.g. "member@otherdomain.com" in the group "group@mydomain.com" will result in a 400 error
// from the HasMember API. In that case, attempt to query the member object directly from the group.
req := service.Members.Get(group, email)
r, err := req.Do()
if err != nil {
logger.Printf("error using get API to check member %s of google group %s: user not in the group", email, group)
continue
}
// If the non-domain user is found within the group, still verify that they are "ACTIVE".
// Do not count the user as belonging to a group if they have another status ("ARCHIVED", "SUSPENDED", or "UNKNOWN").
if r.Status == "ACTIVE" {
return true
}
} else {
logger.Printf("error checking group membership: %v", err)
}
continue
}
if r.IsMember {
return true
}
}
return false
}
// ValidateGroup validates that the provided email exists in the configured Google
// group(s).
func (p *GoogleProvider) ValidateGroup(email string) bool {
return p.GroupValidator(email)
}
// RefreshSessionIfNeeded checks if the session has expired and uses the
// RefreshToken to fetch a new ID token if required
func (p *GoogleProvider) RefreshSessionIfNeeded(s *sessions.SessionState) (bool, error) {
if s == nil || s.ExpiresOn.After(time.Now()) || s.RefreshToken == "" {
return false, nil
}
newToken, newIDToken, duration, err := p.redeemRefreshToken(s.RefreshToken)
if err != nil {
return false, err
}
// re-check that the user is in the proper google group(s)
if !p.ValidateGroup(s.Email) {
return false, fmt.Errorf("%s is no longer in the group(s)", s.Email)
}
origExpiration := s.ExpiresOn
s.AccessToken = newToken
s.IDToken = newIDToken
s.ExpiresOn = time.Now().Add(duration).Truncate(time.Second)
logger.Printf("refreshed access token %s (expired on %s)", s, origExpiration)
return true, nil
}
func (p *GoogleProvider) redeemRefreshToken(refreshToken string) (token string, idToken string, expires time.Duration, err error) {
// https://developers.google.com/identity/protocols/OAuth2WebServer#refresh
params := url.Values{}
params.Add("client_id", p.ClientID)
params.Add("client_secret", p.ClientSecret)
params.Add("refresh_token", refreshToken)
params.Add("grant_type", "refresh_token")
var req *http.Request
req, err = http.NewRequest("POST", p.RedeemURL.String(), bytes.NewBufferString(params.Encode()))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
var body []byte
body, err = ioutil.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return
}
if resp.StatusCode != 200 {
err = fmt.Errorf("got %d from %q %s", resp.StatusCode, p.RedeemURL.String(), body)
return
}
var data struct {
AccessToken string `json:"access_token"`
ExpiresIn int64 `json:"expires_in"`
IDToken string `json:"id_token"`
}
err = json.Unmarshal(body, &data)
if err != nil {
return
}
token = data.AccessToken
idToken = data.IDToken
expires = time.Duration(data.ExpiresIn) * time.Second
return
}