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.
240 lines
7.8 KiB
Go
240 lines
7.8 KiB
Go
package providers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
|
|
admin "google.golang.org/api/admin/directory/v1"
|
|
option "google.golang.org/api/option"
|
|
)
|
|
|
|
func newRedeemServer(body []byte) (*url.URL, *httptest.Server) {
|
|
s := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
rw.Write(body)
|
|
}))
|
|
u, _ := url.Parse(s.URL)
|
|
return u, s
|
|
}
|
|
|
|
func newGoogleProvider() *GoogleProvider {
|
|
return NewGoogleProvider(
|
|
&ProviderData{
|
|
ProviderName: "",
|
|
LoginURL: &url.URL{},
|
|
RedeemURL: &url.URL{},
|
|
ProfileURL: &url.URL{},
|
|
ValidateURL: &url.URL{},
|
|
Scope: ""})
|
|
}
|
|
|
|
func TestGoogleProviderDefaults(t *testing.T) {
|
|
p := newGoogleProvider()
|
|
assert.NotEqual(t, nil, p)
|
|
assert.Equal(t, "Google", p.Data().ProviderName)
|
|
assert.Equal(t, "https://accounts.google.com/o/oauth2/auth?access_type=offline",
|
|
p.Data().LoginURL.String())
|
|
assert.Equal(t, "https://www.googleapis.com/oauth2/v3/token",
|
|
p.Data().RedeemURL.String())
|
|
assert.Equal(t, "https://www.googleapis.com/oauth2/v1/tokeninfo",
|
|
p.Data().ValidateURL.String())
|
|
assert.Equal(t, "", p.Data().ProfileURL.String())
|
|
assert.Equal(t, "profile email", p.Data().Scope)
|
|
}
|
|
|
|
func TestGoogleProviderOverrides(t *testing.T) {
|
|
p := NewGoogleProvider(
|
|
&ProviderData{
|
|
LoginURL: &url.URL{
|
|
Scheme: "https",
|
|
Host: "example.com",
|
|
Path: "/oauth/auth"},
|
|
RedeemURL: &url.URL{
|
|
Scheme: "https",
|
|
Host: "example.com",
|
|
Path: "/oauth/token"},
|
|
ProfileURL: &url.URL{
|
|
Scheme: "https",
|
|
Host: "example.com",
|
|
Path: "/oauth/profile"},
|
|
ValidateURL: &url.URL{
|
|
Scheme: "https",
|
|
Host: "example.com",
|
|
Path: "/oauth/tokeninfo"},
|
|
Scope: "profile"})
|
|
assert.NotEqual(t, nil, p)
|
|
assert.Equal(t, "Google", p.Data().ProviderName)
|
|
assert.Equal(t, "https://example.com/oauth/auth",
|
|
p.Data().LoginURL.String())
|
|
assert.Equal(t, "https://example.com/oauth/token",
|
|
p.Data().RedeemURL.String())
|
|
assert.Equal(t, "https://example.com/oauth/profile",
|
|
p.Data().ProfileURL.String())
|
|
assert.Equal(t, "https://example.com/oauth/tokeninfo",
|
|
p.Data().ValidateURL.String())
|
|
assert.Equal(t, "profile", p.Data().Scope)
|
|
}
|
|
|
|
type redeemResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
ExpiresIn int64 `json:"expires_in"`
|
|
IDToken string `json:"id_token"`
|
|
}
|
|
|
|
func TestGoogleProviderGetEmailAddress(t *testing.T) {
|
|
p := newGoogleProvider()
|
|
body, err := json.Marshal(redeemResponse{
|
|
AccessToken: "a1234",
|
|
ExpiresIn: 10,
|
|
RefreshToken: "refresh12345",
|
|
IDToken: "ignored prefix." + base64.URLEncoding.EncodeToString([]byte(`{"email": "michael.bland@gsa.gov", "email_verified":true}`)),
|
|
})
|
|
assert.Equal(t, nil, err)
|
|
var server *httptest.Server
|
|
p.RedeemURL, server = newRedeemServer(body)
|
|
defer server.Close()
|
|
|
|
session, err := p.Redeem("http://redirect/", "code1234")
|
|
assert.Equal(t, nil, err)
|
|
assert.NotEqual(t, session, nil)
|
|
assert.Equal(t, "michael.bland@gsa.gov", session.Email)
|
|
assert.Equal(t, "a1234", session.AccessToken)
|
|
assert.Equal(t, "refresh12345", session.RefreshToken)
|
|
}
|
|
|
|
func TestGoogleProviderValidateGroup(t *testing.T) {
|
|
p := newGoogleProvider()
|
|
p.GroupValidator = func(email string) bool {
|
|
return email == "michael.bland@gsa.gov"
|
|
}
|
|
assert.Equal(t, true, p.ValidateGroup("michael.bland@gsa.gov"))
|
|
p.GroupValidator = func(email string) bool {
|
|
return email != "michael.bland@gsa.gov"
|
|
}
|
|
assert.Equal(t, false, p.ValidateGroup("michael.bland@gsa.gov"))
|
|
}
|
|
|
|
func TestGoogleProviderWithoutValidateGroup(t *testing.T) {
|
|
p := newGoogleProvider()
|
|
assert.Equal(t, true, p.ValidateGroup("michael.bland@gsa.gov"))
|
|
}
|
|
|
|
//
|
|
func TestGoogleProviderGetEmailAddressInvalidEncoding(t *testing.T) {
|
|
p := newGoogleProvider()
|
|
body, err := json.Marshal(redeemResponse{
|
|
AccessToken: "a1234",
|
|
IDToken: "ignored prefix." + `{"email": "michael.bland@gsa.gov"}`,
|
|
})
|
|
assert.Equal(t, nil, err)
|
|
var server *httptest.Server
|
|
p.RedeemURL, server = newRedeemServer(body)
|
|
defer server.Close()
|
|
|
|
session, err := p.Redeem("http://redirect/", "code1234")
|
|
assert.NotEqual(t, nil, err)
|
|
if session != nil {
|
|
t.Errorf("expect nill session %#v", session)
|
|
}
|
|
}
|
|
|
|
func TestGoogleProviderGetEmailAddressInvalidJson(t *testing.T) {
|
|
p := newGoogleProvider()
|
|
|
|
body, err := json.Marshal(redeemResponse{
|
|
AccessToken: "a1234",
|
|
IDToken: "ignored prefix." + base64.URLEncoding.EncodeToString([]byte(`{"email": michael.bland@gsa.gov}`)),
|
|
})
|
|
assert.Equal(t, nil, err)
|
|
var server *httptest.Server
|
|
p.RedeemURL, server = newRedeemServer(body)
|
|
defer server.Close()
|
|
|
|
session, err := p.Redeem("http://redirect/", "code1234")
|
|
assert.NotEqual(t, nil, err)
|
|
if session != nil {
|
|
t.Errorf("expect nill session %#v", session)
|
|
}
|
|
|
|
}
|
|
|
|
func TestGoogleProviderGetEmailAddressEmailMissing(t *testing.T) {
|
|
p := newGoogleProvider()
|
|
body, err := json.Marshal(redeemResponse{
|
|
AccessToken: "a1234",
|
|
IDToken: "ignored prefix." + base64.URLEncoding.EncodeToString([]byte(`{"not_email": "missing"}`)),
|
|
})
|
|
assert.Equal(t, nil, err)
|
|
var server *httptest.Server
|
|
p.RedeemURL, server = newRedeemServer(body)
|
|
defer server.Close()
|
|
|
|
session, err := p.Redeem("http://redirect/", "code1234")
|
|
assert.NotEqual(t, nil, err)
|
|
if session != nil {
|
|
t.Errorf("expect nill session %#v", session)
|
|
}
|
|
|
|
}
|
|
|
|
func TestGoogleProviderUserInGroup(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/groups/group@example.com/hasMember/member-in-domain@example.com" {
|
|
fmt.Fprintln(w, `{"isMember": true}`)
|
|
} else if r.URL.Path == "/groups/group@example.com/hasMember/non-member-in-domain@example.com" {
|
|
fmt.Fprintln(w, `{"isMember": false}`)
|
|
} else if r.URL.Path == "/groups/group@example.com/hasMember/member-out-of-domain@otherexample.com" {
|
|
http.Error(
|
|
w,
|
|
`{"error": {"errors": [{"domain": "global","reason": "invalid","message": "Invalid Input: memberKey"}],"code": 400,"message": "Invalid Input: memberKey"}}`,
|
|
http.StatusBadRequest,
|
|
)
|
|
} else if r.URL.Path == "/groups/group@example.com/hasMember/non-member-out-of-domain@otherexample.com" {
|
|
http.Error(
|
|
w,
|
|
`{"error": {"errors": [{"domain": "global","reason": "invalid","message": "Invalid Input: memberKey"}],"code": 400,"message": "Invalid Input: memberKey"}}`,
|
|
http.StatusBadRequest,
|
|
)
|
|
} else if r.URL.Path == "/groups/group@example.com/members/non-member-out-of-domain@otherexample.com" {
|
|
// note that the client currently doesn't care what this response text or code is - any error here results in failure to match the group
|
|
http.Error(
|
|
w,
|
|
`{"error": {"errors": [{"domain": "global","reason": "notFound","message": "Resource Not Found: memberKey"}],"code": 404,"message": "Resource Not Found: memberKey"}}`,
|
|
http.StatusNotFound,
|
|
)
|
|
} else if r.URL.Path == "/groups/group@example.com/members/member-out-of-domain@otherexample.com" {
|
|
fmt.Fprintln(w,
|
|
`{"kind": "admin#directory#member","etag":"12345","id":"1234567890","email": "member-out-of-domain@otherexample.com","role": "MEMBER","type": "USER","status": "ACTIVE","delivery_settings": "ALL_MAIL"}}`,
|
|
)
|
|
}
|
|
}))
|
|
defer ts.Close()
|
|
|
|
client := ts.Client()
|
|
ctx := context.Background()
|
|
|
|
service, err := admin.NewService(ctx, option.WithHTTPClient(client))
|
|
service.BasePath = ts.URL
|
|
assert.Equal(t, nil, err)
|
|
|
|
result := userInGroup(service, []string{"group@example.com"}, "member-in-domain@example.com")
|
|
assert.True(t, result)
|
|
|
|
result = userInGroup(service, []string{"group@example.com"}, "member-out-of-domain@otherexample.com")
|
|
assert.True(t, result)
|
|
|
|
result = userInGroup(service, []string{"group@example.com"}, "non-member-in-domain@example.com")
|
|
assert.False(t, result)
|
|
|
|
result = userInGroup(service, []string{"group@example.com"}, "non-member-out-of-domain@otherexample.com")
|
|
assert.False(t, result)
|
|
}
|