Merge pull request #1634 from concourse/pr/oidc-username-key-sync
Support username, email and groups claim in OIDC connector
This commit is contained in:
		| @@ -8,8 +8,6 @@ Prominent examples of OpenID Connect providers include Google Accounts, Salesfor | |||||||
|  |  | ||||||
| ## Caveats | ## 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]. | 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 | ## Configuration | ||||||
| @@ -75,9 +73,8 @@ connectors: | |||||||
|     # getUserInfo: true |     # getUserInfo: true | ||||||
|  |  | ||||||
|     # The set claim is used as user id. |     # The set claim is used as user id. | ||||||
|     # Default: sub |  | ||||||
|     # Claims list at https://openid.net/specs/openid-connect-core-1_0.html#Claims |     # Claims list at https://openid.net/specs/openid-connect-core-1_0.html#Claims | ||||||
|     # |     # Default: sub | ||||||
|     # userIDKey: nickname |     # userIDKey: nickname | ||||||
|  |  | ||||||
|     # The set claim is used as user name. |     # The set claim is used as user name. | ||||||
| @@ -88,9 +85,25 @@ connectors: | |||||||
|     # However this is not supported by all OIDC providers, some of them support different |     # However this is not supported by all OIDC providers, some of them support different | ||||||
|     # value for prompt, like "prompt=login" or "prompt=none" |     # value for prompt, like "prompt=login" or "prompt=none" | ||||||
|     # promptType: consent |     # 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 | [oidc-doc]: openid-connect.md | ||||||
| [issue-863]: https://github.com/dexidp/dex/issues/863 | [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 | [azure-ad-v1]: https://github.com/coreos/go-oidc/issues/133 | ||||||
|   | |||||||
| @@ -69,7 +69,7 @@ Dex implements the following connectors: | |||||||
| | [GitHub](Documentation/connectors/github.md) | yes | yes | yes | stable | | | | [GitHub](Documentation/connectors/github.md) | yes | yes | yes | stable | | | ||||||
| | [SAML 2.0](Documentation/connectors/saml.md) | no | yes | no | stable | | | [SAML 2.0](Documentation/connectors/saml.md) | no | yes | no | stable | | ||||||
| | [GitLab](Documentation/connectors/gitlab.md) | yes | yes | yes | beta | | | | [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 | | | | [Google](Documentation/connectors/google.md) | yes | yes | yes | alpha | | | ||||||
| | [LinkedIn](Documentation/connectors/linkedin.md) | yes | no | no | beta | | | | [LinkedIn](Documentation/connectors/linkedin.md) | yes | no | no | beta | | | ||||||
| | [Microsoft](Documentation/connectors/microsoft.md) | yes | yes | no | beta | | | | [Microsoft](Documentation/connectors/microsoft.md) | yes | yes | no | beta | | | ||||||
|   | |||||||
| @@ -49,14 +49,23 @@ type Config struct { | |||||||
| 	// id tokens | 	// id tokens | ||||||
| 	GetUserInfo bool `json:"getUserInfo"` | 	GetUserInfo bool `json:"getUserInfo"` | ||||||
|  |  | ||||||
| 	// Configurable key which contains the user id claim |  | ||||||
| 	UserIDKey string `json:"userIDKey"` | 	UserIDKey string `json:"userIDKey"` | ||||||
|  |  | ||||||
| 	// Configurable key which contains the user name claim |  | ||||||
| 	UserNameKey string `json:"userNameKey"` | 	UserNameKey string `json:"userNameKey"` | ||||||
|  |  | ||||||
| 	// PromptType will be used fot the prompt parameter (when offline_access, by default prompt=consent) | 	// PromptType will be used fot the prompt parameter (when offline_access, by default prompt=consent) | ||||||
| 	PromptType string `json:"promptType"` | 	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 | // 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, | 		insecureSkipEmailVerified: c.InsecureSkipEmailVerified, | ||||||
| 		insecureEnableGroups:      c.InsecureEnableGroups, | 		insecureEnableGroups:      c.InsecureEnableGroups, | ||||||
| 		getUserInfo:               c.GetUserInfo, | 		getUserInfo:               c.GetUserInfo, | ||||||
|  | 		promptType:                c.PromptType, | ||||||
| 		userIDKey:                 c.UserIDKey, | 		userIDKey:                 c.UserIDKey, | ||||||
| 		userNameKey:               c.UserNameKey, | 		userNameKey:               c.UserNameKey, | ||||||
| 		promptType:                c.PromptType, | 		preferredUsernameKey:      c.ClaimMapping.PreferredUsernameKey, | ||||||
|  | 		emailKey:                  c.ClaimMapping.EmailKey, | ||||||
|  | 		groupsKey:                 c.ClaimMapping.GroupsKey, | ||||||
| 	}, nil | 	}, nil | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -163,9 +175,12 @@ type oidcConnector struct { | |||||||
| 	insecureSkipEmailVerified bool | 	insecureSkipEmailVerified bool | ||||||
| 	insecureEnableGroups      bool | 	insecureEnableGroups      bool | ||||||
| 	getUserInfo               bool | 	getUserInfo               bool | ||||||
|  | 	promptType                string | ||||||
| 	userIDKey                 string | 	userIDKey                 string | ||||||
| 	userNameKey               string | 	userNameKey               string | ||||||
| 	promptType                string | 	preferredUsernameKey      string | ||||||
|  | 	emailKey                  string | ||||||
|  | 	groupsKey                 string | ||||||
| } | } | ||||||
|  |  | ||||||
| func (c *oidcConnector) Close() error { | 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) | 		return identity, fmt.Errorf("missing \"%s\" claim", userNameKey) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | 	preferredUsername, found := claims["preferred_username"].(string) | ||||||
|  | 	if !found { | ||||||
|  | 		preferredUsername, _ = claims[c.preferredUsernameKey].(string) | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	hasEmailScope := false | 	hasEmailScope := false | ||||||
| 	for _, s := range c.oauth2Config.Scopes { | 	for _, s := range c.oauth2Config.Scopes { | ||||||
| 		if s == "email" { | 		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 { | 	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) | 	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") | 			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 { | 	if len(c.hostedDomains) > 0 { | ||||||
| 		found := false | 		found := false | ||||||
| 		for _, domain := range c.hostedDomains { | 		for _, domain := range c.hostedDomains { | ||||||
| @@ -322,8 +369,10 @@ func (c *oidcConnector) createIdentity(ctx context.Context, identity connector.I | |||||||
| 	identity = connector.Identity{ | 	identity = connector.Identity{ | ||||||
| 		UserID:            idToken.Subject, | 		UserID:            idToken.Subject, | ||||||
| 		Username:          name, | 		Username:          name, | ||||||
|  | 		PreferredUsername: preferredUsername, | ||||||
| 		Email:             email, | 		Email:             email, | ||||||
| 		EmailVerified:     emailVerified, | 		EmailVerified:     emailVerified, | ||||||
|  | 		Groups:            groups, | ||||||
| 		ConnectorData:     connData, | 		ConnectorData:     connData, | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -335,18 +384,5 @@ func (c *oidcConnector) createIdentity(ctx context.Context, identity connector.I | |||||||
| 		identity.UserID = userID | 		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 | 	return identity, nil | ||||||
| } | } | ||||||
|   | |||||||
| @@ -49,10 +49,15 @@ func TestHandleCallback(t *testing.T) { | |||||||
| 		name                      string | 		name                      string | ||||||
| 		userIDKey                 string | 		userIDKey                 string | ||||||
| 		userNameKey               string | 		userNameKey               string | ||||||
|  | 		preferredUsernameKey      string | ||||||
|  | 		emailKey                  string | ||||||
|  | 		groupsKey                 string | ||||||
| 		insecureSkipEmailVerified bool | 		insecureSkipEmailVerified bool | ||||||
| 		scopes                    []string | 		scopes                    []string | ||||||
| 		expectUserID              string | 		expectUserID              string | ||||||
| 		expectUserName            string | 		expectUserName            string | ||||||
|  | 		expectGroups              []string | ||||||
|  | 		expectPreferredUsername   string | ||||||
| 		expectedEmailField        string | 		expectedEmailField        string | ||||||
| 		token                     map[string]interface{} | 		token                     map[string]interface{} | ||||||
| 	}{ | 	}{ | ||||||
| @@ -62,14 +67,31 @@ func TestHandleCallback(t *testing.T) { | |||||||
| 			userNameKey:        "", // not configured | 			userNameKey:        "", // not configured | ||||||
| 			expectUserID:       "subvalue", | 			expectUserID:       "subvalue", | ||||||
| 			expectUserName:     "namevalue", | 			expectUserName:     "namevalue", | ||||||
|  | 			expectGroups:       []string{"group1", "group2"}, | ||||||
| 			expectedEmailField: "emailvalue", | 			expectedEmailField: "emailvalue", | ||||||
| 			token: map[string]interface{}{ | 			token: map[string]interface{}{ | ||||||
| 				"sub":            "subvalue", | 				"sub":            "subvalue", | ||||||
| 				"name":           "namevalue", | 				"name":           "namevalue", | ||||||
|  | 				"groups":         []string{"group1", "group2"}, | ||||||
| 				"email":          "emailvalue", | 				"email":          "emailvalue", | ||||||
| 				"email_verified": true, | 				"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", | 			name:                      "email_verified not in claims, configured to be skipped", | ||||||
| 			insecureSkipEmailVerified: true, | 			insecureSkipEmailVerified: true, | ||||||
| @@ -108,6 +130,48 @@ func TestHandleCallback(t *testing.T) { | |||||||
| 				"email_verified": true, | 				"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", | 			name:                      "emptyEmailScope", | ||||||
| 			expectUserID:              "subvalue", | 			expectUserID:              "subvalue", | ||||||
| @@ -135,6 +199,41 @@ func TestHandleCallback(t *testing.T) { | |||||||
| 				"email":     "emailvalue", | 				"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 { | 	for _, tc := range tests { | ||||||
| @@ -162,8 +261,12 @@ func TestHandleCallback(t *testing.T) { | |||||||
| 				UserIDKey:                 tc.userIDKey, | 				UserIDKey:                 tc.userIDKey, | ||||||
| 				UserNameKey:               tc.userNameKey, | 				UserNameKey:               tc.userNameKey, | ||||||
| 				InsecureSkipEmailVerified: tc.insecureSkipEmailVerified, | 				InsecureSkipEmailVerified: tc.insecureSkipEmailVerified, | ||||||
|  | 				InsecureEnableGroups:      true, | ||||||
| 				BasicAuthUnsupported:      &basicAuth, | 				BasicAuthUnsupported:      &basicAuth, | ||||||
| 			} | 			} | ||||||
|  | 			config.ClaimMapping.PreferredUsernameKey = tc.preferredUsernameKey | ||||||
|  | 			config.ClaimMapping.EmailKey = tc.emailKey | ||||||
|  | 			config.ClaimMapping.GroupsKey = tc.groupsKey | ||||||
|  |  | ||||||
| 			conn, err := newConnector(config) | 			conn, err := newConnector(config) | ||||||
| 			if err != nil { | 			if err != nil { | ||||||
| @@ -182,8 +285,10 @@ func TestHandleCallback(t *testing.T) { | |||||||
|  |  | ||||||
| 			expectEquals(t, identity.UserID, tc.expectUserID) | 			expectEquals(t, identity.UserID, tc.expectUserID) | ||||||
| 			expectEquals(t, identity.Username, tc.expectUserName) | 			expectEquals(t, identity.Username, tc.expectUserName) | ||||||
|  | 			expectEquals(t, identity.PreferredUsername, tc.expectPreferredUsername) | ||||||
| 			expectEquals(t, identity.Email, tc.expectedEmailField) | 			expectEquals(t, identity.Email, tc.expectedEmailField) | ||||||
| 			expectEquals(t, identity.EmailVerified, true) | 			expectEquals(t, identity.EmailVerified, true) | ||||||
|  | 			expectEquals(t, identity.Groups, tc.expectGroups) | ||||||
| 		}) | 		}) | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user