git.iko.soy/typeable/oauth2-proxy
on master help sign in
Merge pull request #849 from grnhse/is-831-auth-querystring-groups

Group/Role Access Restriction support in `/oauth2/auth` endpoint

commit89e0a77a8fedd4226ee814ca95ec80f3085024f7 authorNick Meves <nick.meves@greenhouse.io> dateThu 24 Dec 2020, 12:21 (6 years ago) parent8bd2409753f6c5
3 files changed+170−34
CHANGELOG.md+12−2 oauthproxy.go+59−4 oauthproxy_test.go+99−28
CHANGELOG.md+12−2
@@ -6,6 +6,14 @@
66
77- [#936](https://github.com/oauth2-proxy/oauth2-proxy/pull/936) `--user-id-claim` option is deprecated and replaced by `--oidc-email-claim`
88- [#630](https://github.com/oauth2-proxy/oauth2-proxy/pull/630) Gitlab projects needs a Gitlab application with the extra `read_api` enabled
9+- [#849](https://github.com/oauth2-proxy/oauth2-proxy/pull/849) `/oauth2/auth` `allowed_groups` querystring parameter can be paired with the `allowed-groups` configuration option.
10+ - The `allowed_groups` querystring parameter can specify multiple comma delimited groups.
11+ - In this scenario, the user must have a group (from their multiple groups) present in both lists to not get a 401 or 403 response code.
12+ - Example:
13+ - OAuth2-Proxy globally sets the `allowed_groups` as `engineering`.
14+ - An application using Kubernetes ingress uses the `/oauth2/auth` endpoint with `allowed_groups` querystring set to `backend`.
15+ - A user must have a session with the groups `["engineering", "backend"]` to pass authorization.
16+ - Another user with the groups `["engineering", "frontend"]` would fail the querystring authorization portion.
917- [#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.
1018- [#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.
1119- [#797](https://github.com/oauth2-proxy/oauth2-proxy/pull/797) The behavior of the Google provider Groups restriction changes with this
@@ -19,7 +27,8 @@
1927- [#575](https://github.com/oauth2-proxy/oauth2-proxy/pull/575) Sessions from v5.1.1 or earlier will no longer validate since they were not signed with SHA1.
2028 - Sessions from v6.0.0 or later had a graceful conversion to SHA256 that resulted in no reauthentication
2129 - Upgrading from v5.1.1 or earlier will result in a reauthentication
22−- [#616](https://github.com/oauth2-proxy/oauth2-proxy/pull/616) Ensure you have configured oauth2-proxy to use the `groups` scope. The user may be logged out initially as they may not currently have the `groups` claim however after going back through login process wil be authenticated.
30+- [#616](https://github.com/oauth2-proxy/oauth2-proxy/pull/616) Ensure you have configured oauth2-proxy to use the `groups` scope.
31+ - The user may be logged out initially as they may not currently have the `groups` claim however after going back through login process wil be authenticated.
2332- [#839](https://github.com/oauth2-proxy/oauth2-proxy/pull/839) Enables complex data structures for group claim entries, which are output as Json by default.
2433
2534## Breaking Changes
@@ -50,12 +59,13 @@
5059- [#938](https://github.com/oauth2-proxy/oauth2-proxy/pull/938) Cleanup missed provider renaming refactor methods (@NickMeves)
5160- [#816](https://github.com/oauth2-proxy/oauth2-proxy/pull/816) (via [#936](https://github.com/oauth2-proxy/oauth2-proxy/pull/936)) Support non-list group claims (@loafoe)
5261- [#936](https://github.com/oauth2-proxy/oauth2-proxy/pull/936) Refactor OIDC Provider and support groups from Profile URL (@NickMeves)
62+- [#869](https://github.com/oauth2-proxy/oauth2-proxy/pull/869) Streamline provider interface method names and signatures (@NickMeves)
63+- [#849](https://github.com/oauth2-proxy/oauth2-proxy/pull/849) Support group authorization on `oauth2/auth` endpoint via `allowed_groups` querystring (@NickMeves)
5364- [#925](https://github.com/oauth2-proxy/oauth2-proxy/pull/925) Fix basic auth legacy header conversion (@JoelSpeed)
5465- [#916](https://github.com/oauth2-proxy/oauth2-proxy/pull/916) Add AlphaOptions struct to prepare for alpha config loading (@JoelSpeed)
5566- [#923](https://github.com/oauth2-proxy/oauth2-proxy/pull/923) Support TLS 1.3 (@aajisaka)
5667- [#918](https://github.com/oauth2-proxy/oauth2-proxy/pull/918) Fix log header output (@JoelSpeed)
5768- [#911](https://github.com/oauth2-proxy/oauth2-proxy/pull/911) Validate provider type on startup.
58−- [#869](https://github.com/oauth2-proxy/oauth2-proxy/pull/869) Streamline provider interface method names and signatures (@NickMeves)
5969- [#906](https://github.com/oauth2-proxy/oauth2-proxy/pull/906) Set up v6.1.x versioned documentation as default documentation (@JoelSpeed)
6070- [#905](https://github.com/oauth2-proxy/oauth2-proxy/pull/905) Remove v5 legacy sessions support (@NickMeves)
6171- [#904](https://github.com/oauth2-proxy/oauth2-proxy/pull/904) Set `skip-auth-strip-headers` to `true` by default (@NickMeves)
oauthproxy.go+59−4
@@ -744,7 +744,7 @@ func (p *OAuthProxy) serveHTTP(rw http.ResponseWriter, req *http.Request) {
744744 case path == p.OAuthCallbackPath:
745745 p.OAuthCallback(rw, req)
746746 case path == p.AuthOnlyPath:
747− p.AuthenticateOnly(rw, req)
747+ p.AuthOnly(rw, req)
748748 case path == p.UserInfoPath:
749749 p.UserInfo(rw, req)
750750 default:
@@ -925,11 +925,19 @@ func (p *OAuthProxy) OAuthCallback(rw http.ResponseWriter, req *http.Request) {
925925 }
926926}
927927
928−// AuthenticateOnly checks whether the user is currently logged in
929−func (p *OAuthProxy) AuthenticateOnly(rw http.ResponseWriter, req *http.Request) {
928+// AuthOnly checks whether the user is currently logged in (both authentication
929+// and optional authorization).
930+func (p *OAuthProxy) AuthOnly(rw http.ResponseWriter, req *http.Request) {
930931 session, err := p.getAuthenticatedSession(rw, req)
931932 if err != nil {
932− http.Error(rw, "unauthorized request", http.StatusUnauthorized)
933+ http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
934+ return
935+ }
936+
937+ // Unauthorized cases need to return 403 to prevent infinite redirects with
938+ // subrequest architectures
939+ if !authOnlyAuthorize(req, session) {
940+ http.Error(rw, http.StatusText(http.StatusForbidden), http.StatusForbidden)
933941 return
934942 }
935943
@@ -1016,6 +1024,53 @@ func (p *OAuthProxy) getAuthenticatedSession(rw http.ResponseWriter, req *http.R
10161024 return session, nil
10171025}
10181026
1027+// authOnlyAuthorize handles special authorization logic that is only done
1028+// on the AuthOnly endpoint for use with Nginx subrequest architectures.
1029+//
1030+// TODO (@NickMeves): This method is a placeholder to be extended but currently
1031+// fails the linter. Remove the nolint when functionality expands.
1032+//
1033+//nolint:S1008
1034+func authOnlyAuthorize(req *http.Request, s *sessionsapi.SessionState) bool {
1035+ // Allow secondary group restrictions based on the `allowed_groups`
1036+ // querystring parameter
1037+ if !checkAllowedGroups(req, s) {
1038+ return false
1039+ }
1040+
1041+ return true
1042+}
1043+
1044+func checkAllowedGroups(req *http.Request, s *sessionsapi.SessionState) bool {
1045+ allowedGroups := extractAllowedGroups(req)
1046+ if len(allowedGroups) == 0 {
1047+ return true
1048+ }
1049+
1050+ for _, group := range s.Groups {
1051+ if _, ok := allowedGroups[group]; ok {
1052+ return true
1053+ }
1054+ }
1055+
1056+ return false
1057+}
1058+
1059+func extractAllowedGroups(req *http.Request) map[string]struct{} {
1060+ groups := map[string]struct{}{}
1061+
1062+ query := req.URL.Query()
1063+ for _, allowedGroups := range query["allowed_groups"] {
1064+ for _, group := range strings.Split(allowedGroups, ",") {
1065+ if group != "" {
1066+ groups[group] = struct{}{}
1067+ }
1068+ }
1069+ }
1070+
1071+ return groups
1072+}
1073+
10191074// addHeadersForProxying adds the appropriate headers the request / response for proxying
10201075func (p *OAuthProxy) addHeadersForProxying(rw http.ResponseWriter, req *http.Request, session *sessionsapi.SessionState) {
10211076 if session.Email == "" {
oauthproxy_test.go+99−28
@@ -1197,18 +1197,20 @@ func TestUserInfoEndpointUnauthorizedOnNoCookieSetError(t *testing.T) {
11971197 assert.Equal(t, http.StatusUnauthorized, test.rw.Code)
11981198}
11991199
1200−func NewAuthOnlyEndpointTest(modifiers ...OptionsModifier) (*ProcessCookieTest, error) {
1200+func NewAuthOnlyEndpointTest(querystring string, modifiers ...OptionsModifier) (*ProcessCookieTest, error) {
12011201 pcTest, err := NewProcessCookieTestWithOptionsModifiers(modifiers...)
12021202 if err != nil {
12031203 return nil, err
12041204 }
1205− pcTest.req, _ = http.NewRequest("GET",
1206− pcTest.opts.ProxyPrefix+"/auth", nil)
1205+ pcTest.req, _ = http.NewRequest(
1206+ "GET",
1207+ fmt.Sprintf("%s/auth%s", pcTest.opts.ProxyPrefix, querystring),
1208+ nil)
12071209 return pcTest, nil
12081210}
12091211
12101212func TestAuthOnlyEndpointAccepted(t *testing.T) {
1211− test, err := NewAuthOnlyEndpointTest()
1213+ test, err := NewAuthOnlyEndpointTest("")
12121214 if err != nil {
12131215 t.Fatal(err)
12141216 }
@@ -1226,7 +1228,7 @@ func TestAuthOnlyEndpointAccepted(t *testing.T) {
12261228}
12271229
12281230func TestAuthOnlyEndpointUnauthorizedOnNoCookieSetError(t *testing.T) {
1229− test, err := NewAuthOnlyEndpointTest()
1231+ test, err := NewAuthOnlyEndpointTest("")
12301232 if err != nil {
12311233 t.Fatal(err)
12321234 }
@@ -1234,11 +1236,11 @@ func TestAuthOnlyEndpointUnauthorizedOnNoCookieSetError(t *testing.T) {
12341236 test.proxy.ServeHTTP(test.rw, test.req)
12351237 assert.Equal(t, http.StatusUnauthorized, test.rw.Code)
12361238 bodyBytes, _ := ioutil.ReadAll(test.rw.Body)
1237− assert.Equal(t, "unauthorized request\n", string(bodyBytes))
1239+ assert.Equal(t, "Unauthorized\n", string(bodyBytes))
12381240}
12391241
12401242func TestAuthOnlyEndpointUnauthorizedOnExpiration(t *testing.T) {
1241− test, err := NewAuthOnlyEndpointTest(func(opts *options.Options) {
1243+ test, err := NewAuthOnlyEndpointTest("", func(opts *options.Options) {
12421244 opts.Cookie.Expire = time.Duration(24) * time.Hour
12431245 })
12441246 if err != nil {
@@ -1254,11 +1256,11 @@ func TestAuthOnlyEndpointUnauthorizedOnExpiration(t *testing.T) {
12541256 test.proxy.ServeHTTP(test.rw, test.req)
12551257 assert.Equal(t, http.StatusUnauthorized, test.rw.Code)
12561258 bodyBytes, _ := ioutil.ReadAll(test.rw.Body)
1257− assert.Equal(t, "unauthorized request\n", string(bodyBytes))
1259+ assert.Equal(t, "Unauthorized\n", string(bodyBytes))
12581260}
12591261
12601262func TestAuthOnlyEndpointUnauthorizedOnEmailValidationFailure(t *testing.T) {
1261− test, err := NewAuthOnlyEndpointTest()
1263+ test, err := NewAuthOnlyEndpointTest("")
12621264 if err != nil {
12631265 t.Fatal(err)
12641266 }
@@ -1273,7 +1275,7 @@ func TestAuthOnlyEndpointUnauthorizedOnEmailValidationFailure(t *testing.T) {
12731275 test.proxy.ServeHTTP(test.rw, test.req)
12741276 assert.Equal(t, http.StatusUnauthorized, test.rw.Code)
12751277 bodyBytes, _ := ioutil.ReadAll(test.rw.Body)
1276− assert.Equal(t, "unauthorized request\n", string(bodyBytes))
1278+ assert.Equal(t, "Unauthorized\n", string(bodyBytes))
12771279}
12781280
12791281func TestAuthOnlyEndpointSetXAuthRequestHeaders(t *testing.T) {
@@ -1960,7 +1962,7 @@ func TestGetJwtSession(t *testing.T) {
19601962 verifier := oidc.NewVerifier("https://issuer.example.com", keyset,
19611963 &oidc.Config{ClientID: "https://test.myapp.com", SkipExpiryCheck: true})
19621964
1963− test, err := NewAuthOnlyEndpointTest(func(opts *options.Options) {
1965+ test, err := NewAuthOnlyEndpointTest("", func(opts *options.Options) {
19641966 opts.InjectRequestHeaders = []options.Header{
19651967 {
19661968 Name: "Authorization",
@@ -2028,7 +2030,6 @@ func TestGetJwtSession(t *testing.T) {
20282030 },
20292031 },
20302032 }
2031−
20322033 opts.SkipJwtBearerTokens = true
20332034 opts.SetJWTBearerVerifiers(append(opts.GetJWTBearerVerifiers(), verifier))
20342035 })
@@ -2692,32 +2693,106 @@ func TestProxyAllowedGroups(t *testing.T) {
26922693}
26932694
26942695func TestAuthOnlyAllowedGroups(t *testing.T) {
2695− tests := []struct {
2696+ testCases := []struct {
26962697 name string
26972698 allowedGroups []string
26982699 groups []string
2699− expectUnauthorized bool
2700+ querystring string
2701+ expectedStatusCode int
27002702 }{
2701− {"NoAllowedGroups", []string{}, []string{}, false},
2702− {"NoAllowedGroupsUserHasGroups", []string{}, []string{"a", "b"}, false},
2703− {"UserInAllowedGroup", []string{"a"}, []string{"a", "b"}, false},
2704− {"UserNotInAllowedGroup", []string{"a"}, []string{"c"}, true},
2703+ {
2704+ name: "NoAllowedGroups",
2705+ allowedGroups: []string{},
2706+ groups: []string{},
2707+ querystring: "",
2708+ expectedStatusCode: http.StatusAccepted,
2709+ },
2710+ {
2711+ name: "NoAllowedGroupsUserHasGroups",
2712+ allowedGroups: []string{},
2713+ groups: []string{"a", "b"},
2714+ querystring: "",
2715+ expectedStatusCode: http.StatusAccepted,
2716+ },
2717+ {
2718+ name: "UserInAllowedGroup",
2719+ allowedGroups: []string{"a"},
2720+ groups: []string{"a", "b"},
2721+ querystring: "",
2722+ expectedStatusCode: http.StatusAccepted,
2723+ },
2724+ {
2725+ name: "UserNotInAllowedGroup",
2726+ allowedGroups: []string{"a"},
2727+ groups: []string{"c"},
2728+ querystring: "",
2729+ expectedStatusCode: http.StatusUnauthorized,
2730+ },
2731+ {
2732+ name: "UserInQuerystringGroup",
2733+ allowedGroups: []string{"a", "b"},
2734+ groups: []string{"a", "c"},
2735+ querystring: "?allowed_groups=a",
2736+ expectedStatusCode: http.StatusAccepted,
2737+ },
2738+ {
2739+ name: "UserInMultiParamQuerystringGroup",
2740+ allowedGroups: []string{"a", "b"},
2741+ groups: []string{"b"},
2742+ querystring: "?allowed_groups=a&allowed_groups=b,d",
2743+ expectedStatusCode: http.StatusAccepted,
2744+ },
2745+ {
2746+ name: "UserInOnlyQuerystringGroup",
2747+ allowedGroups: []string{},
2748+ groups: []string{"a", "c"},
2749+ querystring: "?allowed_groups=a,b",
2750+ expectedStatusCode: http.StatusAccepted,
2751+ },
2752+ {
2753+ name: "UserInDelimitedQuerystringGroup",
2754+ allowedGroups: []string{"a", "b", "c"},
2755+ groups: []string{"c"},
2756+ querystring: "?allowed_groups=a,c",
2757+ expectedStatusCode: http.StatusAccepted,
2758+ },
2759+ {
2760+ name: "UserNotInQuerystringGroup",
2761+ allowedGroups: []string{},
2762+ groups: []string{"c"},
2763+ querystring: "?allowed_groups=a,b",
2764+ expectedStatusCode: http.StatusForbidden,
2765+ },
2766+ {
2767+ name: "UserInConfigGroupNotInQuerystringGroup",
2768+ allowedGroups: []string{"a", "b", "c"},
2769+ groups: []string{"c"},
2770+ querystring: "?allowed_groups=a,b",
2771+ expectedStatusCode: http.StatusForbidden,
2772+ },
2773+ {
2774+ name: "UserInQuerystringGroupNotInConfigGroup",
2775+ allowedGroups: []string{"a", "b"},
2776+ groups: []string{"c"},
2777+ querystring: "?allowed_groups=b,c",
2778+ expectedStatusCode: http.StatusUnauthorized,
2779+ },
27052780 }
27062781
2707− for _, tt := range tests {
2708− t.Run(tt.name, func(t *testing.T) {
2782+ for _, tc := range testCases {
2783+ t.Run(tc.name, func(t *testing.T) {
27092784 emailAddress := "test"
27102785 created := time.Now()
27112786
27122787 session := &sessions.SessionState{
2713− Groups: tt.groups,
2788+ Groups: tc.groups,
27142789 Email: emailAddress,
27152790 AccessToken: "oauth_token",
27162791 CreatedAt: &created,
27172792 }
27182793
2719− test, err := NewAuthOnlyEndpointTest(func(opts *options.Options) {
2720− opts.AllowedGroups = tt.allowedGroups
2794+ test, err := NewAuthOnlyEndpointTest(tc.querystring, func(opts *options.Options) {
2795+ opts.AllowedGroups = tc.allowedGroups
27212796 })
27222797 if err != nil {
27232798 t.Fatal(err)
@@ -2728,11 +2803,7 @@ func TestAuthOnlyAllowedGroups(t *testing.T) {
27282803
27292804 test.proxy.ServeHTTP(test.rw, test.req)
27302805
2731− if tt.expectUnauthorized {
2732− assert.Equal(t, http.StatusUnauthorized, test.rw.Code)
2733− } else {
2734− assert.Equal(t, http.StatusAccepted, test.rw.Code)
2735− }
2806+ assert.Equal(t, tc.expectedStatusCode, test.rw.Code)
27362807 })
27372808 }
27382809}