git.iko.soy/typeable/oauth2-proxy
on master help sign in
Merge pull request #797 from grnhse/refactor-provider-authz

Centralize Provider authorization interface method

commitc377466411f2aee180a732187edb638f2f7e57fb authorJoel Speed <Joel.speed@hotmail.co.uk> dateThu 12 Nov 2020, 19:38 (6 years ago) parentdf56a34d7fa979
12 files changed+284−149
CHANGELOG.md+9 oauthproxy.go+25−32 oauthproxy_test.go+9−4 pkg/validation/options.go+9−1 providers/azure.go+6−8 providers/google.go+73−53 providers/google_test.go+50−19 providers/logingov.go+7−10 providers/provider_data.go+13 providers/provider_default.go+31−21 providers/provider_default_test.go+51 providers/providers.go+1−1
CHANGELOG.md+9
@@ -6,6 +6,11 @@
66
77- [#905](https://github.com/oauth2-proxy/oauth2-proxy/pull/905) Existing sessions from v6.0.0 or earlier are no longer valid. They will trigger a reauthentication.
88- [#826](https://github.com/oauth2-proxy/oauth2-proxy/pull/826) `skip-auth-strip-headers` now applies to all requests, not just those where authentication would be skipped.
9+- [#797](https://github.com/oauth2-proxy/oauth2-proxy/pull/797) The behavior of the Google provider Groups restriction changes with this
10+ - Either `--google-group` or the new `--allowed-group` will work for Google now (`--google-group` will be used if both are set)
11+ - Group membership lists will be passed to the backend with the `X-Forwarded-Groups` header
12+ - If you change the list of allowed groups, existing sessions that now don't have a valid group will be logged out immediately.
13+ - Previously, group membership was only checked on session creation and refresh.
914- [#789](https://github.com/oauth2-proxy/oauth2-proxy/pull/789) `--skip-auth-route` is (almost) backwards compatible with `--skip-auth-regex`
1015 - We are marking `--skip-auth-regex` as DEPRECATED and will remove it in the next major version.
1116 - If your regex contains an `=` and you want it for all methods, you will need to add a leading `=` (this is the area where `--skip-auth-regex` doesn't port perfectly)
@@ -18,6 +23,9 @@
1823## Breaking Changes
1924
2025- [#911](https://github.com/oauth2-proxy/oauth2-rpoxy/pull/911) Specifying a non-existent provider will cause OAuth2-Proxy to fail on startup instead of defaulting to "google".
26+- [#797](https://github.com/oauth2-proxy/oauth2-proxy/pull/797) Security changes to Google provider group authorization flow
27+ - If you change the list of allowed groups, existing sessions that now don't have a valid group will be logged out immediately.
28+ - Previously, group membership was only checked on session creation and refresh.
2129- [#722](https://github.com/oauth2-proxy/oauth2-proxy/pull/722) When a Redis session store is configured, OAuth2-Proxy will fail to start up unless connection and health checks to Redis pass
2230- [#800](https://github.com/oauth2-proxy/oauth2-proxy/pull/800) Fix import path for v7. The import path has changed to support the go get installation.
2331 - You can now `go get github.com/oauth2-proxy/oauth2-proxy/v7` to get the latest `v7` version of OAuth2 Proxy
@@ -40,6 +48,7 @@
4048- [#905](https://github.com/oauth2-proxy/oauth2-proxy/pull/905) Remove v5 legacy sessions support (@NickMeves)
4149- [#904](https://github.com/oauth2-proxy/oauth2-proxy/pull/904) Set `skip-auth-strip-headers` to `true` by default (@NickMeves)
4250- [#826](https://github.com/oauth2-proxy/oauth2-proxy/pull/826) Integrate new header injectors into project (@JoelSpeed)
51+- [#797](https://github.com/oauth2-proxy/oauth2-proxy/pull/797) Create universal Authorization behavior across providers (@NickMeves)
4352- [#898](https://github.com/oauth2-proxy/oauth2-proxy/pull/898) Migrate documentation to Docusaurus (@JoelSpeed)
4453- [#754](https://github.com/oauth2-proxy/oauth2-proxy/pull/754) Azure token refresh (@codablock)
4554- [#825](https://github.com/oauth2-proxy/oauth2-proxy/pull/825) Fix code coverage reporting on GitHub actions(@JoelSpeed)
oauthproxy.go+25−32
@@ -42,6 +42,9 @@ var (
4242 // ErrNeedsLogin means the user should be redirected to the login page
4343 ErrNeedsLogin = errors.New("redirect to login page")
4444
45+ // ErrAccessDenied means the user should receive a 401 Unauthorized response
46+ ErrAccessDenied = errors.New("access denied")
47+
4548 // Used to check final redirects are not susceptible to open redirects.
4649 // Matches //, /\ and both of these with whitespace in between (eg / / or / \).
4750 invalidRedirectRegex = regexp.MustCompile(`[/\\](?:[\s\v]*|\.{1,2})[/\\]`)
@@ -105,7 +108,6 @@ type OAuthProxy struct {
105108 trustedIPs *ip.NetSet
106109 Banner string
107110 Footer string
108− AllowedGroups []string
109111
110112 sessionChain alice.Chain
111113 headersChain alice.Chain
@@ -219,7 +221,6 @@ func NewOAuthProxy(opts *options.Options, validator func(string) bool) (*OAuthPr
219221 Banner: opts.Banner,
220222 Footer: opts.Footer,
221223 SignInMessage: buildSignInMessage(opts),
222− AllowedGroups: opts.AllowedGroups,
223224
224225 basicAuthValidator: basicAuthValidator,
225226 displayHtpasswdForm: basicAuthValidator != nil && opts.DisplayHtpasswdForm,
@@ -396,7 +397,7 @@ func (p *OAuthProxy) GetRedirectURI(host string) string {
396397
397398func (p *OAuthProxy) redeemCode(ctx context.Context, host, code string) (*sessionsapi.SessionState, error) {
398399 if code == "" {
399− return nil, errors.New("missing code")
400+ return nil, providers.ErrMissingCode
400401 }
401402 redirectURI := p.GetRedirectURI(host)
402403 s, err := p.provider.Redeem(ctx, redirectURI, code)
@@ -909,11 +910,15 @@ func (p *OAuthProxy) OAuthCallback(rw http.ResponseWriter, req *http.Request) {
909910 }
910911
911912 // set cookie, or deny
912− if p.Validator(session.Email) && p.provider.ValidateGroup(session.Email) {
913+ authorized, err := p.provider.Authorize(req.Context(), session)
914+ if err != nil {
915+ logger.Errorf("Error with authorization: %v", err)
916+ }
917+ if p.Validator(session.Email) && authorized {
913918 logger.PrintAuthf(session.Email, req, logger.AuthSuccess, "Authenticated via OAuth2: %s", session)
914919 err := p.SaveSession(rw, req, session)
915920 if err != nil {
916− logger.Printf("Error saving session state for %s: %v", remoteAddr, err)
921+ logger.Errorf("Error saving session state for %s: %v", remoteAddr, err)
917922 p.ErrorPage(rw, http.StatusInternalServerError, "Internal Server Error", err.Error())
918923 return
919924 }
@@ -967,6 +972,9 @@ func (p *OAuthProxy) Proxy(rw http.ResponseWriter, req *http.Request) {
967972 p.SignInPage(rw, req, http.StatusForbidden)
968973 }
969974
975+ case ErrAccessDenied:
976+ p.ErrorPage(rw, http.StatusUnauthorized, "Permission Denied", "Unauthorized")
977+
970978 default:
971979 // unknown error
972980 logger.Errorf("Unexpected internal error: %v", err)
@@ -977,7 +985,9 @@ func (p *OAuthProxy) Proxy(rw http.ResponseWriter, req *http.Request) {
977985}
978986
979987// getAuthenticatedSession checks whether a user is authenticated and returns a session object and nil error if so
980−// Returns nil, ErrNeedsLogin if user needs to login.
988+// Returns:
989+// - `nil, ErrNeedsLogin` if user needs to login.
990+// - `nil, ErrAccessDenied` if the authenticated user is not authorized
981991// Set-Cookie headers may be set on the response as a side-effect of calling this method.
982992func (p *OAuthProxy) getAuthenticatedSession(rw http.ResponseWriter, req *http.Request) (*sessionsapi.SessionState, error) {
983993 var session *sessionsapi.SessionState
@@ -991,17 +1001,20 @@ func (p *OAuthProxy) getAuthenticatedSession(rw http.ResponseWriter, req *http.R
9911001 return nil, ErrNeedsLogin
9921002 }
9931003
994− invalidEmail := session != nil && session.Email != "" && !p.Validator(session.Email)
995− invalidGroups := session != nil && !p.validateGroups(session.Groups)
1004+ invalidEmail := session.Email != "" && !p.Validator(session.Email)
1005+ authorized, err := p.provider.Authorize(req.Context(), session)
1006+ if err != nil {
1007+ logger.Errorf("Error with authorization: %v", err)
1008+ }
9961009
997− if invalidEmail || invalidGroups {
998− logger.Printf(session.Email, req, logger.AuthFailure, "Invalid authentication via session: removing session %s", session)
1010+ if invalidEmail || !authorized {
1011+ logger.PrintAuthf(session.Email, req, logger.AuthFailure, "Invalid authorization via session: removing session %s", session)
9991012 // Invalid session, clear it
10001013 err := p.ClearSessionCookie(rw, req)
10011014 if err != nil {
1002− logger.Printf("Error clearing session cookie: %v", err)
1015+ logger.Errorf("Error clearing session cookie: %v", err)
10031016 }
1004− return nil, ErrNeedsLogin
1017+ return nil, ErrAccessDenied
10051018 }
10061019
10071020 return session, nil
@@ -1033,23 +1046,3 @@ func (p *OAuthProxy) ErrorJSON(rw http.ResponseWriter, code int) {
10331046 rw.Header().Set("Content-Type", applicationJSON)
10341047 rw.WriteHeader(code)
10351048}
1036−
1037−func (p *OAuthProxy) validateGroups(groups []string) bool {
1038− if len(p.AllowedGroups) == 0 {
1039− return true
1040− }
1041−
1042− allowedGroups := map[string]struct{}{}
1043−
1044− for _, group := range p.AllowedGroups {
1045− allowedGroups[group] = struct{}{}
1046− }
1047−
1048− for _, group := range groups {
1049− if _, ok := allowedGroups[group]; ok {
1050− return true
1051− }
1052− }
1053−
1054− return false
1055−}
oauthproxy_test.go+9−4
@@ -976,8 +976,10 @@ func NewProcessCookieTest(opts ProcessCookieTestOpts, modifiers ...OptionsModifi
976976 return nil, err
977977 }
978978 pcTest.proxy.provider = &TestProvider{
979− ValidToken: opts.providerValidateCookieResponse,
979+ ProviderData: &providers.ProviderData{},
980+ ValidToken: opts.providerValidateCookieResponse,
980981 }
982+ pcTest.proxy.provider.(*TestProvider).SetAllowedGroups(pcTest.opts.AllowedGroups)
981983
982984 // Now, zero-out proxy.CookieRefresh for the cases that don't involve
983985 // access_token validation.
@@ -1284,7 +1286,8 @@ func TestAuthOnlyEndpointSetXAuthRequestHeaders(t *testing.T) {
12841286 t.Fatal(err)
12851287 }
12861288 pcTest.proxy.provider = &TestProvider{
1287− ValidToken: true,
1289+ ProviderData: &providers.ProviderData{},
1290+ ValidToken: true,
12881291 }
12891292
12901293 pcTest.validateUser = true
@@ -1376,7 +1379,8 @@ func TestAuthOnlyEndpointSetBasicAuthTrueRequestHeaders(t *testing.T) {
13761379 t.Fatal(err)
13771380 }
13781381 pcTest.proxy.provider = &TestProvider{
1379− ValidToken: true,
1382+ ProviderData: &providers.ProviderData{},
1383+ ValidToken: true,
13801384 }
13811385
13821386 pcTest.validateUser = true
@@ -1455,7 +1459,8 @@ func TestAuthOnlyEndpointSetBasicAuthFalseRequestHeaders(t *testing.T) {
14551459 t.Fatal(err)
14561460 }
14571461 pcTest.proxy.provider = &TestProvider{
1458− ValidToken: true,
1462+ ProviderData: &providers.ProviderData{},
1463+ ValidToken: true,
14591464 }
14601465
14611466 pcTest.validateUser = true
pkg/validation/options.go+9−1
@@ -233,6 +233,8 @@ func parseProviderInfo(o *options.Options, msgs []string) []string {
233233 p.ValidateURL, msgs = parseURL(o.ValidateURL, "validate", msgs)
234234 p.ProtectedResource, msgs = parseURL(o.ProtectedResource, "resource", msgs)
235235
236+ p.SetAllowedGroups(o.AllowedGroups)
237+
236238 provider := providers.New(o.ProviderType, p)
237239 if provider == nil {
238240 msgs = append(msgs, fmt.Sprintf("invalid setting: provider '%s' is not available", o.ProviderType))
@@ -255,7 +257,13 @@ func parseProviderInfo(o *options.Options, msgs []string) []string {
255257 if err != nil {
256258 msgs = append(msgs, "invalid Google credentials file: "+o.GoogleServiceAccountJSON)
257259 } else {
258− p.SetGroupRestriction(o.GoogleGroups, o.GoogleAdminEmail, file)
260+ groups := o.AllowedGroups
261+ // Backwards compatibility with `--google-group` option
262+ if len(o.GoogleGroups) > 0 {
263+ groups = o.GoogleGroups
264+ p.SetAllowedGroups(groups)
265+ }
266+ p.SetGroupRestriction(groups, o.GoogleAdminEmail, file)
259267 }
260268 }
261269 case *providers.BitbucketProvider:
providers/azure.go+6−8
@@ -108,14 +108,13 @@ func overrideTenantURL(current, defaultURL *url.URL, tenant, path string) {
108108}
109109
110110// Redeem exchanges the OAuth2 authentication token for an ID token
111−func (p *AzureProvider) Redeem(ctx context.Context, redirectURL, code string) (s *sessions.SessionState, err error) {
111+func (p *AzureProvider) Redeem(ctx context.Context, redirectURL, code string) (*sessions.SessionState, error) {
112112 if code == "" {
113− err = errors.New("missing code")
114− return
113+ return nil, ErrMissingCode
115114 }
116115 clientSecret, err := p.GetClientSecret()
117116 if err != nil {
118− return
117+ return nil, err
119118 }
120119
121120 params := url.Values{}
@@ -149,15 +148,14 @@ func (p *AzureProvider) Redeem(ctx context.Context, redirectURL, code string) (s
149148
150149 created := time.Now()
151150 expires := time.Unix(jsonResponse.ExpiresOn, 0)
152− s = &sessions.SessionState{
151+
152+ return &sessions.SessionState{
153153 AccessToken: jsonResponse.AccessToken,
154154 IDToken: jsonResponse.IDToken,
155155 CreatedAt: &created,
156156 ExpiresOn: &expires,
157157 RefreshToken: jsonResponse.RefreshToken,
158− }
159− return
160−
158+ }, nil
161159}
162160
163161// RefreshSessionIfNeeded checks if the session has expired and uses the
providers/google.go+73−53
@@ -25,10 +25,16 @@ import (
2525// GoogleProvider represents an Google based Identity Provider
2626type GoogleProvider struct {
2727 *ProviderData
28+
2829 RedeemRefreshURL *url.URL
29− // GroupValidator is a function that determines if the passed email is in
30− // the configured Google group.
31− GroupValidator func(string) bool
30+
31+ // groupValidator is a function that determines if the user in the passed
32+ // session is a member of any of the configured Google groups.
33+ //
34+ // This hits the Google API for each group, so it is called on Redeem &
35+ // Refresh. `Authorize` uses the results of this saved in `session.Groups`
36+ // Since it is called on every request.
37+ groupValidator func(*sessions.SessionState) bool
3238}
3339
3440var _ Provider = (*GoogleProvider)(nil)
@@ -84,9 +90,9 @@ func NewGoogleProvider(p *ProviderData) *GoogleProvider {
8490 })
8591 return &GoogleProvider{
8692 ProviderData: p,
87− // Set a default GroupValidator to just always return valid (true), it will
93+ // Set a default groupValidator to just always return valid (true), it will
8894 // be overwritten if we configured a Google group restriction.
89− GroupValidator: func(email string) bool {
95+ groupValidator: func(*sessions.SessionState) bool {
9096 return true
9197 },
9298 }
@@ -118,14 +124,13 @@ func claimsFromIDToken(idToken string) (*claims, error) {
118124}
119125
120126// Redeem exchanges the OAuth2 authentication token for an ID token
121−func (p *GoogleProvider) Redeem(ctx context.Context, redirectURL, code string) (s *sessions.SessionState, err error) {
127+func (p *GoogleProvider) Redeem(ctx context.Context, redirectURL, code string) (*sessions.SessionState, error) {
122128 if code == "" {
123− err = errors.New("missing code")
124− return
129+ return nil, ErrMissingCode
125130 }
126131 clientSecret, err := p.GetClientSecret()
127132 if err != nil {
128− return
133+ return nil, err
129134 }
130135
131136 params := url.Values{}
@@ -155,12 +160,13 @@ func (p *GoogleProvider) Redeem(ctx context.Context, redirectURL, code string) (
155160
156161 c, err := claimsFromIDToken(jsonResponse.IDToken)
157162 if err != nil {
158− return
163+ return nil, err
159164 }
160165
161166 created := time.Now()
162167 expires := time.Now().Add(time.Duration(jsonResponse.ExpiresIn) * time.Second).Truncate(time.Second)
163− s = &sessions.SessionState{
168+
169+ return &sessions.SessionState{
164170 AccessToken: jsonResponse.AccessToken,
165171 IDToken: jsonResponse.IDToken,
166172 CreatedAt: &created,
@@ -168,18 +174,40 @@ func (p *GoogleProvider) Redeem(ctx context.Context, redirectURL, code string) (
168174 RefreshToken: jsonResponse.RefreshToken,
169175 Email: c.Email,
170176 User: c.Subject,
171− }
172− return
177+ }, nil
178+}
179+
180+// EnrichSessionState checks the listed Google Groups configured and adds any
181+// that the user is a member of to session.Groups.
182+func (p *GoogleProvider) EnrichSessionState(ctx context.Context, s *sessions.SessionState) error {
183+ // TODO (@NickMeves) - Move to pure EnrichSessionState logic and stop
184+ // reusing legacy `groupValidator`.
185+ //
186+ // This is called here to get the validator to do the `session.Groups`
187+ // populating logic.
188+ p.groupValidator(s)
189+
190+ return nil
173191}
174192
175193// SetGroupRestriction configures the GoogleProvider to restrict access to the
176194// specified group(s). AdminEmail has to be an administrative email on the domain that is
177195// checked. CredentialsFile is the path to a json file containing a Google service
178196// account credentials.
197+//
198+// TODO (@NickMeves) - Unit Test this OR refactor away from groupValidator func
179199func (p *GoogleProvider) SetGroupRestriction(groups []string, adminEmail string, credentialsReader io.Reader) {
180200 adminService := getAdminService(adminEmail, credentialsReader)
181− p.GroupValidator = func(email string) bool {
182− return userInGroup(adminService, groups, email)
201+ p.groupValidator = func(s *sessions.SessionState) bool {
202+ // Reset our saved Groups in case membership changed
203+ // This is used by `Authorize` on every request
204+ s.Groups = make([]string, 0, len(groups))
205+ for _, group := range groups {
206+ if userInGroup(adminService, group, s.Email) {
207+ s.Groups = append(s.Groups, group)
208+ }
209+ }
210+ return len(s.Groups) > 0
183211 }
184212}
185213
@@ -203,52 +231,41 @@ func getAdminService(adminEmail string, credentialsReader io.Reader) *admin.Serv
203231 return adminService
204232}
205233
206−func userInGroup(service *admin.Service, groups []string, email string) bool {
207− for _, group := range groups {
208− // Use the HasMember API to checking for the user's presence in each group or nested subgroups
209− req := service.Members.HasMember(group, email)
234+func userInGroup(service *admin.Service, group string, email string) bool {
235+ // Use the HasMember API to checking for the user's presence in each group or nested subgroups
236+ req := service.Members.HasMember(group, email)
237+ r, err := req.Do()
238+ if err == nil {
239+ return r.IsMember
240+ }
241+
242+ gerr, ok := err.(*googleapi.Error)
243+ switch {
244+ case ok && gerr.Code == 404:
245+ logger.Errorf("error checking membership in group %s: group does not exist", group)
246+ case ok && gerr.Code == 400:
247+ // It is possible for Members.HasMember to return false even if the email is a group member.
248+ // One case that can cause this is if the user email is from a different domain than the group,
249+ // e.g. "member@otherdomain.com" in the group "group@mydomain.com" will result in a 400 error
250+ // from the HasMember API. In that case, attempt to query the member object directly from the group.
251+ req := service.Members.Get(group, email)
210252 r, err := req.Do()
211253 if err != nil {
212− gerr, ok := err.(*googleapi.Error)
213− switch {
214− case ok && gerr.Code == 404:
215− logger.Errorf("error checking membership in group %s: group does not exist", group)
216− case ok && gerr.Code == 400:
217− // It is possible for Members.HasMember to return false even if the email is a group member.
218− // One case that can cause this is if the user email is from a different domain than the group,
219− // e.g. "member@otherdomain.com" in the group "group@mydomain.com" will result in a 400 error
220− // from the HasMember API. In that case, attempt to query the member object directly from the group.
221− req := service.Members.Get(group, email)
222− r, err := req.Do()
223−
224− if err != nil {
225− logger.Errorf("error using get API to check member %s of google group %s: user not in the group", email, group)
226− continue
227− }
228−
229− // If the non-domain user is found within the group, still verify that they are "ACTIVE".
230− // Do not count the user as belonging to a group if they have another status ("ARCHIVED", "SUSPENDED", or "UNKNOWN").
231− if r.Status == "ACTIVE" {
232− return true
233− }
234− default:
235− logger.Errorf("error checking group membership: %v", err)
236− }
237− continue
254+ logger.Errorf("error using get API to check member %s of google group %s: user not in the group", email, group)
255+ return false
238256 }
239− if r.IsMember {
257+
258+ // If the non-domain user is found within the group, still verify that they are "ACTIVE".
259+ // Do not count the user as belonging to a group if they have another status ("ARCHIVED", "SUSPENDED", or "UNKNOWN").
260+ if r.Status == "ACTIVE" {
240261 return true
241262 }
263+ default:
264+ logger.Errorf("error checking group membership: %v", err)
242265 }
243266 return false
244267}
245268
246−// ValidateGroup validates that the provided email exists in the configured Google
247−// group(s).
248−func (p *GoogleProvider) ValidateGroup(email string) bool {
249− return p.GroupValidator(email)
250−}
251−
252269// RefreshSessionIfNeeded checks if the session has expired and uses the
253270// RefreshToken to fetch a new ID token if required
254271func (p *GoogleProvider) RefreshSessionIfNeeded(ctx context.Context, s *sessions.SessionState) (bool, error) {
@@ -261,8 +278,11 @@ func (p *GoogleProvider) RefreshSessionIfNeeded(ctx context.Context, s *sessions
261278 return false, err
262279 }
263280
281+ // TODO (@NickMeves) - Align Group authorization needs with other providers'
282+ // behavior in the `RefreshSession` case.
283+ //
264284 // re-check that the user is in the proper google group(s)
265− if !p.ValidateGroup(s.Email) {
285+ if !p.groupValidator(s) {
266286 return false, fmt.Errorf("%s is no longer in the group(s)", s.Email)
267287 }
268288
providers/google_test.go+50−19
@@ -10,6 +10,7 @@ import (
1010 "net/url"
1111 "testing"
1212
13+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
1314 . "github.com/onsi/gomega"
1415 "github.com/stretchr/testify/assert"
1516 admin "google.golang.org/api/admin/directory/v1"
@@ -109,21 +110,50 @@ func TestGoogleProviderGetEmailAddress(t *testing.T) {
109110 assert.Equal(t, "refresh12345", session.RefreshToken)
110111}
111112
112−func TestGoogleProviderValidateGroup(t *testing.T) {
113− p := newGoogleProvider()
114− p.GroupValidator = func(email string) bool {
115− return email == "michael.bland@gsa.gov"
113+func TestGoogleProviderGroupValidator(t *testing.T) {
114+ const sessionEmail = "michael.bland@gsa.gov"
115+
116+ testCases := map[string]struct {
117+ session *sessions.SessionState
118+ validatorFunc func(*sessions.SessionState) bool
119+ expectedAuthZ bool
120+ }{
121+ "Email is authorized with groupValidator": {
122+ session: &sessions.SessionState{
123+ Email: sessionEmail,
124+ },
125+ validatorFunc: func(s *sessions.SessionState) bool {
126+ return s.Email == sessionEmail
127+ },
128+ expectedAuthZ: true,
129+ },
130+ "Email is denied with groupValidator": {
131+ session: &sessions.SessionState{
132+ Email: sessionEmail,
133+ },
134+ validatorFunc: func(s *sessions.SessionState) bool {
135+ return s.Email != sessionEmail
136+ },
137+ expectedAuthZ: false,
138+ },
139+ "Default does no authorization checks": {
140+ session: &sessions.SessionState{
141+ Email: sessionEmail,
142+ },
143+ validatorFunc: nil,
144+ expectedAuthZ: true,
145+ },
116146 }
117− assert.Equal(t, true, p.ValidateGroup("michael.bland@gsa.gov"))
118− p.GroupValidator = func(email string) bool {
119− return email != "michael.bland@gsa.gov"
147+ for name, tc := range testCases {
148+ t.Run(name, func(t *testing.T) {
149+ g := NewWithT(t)
150+ p := newGoogleProvider()
151+ if tc.validatorFunc != nil {
152+ p.groupValidator = tc.validatorFunc
153+ }
154+ g.Expect(p.groupValidator(tc.session)).To(Equal(tc.expectedAuthZ))
155+ })
120156 }
121− assert.Equal(t, false, p.ValidateGroup("michael.bland@gsa.gov"))
122−}
123−
124−func TestGoogleProviderWithoutValidateGroup(t *testing.T) {
125− p := newGoogleProvider()
126− assert.Equal(t, true, p.ValidateGroup("michael.bland@gsa.gov"))
127157}
128158
129159//
@@ -196,7 +226,7 @@ func TestGoogleProviderGetEmailAddressEmailMissing(t *testing.T) {
196226
197227}
198228
199−func TestGoogleProviderUserInGroup(t *testing.T) {
229+func TestGoogleProvider_userInGroup(t *testing.T) {
200230 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
201231 if r.URL.Path == "/groups/group@example.com/hasMember/member-in-domain@example.com" {
202232 fmt.Fprintln(w, `{"isMember": true}`)
@@ -233,18 +263,19 @@ func TestGoogleProviderUserInGroup(t *testing.T) {
233263 ctx := context.Background()
234264
235265 service, err := admin.NewService(ctx, option.WithHTTPClient(client))
266+ assert.NoError(t, err)
267+
236268 service.BasePath = ts.URL
237− assert.Equal(t, nil, err)
238269
239− result := userInGroup(service, []string{"group@example.com"}, "member-in-domain@example.com")
270+ result := userInGroup(service, "group@example.com", "member-in-domain@example.com")
240271 assert.True(t, result)
241272
242− result = userInGroup(service, []string{"group@example.com"}, "member-out-of-domain@otherexample.com")
273+ result = userInGroup(service, "group@example.com", "member-out-of-domain@otherexample.com")
243274 assert.True(t, result)
244275
245− result = userInGroup(service, []string{"group@example.com"}, "non-member-in-domain@example.com")
276+ result = userInGroup(service, "group@example.com", "non-member-in-domain@example.com")
246277 assert.False(t, result)
247278
248− result = userInGroup(service, []string{"group@example.com"}, "non-member-out-of-domain@otherexample.com")
279+ result = userInGroup(service, "group@example.com", "non-member-out-of-domain@otherexample.com")
249280 assert.False(t, result)
250281}
providers/logingov.go+7−10
@@ -4,7 +4,6 @@ import (
44 "bytes"
55 "context"
66 "crypto/rsa"
7− "errors"
87 "fmt"
98 "math/rand"
109 "net/url"
@@ -153,10 +152,9 @@ func emailFromUserInfo(ctx context.Context, accessToken string, userInfoEndpoint
153152}
154153
155154// Redeem exchanges the OAuth2 authentication token for an ID token
156−func (p *LoginGovProvider) Redeem(ctx context.Context, redirectURL, code string) (s *sessions.SessionState, err error) {
155+func (p *LoginGovProvider) Redeem(ctx context.Context, redirectURL, code string) (*sessions.SessionState, error) {
157156 if code == "" {
158− err = errors.New("missing code")
159− return
157+ return nil, ErrMissingCode
160158 }
161159
162160 claims := &jwt.StandardClaims{
@@ -169,7 +167,7 @@ func (p *LoginGovProvider) Redeem(ctx context.Context, redirectURL, code string)
169167 token := jwt.NewWithClaims(jwt.GetSigningMethod("RS256"), claims)
170168 ss, err := token.SignedString(p.JWTKey)
171169 if err != nil {
172− return
170+ return nil, err
173171 }
174172
175173 params := url.Values{}
@@ -199,28 +197,27 @@ func (p *LoginGovProvider) Redeem(ctx context.Context, redirectURL, code string)
199197 // check nonce here
200198 err = checkNonce(jsonResponse.IDToken, p)
201199 if err != nil {
202− return
200+ return nil, err
203201 }
204202
205203 // Get the email address
206204 var email string
207205 email, err = emailFromUserInfo(ctx, jsonResponse.AccessToken, p.ProfileURL.String())
208206 if err != nil {
209− return
207+ return nil, err
210208 }
211209
212210 created := time.Now()
213211 expires := time.Now().Add(time.Duration(jsonResponse.ExpiresIn) * time.Second).Truncate(time.Second)
214212
215213 // Store the data that we found in the session state
216− s = &sessions.SessionState{
214+ return &sessions.SessionState{
217215 AccessToken: jsonResponse.AccessToken,
218216 IDToken: jsonResponse.IDToken,
219217 CreatedAt: &created,
220218 ExpiresOn: &expires,
221219 Email: email,
222− }
223− return
220+ }, nil
224221}
225222
226223// GetLoginURL overrides GetLoginURL to add login.gov parameters
providers/provider_data.go+13
@@ -26,6 +26,10 @@ type ProviderData struct {
2626 ClientSecretFile string
2727 Scope string
2828 Prompt string
29+
30+ // Universal Group authorization data structure
31+ // any provider can set to consume
32+ AllowedGroups map[string]struct{}
2933}
3034
3135// Data returns the ProviderData
@@ -45,6 +49,15 @@ func (p *ProviderData) GetClientSecret() (clientSecret string, err error) {
4549 return string(fileClientSecret), nil
4650}
4751
52+// SetAllowedGroups organizes a group list into the AllowedGroups map
53+// to be consumed by Authorize implementations
54+func (p *ProviderData) SetAllowedGroups(groups []string) {
55+ p.AllowedGroups = make(map[string]struct{}, len(groups))
56+ for _, group := range groups {
57+ p.AllowedGroups[group] = struct{}{}
58+ }
59+}
60+
4861type providerDefaults struct {
4962 name string
5063 loginURL *url.URL
providers/provider_default.go+31−21
@@ -19,18 +19,21 @@ var (
1919 // implementation method that doesn't have sensible defaults
2020 ErrNotImplemented = errors.New("not implemented")
2121
22+ // ErrMissingCode is returned when a Redeem method is called with an empty
23+ // code
24+ ErrMissingCode = errors.New("missing code")
25+
2226 _ Provider = (*ProviderData)(nil)
2327)
2428
2529// Redeem provides a default implementation of the OAuth2 token redemption process
26−func (p *ProviderData) Redeem(ctx context.Context, redirectURL, code string) (s *sessions.SessionState, err error) {
30+func (p *ProviderData) Redeem(ctx context.Context, redirectURL, code string) (*sessions.SessionState, error) {
2731 if code == "" {
28− err = errors.New("missing code")
29− return
32+ return nil, ErrMissingCode
3033 }
3134 clientSecret, err := p.GetClientSecret()
3235 if err != nil {
33− return
36+ return nil, err
3437 }
3538
3639 params := url.Values{}
@@ -59,24 +62,21 @@ func (p *ProviderData) Redeem(ctx context.Context, redirectURL, code string) (s
5962 }
6063 err = result.UnmarshalInto(&jsonResponse)
6164 if err == nil {
62− s = &sessions.SessionState{
65+ return &sessions.SessionState{
6366 AccessToken: jsonResponse.AccessToken,
64− }
65− return
67+ }, nil
6668 }
6769
68− var v url.Values
69− v, err = url.ParseQuery(string(result.Body()))
70+ values, err := url.ParseQuery(string(result.Body()))
7071 if err != nil {
71− return
72+ return nil, err
7273 }
73− if a := v.Get("access_token"); a != "" {
74+ if token := values.Get("access_token"); token != "" {
7475 created := time.Now()
75− s = &sessions.SessionState{AccessToken: a, CreatedAt: &created}
76− } else {
77− err = fmt.Errorf("no access token found %s", result.Body())
76+ return &sessions.SessionState{AccessToken: token, CreatedAt: &created}, nil
7877 }
79− return
78+
79+ return nil, fmt.Errorf("no access token found %s", result.Body())
8080}
8181
8282// GetLoginURL with typical oauth parameters
@@ -92,18 +92,28 @@ func (p *ProviderData) GetEmailAddress(_ context.Context, _ *sessions.SessionSta
9292 return "", ErrNotImplemented
9393}
9494
95−// ValidateGroup validates that the provided email exists in the configured provider
96−// email group(s).
97−func (p *ProviderData) ValidateGroup(_ string) bool {
98− return true
99−}
100−
10195// EnrichSessionState is called after Redeem to allow providers to enrich session fields
10296// such as User, Email, Groups with provider specific API calls.
10397func (p *ProviderData) EnrichSessionState(_ context.Context, _ *sessions.SessionState) error {
10498 return nil
10599}
106100
101+// Authorize performs global authorization on an authenticated session.
102+// This is not used for fine-grained per route authorization rules.
103+func (p *ProviderData) Authorize(_ context.Context, s *sessions.SessionState) (bool, error) {
104+ if len(p.AllowedGroups) == 0 {
105+ return true, nil
106+ }
107+
108+ for _, group := range s.Groups {
109+ if _, ok := p.AllowedGroups[group]; ok {
110+ return true, nil
111+ }
112+ }
113+
114+ return false, nil
115+}
116+
107117// ValidateSessionState validates the AccessToken
108118func (p *ProviderData) ValidateSessionState(ctx context.Context, s *sessions.SessionState) bool {
109119 return validateToken(ctx, p, s.AccessToken, nil)
providers/provider_default_test.go+51
@@ -7,6 +7,7 @@ import (
77 "time"
88
99 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
10+ . "github.com/onsi/gomega"
1011 "github.com/stretchr/testify/assert"
1112)
1213
@@ -53,3 +54,53 @@ func TestEnrichSessionState(t *testing.T) {
5354 s := &sessions.SessionState{}
5455 assert.NoError(t, p.EnrichSessionState(context.Background(), s))
5556}
57+
58+func TestProviderDataAuthorize(t *testing.T) {
59+ testCases := []struct {
60+ name string
61+ allowedGroups []string
62+ groups []string
63+ expectedAuthZ bool
64+ }{
65+ {
66+ name: "NoAllowedGroups",
67+ allowedGroups: []string{},
68+ groups: []string{},
69+ expectedAuthZ: true,
70+ },
71+ {
72+ name: "NoAllowedGroupsUserHasGroups",
73+ allowedGroups: []string{},
74+ groups: []string{"foo", "bar"},
75+ expectedAuthZ: true,
76+ },
77+ {
78+ name: "UserInAllowedGroup",
79+ allowedGroups: []string{"foo"},
80+ groups: []string{"foo", "bar"},
81+ expectedAuthZ: true,
82+ },
83+ {
84+ name: "UserNotInAllowedGroup",
85+ allowedGroups: []string{"bar"},
86+ groups: []string{"baz", "foo"},
87+ expectedAuthZ: false,
88+ },
89+ }
90+
91+ for _, tc := range testCases {
92+ t.Run(tc.name, func(t *testing.T) {
93+ g := NewWithT(t)
94+
95+ session := &sessions.SessionState{
96+ Groups: tc.groups,
97+ }
98+ p := &ProviderData{}
99+ p.SetAllowedGroups(tc.allowedGroups)
100+
101+ authorized, err := p.Authorize(context.Background(), session)
102+ g.Expect(err).ToNot(HaveOccurred())
103+ g.Expect(authorized).To(Equal(tc.expectedAuthZ))
104+ })
105+ }
106+}
providers/providers.go+1−1
@@ -13,8 +13,8 @@ type Provider interface {
1313 // DEPRECATED: Migrate to EnrichSessionState
1414 GetEmailAddress(ctx context.Context, s *sessions.SessionState) (string, error)
1515 Redeem(ctx context.Context, redirectURI, code string) (*sessions.SessionState, error)
16− ValidateGroup(string) bool
1716 EnrichSessionState(ctx context.Context, s *sessions.SessionState) error
17+ Authorize(ctx context.Context, s *sessions.SessionState) (bool, error)
1818 ValidateSessionState(ctx context.Context, s *sessions.SessionState) bool
1919 GetLoginURL(redirectURI, finalRedirect string) string
2020 RefreshSessionIfNeeded(ctx context.Context, s *sessions.SessionState) (bool, error)