diff --git a/path_login.go b/path_login.go index 19da2121..53bd366c 100644 --- a/path_login.go +++ b/path_login.go @@ -182,10 +182,16 @@ func (b *jwtAuthBackend) pathLoginRenew(ctx context.Context, req *logical.Reques // createIdentity creates an alias and set of groups aliases based on the role // definition and received claims. func (b *jwtAuthBackend) createIdentity(ctx context.Context, allClaims map[string]interface{}, role *jwtRole, tokenSource oauth2.TokenSource) (*logical.Alias, []*logical.Alias, error) { - userClaimRaw, ok := allClaims[role.UserClaim] - if !ok { + var userClaimRaw interface{} + if role.UserClaimJSONPointer { + userClaimRaw = getClaim(b.Logger(), allClaims, role.UserClaim) + } else { + userClaimRaw = allClaims[role.UserClaim] + } + if userClaimRaw == nil { return nil, nil, fmt.Errorf("claim %q not found in token", role.UserClaim) } + userName, ok := userClaimRaw.(string) if !ok { return nil, nil, fmt.Errorf("claim %q could not be converted to string", role.UserClaim) diff --git a/path_oidc_test.go b/path_oidc_test.go index e9846aeb..8ed6bb8e 100644 --- a/path_oidc_test.go +++ b/path_oidc_test.go @@ -469,6 +469,140 @@ func TestOIDC_AuthURL_max_age(t *testing.T) { } } +// TestOIDC_UserClaim_JSON_Pointer tests the ability to use JSON +// pointer syntax for the user_claim of roles. For claims used +// in assertions, see the sampleClaims function. +func TestOIDC_UserClaim_JSON_Pointer(t *testing.T) { + b, storage, s := getBackendAndServer(t, false) + defer s.server.Close() + + type args struct { + userClaim string + userClaimJSONPointer bool + } + tests := []struct { + name string + args args + wantAliasName string + wantErr bool + }{ + { + name: "user_claim without JSON pointer", + args: args{ + userClaim: "email", + userClaimJSONPointer: false, + }, + wantAliasName: "bob@example.com", + }, + { + name: "user_claim without JSON pointer using claim that could be JSON pointer", + args: args{ + userClaim: "/nested/username", + userClaimJSONPointer: false, + }, + wantAliasName: "non_nested_username", + }, + { + name: "user_claim without JSON pointer not found", + args: args{ + userClaim: "other", + userClaimJSONPointer: false, + }, + wantErr: true, + }, + { + name: "user_claim with JSON pointer nested", + args: args{ + userClaim: "/nested/username", + userClaimJSONPointer: true, + }, + wantAliasName: "nested_username", + }, + { + name: "user_claim with JSON pointer not nested", + args: args{ + userClaim: "/email", + userClaimJSONPointer: true, + }, + wantAliasName: "bob@example.com", + }, + { + name: "user_claim with JSON pointer not found", + args: args{ + userClaim: "/nested/username/email", + userClaimJSONPointer: true, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Update the role's user_claim config + data := map[string]interface{}{ + "user_claim": tt.args.userClaim, + "user_claim_json_pointer": tt.args.userClaimJSONPointer, + } + req := &logical.Request{ + Operation: logical.CreateOperation, + Path: "role/test", + Storage: storage, + Data: data, + } + resp, err := b.HandleRequest(context.Background(), req) + require.NoError(t, err) + require.False(t, resp.IsError()) + + // Generate an auth URL + data = map[string]interface{}{ + "role": "test", + "redirect_uri": "https://example.com", + } + req = &logical.Request{ + Operation: logical.UpdateOperation, + Path: "oidc/auth_url", + Storage: storage, + Data: data, + } + resp, err = b.HandleRequest(context.Background(), req) + require.NoError(t, err) + require.False(t, resp.IsError()) + + // Parse the state and nonce from the auth URL + authURL := resp.Data["auth_url"].(string) + state := getQueryParam(t, authURL, "state") + nonce := getQueryParam(t, authURL, "nonce") + + // Set test provider custom claims, expected auth code, expected code challenge + s.codeChallenge = getQueryParam(t, authURL, "code_challenge") + s.customClaims = sampleClaims(nonce) + s.code = "abc" + + // Complete authentication by invoking the callback handler + req = &logical.Request{ + Operation: logical.ReadOperation, + Path: "oidc/callback", + Storage: storage, + Data: map[string]interface{}{ + "state": state, + "code": "abc", + }, + } + + // Assert that we get the expected alias name + resp, err = b.HandleRequest(context.Background(), req) + if tt.wantErr { + require.True(t, resp.IsError()) + return + } + require.NoError(t, err) + require.False(t, resp.IsError()) + require.NotNil(t, resp.Auth) + require.NotNil(t, resp.Auth.Alias) + require.Equal(t, tt.wantAliasName, resp.Auth.Alias.Name) + }) + } +} + // TestOIDC_ResponseTypeIDToken tests authentication using an implicit flow // by setting oidc_response_types=id_token and oidc_response_mode=form_post. // This means that there is no exchange of an authorization code for tokens. @@ -1474,14 +1608,16 @@ func getBackendAndServer(t *testing.T, boundCIDRs bool) (logical.Backend, logica func sampleClaims(nonce string) map[string]interface{} { return map[string]interface{}{ - "nonce": nonce, - "email": "bob@example.com", - "COLOR": "green", - "sk": "42", + "nonce": nonce, + "email": "bob@example.com", + "/nested/username": "non_nested_username", + "COLOR": "green", + "sk": "42", "nested": map[string]interface{}{ "Size": "medium", "Groups": []string{"a", "b"}, "secret_code": "bar", + "username": "nested_username", }, "password": "foo", } diff --git a/path_role.go b/path_role.go index 033d9afa..e409dc43 100644 --- a/path_role.go +++ b/path_role.go @@ -125,6 +125,11 @@ Defaults to 60 (1 minute) if set to 0 and can be disabled if set to -1.`, Type: framework.TypeString, Description: `The claim to use for the Identity entity alias name`, }, + "user_claim_json_pointer": { + Type: framework.TypeBool, + Description: `If true, the user_claim value will use JSON pointer syntax +for referencing claims.`, + }, "groups_claim": { Type: framework.TypeString, Description: `The claim to use for the Identity group alias names`, @@ -196,17 +201,18 @@ type jwtRole struct { ClockSkewLeeway time.Duration `json:"clock_skew_leeway"` // Role binding properties - BoundAudiences []string `json:"bound_audiences"` - BoundSubject string `json:"bound_subject"` - BoundClaimsType string `json:"bound_claims_type"` - BoundClaims map[string]interface{} `json:"bound_claims"` - ClaimMappings map[string]string `json:"claim_mappings"` - UserClaim string `json:"user_claim"` - GroupsClaim string `json:"groups_claim"` - OIDCScopes []string `json:"oidc_scopes"` - AllowedRedirectURIs []string `json:"allowed_redirect_uris"` - VerboseOIDCLogging bool `json:"verbose_oidc_logging"` - MaxAge time.Duration `json:"max_age"` + BoundAudiences []string `json:"bound_audiences"` + BoundSubject string `json:"bound_subject"` + BoundClaimsType string `json:"bound_claims_type"` + BoundClaims map[string]interface{} `json:"bound_claims"` + ClaimMappings map[string]string `json:"claim_mappings"` + UserClaim string `json:"user_claim"` + GroupsClaim string `json:"groups_claim"` + OIDCScopes []string `json:"oidc_scopes"` + AllowedRedirectURIs []string `json:"allowed_redirect_uris"` + VerboseOIDCLogging bool `json:"verbose_oidc_logging"` + MaxAge time.Duration `json:"max_age"` + UserClaimJSONPointer bool `json:"user_claim_json_pointer"` // Deprecated by TokenParams Policies []string `json:"policies"` @@ -299,21 +305,22 @@ func (b *jwtAuthBackend) pathRoleRead(ctx context.Context, req *logical.Request, // Create a map of data to be returned d := map[string]interface{}{ - "role_type": role.RoleType, - "expiration_leeway": int64(role.ExpirationLeeway.Seconds()), - "not_before_leeway": int64(role.NotBeforeLeeway.Seconds()), - "clock_skew_leeway": int64(role.ClockSkewLeeway.Seconds()), - "bound_audiences": role.BoundAudiences, - "bound_subject": role.BoundSubject, - "bound_claims_type": role.BoundClaimsType, - "bound_claims": role.BoundClaims, - "claim_mappings": role.ClaimMappings, - "user_claim": role.UserClaim, - "groups_claim": role.GroupsClaim, - "allowed_redirect_uris": role.AllowedRedirectURIs, - "oidc_scopes": role.OIDCScopes, - "verbose_oidc_logging": role.VerboseOIDCLogging, - "max_age": int64(role.MaxAge.Seconds()), + "role_type": role.RoleType, + "expiration_leeway": int64(role.ExpirationLeeway.Seconds()), + "not_before_leeway": int64(role.NotBeforeLeeway.Seconds()), + "clock_skew_leeway": int64(role.ClockSkewLeeway.Seconds()), + "bound_audiences": role.BoundAudiences, + "bound_subject": role.BoundSubject, + "bound_claims_type": role.BoundClaimsType, + "bound_claims": role.BoundClaims, + "claim_mappings": role.ClaimMappings, + "user_claim": role.UserClaim, + "user_claim_json_pointer": role.UserClaimJSONPointer, + "groups_claim": role.GroupsClaim, + "allowed_redirect_uris": role.AllowedRedirectURIs, + "oidc_scopes": role.OIDCScopes, + "verbose_oidc_logging": role.VerboseOIDCLogging, + "max_age": int64(role.MaxAge.Seconds()), } role.PopulateTokenData(d) @@ -506,6 +513,10 @@ func (b *jwtAuthBackend) pathRoleCreateUpdate(ctx context.Context, req *logical. return logical.ErrorResponse("a user claim must be defined on the role"), nil } + if userClaimJSONPointer, ok := data.GetOk("user_claim_json_pointer"); ok { + role.UserClaimJSONPointer = userClaimJSONPointer.(bool) + } + if groupsClaim, ok := data.GetOk("groups_claim"); ok { role.GroupsClaim = groupsClaim.(string) } diff --git a/path_role_test.go b/path_role_test.go index 19de176d..8fa1ac4c 100644 --- a/path_role_test.go +++ b/path_role_test.go @@ -42,18 +42,19 @@ func TestPath_Create(t *testing.T) { b, storage := getBackend(t) data := map[string]interface{}{ - "role_type": "jwt", - "bound_subject": "testsub", - "bound_audiences": "vault", - "user_claim": "user", - "groups_claim": "groups", - "bound_cidrs": "127.0.0.1/8", - "policies": "test", - "period": "3s", - "ttl": "1s", - "num_uses": 12, - "max_ttl": "5s", - "max_age": "60s", + "role_type": "jwt", + "bound_subject": "testsub", + "bound_audiences": "vault", + "user_claim": "user", + "user_claim_json_pointer": true, + "groups_claim": "groups", + "bound_cidrs": "127.0.0.1/8", + "policies": "test", + "period": "3s", + "ttl": "1s", + "num_uses": 12, + "max_ttl": "5s", + "max_age": "60s", } expectedSockAddr, err := sockaddr.NewSockAddr("127.0.0.1/8") @@ -70,23 +71,24 @@ func TestPath_Create(t *testing.T) { TokenNumUses: 12, TokenBoundCIDRs: []*sockaddr.SockAddrMarshaler{{SockAddr: expectedSockAddr}}, }, - RoleType: "jwt", - Policies: []string{"test"}, - Period: 3 * time.Second, - BoundSubject: "testsub", - BoundAudiences: []string{"vault"}, - BoundClaimsType: "string", - UserClaim: "user", - GroupsClaim: "groups", - TTL: 1 * time.Second, - MaxTTL: 5 * time.Second, - ExpirationLeeway: 0, - NotBeforeLeeway: 0, - ClockSkewLeeway: 0, - NumUses: 12, - BoundCIDRs: []*sockaddr.SockAddrMarshaler{{SockAddr: expectedSockAddr}}, - AllowedRedirectURIs: []string(nil), - MaxAge: 60 * time.Second, + RoleType: "jwt", + Policies: []string{"test"}, + Period: 3 * time.Second, + BoundSubject: "testsub", + BoundAudiences: []string{"vault"}, + BoundClaimsType: "string", + UserClaim: "user", + UserClaimJSONPointer: true, + GroupsClaim: "groups", + TTL: 1 * time.Second, + MaxTTL: 5 * time.Second, + ExpirationLeeway: 0, + NotBeforeLeeway: 0, + ClockSkewLeeway: 0, + NumUses: 12, + BoundCIDRs: []*sockaddr.SockAddrMarshaler{{SockAddr: expectedSockAddr}}, + AllowedRedirectURIs: []string(nil), + MaxAge: 60 * time.Second, } req := &logical.Request{ @@ -767,6 +769,7 @@ func TestPath_Read(t *testing.T) { "allowed_redirect_uris": []string{"http://127.0.0.1"}, "oidc_scopes": []string{"email", "profile"}, "user_claim": "user", + "user_claim_json_pointer": false, "groups_claim": "groups", "token_policies": []string{"test"}, "policies": []string{"test"},