git.iko.soy/typeable/oauth2-proxy
on master help sign in
Add support to ensure user belongs in required groups when using the OIDC provider
9d59519Stefan Sedich
CHANGELOG.md+3
@@ -4,11 +4,14 @@
4
4
5## Important Notes
5## Important Notes
6
6
7+- [#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.
8+
7## Breaking Changes
9## Breaking Changes
8
10
9## Changes since v6.1.1
11## Changes since v6.1.1
10
12
11- [#764](https://github.com/oauth2-proxy/oauth2-proxy/pull/764) Document bcrypt encryption for htpasswd (and hide SHA) (@lentzi90)
13- [#764](https://github.com/oauth2-proxy/oauth2-proxy/pull/764) Document bcrypt encryption for htpasswd (and hide SHA) (@lentzi90)
14+- [#616](https://github.com/oauth2-proxy/oauth2-proxy/pull/616) Add support to ensure user belongs in required groups when using the OIDC provider (@stefansedich)
12
15
13# v6.1.1
16# v6.1.1
14
17
docs/configuration/configuration.md+4−2
@@ -78,12 +78,13 @@ An example [oauth2-proxy.cfg]({{ site.gitweb }}/contrib/oauth2-proxy.cfg.example
78| `--insecure-oidc-skip-issuer-verification` | bool | allow the OIDC issuer URL to differ from the expected (currently required for Azure multi-tenant compatibility) | false |
78| `--insecure-oidc-skip-issuer-verification` | bool | allow the OIDC issuer URL to differ from the expected (currently required for Azure multi-tenant compatibility) | false |
79| `--oidc-issuer-url` | string | the OpenID Connect issuer URL, e.g. `"https://accounts.google.com"` | |
79| `--oidc-issuer-url` | string | the OpenID Connect issuer URL, e.g. `"https://accounts.google.com"` | |
80| `--oidc-jwks-url` | string | OIDC JWKS URI for token verification; required if OIDC discovery is disabled | |
80| `--oidc-jwks-url` | string | OIDC JWKS URI for token verification; required if OIDC discovery is disabled | |
81+| `--oidc-groups-claim` | string | which claim contains the user groups | `"groups"` |
81| `--pass-access-token` | bool | pass OAuth access_token to upstream via X-Forwarded-Access-Token header | false |
82| `--pass-access-token` | bool | pass OAuth access_token to upstream via X-Forwarded-Access-Token header | false |
82| `--pass-authorization-header` | bool | pass OIDC IDToken to upstream via Authorization Bearer header | false |
83| `--pass-authorization-header` | bool | pass OIDC IDToken to upstream via Authorization Bearer header | false |
83| `--pass-basic-auth` | bool | pass HTTP Basic Auth, X-Forwarded-User, X-Forwarded-Email and X-Forwarded-Preferred-Username information to upstream | true |
84| `--pass-basic-auth` | bool | pass HTTP Basic Auth, X-Forwarded-User, X-Forwarded-Email and X-Forwarded-Preferred-Username information to upstream | true |
84| `--prefer-email-to-user` | bool | Prefer to use the Email address as the Username when passing information to upstream. Will only use Username if Email is unavailable, e.g. htaccess authentication. Used in conjunction with `--pass-basic-auth` and `--pass-user-headers` | false |
85| `--prefer-email-to-user` | bool | Prefer to use the Email address as the Username when passing information to upstream. Will only use Username if Email is unavailable, e.g. htaccess authentication. Used in conjunction with `--pass-basic-auth` and `--pass-user-headers` | false |
85| `--pass-host-header` | bool | pass the request Host Header to upstream | true |
86| `--pass-host-header` | bool | pass the request Host Header to upstream | true |
86−| `--pass-user-headers` | bool | pass X-Forwarded-User, X-Forwarded-Email and X-Forwarded-Preferred-Username information to upstream | true |
87+| `--pass-user-headers` | bool | pass X-Forwarded-User, X-Forwarded-Groups, X-Forwarded-Email and X-Forwarded-Preferred-Username information to upstream | true |
87| `--profile-url` | string | Profile access endpoint | |
88| `--profile-url` | string | Profile access endpoint | |
88| `--prompt` | string | [OIDC prompt](https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest); if present, `approval-prompt` is ignored | `""` |
89| `--prompt` | string | [OIDC prompt](https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest); if present, `approval-prompt` is ignored | `""` |
89| `--provider` | string | OAuth provider | google |
90| `--provider` | string | OAuth provider | google |
@@ -112,7 +113,7 @@ An example [oauth2-proxy.cfg]({{ site.gitweb }}/contrib/oauth2-proxy.cfg.example
112| `--scope` | string | OAuth scope specification | |
113| `--scope` | string | OAuth scope specification | |
113| `--session-cookie-minimal` | bool | strip OAuth tokens from cookie session stores if they aren't needed (cookie session store only) | false |
114| `--session-cookie-minimal` | bool | strip OAuth tokens from cookie session stores if they aren't needed (cookie session store only) | false |
114| `--session-store-type` | string | [Session data storage backend](configuration/sessions); redis or cookie | cookie |
115| `--session-store-type` | string | [Session data storage backend](configuration/sessions); redis or cookie | cookie |
115−| `--set-xauthrequest` | bool | set X-Auth-Request-User, X-Auth-Request-Email and X-Auth-Request-Preferred-Username response headers (useful in Nginx auth_request mode) | false |
116+| `--set-xauthrequest` | bool | set X-Auth-Request-User, X-Auth-Request-Groups, X-Auth-Request-Email and X-Auth-Request-Preferred-Username response headers (useful in Nginx auth_request mode) | false |
116| `--set-authorization-header` | bool | set Authorization Bearer response header (useful in Nginx auth_request mode) | false |
117| `--set-authorization-header` | bool | set Authorization Bearer response header (useful in Nginx auth_request mode) | false |
117| `--set-basic-auth` | bool | set HTTP Basic Auth information in response (useful in Nginx auth_request mode) | false |
118| `--set-basic-auth` | bool | set HTTP Basic Auth information in response (useful in Nginx auth_request mode) | false |
118| `--signature-key` | string | GAP-Signature request signature key (algorithm:secretkey) | |
119| `--signature-key` | string | GAP-Signature request signature key (algorithm:secretkey) | |
@@ -131,6 +132,7 @@ An example [oauth2-proxy.cfg]({{ site.gitweb }}/contrib/oauth2-proxy.cfg.example
131| `--tls-key-file` | string | path to private key file | |
132| `--tls-key-file` | string | path to private key file | |
132| `--upstream` | string \| list | the http url(s) of the upstream endpoint, file:// paths for static files or `static://<status_code>` for static response. Routing is based on the path | |
133| `--upstream` | string \| list | the http url(s) of the upstream endpoint, file:// paths for static files or `static://<status_code>` for static response. Routing is based on the path | |
133| `--user-id-claim` | string | which claim contains the user ID | \["email"\] |
134| `--user-id-claim` | string | which claim contains the user ID | \["email"\] |
135+| `--allowed-group` | string \| list | restrict logins to members of this group (may be given multiple times) | |
134| `--validate-url` | string | Access token validation endpoint | |
136| `--validate-url` | string | Access token validation endpoint | |
135| `--version` | n/a | print version string | |
137| `--version` | n/a | print version string | |
136| `--whitelist-domain` | string \| list | allowed domains for redirection after authentication. Prefix domain with a `.` to allow subdomains (e.g. `.example.com`)&nbsp;\[[2](#footnote2)\] | |
138| `--whitelist-domain` | string \| list | allowed domains for redirection after authentication. Prefix domain with a `.` to allow subdomains (e.g. `.example.com`)&nbsp;\[[2](#footnote2)\] | |
oauthproxy.go+44−1
@@ -102,6 +102,7 @@ type OAuthProxy struct {
102 trustedIPs *ip.NetSet
102 trustedIPs *ip.NetSet
103 Banner string
103 Banner string
104 Footer string
104 Footer string
105+ AllowedGroups []string
105
106
106 sessionChain alice.Chain
107 sessionChain alice.Chain
107}
108}
@@ -215,6 +216,7 @@ func NewOAuthProxy(opts *options.Options, validator func(string) bool) (*OAuthPr
215 Banner: opts.Banner,
216 Banner: opts.Banner,
216 Footer: opts.Footer,
217 Footer: opts.Footer,
217 SignInMessage: buildSignInMessage(opts),
218 SignInMessage: buildSignInMessage(opts),
219+ AllowedGroups: opts.AllowedGroups,
218
220
219 basicAuthValidator: basicAuthValidator,
221 basicAuthValidator: basicAuthValidator,
220 displayHtpasswdForm: basicAuthValidator != nil,
222 displayHtpasswdForm: basicAuthValidator != nil,
@@ -888,7 +890,10 @@ func (p *OAuthProxy) getAuthenticatedSession(rw http.ResponseWriter, req *http.R
888 return nil, ErrNeedsLogin
890 return nil, ErrNeedsLogin
889 }
891 }
890
892
891− if session != nil && session.Email != "" && !p.Validator(session.Email) {
893+ invalidEmail := session != nil && session.Email != "" && !p.Validator(session.Email)
894+ invalidGroups := session != nil && !p.validateGroups(session.Groups)
895+
896+ if invalidEmail || invalidGroups {
892 logger.Printf(session.Email, req, logger.AuthFailure, "Invalid authentication via session: removing session %s", session)
897 logger.Printf(session.Email, req, logger.AuthFailure, "Invalid authentication via session: removing session %s", session)
893 // Invalid session, clear it
898 // Invalid session, clear it
894 err := p.ClearSessionCookie(rw, req)
899 err := p.ClearSessionCookie(rw, req)
@@ -942,6 +947,14 @@ func (p *OAuthProxy) addHeadersForProxying(rw http.ResponseWriter, req *http.Req
942 } else {
947 } else {
943 req.Header.Del("X-Forwarded-Preferred-Username")
948 req.Header.Del("X-Forwarded-Preferred-Username")
944 }
949 }
950+
951+ if len(session.Groups) > 0 {
952+ for _, group := range session.Groups {
953+ req.Header.Add("X-Forwarded-Groups", group)
954+ }
955+ } else {
956+ req.Header.Del("X-Forwarded-Groups")
957+ }
945 }
958 }
946
959
947 if p.SetXAuthRequest {
960 if p.SetXAuthRequest {
@@ -964,6 +977,14 @@ func (p *OAuthProxy) addHeadersForProxying(rw http.ResponseWriter, req *http.Req
964 rw.Header().Del("X-Auth-Request-Access-Token")
977 rw.Header().Del("X-Auth-Request-Access-Token")
965 }
978 }
966 }
979 }
980+
981+ if len(session.Groups) > 0 {
982+ for _, group := range session.Groups {
983+ rw.Header().Add("X-Auth-Request-Groups", group)
984+ }
985+ } else {
986+ rw.Header().Del("X-Auth-Request-Groups")
987+ }
967 }
988 }
968
989
969 if p.PassAccessToken {
990 if p.PassAccessToken {
@@ -1012,6 +1033,7 @@ func (p *OAuthProxy) addHeadersForProxying(rw http.ResponseWriter, req *http.Req
1012func (p *OAuthProxy) stripAuthHeaders(req *http.Request) {
1033func (p *OAuthProxy) stripAuthHeaders(req *http.Request) {
1013 if p.PassBasicAuth {
1034 if p.PassBasicAuth {
1014 req.Header.Del("X-Forwarded-User")
1035 req.Header.Del("X-Forwarded-User")
1036+ req.Header.Del("X-Forwarded-Groups")
1015 req.Header.Del("X-Forwarded-Email")
1037 req.Header.Del("X-Forwarded-Email")
1016 req.Header.Del("X-Forwarded-Preferred-Username")
1038 req.Header.Del("X-Forwarded-Preferred-Username")
1017 req.Header.Del("Authorization")
1039 req.Header.Del("Authorization")
@@ -1019,6 +1041,7 @@ func (p *OAuthProxy) stripAuthHeaders(req *http.Request) {
1019
1041
1020 if p.PassUserHeaders {
1042 if p.PassUserHeaders {
1021 req.Header.Del("X-Forwarded-User")
1043 req.Header.Del("X-Forwarded-User")
1044+ req.Header.Del("X-Forwarded-Groups")
1022 req.Header.Del("X-Forwarded-Email")
1045 req.Header.Del("X-Forwarded-Email")
1023 req.Header.Del("X-Forwarded-Preferred-Username")
1046 req.Header.Del("X-Forwarded-Preferred-Username")
1024 }
1047 }
@@ -1049,3 +1072,23 @@ func (p *OAuthProxy) ErrorJSON(rw http.ResponseWriter, code int) {
1049 rw.Header().Set("Content-Type", applicationJSON)
1072 rw.Header().Set("Content-Type", applicationJSON)
1050 rw.WriteHeader(code)
1073 rw.WriteHeader(code)
1051}
1074}
1075+
1076+func (p *OAuthProxy) validateGroups(groups []string) bool {
1077+ if len(p.AllowedGroups) == 0 {
1078+ return true
1079+ }
1080+
1081+ allowedGroups := map[string]struct{}{}
1082+
1083+ for _, group := range p.AllowedGroups {
1084+ allowedGroups[group] = struct{}{}
1085+ }
1086+
1087+ for _, group := range groups {
1088+ if _, ok := allowedGroups[group]; ok {
1089+ return true
1090+ }
1091+ }
1092+
1093+ return false
1094+}
oauthproxy_test.go+146−1
@@ -592,6 +592,37 @@ func TestPassUserHeadersWithEmail(t *testing.T) {
592 }
592 }
593}
593}
594
594
595+func TestPassGroupsHeadersWithGroups(t *testing.T) {
596+ opts := baseTestOptions()
597+ err := validation.Validate(opts)
598+ assert.NoError(t, err)
599+
600+ const emailAddress = "john.doe@example.com"
601+ const userName = "9fcab5c9b889a557"
602+
603+ groups := []string{"a", "b"}
604+ created := time.Now()
605+ session := &sessions.SessionState{
606+ User: userName,
607+ Groups: groups,
608+ Email: emailAddress,
609+ AccessToken: "oauth_token",
610+ CreatedAt: &created,
611+ }
612+ {
613+ rw := httptest.NewRecorder()
614+ req, _ := http.NewRequest("GET", opts.ProxyPrefix+"/testCase0", nil)
615+ proxy, err := NewOAuthProxy(opts, func(email string) bool {
616+ return email == emailAddress
617+ })
618+ if err != nil {
619+ t.Fatal(err)
620+ }
621+ proxy.addHeadersForProxying(rw, req, session)
622+ assert.Equal(t, groups, req.Header["X-Forwarded-Groups"])
623+ }
624+}
625+
595func TestStripAuthHeaders(t *testing.T) {
626func TestStripAuthHeaders(t *testing.T) {
596 testCases := map[string]struct {
627 testCases := map[string]struct {
597 SkipAuthStripHeaders bool
628 SkipAuthStripHeaders bool
@@ -609,6 +640,7 @@ func TestStripAuthHeaders(t *testing.T) {
609 PassAuthorization: false,
640 PassAuthorization: false,
610 StrippedHeaders: map[string]bool{
641 StrippedHeaders: map[string]bool{
611 "X-Forwarded-User": true,
642 "X-Forwarded-User": true,
643+ "X-Forwared-Groups": true,
612 "X-Forwarded-Email": true,
644 "X-Forwarded-Email": true,
613 "X-Forwarded-Preferred-Username": true,
645 "X-Forwarded-Preferred-Username": true,
614 "X-Forwarded-Access-Token": false,
646 "X-Forwarded-Access-Token": false,
@@ -623,6 +655,7 @@ func TestStripAuthHeaders(t *testing.T) {
623 PassAuthorization: false,
655 PassAuthorization: false,
624 StrippedHeaders: map[string]bool{
656 StrippedHeaders: map[string]bool{
625 "X-Forwarded-User": true,
657 "X-Forwarded-User": true,
658+ "X-Forwared-Groups": true,
626 "X-Forwarded-Email": true,
659 "X-Forwarded-Email": true,
627 "X-Forwarded-Preferred-Username": true,
660 "X-Forwarded-Preferred-Username": true,
628 "X-Forwarded-Access-Token": true,
661 "X-Forwarded-Access-Token": true,
@@ -637,6 +670,7 @@ func TestStripAuthHeaders(t *testing.T) {
637 PassAuthorization: false,
670 PassAuthorization: false,
638 StrippedHeaders: map[string]bool{
671 StrippedHeaders: map[string]bool{
639 "X-Forwarded-User": true,
672 "X-Forwarded-User": true,
673+ "X-Forwared-Groups": true,
640 "X-Forwarded-Email": true,
674 "X-Forwarded-Email": true,
641 "X-Forwarded-Preferred-Username": true,
675 "X-Forwarded-Preferred-Username": true,
642 "X-Forwarded-Access-Token": true,
676 "X-Forwarded-Access-Token": true,
@@ -651,6 +685,7 @@ func TestStripAuthHeaders(t *testing.T) {
651 PassAuthorization: true,
685 PassAuthorization: true,
652 StrippedHeaders: map[string]bool{
686 StrippedHeaders: map[string]bool{
653 "X-Forwarded-User": false,
687 "X-Forwarded-User": false,
688+ "X-Forwared-Groups": false,
654 "X-Forwarded-Email": false,
689 "X-Forwarded-Email": false,
655 "X-Forwarded-Preferred-Username": false,
690 "X-Forwarded-Preferred-Username": false,
656 "X-Forwarded-Access-Token": false,
691 "X-Forwarded-Access-Token": false,
@@ -665,6 +700,7 @@ func TestStripAuthHeaders(t *testing.T) {
665 PassAuthorization: false,
700 PassAuthorization: false,
666 StrippedHeaders: map[string]bool{
701 StrippedHeaders: map[string]bool{
667 "X-Forwarded-User": false,
702 "X-Forwarded-User": false,
703+ "X-Forwared-Groups": false,
668 "X-Forwarded-Email": false,
704 "X-Forwarded-Email": false,
669 "X-Forwarded-Preferred-Username": false,
705 "X-Forwarded-Preferred-Username": false,
670 "X-Forwarded-Access-Token": false,
706 "X-Forwarded-Access-Token": false,
@@ -679,6 +715,7 @@ func TestStripAuthHeaders(t *testing.T) {
679 PassAuthorization: false,
715 PassAuthorization: false,
680 StrippedHeaders: map[string]bool{
716 StrippedHeaders: map[string]bool{
681 "X-Forwarded-User": false,
717 "X-Forwarded-User": false,
718+ "X-Forwared-Groups": false,
682 "X-Forwarded-Email": false,
719 "X-Forwarded-Email": false,
683 "X-Forwarded-Preferred-Username": false,
720 "X-Forwarded-Preferred-Username": false,
684 "X-Forwarded-Access-Token": false,
721 "X-Forwarded-Access-Token": false,
@@ -690,6 +727,7 @@ func TestStripAuthHeaders(t *testing.T) {
690 initialHeaders := map[string]string{
727 initialHeaders := map[string]string{
691 "X-Forwarded-User": "9fcab5c9b889a557",
728 "X-Forwarded-User": "9fcab5c9b889a557",
692 "X-Forwarded-Email": "john.doe@example.com",
729 "X-Forwarded-Email": "john.doe@example.com",
730+ "X-Forwarded-Groups": "a,b,c",
693 "X-Forwarded-Preferred-Username": "john.doe",
731 "X-Forwarded-Preferred-Username": "john.doe",
694 "X-Forwarded-Access-Token": "AccessToken",
732 "X-Forwarded-Access-Token": "AccessToken",
695 "Authorization": "bearer IDToken",
733 "Authorization": "bearer IDToken",
@@ -1333,6 +1371,7 @@ func TestAuthOnlyEndpointSetXAuthRequestHeaders(t *testing.T) {
1333
1371
1334 pcTest.opts = baseTestOptions()
1372 pcTest.opts = baseTestOptions()
1335 pcTest.opts.SetXAuthRequest = true
1373 pcTest.opts.SetXAuthRequest = true
1374+ pcTest.opts.AllowedGroups = []string{"oauth_groups"}
1336 err := validation.Validate(pcTest.opts)
1375 err := validation.Validate(pcTest.opts)
1337 assert.NoError(t, err)
1376 assert.NoError(t, err)
1338
1377
@@ -1354,13 +1393,14 @@ func TestAuthOnlyEndpointSetXAuthRequestHeaders(t *testing.T) {
1354
1393
1355 created := time.Now()
1394 created := time.Now()
1356 startSession := &sessions.SessionState{
1395 startSession := &sessions.SessionState{
1357− User: "oauth_user", Email: "oauth_user@example.com", AccessToken: "oauth_token", CreatedAt: &created}
1396+ User: "oauth_user", Groups: []string{"oauth_groups"}, Email: "oauth_user@example.com", AccessToken: "oauth_token", CreatedAt: &created}
1358 err = pcTest.SaveSession(startSession)
1397 err = pcTest.SaveSession(startSession)
1359 assert.NoError(t, err)
1398 assert.NoError(t, err)
1360
1399
1361 pcTest.proxy.ServeHTTP(pcTest.rw, pcTest.req)
1400 pcTest.proxy.ServeHTTP(pcTest.rw, pcTest.req)
1362 assert.Equal(t, http.StatusAccepted, pcTest.rw.Code)
1401 assert.Equal(t, http.StatusAccepted, pcTest.rw.Code)
1363 assert.Equal(t, "oauth_user", pcTest.rw.Header().Get("X-Auth-Request-User"))
1402 assert.Equal(t, "oauth_user", pcTest.rw.Header().Get("X-Auth-Request-User"))
1403+ assert.Equal(t, startSession.Groups, pcTest.rw.Header().Values("X-Auth-Request-Groups"))
1364 assert.Equal(t, "oauth_user@example.com", pcTest.rw.Header().Get("X-Auth-Request-Email"))
1404 assert.Equal(t, "oauth_user@example.com", pcTest.rw.Header().Get("X-Auth-Request-Email"))
1365}
1405}
1366
1406
@@ -2199,3 +2239,108 @@ func TestTrustedIPs(t *testing.T) {
2199 })
2239 })
2200 }
2240 }
2201}
2241}
2242+
2243+func TestProxyAllowedGroups(t *testing.T) {
2244+ tests := []struct {
2245+ name string
2246+ allowedGroups []string
2247+ groups []string
2248+ expectUnauthorized bool
2249+ }{
2250+ {"NoAllowedGroups", []string{}, []string{}, false},
2251+ {"NoAllowedGroupsUserHasGroups", []string{}, []string{"a", "b"}, false},
2252+ {"UserInAllowedGroup", []string{"a"}, []string{"a", "b"}, false},
2253+ {"UserNotInAllowedGroup", []string{"a"}, []string{"c"}, true},
2254+ }
2255+
2256+ for _, tt := range tests {
2257+ t.Run(tt.name, func(t *testing.T) {
2258+ emailAddress := "test"
2259+ created := time.Now()
2260+
2261+ session := &sessions.SessionState{
2262+ Groups: tt.groups,
2263+ Email: emailAddress,
2264+ AccessToken: "oauth_token",
2265+ CreatedAt: &created,
2266+ }
2267+
2268+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2269+ w.WriteHeader(200)
2270+ }))
2271+ t.Cleanup(upstream.Close)
2272+
2273+ test, err := NewProcessCookieTestWithOptionsModifiers(func(opts *options.Options) {
2274+ opts.AllowedGroups = tt.allowedGroups
2275+ opts.UpstreamServers = options.Upstreams{
2276+ {
2277+ ID: upstream.URL,
2278+ Path: "/",
2279+ URI: upstream.URL,
2280+ },
2281+ }
2282+ })
2283+ if err != nil {
2284+ t.Fatal(err)
2285+ }
2286+
2287+ test.req, _ = http.NewRequest("GET", "/", nil)
2288+
2289+ test.req.Header.Add("accept", applicationJSON)
2290+ test.SaveSession(session)
2291+ test.proxy.ServeHTTP(test.rw, test.req)
2292+
2293+ if tt.expectUnauthorized {
2294+ assert.Equal(t, http.StatusUnauthorized, test.rw.Code)
2295+ } else {
2296+ assert.Equal(t, http.StatusOK, test.rw.Code)
2297+ }
2298+ })
2299+ }
2300+}
2301+
2302+func TestAuthOnlyAllowedGroups(t *testing.T) {
2303+ tests := []struct {
2304+ name string
2305+ allowedGroups []string
2306+ groups []string
2307+ expectUnauthorized bool
2308+ }{
2309+ {"NoAllowedGroups", []string{}, []string{}, false},
2310+ {"NoAllowedGroupsUserHasGroups", []string{}, []string{"a", "b"}, false},
2311+ {"UserInAllowedGroup", []string{"a"}, []string{"a", "b"}, false},
2312+ {"UserNotInAllowedGroup", []string{"a"}, []string{"c"}, true},
2313+ }
2314+
2315+ for _, tt := range tests {
2316+ t.Run(tt.name, func(t *testing.T) {
2317+ emailAddress := "test"
2318+ created := time.Now()
2319+
2320+ session := &sessions.SessionState{
2321+ Groups: tt.groups,
2322+ Email: emailAddress,
2323+ AccessToken: "oauth_token",
2324+ CreatedAt: &created,
2325+ }
2326+
2327+ test, err := NewAuthOnlyEndpointTest(func(opts *options.Options) {
2328+ opts.AllowedGroups = tt.allowedGroups
2329+ })
2330+ if err != nil {
2331+ t.Fatal(err)
2332+ }
2333+
2334+ err = test.SaveSession(session)
2335+ assert.NoError(t, err)
2336+
2337+ test.proxy.ServeHTTP(test.rw, test.req)
2338+
2339+ if tt.expectUnauthorized {
2340+ assert.Equal(t, http.StatusUnauthorized, test.rw.Code)
2341+ } else {
2342+ assert.Equal(t, http.StatusAccepted, test.rw.Code)
2343+ }
2344+ })
2345+ }
2346+}
pkg/apis/options/options.go+5
@@ -93,6 +93,7 @@ type Options struct {
93 InsecureOIDCSkipIssuerVerification bool `flag:"insecure-oidc-skip-issuer-verification" cfg:"insecure_oidc_skip_issuer_verification"`
93 InsecureOIDCSkipIssuerVerification bool `flag:"insecure-oidc-skip-issuer-verification" cfg:"insecure_oidc_skip_issuer_verification"`
94 SkipOIDCDiscovery bool `flag:"skip-oidc-discovery" cfg:"skip_oidc_discovery"`
94 SkipOIDCDiscovery bool `flag:"skip-oidc-discovery" cfg:"skip_oidc_discovery"`
95 OIDCJwksURL string `flag:"oidc-jwks-url" cfg:"oidc_jwks_url"`
95 OIDCJwksURL string `flag:"oidc-jwks-url" cfg:"oidc_jwks_url"`
96+ OIDCGroupsClaim string `flag:"oidc-groups-claim" cfg:"oidc_groups_claim"`
96 LoginURL string `flag:"login-url" cfg:"login_url"`
97 LoginURL string `flag:"login-url" cfg:"login_url"`
97 RedeemURL string `flag:"redeem-url" cfg:"redeem_url"`
98 RedeemURL string `flag:"redeem-url" cfg:"redeem_url"`
98 ProfileURL string `flag:"profile-url" cfg:"profile_url"`
99 ProfileURL string `flag:"profile-url" cfg:"profile_url"`
@@ -102,6 +103,7 @@ type Options struct {
102 Prompt string `flag:"prompt" cfg:"prompt"`
103 Prompt string `flag:"prompt" cfg:"prompt"`
103 ApprovalPrompt string `flag:"approval-prompt" cfg:"approval_prompt"` // Deprecated by OIDC 1.0
104 ApprovalPrompt string `flag:"approval-prompt" cfg:"approval_prompt"` // Deprecated by OIDC 1.0
104 UserIDClaim string `flag:"user-id-claim" cfg:"user_id_claim"`
105 UserIDClaim string `flag:"user-id-claim" cfg:"user_id_claim"`
106+ AllowedGroups []string `flag:"allowed-group" cfg:"allowed_groups"`
105
107
106 SignatureKey string `flag:"signature-key" cfg:"signature_key"`
108 SignatureKey string `flag:"signature-key" cfg:"signature_key"`
107 AcrValues string `flag:"acr-values" cfg:"acr_values"`
109 AcrValues string `flag:"acr-values" cfg:"acr_values"`
@@ -167,6 +169,7 @@ func NewOptions() *Options {
167 InsecureOIDCAllowUnverifiedEmail: false,
169 InsecureOIDCAllowUnverifiedEmail: false,
168 SkipOIDCDiscovery: false,
170 SkipOIDCDiscovery: false,
169 Logging: loggingDefaults(),
171 Logging: loggingDefaults(),
172+ OIDCGroupsClaim: "groups",
170 }
173 }
171}
174}
172
175
@@ -248,6 +251,7 @@ func NewFlagSet() *pflag.FlagSet {
248 flagSet.Bool("insecure-oidc-skip-issuer-verification", false, "Do not verify if issuer matches OIDC discovery URL")
251 flagSet.Bool("insecure-oidc-skip-issuer-verification", false, "Do not verify if issuer matches OIDC discovery URL")
249 flagSet.Bool("skip-oidc-discovery", false, "Skip OIDC discovery and use manually supplied Endpoints")
252 flagSet.Bool("skip-oidc-discovery", false, "Skip OIDC discovery and use manually supplied Endpoints")
250 flagSet.String("oidc-jwks-url", "", "OpenID Connect JWKS URL (ie: https://www.googleapis.com/oauth2/v3/certs)")
253 flagSet.String("oidc-jwks-url", "", "OpenID Connect JWKS URL (ie: https://www.googleapis.com/oauth2/v3/certs)")
254+ flagSet.String("oidc-groups-claim", "groups", "which claim contains the user groups")
251 flagSet.String("login-url", "", "Authentication endpoint")
255 flagSet.String("login-url", "", "Authentication endpoint")
252 flagSet.String("redeem-url", "", "Token redemption endpoint")
256 flagSet.String("redeem-url", "", "Token redemption endpoint")
253 flagSet.String("profile-url", "", "Profile access endpoint")
257 flagSet.String("profile-url", "", "Profile access endpoint")
@@ -265,6 +269,7 @@ func NewFlagSet() *pflag.FlagSet {
265 flagSet.Bool("gcp-healthchecks", false, "Enable GCP/GKE healthcheck endpoints")
269 flagSet.Bool("gcp-healthchecks", false, "Enable GCP/GKE healthcheck endpoints")
266
270
267 flagSet.String("user-id-claim", "email", "which claim contains the user ID")
271 flagSet.String("user-id-claim", "email", "which claim contains the user ID")
272+ flagSet.StringSlice("allowed-group", []string{}, "restrict logins to members of this group (may be given multiple times)")
268
273
269 flagSet.AddFlagSet(cookieFlagSet())
274 flagSet.AddFlagSet(cookieFlagSet())
270 flagSet.AddFlagSet(loggingFlagSet())
275 flagSet.AddFlagSet(loggingFlagSet())
pkg/apis/sessions/session_state.go+6−1
@@ -7,6 +7,7 @@ import (
7 "fmt"
7 "fmt"
8 "io"
8 "io"
9 "io/ioutil"
9 "io/ioutil"
10+ "reflect"
10 "time"
11 "time"
11 "unicode/utf8"
12 "unicode/utf8"
12
13
@@ -24,6 +25,7 @@ type SessionState struct {
24 RefreshToken string `json:",omitempty" msgpack:"rt,omitempty"`
25 RefreshToken string `json:",omitempty" msgpack:"rt,omitempty"`
25 Email string `json:",omitempty" msgpack:"e,omitempty"`
26 Email string `json:",omitempty" msgpack:"e,omitempty"`
26 User string `json:",omitempty" msgpack:"u,omitempty"`
27 User string `json:",omitempty" msgpack:"u,omitempty"`
28+ Groups []string `json:",omitempty" msgpack:"g,omitempty"`
27 PreferredUsername string `json:",omitempty" msgpack:"pu,omitempty"`
29 PreferredUsername string `json:",omitempty" msgpack:"pu,omitempty"`
28}
30}
29
31
@@ -61,6 +63,9 @@ func (s *SessionState) String() string {
61 if s.RefreshToken != "" {
63 if s.RefreshToken != "" {
62 o += " refresh_token:true"
64 o += " refresh_token:true"
63 }
65 }
66+ if len(s.Groups) > 0 {
67+ o += fmt.Sprintf(" groups:%v", s.Groups)
68+ }
64 return o + "}"
69 return o + "}"
65}
70}
66
71
@@ -233,7 +238,7 @@ func (s *SessionState) validate() error {
233 }
238 }
234
239
235 empty := new(SessionState)
240 empty := new(SessionState)
236− if *s == *empty {
241+ if reflect.DeepEqual(*s, *empty) {
237 return errors.New("invalid empty session unmarshalled")
242 return errors.New("invalid empty session unmarshalled")
238 }
243 }
239
244
pkg/apis/sessions/session_state_test.go+11
@@ -186,6 +186,17 @@ func TestEncodeAndDecodeSessionState(t *testing.T) {
186 IDToken: "IDToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7",
186 IDToken: "IDToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7",
187 ExpiresOn: &expires,
187 ExpiresOn: &expires,
188 },
188 },
189+ "With groups": {
190+ Email: "username@example.com",
191+ User: "username",
192+ PreferredUsername: "preferred.username",
193+ AccessToken: "AccessToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7",
194+ IDToken: "IDToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7",
195+ CreatedAt: &created,
196+ ExpiresOn: &expires,
197+ RefreshToken: "RefreshToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7",
198+ Groups: []string{"group-a", "group-b"},
199+ },
189 }
200 }
190
201
191 for _, secretSize := range []int{16, 24, 32} {
202 for _, secretSize := range []int{16, 24, 32} {
pkg/validation/options.go+5
@@ -152,6 +152,10 @@ func Validate(o *options.Options) error {
152 }
152 }
153 if o.Scope == "" {
153 if o.Scope == "" {
154 o.Scope = "openid email profile"
154 o.Scope = "openid email profile"
155+
156+ if len(o.AllowedGroups) > 0 {
157+ o.Scope += " groups"
158+ }
155 }
159 }
156 }
160 }
157
161
@@ -279,6 +283,7 @@ func parseProviderInfo(o *options.Options, msgs []string) []string {
279 case *providers.OIDCProvider:
283 case *providers.OIDCProvider:
280 p.AllowUnverifiedEmail = o.InsecureOIDCAllowUnverifiedEmail
284 p.AllowUnverifiedEmail = o.InsecureOIDCAllowUnverifiedEmail
281 p.UserIDClaim = o.UserIDClaim
285 p.UserIDClaim = o.UserIDClaim
286+ p.GroupsClaim = o.OIDCGroupsClaim
282 if o.GetOIDCVerifier() == nil {
287 if o.GetOIDCVerifier() == nil {
283 msgs = append(msgs, "oidc provider requires an oidc issuer URL")
288 msgs = append(msgs, "oidc provider requires an oidc issuer URL")
284 } else {
289 } else {
providers/oidc.go+23
@@ -22,6 +22,7 @@ type OIDCProvider struct {
22 Verifier *oidc.IDTokenVerifier
22 Verifier *oidc.IDTokenVerifier
23 AllowUnverifiedEmail bool
23 AllowUnverifiedEmail bool
24 UserIDClaim string
24 UserIDClaim string
25+ GroupsClaim string
25}
26}
26
27
27// NewOIDCProvider initiates a new OIDCProvider
28// NewOIDCProvider initiates a new OIDCProvider
@@ -123,6 +124,7 @@ func (p *OIDCProvider) redeemRefreshToken(ctx context.Context, s *sessions.Sessi
123 s.IDToken = newSession.IDToken
124 s.IDToken = newSession.IDToken
124 s.Email = newSession.Email
125 s.Email = newSession.Email
125 s.User = newSession.User
126 s.User = newSession.User
127+ s.Groups = newSession.Groups
126 s.PreferredUsername = newSession.PreferredUsername
128 s.PreferredUsername = newSession.PreferredUsername
127 }
129 }
128
130
@@ -204,6 +206,7 @@ func (p *OIDCProvider) createSessionStateInternal(ctx context.Context, idToken *
204 newSession.Email = claims.UserID // TODO Rename SessionState.Email to .UserID in the near future
206 newSession.Email = claims.UserID // TODO Rename SessionState.Email to .UserID in the near future
205
207
206 newSession.User = claims.Subject
208 newSession.User = claims.Subject
209+ newSession.Groups = claims.Groups
207 newSession.PreferredUsername = claims.PreferredUsername
210 newSession.PreferredUsername = claims.PreferredUsername
208
211
209 verifyEmail := (p.UserIDClaim == emailClaim) && !p.AllowUnverifiedEmail
212 verifyEmail := (p.UserIDClaim == emailClaim) && !p.AllowUnverifiedEmail
@@ -222,6 +225,7 @@ func (p *OIDCProvider) ValidateSessionState(ctx context.Context, s *sessions.Ses
222
225
223func (p *OIDCProvider) findClaimsFromIDToken(ctx context.Context, idToken *oidc.IDToken, token *oauth2.Token) (*OIDCClaims, error) {
226func (p *OIDCProvider) findClaimsFromIDToken(ctx context.Context, idToken *oidc.IDToken, token *oauth2.Token) (*OIDCClaims, error) {
224 claims := &OIDCClaims{}
227 claims := &OIDCClaims{}
228+
225 // Extract default claims.
229 // Extract default claims.
226 if err := idToken.Claims(&claims); err != nil {
230 if err := idToken.Claims(&claims); err != nil {
227 return nil, fmt.Errorf("failed to parse default id_token claims: %v", err)
231 return nil, fmt.Errorf("failed to parse default id_token claims: %v", err)
@@ -236,6 +240,8 @@ func (p *OIDCProvider) findClaimsFromIDToken(ctx context.Context, idToken *oidc.
236 claims.UserID = fmt.Sprint(userID)
240 claims.UserID = fmt.Sprint(userID)
237 }
241 }
238
242
243+ claims.Groups = p.extractGroupsFromRawClaims(claims.rawClaims)
244+
239 // userID claim was not present or was empty in the ID Token
245 // userID claim was not present or was empty in the ID Token
240 if claims.UserID == "" {
246 if claims.UserID == "" {
241 // BearerToken case, allow empty UserID
247 // BearerToken case, allow empty UserID
@@ -273,10 +279,27 @@ func (p *OIDCProvider) findClaimsFromIDToken(ctx context.Context, idToken *oidc.
273 return claims, nil
279 return claims, nil
274}
280}
275
281
282+func (p *OIDCProvider) extractGroupsFromRawClaims(rawClaims map[string]interface{}) []string {
283+ groups := []string{}
284+
285+ rawGroups, ok := rawClaims[p.GroupsClaim].([]interface{})
286+ if rawGroups != nil && ok {
287+ for _, rawGroup := range rawGroups {
288+ group, ok := rawGroup.(string)
289+ if ok {
290+ groups = append(groups, group)
291+ }
292+ }
293+ }
294+
295+ return groups
296+}
297+
276type OIDCClaims struct {
298type OIDCClaims struct {
277 rawClaims map[string]interface{}
299 rawClaims map[string]interface{}
278 UserID string
300 UserID string
279 Subject string `json:"sub"`
301 Subject string `json:"sub"`
280 Verified *bool `json:"email_verified"`
302 Verified *bool `json:"email_verified"`
281 PreferredUsername string `json:"preferred_username"`
303 PreferredUsername string `json:"preferred_username"`
304+ Groups []string
282}
305}
providers/oidc_test.go+34−13
@@ -29,10 +29,12 @@ const clientID = "https://test.myapp.com"
29const secret = "secret"
29const secret = "secret"
30
30
31type idTokenClaims struct {
31type idTokenClaims struct {
32− Name string `json:"name,omitempty"`
32+ Name string `json:"name,omitempty"`
33− Email string `json:"email,omitempty"`
33+ Email string `json:"email,omitempty"`
34− Phone string `json:"phone_number,omitempty"`
34+ Phone string `json:"phone_number,omitempty"`
35− Picture string `json:"picture,omitempty"`
35+ Picture string `json:"picture,omitempty"`
36+ Groups []string `json:"groups,omitempty"`
37+ OtherGroups []string `json:"other_groups,omitempty"`
36 jwt.StandardClaims
38 jwt.StandardClaims
37}
39}
38
40
@@ -49,6 +51,8 @@ var defaultIDToken idTokenClaims = idTokenClaims{
49 "janed@me.com",
51 "janed@me.com",
50 "+4798765432",
52 "+4798765432",
51 "http://mugbook.com/janed/me.jpg",
53 "http://mugbook.com/janed/me.jpg",
54+ []string{"test:a", "test:b"},
55+ []string{"test:c", "test:d"},
52 jwt.StandardClaims{
56 jwt.StandardClaims{
53 Audience: "https://test.myapp.com",
57 Audience: "https://test.myapp.com",
54 ExpiresAt: time.Now().Add(time.Duration(5) * time.Minute).Unix(),
58 ExpiresAt: time.Now().Add(time.Duration(5) * time.Minute).Unix(),
@@ -65,6 +69,8 @@ var minimalIDToken idTokenClaims = idTokenClaims{
65 "",
69 "",
66 "",
70 "",
67 "",
71 "",
72+ []string{},
73+ []string{},
68 jwt.StandardClaims{
74 jwt.StandardClaims{
69 Audience: "https://test.myapp.com",
75 Audience: "https://test.myapp.com",
70 ExpiresAt: time.Now().Add(time.Duration(5) * time.Minute).Unix(),
76 ExpiresAt: time.Now().Add(time.Duration(5) * time.Minute).Unix(),
@@ -273,25 +279,39 @@ func TestCreateSessionStateFromBearerToken(t *testing.T) {
273 const profileURLEmail = "janed@me.com"
279 const profileURLEmail = "janed@me.com"
274
280
275 testCases := map[string]struct {
281 testCases := map[string]struct {
276− IDToken idTokenClaims
282+ IDToken idTokenClaims
283+ GroupsClaim string
277− ExpectedUser string
284+ ExpectedUser string
278− ExpectedEmail string
285+ ExpectedEmail string
286+ ExpectedGroups []string
279 }{
287 }{
280 "Default IDToken": {
288 "Default IDToken": {
281− IDToken: defaultIDToken,
289+ IDToken: defaultIDToken,
290+ GroupsClaim: "groups",
282− ExpectedUser: defaultIDToken.Subject,
291+ ExpectedUser: defaultIDToken.Subject,
283− ExpectedEmail: defaultIDToken.Email,
292+ ExpectedEmail: defaultIDToken.Email,
293+ ExpectedGroups: []string{"test:a", "test:b"},
284 },
294 },
285 "Minimal IDToken with no email claim": {
295 "Minimal IDToken with no email claim": {
286− IDToken: minimalIDToken,
296+ IDToken: minimalIDToken,
297+ GroupsClaim: "groups",
287− ExpectedUser: minimalIDToken.Subject,
298+ ExpectedUser: minimalIDToken.Subject,
288− ExpectedEmail: minimalIDToken.Subject,
299+ ExpectedEmail: minimalIDToken.Subject,
300+ ExpectedGroups: []string{},
301+ },
302+ "Custom Groups Claim": {
303+ IDToken: defaultIDToken,
304+ GroupsClaim: "other_groups",
305+ ExpectedUser: defaultIDToken.Subject,
306+ ExpectedEmail: defaultIDToken.Email,
307+ ExpectedGroups: []string{"test:c", "test:d"},
289 },
308 },
290 }
309 }
291 for testName, tc := range testCases {
310 for testName, tc := range testCases {
292 t.Run(testName, func(t *testing.T) {
311 t.Run(testName, func(t *testing.T) {
293 jsonResp := []byte(fmt.Sprintf(`{"email":"%s"}`, profileURLEmail))
312 jsonResp := []byte(fmt.Sprintf(`{"email":"%s"}`, profileURLEmail))
294 server, provider := newTestSetup(jsonResp)
313 server, provider := newTestSetup(jsonResp)
314+ provider.GroupsClaim = tc.GroupsClaim
295 defer server.Close()
315 defer server.Close()
296
316
297 rawIDToken, err := newSignedTestIDToken(tc.IDToken)
317 rawIDToken, err := newSignedTestIDToken(tc.IDToken)
@@ -311,6 +331,7 @@ func TestCreateSessionStateFromBearerToken(t *testing.T) {
311 assert.Equal(t, tc.ExpectedEmail, ss.Email)
331 assert.Equal(t, tc.ExpectedEmail, ss.Email)
312 assert.Equal(t, rawIDToken, ss.IDToken)
332 assert.Equal(t, rawIDToken, ss.IDToken)
313 assert.Equal(t, rawIDToken, ss.AccessToken)
333 assert.Equal(t, rawIDToken, ss.AccessToken)
334+ assert.Equal(t, tc.ExpectedGroups, ss.Groups)
314 assert.Equal(t, "", ss.RefreshToken)
335 assert.Equal(t, "", ss.RefreshToken)
315 })
336 })
316 }
337 }