Validate the upstream email_verified claim when it makes sense

This commit is contained in:
Ryan Richard 2021-01-25 09:53:52 -08:00
parent 156e8d9df4
commit b77297c68d
2 changed files with 129 additions and 14 deletions

View File

@ -24,6 +24,14 @@ import (
"go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/plog"
) )
const (
// The name of the email claim from https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
emailClaimName = "email"
// The name of the email_verified claim from https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
emailVerifiedClaimName = "email_verified"
)
func NewHandler( func NewHandler(
idpListGetter oidc.IDPListGetter, idpListGetter oidc.IDPListGetter,
oauthHelper fosite.OAuth2Provider, oauthHelper fosite.OAuth2Provider,
@ -222,18 +230,41 @@ func getSubjectAndUsernameFromUpstreamIDToken(
subject := fmt.Sprintf("%s?%s=%s", upstreamIssuerAsString, oidc.IDTokenSubjectClaim, upstreamSubject) subject := fmt.Sprintf("%s?%s=%s", upstreamIssuerAsString, oidc.IDTokenSubjectClaim, upstreamSubject)
usernameClaim := upstreamIDPConfig.GetUsernameClaim() usernameClaimName := upstreamIDPConfig.GetUsernameClaim()
if usernameClaim == "" { if usernameClaimName == "" {
return subject, subject, nil return subject, subject, nil
} }
usernameAsInterface, ok := idTokenClaims[usernameClaim] // If the upstream username claim is configured to be the special "email" claim and the upstream "email_verified"
// claim is present, then validate that the "email_verified" claim is true.
emailVerifiedAsInterface, ok := idTokenClaims[emailVerifiedClaimName]
if usernameClaimName == emailClaimName && ok {
emailVerified, ok := emailVerifiedAsInterface.(bool)
if !ok {
plog.Warning(
"username claim configured as \"email\" and upstream email_verified claim is not a boolean",
"upstreamName", upstreamIDPConfig.GetName(),
"configuredUsernameClaim", usernameClaimName,
"emailVerifiedClaim", emailVerifiedAsInterface,
)
return "", "", httperr.New(http.StatusUnprocessableEntity, "email_verified claim in upstream ID token has invalid format")
}
if !emailVerified {
plog.Warning(
"username claim configured as \"email\" and upstream email_verified claim has false value",
"upstreamName", upstreamIDPConfig.GetName(),
"configuredUsernameClaim", usernameClaimName,
)
return "", "", httperr.New(http.StatusUnprocessableEntity, "email_verified claim in upstream ID token has false value")
}
}
usernameAsInterface, ok := idTokenClaims[usernameClaimName]
if !ok { if !ok {
plog.Warning( plog.Warning(
"no username claim in upstream ID token", "no username claim in upstream ID token",
"upstreamName", upstreamIDPConfig.GetName(), "upstreamName", upstreamIDPConfig.GetName(),
"configuredUsernameClaim", upstreamIDPConfig.GetUsernameClaim(), "configuredUsernameClaim", usernameClaimName,
"usernameClaim", usernameClaim,
) )
return "", "", httperr.New(http.StatusUnprocessableEntity, "no username claim in upstream ID token") return "", "", httperr.New(http.StatusUnprocessableEntity, "no username claim in upstream ID token")
} }
@ -243,8 +274,7 @@ func getSubjectAndUsernameFromUpstreamIDToken(
plog.Warning( plog.Warning(
"username claim in upstream ID token has invalid format", "username claim in upstream ID token has invalid format",
"upstreamName", upstreamIDPConfig.GetName(), "upstreamName", upstreamIDPConfig.GetName(),
"configuredUsernameClaim", upstreamIDPConfig.GetUsernameClaim(), "configuredUsernameClaim", usernameClaimName,
"usernameClaim", usernameClaim,
) )
return "", "", httperr.New(http.StatusUnprocessableEntity, "username claim in upstream ID token has invalid format") return "", "", httperr.New(http.StatusUnprocessableEntity, "username claim in upstream ID token has invalid format")
} }
@ -256,18 +286,17 @@ func getGroupsFromUpstreamIDToken(
upstreamIDPConfig provider.UpstreamOIDCIdentityProviderI, upstreamIDPConfig provider.UpstreamOIDCIdentityProviderI,
idTokenClaims map[string]interface{}, idTokenClaims map[string]interface{},
) ([]string, error) { ) ([]string, error) {
groupsClaim := upstreamIDPConfig.GetGroupsClaim() groupsClaimName := upstreamIDPConfig.GetGroupsClaim()
if groupsClaim == "" { if groupsClaimName == "" {
return nil, nil return nil, nil
} }
groupsAsInterface, ok := idTokenClaims[groupsClaim] groupsAsInterface, ok := idTokenClaims[groupsClaimName]
if !ok { if !ok {
plog.Warning( plog.Warning(
"no groups claim in upstream ID token", "no groups claim in upstream ID token",
"upstreamName", upstreamIDPConfig.GetName(), "upstreamName", upstreamIDPConfig.GetName(),
"configuredGroupsClaim", upstreamIDPConfig.GetGroupsClaim(), "configuredGroupsClaim", groupsClaimName,
"groupsClaim", groupsClaim,
) )
return nil, nil // the upstream IDP may have omitted the claim if the user has no groups return nil, nil // the upstream IDP may have omitted the claim if the user has no groups
} }
@ -277,8 +306,7 @@ func getGroupsFromUpstreamIDToken(
plog.Warning( plog.Warning(
"groups claim in upstream ID token has invalid format", "groups claim in upstream ID token has invalid format",
"upstreamName", upstreamIDPConfig.GetName(), "upstreamName", upstreamIDPConfig.GetName(),
"configuredGroupsClaim", upstreamIDPConfig.GetGroupsClaim(), "configuredGroupsClaim", groupsClaimName,
"groupsClaim", groupsClaim,
) )
return nil, httperr.New(http.StatusUnprocessableEntity, "groups claim in upstream ID token has invalid format") return nil, httperr.New(http.StatusUnprocessableEntity, "groups claim in upstream ID token has invalid format")
} }

View File

@ -180,6 +180,93 @@ func TestCallbackEndpoint(t *testing.T) {
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs, wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs,
}, },
{
name: "upstream IDP configures username claim as special claim `email` and `email_verified` upstream claim is missing",
idp: happyUpstream().WithUsernameClaim("email").
WithIDTokenClaim("email", "joe@whitehouse.gov").Build(),
method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusFound,
wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp,
wantBody: "",
wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + upstreamSubject,
wantDownstreamIDTokenUsername: "joe@whitehouse.gov",
wantDownstreamIDTokenGroups: upstreamGroupMembership,
wantDownstreamRequestedScopes: happyDownstreamScopesRequested,
wantDownstreamGrantedScopes: happyDownstreamScopesGranted,
wantDownstreamNonce: downstreamNonce,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs,
},
{
name: "upstream IDP configures username claim as special claim `email` and `email_verified` upstream claim is present with true value",
idp: happyUpstream().WithUsernameClaim("email").
WithIDTokenClaim("email", "joe@whitehouse.gov").
WithIDTokenClaim("email_verified", true).Build(),
method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusFound,
wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp,
wantBody: "",
wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + upstreamSubject,
wantDownstreamIDTokenUsername: "joe@whitehouse.gov",
wantDownstreamIDTokenGroups: upstreamGroupMembership,
wantDownstreamRequestedScopes: happyDownstreamScopesRequested,
wantDownstreamGrantedScopes: happyDownstreamScopesGranted,
wantDownstreamNonce: downstreamNonce,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs,
},
{
name: "upstream IDP configures username claim as anything other than special claim `email` and `email_verified` upstream claim is present with false value",
idp: happyUpstream().WithUsernameClaim("some-claim").
WithIDTokenClaim("some-claim", "joe").
WithIDTokenClaim("email", "joe@whitehouse.gov").
WithIDTokenClaim("email_verified", false).Build(),
method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusFound, // succeed despite `email_verified=false` because we're not using the email claim for anything
wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp,
wantBody: "",
wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + upstreamSubject,
wantDownstreamIDTokenUsername: "joe",
wantDownstreamIDTokenGroups: upstreamGroupMembership,
wantDownstreamRequestedScopes: happyDownstreamScopesRequested,
wantDownstreamGrantedScopes: happyDownstreamScopesGranted,
wantDownstreamNonce: downstreamNonce,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs,
},
{
name: "upstream IDP configures username claim as special claim `email` and `email_verified` upstream claim is present with illegal value",
idp: happyUpstream().WithUsernameClaim("email").
WithIDTokenClaim("email", "joe@whitehouse.gov").
WithIDTokenClaim("email_verified", "supposed to be boolean").Build(),
method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusUnprocessableEntity,
wantBody: "Unprocessable Entity: email_verified claim in upstream ID token has invalid format\n",
wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs,
},
{
name: "upstream IDP configures username claim as special claim `email` and `email_verified` upstream claim is present with false value",
idp: happyUpstream().WithUsernameClaim("email").
WithIDTokenClaim("email", "joe@whitehouse.gov").
WithIDTokenClaim("email_verified", false).Build(),
method: http.MethodGet,
path: newRequestPath().WithState(happyState).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusUnprocessableEntity,
wantBody: "Unprocessable Entity: email_verified claim in upstream ID token has false value\n",
wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs,
},
{ {
name: "upstream IDP provides username claim configuration as `sub`, so the downstream token subject should be exactly what they asked for", name: "upstream IDP provides username claim configuration as `sub`, so the downstream token subject should be exactly what they asked for",
idp: happyUpstream().WithUsernameClaim("sub").Build(), idp: happyUpstream().WithUsernameClaim("sub").Build(),