7d910c0ae8
* 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.
305 lines
8.9 KiB
Go
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
|
|
}
|