Skip to content

Commit 6d41541

Browse files
authored
Merge pull request #1544 from kenperkins/saml-groups
Adding support for allowed groups in SAML Connector
2 parents f2590ee + 285c1f1 commit 6d41541

3 files changed

Lines changed: 150 additions & 27 deletions

File tree

Documentation/connectors/saml.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ __The connector doesn't support refresh tokens__ since the SAML 2.0 protocol doe
1414

1515
The connector doesn't support signed AuthnRequests or encrypted attributes.
1616

17+
## Group Filtering
18+
19+
The SAML Connector supports providing a whitelist of SAML Groups to filter access based on, and when the `groupsattr` is set with a scope including groups, Dex will check for membership based on configured groups in the `allowedGroups` config setting for the SAML connector.
20+
1721
## Configuration
1822

1923
```yaml
@@ -44,6 +48,10 @@ connectors:
4448
emailAttr: email
4549
groupsAttr: groups # optional
4650

51+
# List of groups to filter access based on membership
52+
# allowedGroups
53+
# - Admins
54+
4755
# CA's can also be provided inline as a base64'd blob.
4856
#
4957
# caData: ( RAW base64'd PEM encoded CA )

connector/saml/saml.go

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515

1616
"github.com/beevik/etree"
1717
"github.com/dexidp/dex/connector"
18+
"github.com/dexidp/dex/pkg/groups"
1819
"github.com/dexidp/dex/pkg/log"
1920
dsig "github.com/russellhaering/goxmldsig"
2021
"github.com/russellhaering/goxmldsig/etreeutils"
@@ -97,9 +98,9 @@ type Config struct {
9798
// If GroupsDelim is supplied the connector assumes groups are returned as a
9899
// single string instead of multiple attribute values. This delimiter will be
99100
// used split the groups string.
100-
GroupsDelim string `json:"groupsDelim"`
101-
102-
RedirectURI string `json:"redirectURI"`
101+
GroupsDelim string `json:"groupsDelim"`
102+
AllowedGroups []string `json:"allowedGroups"`
103+
RedirectURI string `json:"redirectURI"`
103104

104105
// Requested format of the NameID. The NameID value is is mapped to the ID Token
105106
// 'sub' claim.
@@ -154,16 +155,17 @@ func (c *Config) openConnector(logger log.Logger) (*provider, error) {
154155
}
155156

156157
p := &provider{
157-
entityIssuer: c.EntityIssuer,
158-
ssoIssuer: c.SSOIssuer,
159-
ssoURL: c.SSOURL,
160-
now: time.Now,
161-
usernameAttr: c.UsernameAttr,
162-
emailAttr: c.EmailAttr,
163-
groupsAttr: c.GroupsAttr,
164-
groupsDelim: c.GroupsDelim,
165-
redirectURI: c.RedirectURI,
166-
logger: logger,
158+
entityIssuer: c.EntityIssuer,
159+
ssoIssuer: c.SSOIssuer,
160+
ssoURL: c.SSOURL,
161+
now: time.Now,
162+
usernameAttr: c.UsernameAttr,
163+
emailAttr: c.EmailAttr,
164+
groupsAttr: c.GroupsAttr,
165+
groupsDelim: c.GroupsDelim,
166+
allowedGroups: c.AllowedGroups,
167+
redirectURI: c.RedirectURI,
168+
logger: logger,
167169

168170
nameIDPolicyFormat: c.NameIDPolicyFormat,
169171
}
@@ -232,10 +234,11 @@ type provider struct {
232234
validator *dsig.ValidationContext
233235

234236
// Attribute mappings
235-
usernameAttr string
236-
emailAttr string
237-
groupsAttr string
238-
groupsDelim string
237+
usernameAttr string
238+
emailAttr string
239+
groupsAttr string
240+
groupsDelim string
241+
allowedGroups []string
239242

240243
redirectURI string
241244

@@ -388,11 +391,16 @@ func (p *provider) HandlePOST(s connector.Scopes, samlResponse, inResponseTo str
388391
return ident, fmt.Errorf("no attribute with name %q: %s", p.usernameAttr, attributes.names())
389392
}
390393

391-
if !s.Groups || p.groupsAttr == "" {
394+
if len(p.allowedGroups) == 0 && (!s.Groups || p.groupsAttr == "") {
392395
// Groups not requested or not configured. We're done.
393396
return ident, nil
394397
}
395398

399+
if len(p.allowedGroups) > 0 && (!s.Groups || p.groupsAttr == "") {
400+
// allowedGroups set but no groups or groupsAttr. Disallowing.
401+
return ident, fmt.Errorf("User not a member of allowed groups")
402+
}
403+
396404
// Grab the groups.
397405
if p.groupsDelim != "" {
398406
groupsStr, ok := attributes.get(p.groupsAttr)
@@ -408,6 +416,21 @@ func (p *provider) HandlePOST(s connector.Scopes, samlResponse, inResponseTo str
408416
}
409417
ident.Groups = groups
410418
}
419+
420+
if len(p.allowedGroups) == 0 {
421+
// No allowed groups set, just return the ident
422+
return ident, nil
423+
}
424+
425+
// Look for membership in one of the allowed groups
426+
groupMatches := groups.Filter(ident.Groups, p.allowedGroups)
427+
428+
if len(groupMatches) == 0 {
429+
// No group membership matches found, disallowing
430+
return ident, fmt.Errorf("User not a member of allowed groups")
431+
}
432+
433+
// Otherwise, we're good
411434
return ident, nil
412435
}
413436

connector/saml/saml_test.go

Lines changed: 101 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,10 @@ type responseTest struct {
4949
entityIssuer string
5050

5151
// Attribute customization.
52-
usernameAttr string
53-
emailAttr string
54-
groupsAttr string
52+
usernameAttr string
53+
emailAttr string
54+
groupsAttr string
55+
allowedGroups []string
5556

5657
// Expected outcome of the test.
5758
wantErr bool
@@ -98,6 +99,96 @@ func TestGroups(t *testing.T) {
9899
test.run(t)
99100
}
100101

102+
func TestGroupsWhitelist(t *testing.T) {
103+
test := responseTest{
104+
caFile: "testdata/ca.crt",
105+
respFile: "testdata/good-resp.xml",
106+
now: "2017-04-04T04:34:59.330Z",
107+
usernameAttr: "Name",
108+
emailAttr: "email",
109+
groupsAttr: "groups",
110+
allowedGroups: []string{"Admins"},
111+
inResponseTo: "6zmm5mguyebwvajyf2sdwwcw6m",
112+
redirectURI: "http://127.0.0.1:5556/dex/callback",
113+
wantIdent: connector.Identity{
114+
UserID: "eric.chiang+okta@coreos.com",
115+
Username: "Eric",
116+
Email: "eric.chiang+okta@coreos.com",
117+
EmailVerified: true,
118+
Groups: []string{"Admins", "Everyone"},
119+
},
120+
}
121+
test.run(t)
122+
}
123+
124+
func TestGroupsWhitelistEmpty(t *testing.T) {
125+
test := responseTest{
126+
caFile: "testdata/ca.crt",
127+
respFile: "testdata/good-resp.xml",
128+
now: "2017-04-04T04:34:59.330Z",
129+
usernameAttr: "Name",
130+
emailAttr: "email",
131+
groupsAttr: "groups",
132+
allowedGroups: []string{},
133+
inResponseTo: "6zmm5mguyebwvajyf2sdwwcw6m",
134+
redirectURI: "http://127.0.0.1:5556/dex/callback",
135+
wantIdent: connector.Identity{
136+
UserID: "eric.chiang+okta@coreos.com",
137+
Username: "Eric",
138+
Email: "eric.chiang+okta@coreos.com",
139+
EmailVerified: true,
140+
Groups: []string{"Admins", "Everyone"},
141+
},
142+
}
143+
test.run(t)
144+
}
145+
146+
func TestGroupsWhitelistDisallowed(t *testing.T) {
147+
test := responseTest{
148+
wantErr: true,
149+
caFile: "testdata/ca.crt",
150+
respFile: "testdata/good-resp.xml",
151+
now: "2017-04-04T04:34:59.330Z",
152+
usernameAttr: "Name",
153+
emailAttr: "email",
154+
groupsAttr: "groups",
155+
allowedGroups: []string{"Nope"},
156+
inResponseTo: "6zmm5mguyebwvajyf2sdwwcw6m",
157+
redirectURI: "http://127.0.0.1:5556/dex/callback",
158+
wantIdent: connector.Identity{
159+
UserID: "eric.chiang+okta@coreos.com",
160+
Username: "Eric",
161+
Email: "eric.chiang+okta@coreos.com",
162+
EmailVerified: true,
163+
Groups: []string{"Admins", "Everyone"},
164+
},
165+
}
166+
test.run(t)
167+
}
168+
169+
func TestGroupsWhitelistDisallowedNoGroupsOnIdent(t *testing.T) {
170+
test := responseTest{
171+
wantErr: true,
172+
caFile: "testdata/ca.crt",
173+
respFile: "testdata/good-resp.xml",
174+
now: "2017-04-04T04:34:59.330Z",
175+
usernameAttr: "Name",
176+
emailAttr: "email",
177+
groupsAttr: "groups",
178+
allowedGroups: []string{"Nope"},
179+
inResponseTo: "6zmm5mguyebwvajyf2sdwwcw6m",
180+
redirectURI: "http://127.0.0.1:5556/dex/callback",
181+
wantIdent: connector.Identity{
182+
UserID: "eric.chiang+okta@coreos.com",
183+
Username: "Eric",
184+
Email: "eric.chiang+okta@coreos.com",
185+
EmailVerified: true,
186+
Groups: []string{},
187+
},
188+
}
189+
test.run(t)
190+
}
191+
101192
// TestOkta tests against an actual response from Okta.
102193
func TestOkta(t *testing.T) {
103194
test := responseTest{
@@ -290,12 +381,13 @@ func loadCert(ca string) (*x509.Certificate, error) {
290381

291382
func (r responseTest) run(t *testing.T) {
292383
c := Config{
293-
CA: r.caFile,
294-
UsernameAttr: r.usernameAttr,
295-
EmailAttr: r.emailAttr,
296-
GroupsAttr: r.groupsAttr,
297-
RedirectURI: r.redirectURI,
298-
EntityIssuer: r.entityIssuer,
384+
CA: r.caFile,
385+
UsernameAttr: r.usernameAttr,
386+
EmailAttr: r.emailAttr,
387+
GroupsAttr: r.groupsAttr,
388+
RedirectURI: r.redirectURI,
389+
EntityIssuer: r.entityIssuer,
390+
AllowedGroups: r.allowedGroups,
299391
// Never logging in, don't need this.
300392
SSOURL: "http://foo.bar/",
301393
}

0 commit comments

Comments
 (0)