Add support to ensure user belongs in required groups when using the OIDC provider

This commit is contained in:
Stefan Sedich 2020-07-28 11:42:09 -07:00
parent a87beab1a0
commit 9d59519a96
No known key found for this signature in database
GPG Key ID: BBDC4034B788E0C6
10 changed files with 281 additions and 18 deletions

View File

@ -4,11 +4,14 @@
## Important Notes ## Important Notes
- [#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.
## Breaking Changes ## Breaking Changes
## Changes since v6.1.1 ## Changes since v6.1.1
- [#764](https://github.com/oauth2-proxy/oauth2-proxy/pull/764) Document bcrypt encryption for htpasswd (and hide SHA) (@lentzi90) - [#764](https://github.com/oauth2-proxy/oauth2-proxy/pull/764) Document bcrypt encryption for htpasswd (and hide SHA) (@lentzi90)
- [#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)
# v6.1.1 # v6.1.1

View File

@ -78,12 +78,13 @@ An example [oauth2-proxy.cfg]({{ site.gitweb }}/contrib/oauth2-proxy.cfg.example
| `--insecure-oidc-skip-issuer-verification` | bool | allow the OIDC issuer URL to differ from the expected (currently required for Azure multi-tenant compatibility) | false | | `--insecure-oidc-skip-issuer-verification` | bool | allow the OIDC issuer URL to differ from the expected (currently required for Azure multi-tenant compatibility) | false |
| `--oidc-issuer-url` | string | the OpenID Connect issuer URL, e.g. `"https://accounts.google.com"` | | | `--oidc-issuer-url` | string | the OpenID Connect issuer URL, e.g. `"https://accounts.google.com"` | |
| `--oidc-jwks-url` | string | OIDC JWKS URI for token verification; required if OIDC discovery is disabled | | | `--oidc-jwks-url` | string | OIDC JWKS URI for token verification; required if OIDC discovery is disabled | |
| `--oidc-groups-claim` | string | which claim contains the user groups | `"groups"` |
| `--pass-access-token` | bool | pass OAuth access_token to upstream via X-Forwarded-Access-Token header | false | | `--pass-access-token` | bool | pass OAuth access_token to upstream via X-Forwarded-Access-Token header | false |
| `--pass-authorization-header` | bool | pass OIDC IDToken to upstream via Authorization Bearer header | false | | `--pass-authorization-header` | bool | pass OIDC IDToken to upstream via Authorization Bearer header | false |
| `--pass-basic-auth` | bool | pass HTTP Basic Auth, X-Forwarded-User, X-Forwarded-Email and X-Forwarded-Preferred-Username information to upstream | true | | `--pass-basic-auth` | bool | pass HTTP Basic Auth, X-Forwarded-User, X-Forwarded-Email and X-Forwarded-Preferred-Username information to upstream | true |
| `--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 | | `--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 |
| `--pass-host-header` | bool | pass the request Host Header to upstream | true | | `--pass-host-header` | bool | pass the request Host Header to upstream | true |
| `--pass-user-headers` | bool | pass X-Forwarded-User, X-Forwarded-Email and X-Forwarded-Preferred-Username information to upstream | true | | `--pass-user-headers` | bool | pass X-Forwarded-User, X-Forwarded-Groups, X-Forwarded-Email and X-Forwarded-Preferred-Username information to upstream | true |
| `--profile-url` | string | Profile access endpoint | | | `--profile-url` | string | Profile access endpoint | |
| `--prompt` | string | [OIDC prompt](https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest); if present, `approval-prompt` is ignored | `""` | | `--prompt` | string | [OIDC prompt](https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest); if present, `approval-prompt` is ignored | `""` |
| `--provider` | string | OAuth provider | google | | `--provider` | string | OAuth provider | google |
@ -112,7 +113,7 @@ An example [oauth2-proxy.cfg]({{ site.gitweb }}/contrib/oauth2-proxy.cfg.example
| `--scope` | string | OAuth scope specification | | | `--scope` | string | OAuth scope specification | |
| `--session-cookie-minimal` | bool | strip OAuth tokens from cookie session stores if they aren't needed (cookie session store only) | false | | `--session-cookie-minimal` | bool | strip OAuth tokens from cookie session stores if they aren't needed (cookie session store only) | false |
| `--session-store-type` | string | [Session data storage backend](configuration/sessions); redis or cookie | cookie | | `--session-store-type` | string | [Session data storage backend](configuration/sessions); redis or cookie | cookie |
| `--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 | | `--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 |
| `--set-authorization-header` | bool | set Authorization Bearer response header (useful in Nginx auth_request mode) | false | | `--set-authorization-header` | bool | set Authorization Bearer response header (useful in Nginx auth_request mode) | false |
| `--set-basic-auth` | bool | set HTTP Basic Auth information in response (useful in Nginx auth_request mode) | false | | `--set-basic-auth` | bool | set HTTP Basic Auth information in response (useful in Nginx auth_request mode) | false |
| `--signature-key` | string | GAP-Signature request signature key (algorithm:secretkey) | | | `--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
| `--tls-key-file` | string | path to private key file | | | `--tls-key-file` | string | path to private key file | |
| `--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 | | | `--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 | |
| `--user-id-claim` | string | which claim contains the user ID | \["email"\] | | `--user-id-claim` | string | which claim contains the user ID | \["email"\] |
| `--allowed-group` | string \| list | restrict logins to members of this group (may be given multiple times) | |
| `--validate-url` | string | Access token validation endpoint | | | `--validate-url` | string | Access token validation endpoint | |
| `--version` | n/a | print version string | | | `--version` | n/a | print version string | |
| `--whitelist-domain` | string \| list | allowed domains for redirection after authentication. Prefix domain with a `.` to allow subdomains (e.g. `.example.com`)&nbsp;\[[2](#footnote2)\] | | | `--whitelist-domain` | string \| list | allowed domains for redirection after authentication. Prefix domain with a `.` to allow subdomains (e.g. `.example.com`)&nbsp;\[[2](#footnote2)\] | |

View File

@ -102,6 +102,7 @@ type OAuthProxy struct {
trustedIPs *ip.NetSet trustedIPs *ip.NetSet
Banner string Banner string
Footer string Footer string
AllowedGroups []string
sessionChain alice.Chain sessionChain alice.Chain
} }
@ -215,6 +216,7 @@ func NewOAuthProxy(opts *options.Options, validator func(string) bool) (*OAuthPr
Banner: opts.Banner, Banner: opts.Banner,
Footer: opts.Footer, Footer: opts.Footer,
SignInMessage: buildSignInMessage(opts), SignInMessage: buildSignInMessage(opts),
AllowedGroups: opts.AllowedGroups,
basicAuthValidator: basicAuthValidator, basicAuthValidator: basicAuthValidator,
displayHtpasswdForm: basicAuthValidator != nil, displayHtpasswdForm: basicAuthValidator != nil,
@ -888,7 +890,10 @@ func (p *OAuthProxy) getAuthenticatedSession(rw http.ResponseWriter, req *http.R
return nil, ErrNeedsLogin return nil, ErrNeedsLogin
} }
if session != nil && session.Email != "" && !p.Validator(session.Email) { invalidEmail := session != nil && session.Email != "" && !p.Validator(session.Email)
invalidGroups := session != nil && !p.validateGroups(session.Groups)
if invalidEmail || invalidGroups {
logger.Printf(session.Email, req, logger.AuthFailure, "Invalid authentication via session: removing session %s", session) logger.Printf(session.Email, req, logger.AuthFailure, "Invalid authentication via session: removing session %s", session)
// Invalid session, clear it // Invalid session, clear it
err := p.ClearSessionCookie(rw, req) err := p.ClearSessionCookie(rw, req)
@ -942,6 +947,14 @@ func (p *OAuthProxy) addHeadersForProxying(rw http.ResponseWriter, req *http.Req
} else { } else {
req.Header.Del("X-Forwarded-Preferred-Username") req.Header.Del("X-Forwarded-Preferred-Username")
} }
if len(session.Groups) > 0 {
for _, group := range session.Groups {
req.Header.Add("X-Forwarded-Groups", group)
}
} else {
req.Header.Del("X-Forwarded-Groups")
}
} }
if p.SetXAuthRequest { if p.SetXAuthRequest {
@ -964,6 +977,14 @@ func (p *OAuthProxy) addHeadersForProxying(rw http.ResponseWriter, req *http.Req
rw.Header().Del("X-Auth-Request-Access-Token") rw.Header().Del("X-Auth-Request-Access-Token")
} }
} }
if len(session.Groups) > 0 {
for _, group := range session.Groups {
rw.Header().Add("X-Auth-Request-Groups", group)
}
} else {
rw.Header().Del("X-Auth-Request-Groups")
}
} }
if p.PassAccessToken { if p.PassAccessToken {
@ -1012,6 +1033,7 @@ func (p *OAuthProxy) addHeadersForProxying(rw http.ResponseWriter, req *http.Req
func (p *OAuthProxy) stripAuthHeaders(req *http.Request) { func (p *OAuthProxy) stripAuthHeaders(req *http.Request) {
if p.PassBasicAuth { if p.PassBasicAuth {
req.Header.Del("X-Forwarded-User") req.Header.Del("X-Forwarded-User")
req.Header.Del("X-Forwarded-Groups")
req.Header.Del("X-Forwarded-Email") req.Header.Del("X-Forwarded-Email")
req.Header.Del("X-Forwarded-Preferred-Username") req.Header.Del("X-Forwarded-Preferred-Username")
req.Header.Del("Authorization") req.Header.Del("Authorization")
@ -1019,6 +1041,7 @@ func (p *OAuthProxy) stripAuthHeaders(req *http.Request) {
if p.PassUserHeaders { if p.PassUserHeaders {
req.Header.Del("X-Forwarded-User") req.Header.Del("X-Forwarded-User")
req.Header.Del("X-Forwarded-Groups")
req.Header.Del("X-Forwarded-Email") req.Header.Del("X-Forwarded-Email")
req.Header.Del("X-Forwarded-Preferred-Username") req.Header.Del("X-Forwarded-Preferred-Username")
} }
@ -1049,3 +1072,23 @@ func (p *OAuthProxy) ErrorJSON(rw http.ResponseWriter, code int) {
rw.Header().Set("Content-Type", applicationJSON) rw.Header().Set("Content-Type", applicationJSON)
rw.WriteHeader(code) rw.WriteHeader(code)
} }
func (p *OAuthProxy) validateGroups(groups []string) bool {
if len(p.AllowedGroups) == 0 {
return true
}
allowedGroups := map[string]struct{}{}
for _, group := range p.AllowedGroups {
allowedGroups[group] = struct{}{}
}
for _, group := range groups {
if _, ok := allowedGroups[group]; ok {
return true
}
}
return false
}

View File

@ -592,6 +592,37 @@ func TestPassUserHeadersWithEmail(t *testing.T) {
} }
} }
func TestPassGroupsHeadersWithGroups(t *testing.T) {
opts := baseTestOptions()
err := validation.Validate(opts)
assert.NoError(t, err)
const emailAddress = "john.doe@example.com"
const userName = "9fcab5c9b889a557"
groups := []string{"a", "b"}
created := time.Now()
session := &sessions.SessionState{
User: userName,
Groups: groups,
Email: emailAddress,
AccessToken: "oauth_token",
CreatedAt: &created,
}
{
rw := httptest.NewRecorder()
req, _ := http.NewRequest("GET", opts.ProxyPrefix+"/testCase0", nil)
proxy, err := NewOAuthProxy(opts, func(email string) bool {
return email == emailAddress
})
if err != nil {
t.Fatal(err)
}
proxy.addHeadersForProxying(rw, req, session)
assert.Equal(t, groups, req.Header["X-Forwarded-Groups"])
}
}
func TestStripAuthHeaders(t *testing.T) { func TestStripAuthHeaders(t *testing.T) {
testCases := map[string]struct { testCases := map[string]struct {
SkipAuthStripHeaders bool SkipAuthStripHeaders bool
@ -609,6 +640,7 @@ func TestStripAuthHeaders(t *testing.T) {
PassAuthorization: false, PassAuthorization: false,
StrippedHeaders: map[string]bool{ StrippedHeaders: map[string]bool{
"X-Forwarded-User": true, "X-Forwarded-User": true,
"X-Forwared-Groups": true,
"X-Forwarded-Email": true, "X-Forwarded-Email": true,
"X-Forwarded-Preferred-Username": true, "X-Forwarded-Preferred-Username": true,
"X-Forwarded-Access-Token": false, "X-Forwarded-Access-Token": false,
@ -623,6 +655,7 @@ func TestStripAuthHeaders(t *testing.T) {
PassAuthorization: false, PassAuthorization: false,
StrippedHeaders: map[string]bool{ StrippedHeaders: map[string]bool{
"X-Forwarded-User": true, "X-Forwarded-User": true,
"X-Forwared-Groups": true,
"X-Forwarded-Email": true, "X-Forwarded-Email": true,
"X-Forwarded-Preferred-Username": true, "X-Forwarded-Preferred-Username": true,
"X-Forwarded-Access-Token": true, "X-Forwarded-Access-Token": true,
@ -637,6 +670,7 @@ func TestStripAuthHeaders(t *testing.T) {
PassAuthorization: false, PassAuthorization: false,
StrippedHeaders: map[string]bool{ StrippedHeaders: map[string]bool{
"X-Forwarded-User": true, "X-Forwarded-User": true,
"X-Forwared-Groups": true,
"X-Forwarded-Email": true, "X-Forwarded-Email": true,
"X-Forwarded-Preferred-Username": true, "X-Forwarded-Preferred-Username": true,
"X-Forwarded-Access-Token": true, "X-Forwarded-Access-Token": true,
@ -651,6 +685,7 @@ func TestStripAuthHeaders(t *testing.T) {
PassAuthorization: true, PassAuthorization: true,
StrippedHeaders: map[string]bool{ StrippedHeaders: map[string]bool{
"X-Forwarded-User": false, "X-Forwarded-User": false,
"X-Forwared-Groups": false,
"X-Forwarded-Email": false, "X-Forwarded-Email": false,
"X-Forwarded-Preferred-Username": false, "X-Forwarded-Preferred-Username": false,
"X-Forwarded-Access-Token": false, "X-Forwarded-Access-Token": false,
@ -665,6 +700,7 @@ func TestStripAuthHeaders(t *testing.T) {
PassAuthorization: false, PassAuthorization: false,
StrippedHeaders: map[string]bool{ StrippedHeaders: map[string]bool{
"X-Forwarded-User": false, "X-Forwarded-User": false,
"X-Forwared-Groups": false,
"X-Forwarded-Email": false, "X-Forwarded-Email": false,
"X-Forwarded-Preferred-Username": false, "X-Forwarded-Preferred-Username": false,
"X-Forwarded-Access-Token": false, "X-Forwarded-Access-Token": false,
@ -679,6 +715,7 @@ func TestStripAuthHeaders(t *testing.T) {
PassAuthorization: false, PassAuthorization: false,
StrippedHeaders: map[string]bool{ StrippedHeaders: map[string]bool{
"X-Forwarded-User": false, "X-Forwarded-User": false,
"X-Forwared-Groups": false,
"X-Forwarded-Email": false, "X-Forwarded-Email": false,
"X-Forwarded-Preferred-Username": false, "X-Forwarded-Preferred-Username": false,
"X-Forwarded-Access-Token": false, "X-Forwarded-Access-Token": false,
@ -690,6 +727,7 @@ func TestStripAuthHeaders(t *testing.T) {
initialHeaders := map[string]string{ initialHeaders := map[string]string{
"X-Forwarded-User": "9fcab5c9b889a557", "X-Forwarded-User": "9fcab5c9b889a557",
"X-Forwarded-Email": "john.doe@example.com", "X-Forwarded-Email": "john.doe@example.com",
"X-Forwarded-Groups": "a,b,c",
"X-Forwarded-Preferred-Username": "john.doe", "X-Forwarded-Preferred-Username": "john.doe",
"X-Forwarded-Access-Token": "AccessToken", "X-Forwarded-Access-Token": "AccessToken",
"Authorization": "bearer IDToken", "Authorization": "bearer IDToken",
@ -1333,6 +1371,7 @@ func TestAuthOnlyEndpointSetXAuthRequestHeaders(t *testing.T) {
pcTest.opts = baseTestOptions() pcTest.opts = baseTestOptions()
pcTest.opts.SetXAuthRequest = true pcTest.opts.SetXAuthRequest = true
pcTest.opts.AllowedGroups = []string{"oauth_groups"}
err := validation.Validate(pcTest.opts) err := validation.Validate(pcTest.opts)
assert.NoError(t, err) assert.NoError(t, err)
@ -1354,13 +1393,14 @@ func TestAuthOnlyEndpointSetXAuthRequestHeaders(t *testing.T) {
created := time.Now() created := time.Now()
startSession := &sessions.SessionState{ startSession := &sessions.SessionState{
User: "oauth_user", Email: "oauth_user@example.com", AccessToken: "oauth_token", CreatedAt: &created} User: "oauth_user", Groups: []string{"oauth_groups"}, Email: "oauth_user@example.com", AccessToken: "oauth_token", CreatedAt: &created}
err = pcTest.SaveSession(startSession) err = pcTest.SaveSession(startSession)
assert.NoError(t, err) assert.NoError(t, err)
pcTest.proxy.ServeHTTP(pcTest.rw, pcTest.req) pcTest.proxy.ServeHTTP(pcTest.rw, pcTest.req)
assert.Equal(t, http.StatusAccepted, pcTest.rw.Code) assert.Equal(t, http.StatusAccepted, pcTest.rw.Code)
assert.Equal(t, "oauth_user", pcTest.rw.Header().Get("X-Auth-Request-User")) assert.Equal(t, "oauth_user", pcTest.rw.Header().Get("X-Auth-Request-User"))
assert.Equal(t, startSession.Groups, pcTest.rw.Header().Values("X-Auth-Request-Groups"))
assert.Equal(t, "oauth_user@example.com", pcTest.rw.Header().Get("X-Auth-Request-Email")) assert.Equal(t, "oauth_user@example.com", pcTest.rw.Header().Get("X-Auth-Request-Email"))
} }
@ -2199,3 +2239,108 @@ func TestTrustedIPs(t *testing.T) {
}) })
} }
} }
func TestProxyAllowedGroups(t *testing.T) {
tests := []struct {
name string
allowedGroups []string
groups []string
expectUnauthorized bool
}{
{"NoAllowedGroups", []string{}, []string{}, false},
{"NoAllowedGroupsUserHasGroups", []string{}, []string{"a", "b"}, false},
{"UserInAllowedGroup", []string{"a"}, []string{"a", "b"}, false},
{"UserNotInAllowedGroup", []string{"a"}, []string{"c"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
emailAddress := "test"
created := time.Now()
session := &sessions.SessionState{
Groups: tt.groups,
Email: emailAddress,
AccessToken: "oauth_token",
CreatedAt: &created,
}
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
t.Cleanup(upstream.Close)
test, err := NewProcessCookieTestWithOptionsModifiers(func(opts *options.Options) {
opts.AllowedGroups = tt.allowedGroups
opts.UpstreamServers = options.Upstreams{
{
ID: upstream.URL,
Path: "/",
URI: upstream.URL,
},
}
})
if err != nil {
t.Fatal(err)
}
test.req, _ = http.NewRequest("GET", "/", nil)
test.req.Header.Add("accept", applicationJSON)
test.SaveSession(session)
test.proxy.ServeHTTP(test.rw, test.req)
if tt.expectUnauthorized {
assert.Equal(t, http.StatusUnauthorized, test.rw.Code)
} else {
assert.Equal(t, http.StatusOK, test.rw.Code)
}
})
}
}
func TestAuthOnlyAllowedGroups(t *testing.T) {
tests := []struct {
name string
allowedGroups []string
groups []string
expectUnauthorized bool
}{
{"NoAllowedGroups", []string{}, []string{}, false},
{"NoAllowedGroupsUserHasGroups", []string{}, []string{"a", "b"}, false},
{"UserInAllowedGroup", []string{"a"}, []string{"a", "b"}, false},
{"UserNotInAllowedGroup", []string{"a"}, []string{"c"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
emailAddress := "test"
created := time.Now()
session := &sessions.SessionState{
Groups: tt.groups,
Email: emailAddress,
AccessToken: "oauth_token",
CreatedAt: &created,
}
test, err := NewAuthOnlyEndpointTest(func(opts *options.Options) {
opts.AllowedGroups = tt.allowedGroups
})
if err != nil {
t.Fatal(err)
}
err = test.SaveSession(session)
assert.NoError(t, err)
test.proxy.ServeHTTP(test.rw, test.req)
if tt.expectUnauthorized {
assert.Equal(t, http.StatusUnauthorized, test.rw.Code)
} else {
assert.Equal(t, http.StatusAccepted, test.rw.Code)
}
})
}
}

View File

@ -93,6 +93,7 @@ type Options struct {
InsecureOIDCSkipIssuerVerification bool `flag:"insecure-oidc-skip-issuer-verification" cfg:"insecure_oidc_skip_issuer_verification"` InsecureOIDCSkipIssuerVerification bool `flag:"insecure-oidc-skip-issuer-verification" cfg:"insecure_oidc_skip_issuer_verification"`
SkipOIDCDiscovery bool `flag:"skip-oidc-discovery" cfg:"skip_oidc_discovery"` SkipOIDCDiscovery bool `flag:"skip-oidc-discovery" cfg:"skip_oidc_discovery"`
OIDCJwksURL string `flag:"oidc-jwks-url" cfg:"oidc_jwks_url"` OIDCJwksURL string `flag:"oidc-jwks-url" cfg:"oidc_jwks_url"`
OIDCGroupsClaim string `flag:"oidc-groups-claim" cfg:"oidc_groups_claim"`
LoginURL string `flag:"login-url" cfg:"login_url"` LoginURL string `flag:"login-url" cfg:"login_url"`
RedeemURL string `flag:"redeem-url" cfg:"redeem_url"` RedeemURL string `flag:"redeem-url" cfg:"redeem_url"`
ProfileURL string `flag:"profile-url" cfg:"profile_url"` ProfileURL string `flag:"profile-url" cfg:"profile_url"`
@ -102,6 +103,7 @@ type Options struct {
Prompt string `flag:"prompt" cfg:"prompt"` Prompt string `flag:"prompt" cfg:"prompt"`
ApprovalPrompt string `flag:"approval-prompt" cfg:"approval_prompt"` // Deprecated by OIDC 1.0 ApprovalPrompt string `flag:"approval-prompt" cfg:"approval_prompt"` // Deprecated by OIDC 1.0
UserIDClaim string `flag:"user-id-claim" cfg:"user_id_claim"` UserIDClaim string `flag:"user-id-claim" cfg:"user_id_claim"`
AllowedGroups []string `flag:"allowed-group" cfg:"allowed_groups"`
SignatureKey string `flag:"signature-key" cfg:"signature_key"` SignatureKey string `flag:"signature-key" cfg:"signature_key"`
AcrValues string `flag:"acr-values" cfg:"acr_values"` AcrValues string `flag:"acr-values" cfg:"acr_values"`
@ -167,6 +169,7 @@ func NewOptions() *Options {
InsecureOIDCAllowUnverifiedEmail: false, InsecureOIDCAllowUnverifiedEmail: false,
SkipOIDCDiscovery: false, SkipOIDCDiscovery: false,
Logging: loggingDefaults(), Logging: loggingDefaults(),
OIDCGroupsClaim: "groups",
} }
} }
@ -248,6 +251,7 @@ func NewFlagSet() *pflag.FlagSet {
flagSet.Bool("insecure-oidc-skip-issuer-verification", false, "Do not verify if issuer matches OIDC discovery URL") flagSet.Bool("insecure-oidc-skip-issuer-verification", false, "Do not verify if issuer matches OIDC discovery URL")
flagSet.Bool("skip-oidc-discovery", false, "Skip OIDC discovery and use manually supplied Endpoints") flagSet.Bool("skip-oidc-discovery", false, "Skip OIDC discovery and use manually supplied Endpoints")
flagSet.String("oidc-jwks-url", "", "OpenID Connect JWKS URL (ie: https://www.googleapis.com/oauth2/v3/certs)") flagSet.String("oidc-jwks-url", "", "OpenID Connect JWKS URL (ie: https://www.googleapis.com/oauth2/v3/certs)")
flagSet.String("oidc-groups-claim", "groups", "which claim contains the user groups")
flagSet.String("login-url", "", "Authentication endpoint") flagSet.String("login-url", "", "Authentication endpoint")
flagSet.String("redeem-url", "", "Token redemption endpoint") flagSet.String("redeem-url", "", "Token redemption endpoint")
flagSet.String("profile-url", "", "Profile access endpoint") flagSet.String("profile-url", "", "Profile access endpoint")
@ -265,6 +269,7 @@ func NewFlagSet() *pflag.FlagSet {
flagSet.Bool("gcp-healthchecks", false, "Enable GCP/GKE healthcheck endpoints") flagSet.Bool("gcp-healthchecks", false, "Enable GCP/GKE healthcheck endpoints")
flagSet.String("user-id-claim", "email", "which claim contains the user ID") flagSet.String("user-id-claim", "email", "which claim contains the user ID")
flagSet.StringSlice("allowed-group", []string{}, "restrict logins to members of this group (may be given multiple times)")
flagSet.AddFlagSet(cookieFlagSet()) flagSet.AddFlagSet(cookieFlagSet())
flagSet.AddFlagSet(loggingFlagSet()) flagSet.AddFlagSet(loggingFlagSet())

View File

@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"io" "io"
"io/ioutil" "io/ioutil"
"reflect"
"time" "time"
"unicode/utf8" "unicode/utf8"
@ -24,6 +25,7 @@ type SessionState struct {
RefreshToken string `json:",omitempty" msgpack:"rt,omitempty"` RefreshToken string `json:",omitempty" msgpack:"rt,omitempty"`
Email string `json:",omitempty" msgpack:"e,omitempty"` Email string `json:",omitempty" msgpack:"e,omitempty"`
User string `json:",omitempty" msgpack:"u,omitempty"` User string `json:",omitempty" msgpack:"u,omitempty"`
Groups []string `json:",omitempty" msgpack:"g,omitempty"`
PreferredUsername string `json:",omitempty" msgpack:"pu,omitempty"` PreferredUsername string `json:",omitempty" msgpack:"pu,omitempty"`
} }
@ -61,6 +63,9 @@ func (s *SessionState) String() string {
if s.RefreshToken != "" { if s.RefreshToken != "" {
o += " refresh_token:true" o += " refresh_token:true"
} }
if len(s.Groups) > 0 {
o += fmt.Sprintf(" groups:%v", s.Groups)
}
return o + "}" return o + "}"
} }
@ -233,7 +238,7 @@ func (s *SessionState) validate() error {
} }
empty := new(SessionState) empty := new(SessionState)
if *s == *empty { if reflect.DeepEqual(*s, *empty) {
return errors.New("invalid empty session unmarshalled") return errors.New("invalid empty session unmarshalled")
} }

View File

@ -186,6 +186,17 @@ func TestEncodeAndDecodeSessionState(t *testing.T) {
IDToken: "IDToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7", IDToken: "IDToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7",
ExpiresOn: &expires, ExpiresOn: &expires,
}, },
"With groups": {
Email: "username@example.com",
User: "username",
PreferredUsername: "preferred.username",
AccessToken: "AccessToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7",
IDToken: "IDToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7",
CreatedAt: &created,
ExpiresOn: &expires,
RefreshToken: "RefreshToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7",
Groups: []string{"group-a", "group-b"},
},
} }
for _, secretSize := range []int{16, 24, 32} { for _, secretSize := range []int{16, 24, 32} {

View File

@ -152,6 +152,10 @@ func Validate(o *options.Options) error {
} }
if o.Scope == "" { if o.Scope == "" {
o.Scope = "openid email profile" o.Scope = "openid email profile"
if len(o.AllowedGroups) > 0 {
o.Scope += " groups"
}
} }
} }
@ -279,6 +283,7 @@ func parseProviderInfo(o *options.Options, msgs []string) []string {
case *providers.OIDCProvider: case *providers.OIDCProvider:
p.AllowUnverifiedEmail = o.InsecureOIDCAllowUnverifiedEmail p.AllowUnverifiedEmail = o.InsecureOIDCAllowUnverifiedEmail
p.UserIDClaim = o.UserIDClaim p.UserIDClaim = o.UserIDClaim
p.GroupsClaim = o.OIDCGroupsClaim
if o.GetOIDCVerifier() == nil { if o.GetOIDCVerifier() == nil {
msgs = append(msgs, "oidc provider requires an oidc issuer URL") msgs = append(msgs, "oidc provider requires an oidc issuer URL")
} else { } else {

View File

@ -22,6 +22,7 @@ type OIDCProvider struct {
Verifier *oidc.IDTokenVerifier Verifier *oidc.IDTokenVerifier
AllowUnverifiedEmail bool AllowUnverifiedEmail bool
UserIDClaim string UserIDClaim string
GroupsClaim string
} }
// NewOIDCProvider initiates a new OIDCProvider // NewOIDCProvider initiates a new OIDCProvider
@ -123,6 +124,7 @@ func (p *OIDCProvider) redeemRefreshToken(ctx context.Context, s *sessions.Sessi
s.IDToken = newSession.IDToken s.IDToken = newSession.IDToken
s.Email = newSession.Email s.Email = newSession.Email
s.User = newSession.User s.User = newSession.User
s.Groups = newSession.Groups
s.PreferredUsername = newSession.PreferredUsername s.PreferredUsername = newSession.PreferredUsername
} }
@ -204,6 +206,7 @@ func (p *OIDCProvider) createSessionStateInternal(ctx context.Context, idToken *
newSession.Email = claims.UserID // TODO Rename SessionState.Email to .UserID in the near future newSession.Email = claims.UserID // TODO Rename SessionState.Email to .UserID in the near future
newSession.User = claims.Subject newSession.User = claims.Subject
newSession.Groups = claims.Groups
newSession.PreferredUsername = claims.PreferredUsername newSession.PreferredUsername = claims.PreferredUsername
verifyEmail := (p.UserIDClaim == emailClaim) && !p.AllowUnverifiedEmail verifyEmail := (p.UserIDClaim == emailClaim) && !p.AllowUnverifiedEmail
@ -222,6 +225,7 @@ func (p *OIDCProvider) ValidateSessionState(ctx context.Context, s *sessions.Ses
func (p *OIDCProvider) findClaimsFromIDToken(ctx context.Context, idToken *oidc.IDToken, token *oauth2.Token) (*OIDCClaims, error) { func (p *OIDCProvider) findClaimsFromIDToken(ctx context.Context, idToken *oidc.IDToken, token *oauth2.Token) (*OIDCClaims, error) {
claims := &OIDCClaims{} claims := &OIDCClaims{}
// Extract default claims. // Extract default claims.
if err := idToken.Claims(&claims); err != nil { if err := idToken.Claims(&claims); err != nil {
return nil, fmt.Errorf("failed to parse default id_token claims: %v", err) 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.
claims.UserID = fmt.Sprint(userID) claims.UserID = fmt.Sprint(userID)
} }
claims.Groups = p.extractGroupsFromRawClaims(claims.rawClaims)
// userID claim was not present or was empty in the ID Token // userID claim was not present or was empty in the ID Token
if claims.UserID == "" { if claims.UserID == "" {
// BearerToken case, allow empty UserID // BearerToken case, allow empty UserID
@ -273,10 +279,27 @@ func (p *OIDCProvider) findClaimsFromIDToken(ctx context.Context, idToken *oidc.
return claims, nil return claims, nil
} }
func (p *OIDCProvider) extractGroupsFromRawClaims(rawClaims map[string]interface{}) []string {
groups := []string{}
rawGroups, ok := rawClaims[p.GroupsClaim].([]interface{})
if rawGroups != nil && ok {
for _, rawGroup := range rawGroups {
group, ok := rawGroup.(string)
if ok {
groups = append(groups, group)
}
}
}
return groups
}
type OIDCClaims struct { type OIDCClaims struct {
rawClaims map[string]interface{} rawClaims map[string]interface{}
UserID string UserID string
Subject string `json:"sub"` Subject string `json:"sub"`
Verified *bool `json:"email_verified"` Verified *bool `json:"email_verified"`
PreferredUsername string `json:"preferred_username"` PreferredUsername string `json:"preferred_username"`
Groups []string
} }

View File

@ -29,10 +29,12 @@ const clientID = "https://test.myapp.com"
const secret = "secret" const secret = "secret"
type idTokenClaims struct { type idTokenClaims struct {
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"` Email string `json:"email,omitempty"`
Phone string `json:"phone_number,omitempty"` Phone string `json:"phone_number,omitempty"`
Picture string `json:"picture,omitempty"` Picture string `json:"picture,omitempty"`
Groups []string `json:"groups,omitempty"`
OtherGroups []string `json:"other_groups,omitempty"`
jwt.StandardClaims jwt.StandardClaims
} }
@ -49,6 +51,8 @@ var defaultIDToken idTokenClaims = idTokenClaims{
"janed@me.com", "janed@me.com",
"+4798765432", "+4798765432",
"http://mugbook.com/janed/me.jpg", "http://mugbook.com/janed/me.jpg",
[]string{"test:a", "test:b"},
[]string{"test:c", "test:d"},
jwt.StandardClaims{ jwt.StandardClaims{
Audience: "https://test.myapp.com", Audience: "https://test.myapp.com",
ExpiresAt: time.Now().Add(time.Duration(5) * time.Minute).Unix(), ExpiresAt: time.Now().Add(time.Duration(5) * time.Minute).Unix(),
@ -65,6 +69,8 @@ var minimalIDToken idTokenClaims = idTokenClaims{
"", "",
"", "",
"", "",
[]string{},
[]string{},
jwt.StandardClaims{ jwt.StandardClaims{
Audience: "https://test.myapp.com", Audience: "https://test.myapp.com",
ExpiresAt: time.Now().Add(time.Duration(5) * time.Minute).Unix(), ExpiresAt: time.Now().Add(time.Duration(5) * time.Minute).Unix(),
@ -273,25 +279,39 @@ func TestCreateSessionStateFromBearerToken(t *testing.T) {
const profileURLEmail = "janed@me.com" const profileURLEmail = "janed@me.com"
testCases := map[string]struct { testCases := map[string]struct {
IDToken idTokenClaims IDToken idTokenClaims
ExpectedUser string GroupsClaim string
ExpectedEmail string ExpectedUser string
ExpectedEmail string
ExpectedGroups []string
}{ }{
"Default IDToken": { "Default IDToken": {
IDToken: defaultIDToken, IDToken: defaultIDToken,
ExpectedUser: defaultIDToken.Subject, GroupsClaim: "groups",
ExpectedEmail: defaultIDToken.Email, ExpectedUser: defaultIDToken.Subject,
ExpectedEmail: defaultIDToken.Email,
ExpectedGroups: []string{"test:a", "test:b"},
}, },
"Minimal IDToken with no email claim": { "Minimal IDToken with no email claim": {
IDToken: minimalIDToken, IDToken: minimalIDToken,
ExpectedUser: minimalIDToken.Subject, GroupsClaim: "groups",
ExpectedEmail: minimalIDToken.Subject, ExpectedUser: minimalIDToken.Subject,
ExpectedEmail: minimalIDToken.Subject,
ExpectedGroups: []string{},
},
"Custom Groups Claim": {
IDToken: defaultIDToken,
GroupsClaim: "other_groups",
ExpectedUser: defaultIDToken.Subject,
ExpectedEmail: defaultIDToken.Email,
ExpectedGroups: []string{"test:c", "test:d"},
}, },
} }
for testName, tc := range testCases { for testName, tc := range testCases {
t.Run(testName, func(t *testing.T) { t.Run(testName, func(t *testing.T) {
jsonResp := []byte(fmt.Sprintf(`{"email":"%s"}`, profileURLEmail)) jsonResp := []byte(fmt.Sprintf(`{"email":"%s"}`, profileURLEmail))
server, provider := newTestSetup(jsonResp) server, provider := newTestSetup(jsonResp)
provider.GroupsClaim = tc.GroupsClaim
defer server.Close() defer server.Close()
rawIDToken, err := newSignedTestIDToken(tc.IDToken) rawIDToken, err := newSignedTestIDToken(tc.IDToken)
@ -311,6 +331,7 @@ func TestCreateSessionStateFromBearerToken(t *testing.T) {
assert.Equal(t, tc.ExpectedEmail, ss.Email) assert.Equal(t, tc.ExpectedEmail, ss.Email)
assert.Equal(t, rawIDToken, ss.IDToken) assert.Equal(t, rawIDToken, ss.IDToken)
assert.Equal(t, rawIDToken, ss.AccessToken) assert.Equal(t, rawIDToken, ss.AccessToken)
assert.Equal(t, tc.ExpectedGroups, ss.Groups)
assert.Equal(t, "", ss.RefreshToken) assert.Equal(t, "", ss.RefreshToken)
}) })
} }