diff --git a/Documentation/connectors/oidc.md b/Documentation/connectors/oidc.md index c472e303ff..e64cd2de2f 100644 --- a/Documentation/connectors/oidc.md +++ b/Documentation/connectors/oidc.md @@ -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 @@ -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 @@ -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 diff --git a/README.md b/README.md index 412aa84e69..432c2fa18b 100644 --- a/README.md +++ b/README.md @@ -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 | | diff --git a/connector/oidc/oidc.go b/connector/oidc/oidc.go index 675e4b95df..b8e543d4bc 100644 --- a/connector/oidc/oidc.go +++ b/connector/oidc/oidc.go @@ -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 @@ -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 } @@ -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 { @@ -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" { @@ -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) @@ -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 { @@ -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 != "" { @@ -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 } diff --git a/connector/oidc/oidc_test.go b/connector/oidc/oidc_test.go index 52afa15804..ae92f70caa 100644 --- a/connector/oidc/oidc_test.go +++ b/connector/oidc/oidc_test.go @@ -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{} }{ @@ -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, @@ -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", @@ -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 { @@ -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 { @@ -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) }) } }