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:
Márk Sági-Kazár 2020-09-08 19:19:44 +02:00 committed by GitHub
commit ca0a9e821e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 186 additions and 32 deletions

View File

@ -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

View File

@ -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 | |

View File

@ -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 {
@ -320,11 +367,13 @@ 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,
Email: email, PreferredUsername: preferredUsername,
EmailVerified: emailVerified, Email: email,
ConnectorData: connData, EmailVerified: emailVerified,
Groups: groups,
ConnectorData: connData,
} }
if c.userIDKey != "" { if c.userIDKey != "" {
@ -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
} }

View File

@ -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)
}) })
} }
} }