Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support username, email and groups claim in OIDC connector #1634

Merged
merged 9 commits into from
Sep 8, 2020
25 changes: 19 additions & 6 deletions Documentation/connectors/oidc.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ Prominent examples of OpenID Connect providers include Google Accounts, Salesfor

## Caveats

This connector does not support the "groups" claim. Progress for this is tracked in [issue #1065][issue-1065].

When using refresh tokens, changes to the upstream claims aren't propagated to the id_token returned by dex. If a user's email changes, the "email" claim returned by dex won't change unless the user logs in again. Progress for this is tracked in [issue #863][issue-863].

## Configuration
Expand Down Expand Up @@ -75,11 +73,10 @@ connectors:
# getUserInfo: true

# The set claim is used as user id.
# Default: sub
# Claims list at https://openid.net/specs/openid-connect-core-1_0.html#Claims
#
# Default: sub
# userIDKey: nickname

# The set claim is used as user name.
# Default: name
# userNameKey: nickname
Expand All @@ -88,9 +85,25 @@ connectors:
# However this is not supported by all OIDC providers, some of them support different
# value for prompt, like "prompt=login" or "prompt=none"
# promptType: consent

# Some providers return non-standard claims (eg. mail).
# Use claimMapping to map those claims to standard claims:
# https://openid.net/specs/openid-connect-core-1_0.html#Claims
# claimMapping can only map a non-standard claim to a standard one if it's not returned in the id_token.
claimMapping:
# The set claim is used as preferred username.
# Default: preferred_username
# preferred_username: other_user_name

# The set claim is used as email.
# Default: email
# email: mail

# The set claim is used as groups.
# Default: groups
# groups: "cognito:groups"
```

[oidc-doc]: openid-connect.md
[issue-863]: https://github.com/dexidp/dex/issues/863
[issue-1065]: https://github.com/dexidp/dex/issues/1065
[azure-ad-v1]: https://github.com/coreos/go-oidc/issues/133
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ Dex implements the following connectors:
| [GitHub](Documentation/connectors/github.md) | yes | yes | yes | stable | |
| [SAML 2.0](Documentation/connectors/saml.md) | no | yes | no | stable |
| [GitLab](Documentation/connectors/gitlab.md) | yes | yes | yes | beta | |
| [OpenID Connect](Documentation/connectors/oidc.md) | yes | no ([#1065][issue-1065]) | no | beta | Includes Salesforce, Azure, etc. |
| [OpenID Connect](Documentation/connectors/oidc.md) | yes | yes | yes | beta | Includes Salesforce, Azure, etc. |
| [Google](Documentation/connectors/google.md) | yes | yes | yes | alpha | |
| [LinkedIn](Documentation/connectors/linkedin.md) | yes | no | no | beta | |
| [Microsoft](Documentation/connectors/microsoft.md) | yes | yes | no | beta | |
Expand Down
86 changes: 61 additions & 25 deletions connector/oidc/oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,23 @@ type Config struct {
// id tokens
GetUserInfo bool `json:"getUserInfo"`

// Configurable key which contains the user id claim
UserIDKey string `json:"userIDKey"`

// Configurable key which contains the user name claim
UserNameKey string `json:"userNameKey"`

// PromptType will be used fot the prompt parameter (when offline_access, by default prompt=consent)
PromptType string `json:"promptType"`

ClaimMapping struct {
// Configurable key which contains the preferred username claims
PreferredUsernameKey string `json:"preferred_username"` // defaults to "preferred_username"

// Configurable key which contains the email claims
EmailKey string `json:"email"` // defaults to "email"

// Configurable key which contains the groups claims
GroupsKey string `json:"groups"` // defaults to "groups"
} `json:"claimMapping"`
}

// Domains that don't support basic auth. golang.org/x/oauth2 has an internal
Expand Down Expand Up @@ -141,9 +150,12 @@ func (c *Config) Open(id string, logger log.Logger) (conn connector.Connector, e
insecureSkipEmailVerified: c.InsecureSkipEmailVerified,
insecureEnableGroups: c.InsecureEnableGroups,
getUserInfo: c.GetUserInfo,
promptType: c.PromptType,
userIDKey: c.UserIDKey,
userNameKey: c.UserNameKey,
promptType: c.PromptType,
preferredUsernameKey: c.ClaimMapping.PreferredUsernameKey,
emailKey: c.ClaimMapping.EmailKey,
groupsKey: c.ClaimMapping.GroupsKey,
}, nil
}

Expand All @@ -163,9 +175,12 @@ type oidcConnector struct {
insecureSkipEmailVerified bool
insecureEnableGroups bool
getUserInfo bool
promptType string
userIDKey string
userNameKey string
promptType string
preferredUsernameKey string
emailKey string
groupsKey string
}

func (c *oidcConnector) Close() error {
Expand Down Expand Up @@ -273,6 +288,11 @@ func (c *oidcConnector) createIdentity(ctx context.Context, identity connector.I
return identity, fmt.Errorf("missing \"%s\" claim", userNameKey)
}

preferredUsername, found := claims["preferred_username"].(string)
if !found {
preferredUsername, _ = claims[c.preferredUsernameKey].(string)
}

hasEmailScope := false
for _, s := range c.oauth2Config.Scopes {
if s == "email" {
Expand All @@ -281,9 +301,16 @@ func (c *oidcConnector) createIdentity(ctx context.Context, identity connector.I
}
}

email, found := claims["email"].(string)
var email string
emailKey := "email"
email, found = claims[emailKey].(string)
if !found && c.emailKey != "" {
emailKey = c.emailKey
email, found = claims[emailKey].(string)
}

if !found && hasEmailScope {
return identity, errors.New("missing \"email\" claim")
return identity, fmt.Errorf("missing email claim, not found \"%s\" key", emailKey)
}

emailVerified, found := claims["email_verified"].(bool)
Expand All @@ -294,8 +321,28 @@ func (c *oidcConnector) createIdentity(ctx context.Context, identity connector.I
return identity, errors.New("missing \"email_verified\" claim")
}
}
hostedDomain, _ := claims["hd"].(string)

var groups []string
if c.insecureEnableGroups {
groupsKey := "groups"
vs, found := claims[groupsKey].([]interface{})
if !found {
groupsKey = c.groupsKey
vs, found = claims[groupsKey].([]interface{})
}

if found {
for _, v := range vs {
if s, ok := v.(string); ok {
groups = append(groups, s)
} else {
return identity, fmt.Errorf("malformed \"%v\" claim", groupsKey)
}
}
}
}

hostedDomain, _ := claims["hd"].(string)
if len(c.hostedDomains) > 0 {
found := false
for _, domain := range c.hostedDomains {
Expand All @@ -320,11 +367,13 @@ func (c *oidcConnector) createIdentity(ctx context.Context, identity connector.I
}

identity = connector.Identity{
UserID: idToken.Subject,
Username: name,
Email: email,
EmailVerified: emailVerified,
ConnectorData: connData,
UserID: idToken.Subject,
Username: name,
PreferredUsername: preferredUsername,
Email: email,
EmailVerified: emailVerified,
Groups: groups,
ConnectorData: connData,
}

if c.userIDKey != "" {
Expand All @@ -335,18 +384,5 @@ func (c *oidcConnector) createIdentity(ctx context.Context, identity connector.I
identity.UserID = userID
}

if c.insecureEnableGroups {
vs, ok := claims["groups"].([]interface{})
if ok {
for _, v := range vs {
if s, ok := v.(string); ok {
identity.Groups = append(identity.Groups, s)
} else {
return identity, errors.New("malformed \"groups\" claim")
}
}
}
}

return identity, nil
}
105 changes: 105 additions & 0 deletions connector/oidc/oidc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,15 @@ func TestHandleCallback(t *testing.T) {
name string
userIDKey string
userNameKey string
preferredUsernameKey string
emailKey string
groupsKey string
insecureSkipEmailVerified bool
scopes []string
expectUserID string
expectUserName string
expectGroups []string
expectPreferredUsername string
expectedEmailField string
token map[string]interface{}
}{
Expand All @@ -62,14 +67,31 @@ func TestHandleCallback(t *testing.T) {
userNameKey: "", // not configured
expectUserID: "subvalue",
expectUserName: "namevalue",
expectGroups: []string{"group1", "group2"},
expectedEmailField: "emailvalue",
token: map[string]interface{}{
"sub": "subvalue",
"name": "namevalue",
"groups": []string{"group1", "group2"},
"email": "emailvalue",
"email_verified": true,
},
},
{
name: "customEmailClaim",
userIDKey: "", // not configured
userNameKey: "", // not configured
emailKey: "mail",
expectUserID: "subvalue",
expectUserName: "namevalue",
expectedEmailField: "emailvalue",
token: map[string]interface{}{
"sub": "subvalue",
"name": "namevalue",
"mail": "emailvalue",
"email_verified": true,
},
},
{
name: "email_verified not in claims, configured to be skipped",
insecureSkipEmailVerified: true,
Expand Down Expand Up @@ -108,6 +130,48 @@ func TestHandleCallback(t *testing.T) {
"email_verified": true,
},
},
{
name: "withPreferredUsernameKey",
preferredUsernameKey: "username_key",
expectUserID: "subvalue",
expectUserName: "namevalue",
expectPreferredUsername: "username_value",
expectedEmailField: "emailvalue",
token: map[string]interface{}{
"sub": "subvalue",
"name": "namevalue",
"username_key": "username_value",
"email": "emailvalue",
"email_verified": true,
},
},
{
name: "withoutPreferredUsernameKeyAndBackendReturns",
expectUserID: "subvalue",
expectUserName: "namevalue",
expectPreferredUsername: "preferredusernamevalue",
expectedEmailField: "emailvalue",
token: map[string]interface{}{
"sub": "subvalue",
"name": "namevalue",
"preferred_username": "preferredusernamevalue",
"email": "emailvalue",
"email_verified": true,
},
},
{
name: "withoutPreferredUsernameKeyAndBackendNotReturn",
expectUserID: "subvalue",
expectUserName: "namevalue",
expectPreferredUsername: "",
expectedEmailField: "emailvalue",
token: map[string]interface{}{
"sub": "subvalue",
"name": "namevalue",
"email": "emailvalue",
"email_verified": true,
},
},
{
name: "emptyEmailScope",
expectUserID: "subvalue",
Expand Down Expand Up @@ -135,6 +199,41 @@ func TestHandleCallback(t *testing.T) {
"email": "emailvalue",
},
},
{
name: "customGroupsKey",
groupsKey: "cognito:groups",
expectUserID: "subvalue",
expectUserName: "namevalue",
expectedEmailField: "emailvalue",
expectGroups: []string{"group3", "group4"},
scopes: []string{"groups"},
insecureSkipEmailVerified: true,
token: map[string]interface{}{
"sub": "subvalue",
"name": "namevalue",
"user_name": "username",
"email": "emailvalue",
"cognito:groups": []string{"group3", "group4"},
},
},
{
name: "customGroupsKeyButGroupsProvided",
groupsKey: "cognito:groups",
expectUserID: "subvalue",
expectUserName: "namevalue",
expectedEmailField: "emailvalue",
expectGroups: []string{"group1", "group2"},
scopes: []string{"groups"},
insecureSkipEmailVerified: true,
token: map[string]interface{}{
"sub": "subvalue",
"name": "namevalue",
"user_name": "username",
"email": "emailvalue",
"groups": []string{"group1", "group2"},
"cognito:groups": []string{"group3", "group4"},
},
},
}

for _, tc := range tests {
Expand Down Expand Up @@ -162,8 +261,12 @@ func TestHandleCallback(t *testing.T) {
UserIDKey: tc.userIDKey,
UserNameKey: tc.userNameKey,
InsecureSkipEmailVerified: tc.insecureSkipEmailVerified,
InsecureEnableGroups: true,
BasicAuthUnsupported: &basicAuth,
}
config.ClaimMapping.PreferredUsernameKey = tc.preferredUsernameKey
config.ClaimMapping.EmailKey = tc.emailKey
config.ClaimMapping.GroupsKey = tc.groupsKey

conn, err := newConnector(config)
if err != nil {
Expand All @@ -182,8 +285,10 @@ func TestHandleCallback(t *testing.T) {

expectEquals(t, identity.UserID, tc.expectUserID)
expectEquals(t, identity.Username, tc.expectUserName)
expectEquals(t, identity.PreferredUsername, tc.expectPreferredUsername)
expectEquals(t, identity.Email, tc.expectedEmailField)
expectEquals(t, identity.EmailVerified, true)
expectEquals(t, identity.Groups, tc.expectGroups)
})
}
}
Expand Down