mirror of
				https://github.com/optim-enterprises-bv/vault.git
				synced 2025-10-30 18:17:55 +00:00 
			
		
		
		
	identity: adds generation of plugin identity tokens (#25219)
* adds generation of plugin identity tokens * adds constants * fix namespace path when getting matching identity storage * adds changelog * adds godoc on test * fix data race with default key generation by moving locks up * Update changelog/25219.txt Co-authored-by: Tom Proctor <tomhjp@users.noreply.github.com> * use namespace from context instead of mount entry * translate mount table entry from mounts to secret * godoc on test --------- Co-authored-by: Tom Proctor <tomhjp@users.noreply.github.com>
This commit is contained in:
		
							
								
								
									
										3
									
								
								changelog/25219.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								changelog/25219.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| ```release-note:feature | ||||
| **Plugin Workload Identity**: Vault can generate identity tokens for plugins to use in workload identity federation auth flows. | ||||
| ``` | ||||
| @@ -458,10 +458,19 @@ func (d dynamicSystemView) ClusterID(ctx context.Context) (string, error) { | ||||
| 	return clusterInfo.ID, nil | ||||
| } | ||||
|  | ||||
| func (d dynamicSystemView) GenerateIdentityToken(_ context.Context, _ *pluginutil.IdentityTokenRequest) (*pluginutil.IdentityTokenResponse, error) { | ||||
| 	// TODO: implement plugin identity token generation using identity store | ||||
| func (d dynamicSystemView) GenerateIdentityToken(ctx context.Context, req *pluginutil.IdentityTokenRequest) (*pluginutil.IdentityTokenResponse, error) { | ||||
| 	storage := d.core.router.MatchingStorageByAPIPath(ctx, mountPathIdentity) | ||||
| 	if storage == nil { | ||||
| 		return nil, fmt.Errorf("failed to find storage entry for identity mount") | ||||
| 	} | ||||
|  | ||||
| 	token, ttl, err := d.core.IdentityStore().generatePluginIdentityToken(ctx, storage, d.mountEntry, req.Audience, req.TTL) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to generate plugin identity token: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	return &pluginutil.IdentityTokenResponse{ | ||||
| 		Token: "unimplemented", | ||||
| 		TTL:   time.Duration(0), | ||||
| 		Token: pluginutil.IdentityToken(token), | ||||
| 		TTL:   ttl, | ||||
| 	}, nil | ||||
| } | ||||
|   | ||||
| @@ -64,6 +64,7 @@ func NewIdentityStore(ctx context.Context, core *Core, config *logical.BackendCo | ||||
| 		groupUpdater:  core, | ||||
| 		tokenStorer:   core, | ||||
| 		entityCreator: core, | ||||
| 		mountLister:   core, | ||||
| 		mfaBackend:    core.loginMFABackend, | ||||
| 	} | ||||
|  | ||||
|   | ||||
| @@ -34,6 +34,7 @@ import ( | ||||
| 	"github.com/hashicorp/vault/sdk/logical" | ||||
| 	"github.com/patrickmn/go-cache" | ||||
| 	"golang.org/x/crypto/ed25519" | ||||
| 	"golang.org/x/exp/maps" | ||||
| ) | ||||
|  | ||||
| type oidcConfig struct { | ||||
| @@ -126,23 +127,12 @@ type oidcCache struct { | ||||
| 	c *cache.Cache | ||||
| } | ||||
|  | ||||
| var errNilNamespace = errors.New("nil namespace in oidc cache request") | ||||
|  | ||||
| const ( | ||||
| 	issuerPath           = "identity/oidc" | ||||
| 	oidcTokensPrefix     = "oidc_tokens/" | ||||
| 	namedKeyCachePrefix  = "namedKeys/" | ||||
| 	oidcConfigStorageKey = oidcTokensPrefix + "config/" | ||||
| 	namedKeyConfigPath   = oidcTokensPrefix + "named_keys/" | ||||
| 	publicKeysConfigPath = oidcTokensPrefix + "public_keys/" | ||||
| 	roleConfigPath       = oidcTokensPrefix + "roles/" | ||||
|  | ||||
| 	// Identity tokens have a base issuer and plugin issuer | ||||
| 	baseIdentityTokenIssuer   = "" | ||||
| 	pluginIdentityTokenIssuer = "plugins" | ||||
| ) | ||||
|  | ||||
| var ( | ||||
| 	errNilNamespace = errors.New("nil namespace in oidc cache request") | ||||
|  | ||||
| 	// pseudo-namespace for cache items that don't belong to any real namespace. | ||||
| 	noNamespace = &namespace.Namespace{ID: "__NO_NAMESPACE"} | ||||
|  | ||||
| 	reservedClaims = []string{ | ||||
| 		"iat", "aud", "exp", "iss", | ||||
| 		"sub", "namespace", "nonce", | ||||
| @@ -159,8 +149,24 @@ var ( | ||||
| 	} | ||||
| ) | ||||
|  | ||||
| // pseudo-namespace for cache items that don't belong to any real namespace. | ||||
| var noNamespace = &namespace.Namespace{ID: "__NO_NAMESPACE"} | ||||
| const ( | ||||
| 	issuerPath           = "identity/oidc" | ||||
| 	oidcTokensPrefix     = "oidc_tokens/" | ||||
| 	namedKeyCachePrefix  = "namedKeys/" | ||||
| 	oidcConfigStorageKey = oidcTokensPrefix + "config/" | ||||
| 	namedKeyConfigPath   = oidcTokensPrefix + "named_keys/" | ||||
| 	publicKeysConfigPath = oidcTokensPrefix + "public_keys/" | ||||
| 	roleConfigPath       = oidcTokensPrefix + "roles/" | ||||
|  | ||||
| 	// Identity tokens have a base issuer and plugin issuer | ||||
| 	baseIdentityTokenIssuer   = "" | ||||
| 	pluginIdentityTokenIssuer = "plugins" | ||||
|  | ||||
| 	pluginTokenSubjectPrefix   = "plugin-identity" | ||||
| 	pluginTokenPrivateClaimKey = "vaultproject.io" | ||||
| 	secretTableValue           = "secret" | ||||
| 	deleteKeyErrorFmt          = "unable to delete key %q because it is currently referenced by these %s: %s" | ||||
| ) | ||||
|  | ||||
| // optionalChildIssuerRegex is a regex for optionally accepting a field in an | ||||
| // API request as a single path segment. Adapted from framework.OptionalParamRegex | ||||
| @@ -784,6 +790,56 @@ func (i *IdentityStore) roleNamesReferencingTargetKeyName(ctx context.Context, r | ||||
| 	return names, nil | ||||
| } | ||||
|  | ||||
| // listMounts returns all mount entries in the namespace. | ||||
| // Returns an error if the namespace is nil. | ||||
| func (i *IdentityStore) listMounts(ns *namespace.Namespace) ([]*MountEntry, error) { | ||||
| 	if ns == nil { | ||||
| 		return nil, errors.New("namespace must not be nil") | ||||
| 	} | ||||
|  | ||||
| 	secretMounts, err := i.mountLister.ListMounts() | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	authMounts, err := i.mountLister.ListAuths() | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	var allMounts []*MountEntry | ||||
| 	for _, mount := range append(authMounts, secretMounts...) { | ||||
| 		if mount.NamespaceID == ns.ID { | ||||
| 			allMounts = append(allMounts, mount) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return allMounts, nil | ||||
| } | ||||
|  | ||||
| // mountsReferencingKey returns a sorted list of all mount entry paths referencing | ||||
| // the key in the namespace. Returns an error if the namespace is nil. | ||||
| func (i *IdentityStore) mountsReferencingKey(ns *namespace.Namespace, key string) ([]string, error) { | ||||
| 	if ns == nil { | ||||
| 		return nil, errors.New("namespace must not be nil") | ||||
| 	} | ||||
|  | ||||
| 	allMounts, err := i.listMounts(ns) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	pathsWithKey := make(map[string]struct{}) | ||||
| 	for _, mount := range allMounts { | ||||
| 		if mount.Config.IdentityTokenKey == key { | ||||
| 			pathsWithKey[mount.Path] = struct{}{} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	paths := maps.Keys(pathsWithKey) | ||||
| 	sort.Strings(paths) | ||||
| 	return paths, nil | ||||
| } | ||||
|  | ||||
| // handleOIDCDeleteKey is used to delete a key | ||||
| func (i *IdentityStore) pathOIDCDeleteKey(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { | ||||
| 	ns, err := namespace.FromContext(ctx) | ||||
| @@ -807,8 +863,8 @@ func (i *IdentityStore) pathOIDCDeleteKey(ctx context.Context, req *logical.Requ | ||||
| 	} | ||||
|  | ||||
| 	if len(roleNames) > 0 { | ||||
| 		errorMessage := fmt.Sprintf("unable to delete key %q because it is currently referenced by these roles: %s", | ||||
| 			targetKeyName, strings.Join(roleNames, ", ")) | ||||
| 		errorMessage := fmt.Sprintf(deleteKeyErrorFmt, | ||||
| 			targetKeyName, "roles", strings.Join(roleNames, ", ")) | ||||
| 		i.oidcLock.Unlock() | ||||
| 		return logical.ErrorResponse(errorMessage), logical.ErrInvalidRequest | ||||
| 	} | ||||
| @@ -820,8 +876,20 @@ func (i *IdentityStore) pathOIDCDeleteKey(ctx context.Context, req *logical.Requ | ||||
| 	} | ||||
|  | ||||
| 	if len(clientNames) > 0 { | ||||
| 		errorMessage := fmt.Sprintf("unable to delete key %q because it is currently referenced by these clients: %s", | ||||
| 			targetKeyName, strings.Join(clientNames, ", ")) | ||||
| 		errorMessage := fmt.Sprintf(deleteKeyErrorFmt, | ||||
| 			targetKeyName, "clients", strings.Join(clientNames, ", ")) | ||||
| 		i.oidcLock.Unlock() | ||||
| 		return logical.ErrorResponse(errorMessage), logical.ErrInvalidRequest | ||||
| 	} | ||||
|  | ||||
| 	mounts, err := i.mountsReferencingKey(ns, targetKeyName) | ||||
| 	if err != nil { | ||||
| 		i.oidcLock.Unlock() | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	if len(mounts) > 0 { | ||||
| 		errorMessage := fmt.Sprintf(deleteKeyErrorFmt, | ||||
| 			targetKeyName, "mounts", strings.Join(mounts, ", ")) | ||||
| 		i.oidcLock.Unlock() | ||||
| 		return logical.ErrorResponse(errorMessage), logical.ErrInvalidRequest | ||||
| 	} | ||||
| @@ -1028,6 +1096,99 @@ func (i *IdentityStore) pathOIDCGenerateToken(ctx context.Context, req *logical. | ||||
| 	return retResp, nil | ||||
| } | ||||
|  | ||||
| func (i *IdentityStore) generatePluginIdentityToken(ctx context.Context, storage logical.Storage, me *MountEntry, audience string, ttl time.Duration) (string, time.Duration, error) { | ||||
| 	ns, err := namespace.FromContext(ctx) | ||||
| 	if err != nil { | ||||
| 		return "", 0, err | ||||
| 	} | ||||
|  | ||||
| 	if me == nil { | ||||
| 		i.Logger().Error("unexpected nil mount entry when generating plugin identity token") | ||||
| 		return "", 0, errors.New("mount entry must not be nil") | ||||
| 	} | ||||
|  | ||||
| 	key := defaultKeyName | ||||
| 	if me.Config.IdentityTokenKey != "" { | ||||
| 		key = me.Config.IdentityTokenKey | ||||
| 	} | ||||
| 	if ttl == 0 { | ||||
| 		ttl = time.Hour | ||||
| 	} | ||||
| 	namedKey, err := i.getNamedKey(ctx, storage, key) | ||||
| 	if err != nil { | ||||
| 		return "", 0, err | ||||
| 	} | ||||
| 	if namedKey == nil { | ||||
| 		return "", 0, fmt.Errorf("key %q not found", key) | ||||
| 	} | ||||
|  | ||||
| 	// Validate that the role is allowed to sign with its key (the key could have been updated) | ||||
| 	if !strutil.StrListContains(namedKey.AllowedClientIDs, "*") && !strutil.StrListContains(namedKey.AllowedClientIDs, audience) { | ||||
| 		return "", 0, fmt.Errorf("the key %q does not list %q as an allowed audience", key, audience) | ||||
| 	} | ||||
|  | ||||
| 	config, err := i.getOIDCConfig(ctx, storage) | ||||
| 	if err != nil { | ||||
| 		return "", 0, err | ||||
| 	} | ||||
|  | ||||
| 	// Cap the TTL to the key's verification TTL. This is the maximum amount of | ||||
| 	// time the key will remain in the JWKS after it's been rotated. | ||||
| 	if ttl > namedKey.VerificationTTL { | ||||
| 		ttl = namedKey.VerificationTTL | ||||
| 	} | ||||
|  | ||||
| 	// Tokens for plugins have a distinct issuer from Vault's identity token issuer | ||||
| 	issuer, err := config.fullIssuer(pluginIdentityTokenIssuer) | ||||
| 	if err != nil { | ||||
| 		return "", 0, err | ||||
| 	} | ||||
|  | ||||
| 	// The subject uniquely identifies the plugin | ||||
| 	subject := fmt.Sprintf("%s:%s:%s:%s", pluginTokenSubjectPrefix, ns.ID, | ||||
| 		translateTableClaim(me.Table), me.Accessor) | ||||
|  | ||||
| 	now := time.Now() | ||||
| 	claims := map[string]any{ | ||||
| 		"iss": issuer, | ||||
| 		"sub": subject, | ||||
| 		"aud": []string{audience}, | ||||
| 		"nbf": now.Unix(), | ||||
| 		"iat": now.Unix(), | ||||
| 		"exp": now.Add(ttl).Unix(), | ||||
| 		pluginTokenPrivateClaimKey: map[string]any{ | ||||
| 			"namespace_id":   ns.ID, | ||||
| 			"namespace_path": ns.Path, | ||||
| 			"class":          translateTableClaim(me.Table), | ||||
| 			"plugin":         me.Type, | ||||
| 			"version":        me.RunningVersion, | ||||
| 			"path":           me.Path, | ||||
| 			"accessor":       me.Accessor, | ||||
| 			"local":          me.Local, | ||||
| 		}, | ||||
| 	} | ||||
| 	payload, err := json.Marshal(claims) | ||||
| 	if err != nil { | ||||
| 		return "", 0, err | ||||
| 	} | ||||
|  | ||||
| 	signedToken, err := namedKey.signPayload(payload) | ||||
| 	if err != nil { | ||||
| 		return "", 0, fmt.Errorf("error signing plugin identity token: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	return signedToken, ttl, nil | ||||
| } | ||||
|  | ||||
| func translateTableClaim(table string) string { | ||||
| 	switch table { | ||||
| 	case mountTableType: | ||||
| 		return secretTableValue | ||||
| 	default: | ||||
| 		return table | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (i *IdentityStore) getNamedKey(ctx context.Context, s logical.Storage, name string) (*namedKey, error) { | ||||
| 	ns, err := namespace.FromContext(ctx) | ||||
| 	if err != nil { | ||||
| @@ -1804,14 +1965,16 @@ func (i *IdentityStore) generatePublicJWKS(ctx context.Context, s logical.Storag | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	// only return keys that are associated with a role | ||||
| 	// Only return keys that are associated with a role or plugin mount | ||||
| 	// by collecting and de-duplicating keys and key IDs for each | ||||
| 	keyNames := make(map[string]struct{}) | ||||
| 	keyIDs := make(map[string]struct{}) | ||||
|  | ||||
| 	// First collect the set of unique key names | ||||
| 	roleNames, err := s.List(ctx, roleConfigPath) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	// collect and deduplicate the key IDs for all roles | ||||
| 	keyIDs := make(map[string]struct{}) | ||||
| 	for _, roleName := range roleNames { | ||||
| 		role, err := i.getOIDCRole(ctx, s, roleName) | ||||
| 		if err != nil { | ||||
| @@ -1821,13 +1984,30 @@ func (i *IdentityStore) generatePublicJWKS(ctx context.Context, s logical.Storag | ||||
| 			continue | ||||
| 		} | ||||
|  | ||||
| 		roleKeyIDs, err := i.keyIDsByName(ctx, s, role.Key) | ||||
| 		keyNames[role.Key] = struct{}{} | ||||
| 	} | ||||
| 	mounts, err := i.listMounts(ns) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	for _, me := range mounts { | ||||
| 		key := defaultKeyName | ||||
| 		if me.Config.IdentityTokenKey != "" { | ||||
| 			key = me.Config.IdentityTokenKey | ||||
| 		} | ||||
|  | ||||
| 		keyNames[key] = struct{}{} | ||||
| 	} | ||||
|  | ||||
| 	// Second collect the set of unique key IDs for each key name | ||||
| 	for name := range keyNames { | ||||
| 		ids, err := i.keyIDsByName(ctx, s, name) | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
|  | ||||
| 		for _, keyID := range roleKeyIDs { | ||||
| 			keyIDs[keyID] = struct{}{} | ||||
| 		for _, id := range ids { | ||||
| 			keyIDs[id] = struct{}{} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
|   | ||||
| @@ -2629,10 +2629,6 @@ func (i *IdentityStore) lazyGenerateDefaultKey(ctx context.Context, storage logi | ||||
| 			return err | ||||
| 		} | ||||
|  | ||||
| 		if err := i.oidcCache.Delete(ns, namedKeyCachePrefix+defaultKeyName); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
|  | ||||
| 		entry, err := logical.StorageEntryJSON(namedKeyConfigPath+defaultKeyName, defaultKey) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| @@ -2640,6 +2636,10 @@ func (i *IdentityStore) lazyGenerateDefaultKey(ctx context.Context, storage logi | ||||
| 		if err := storage.Put(ctx, entry); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
|  | ||||
| 		if err := i.oidcCache.Flush(ns); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
|   | ||||
| @@ -1176,7 +1176,8 @@ func setupOIDCCommon(t *testing.T, c *Core, s logical.Storage) (string, string, | ||||
| 	ctx := namespace.RootContext(nil) | ||||
|  | ||||
| 	// Create a key | ||||
| 	resp, err := c.identityStore.HandleRequest(ctx, testKeyReq(s, []string{"*"}, "RS256")) | ||||
| 	resp, err := c.identityStore.HandleRequest(ctx, testKeyReq(s, "test-key", | ||||
| 		[]string{"*"}, "RS256")) | ||||
| 	expectSuccess(t, resp, err) | ||||
|  | ||||
| 	// Create an entity | ||||
| @@ -1359,10 +1360,10 @@ func testEntityReq(s logical.Storage) *logical.Request { | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func testKeyReq(s logical.Storage, allowedClientIDs []string, alg string) *logical.Request { | ||||
| func testKeyReq(s logical.Storage, name string, allowedClientIDs []string, alg string) *logical.Request { | ||||
| 	return &logical.Request{ | ||||
| 		Storage:   s, | ||||
| 		Path:      "oidc/key/test-key", | ||||
| 		Path:      fmt.Sprintf("oidc/key/%s", name), | ||||
| 		Operation: logical.CreateOperation, | ||||
| 		Data: map[string]interface{}{ | ||||
| 			"allowed_client_ids": allowedClientIDs, | ||||
|   | ||||
| @@ -5,6 +5,7 @@ package vault | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"crypto" | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"regexp" | ||||
| @@ -16,7 +17,9 @@ import ( | ||||
| 	"github.com/go-jose/go-jose/v3" | ||||
| 	"github.com/go-jose/go-jose/v3/jwt" | ||||
| 	"github.com/go-test/deep" | ||||
| 	capjwt "github.com/hashicorp/cap/jwt" | ||||
| 	"github.com/hashicorp/go-hclog" | ||||
| 	credUserpass "github.com/hashicorp/vault/builtin/credential/userpass" | ||||
| 	"github.com/hashicorp/vault/helper/identity" | ||||
| 	"github.com/hashicorp/vault/helper/namespace" | ||||
| 	"github.com/hashicorp/vault/sdk/framework" | ||||
| @@ -390,8 +393,60 @@ func TestOIDC_Path_OIDCRole(t *testing.T) { | ||||
| 	expectStrings(t, respListRoleAfterDelete.Data["keys"].([]string), expectedStrings) | ||||
| } | ||||
|  | ||||
| // TestOIDC_Path_OIDCKeyKey tests CRUD operations for keys | ||||
| func TestOIDC_Path_OIDCKeyKey(t *testing.T) { | ||||
| // TestOIDC_DeleteKeyWithMountReference ensures that keys cannot be deleted | ||||
| // if they're referenced by mounts for plugin identity tokens. | ||||
| func TestOIDC_DeleteKeyWithMountReference(t *testing.T) { | ||||
| 	ctx := namespace.RootContext(nil) | ||||
| 	core, _, _ := TestCoreUnsealed(t) | ||||
| 	core.credentialBackends["userpass"] = credUserpass.Factory | ||||
| 	idStorage := core.router.MatchingStorageByAPIPath(ctx, mountPathIdentity) | ||||
| 	require.NotNil(t, idStorage) | ||||
|  | ||||
| 	tests := []struct { | ||||
| 		name        string | ||||
| 		mountPrefix string | ||||
| 		mountType   string | ||||
| 		keyName     string | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name:        "delete key referenced by auth mount does not succeed", | ||||
| 			mountPrefix: "auth/", | ||||
| 			mountType:   "userpass/", | ||||
| 			keyName:     "test-key-1", | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:        "delete key referenced by secret mount does not succeed", | ||||
| 			mountPrefix: "mounts/", | ||||
| 			mountType:   "kv/", | ||||
| 			keyName:     "test-key-2", | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			resp, err := core.identityStore.HandleRequest(ctx, testKeyReq(idStorage, tt.keyName, | ||||
| 				[]string{"*"}, "RS256")) | ||||
| 			expectSuccess(t, resp, err) | ||||
|  | ||||
| 			createMountEntryWithKey(t, ctx, core.systemBackend, tt.mountPrefix, tt.mountType, tt.keyName) | ||||
| 			require.NoError(t, err) | ||||
| 			require.Nil(t, resp) | ||||
|  | ||||
| 			// Deleting the key must not succeed | ||||
| 			resp, err = core.identityStore.HandleRequest(ctx, &logical.Request{ | ||||
| 				Path:      fmt.Sprintf("oidc/key/%s", tt.keyName), | ||||
| 				Operation: logical.DeleteOperation, | ||||
| 				Storage:   idStorage, | ||||
| 			}) | ||||
| 			expectError(t, resp, err) | ||||
| 			require.Equal(t, fmt.Sprintf(deleteKeyErrorFmt, tt.keyName, "mounts", tt.mountType), | ||||
| 				resp.Error().Error()) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // TestOIDC_Path_CRUDKey tests CRUD operations for keys | ||||
| func TestOIDC_Path_CRUDKey(t *testing.T) { | ||||
| 	c, _, _ := TestCoreUnsealed(t) | ||||
| 	ctx := namespace.RootContext(nil) | ||||
| 	storage := &logical.InmemStorage{} | ||||
| @@ -461,7 +516,6 @@ func TestOIDC_Path_OIDCKeyKey(t *testing.T) { | ||||
| 		Storage: storage, | ||||
| 	}) | ||||
| 	expectSuccess(t, resp, err) | ||||
| 	// fmt.Printf("resp is:\n%#v", resp) | ||||
|  | ||||
| 	// Delete test-key -- should fail because test-role depends on test-key | ||||
| 	resp, err = c.identityStore.HandleRequest(ctx, &logical.Request{ | ||||
| @@ -558,8 +612,8 @@ func TestOIDC_Path_OIDCKey_InvalidTokenTTL(t *testing.T) { | ||||
| 	expectError(t, resp, err) | ||||
| } | ||||
|  | ||||
| // TestOIDC_Path_OIDCKey tests the List operation for keys | ||||
| func TestOIDC_Path_OIDCKey(t *testing.T) { | ||||
| // TestOIDC_Path_ListKey tests the List operation for keys | ||||
| func TestOIDC_Path_ListKey(t *testing.T) { | ||||
| 	c, _, _ := TestCoreUnsealed(t) | ||||
| 	ctx := namespace.RootContext(nil) | ||||
| 	storage := &logical.InmemStorage{} | ||||
| @@ -1844,3 +1898,177 @@ func Test_optionalChildIssuerRegex(t *testing.T) { | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // TestIdentityStore_generatePluginIdentityToken tests generation of plugin identity | ||||
| // tokens by verifying signatures and validating claims. | ||||
| func TestIdentityStore_generatePluginIdentityToken(t *testing.T) { | ||||
| 	core, _, _ := TestCoreUnsealed(t) | ||||
| 	core.credentialBackends["userpass"] = credUserpass.Factory | ||||
| 	identityStore := core.IdentityStore() | ||||
| 	identityStore.redirectAddr = "http://localhost:8200" | ||||
| 	ctx := namespace.RootContext(nil) | ||||
| 	storage := core.router.MatchingStorageByAPIPath(ctx, mountPathIdentity) | ||||
| 	require.NotNil(t, storage) | ||||
|  | ||||
| 	// Create a key | ||||
| 	testKey := "test-key" | ||||
| 	testAudience := "allowed-audience" | ||||
| 	resp, err := core.identityStore.HandleRequest(ctx, testKeyReq(storage, testKey, | ||||
| 		[]string{testAudience}, "RS256")) | ||||
| 	expectSuccess(t, resp, err) | ||||
|  | ||||
| 	// Enable a secret mount using the test key | ||||
| 	createMountEntryWithKey(t, ctx, core.systemBackend, "mounts/", "kv/", testKey) | ||||
| 	expectSuccess(t, resp, err) | ||||
| 	secretMountEntry := core.router.MatchingMountEntry(ctx, "kv/") | ||||
| 	require.NotNil(t, secretMountEntry) | ||||
|  | ||||
| 	// Enable an auth mount using the default key | ||||
| 	createMountEntryWithKey(t, ctx, core.systemBackend, "auth/", "userpass/", defaultKeyName) | ||||
| 	expectSuccess(t, resp, err) | ||||
| 	authMountEntry := core.router.MatchingMountEntry(ctx, "auth/userpass/") | ||||
| 	require.NotNil(t, authMountEntry) | ||||
|  | ||||
| 	tests := []struct { | ||||
| 		name       string | ||||
| 		ctx        context.Context | ||||
| 		mountEntry *MountEntry | ||||
| 		audience   string | ||||
| 		ttl        time.Duration | ||||
| 		wantErr    bool | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name:    "expect error with nil context", | ||||
| 			ctx:     nil, | ||||
| 			wantErr: true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:       "expect error with nil mount entry", | ||||
| 			ctx:        ctx, | ||||
| 			mountEntry: nil, | ||||
| 			wantErr:    true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "expect error with key that doesn't exist", | ||||
| 			ctx:  ctx, | ||||
| 			mountEntry: &MountEntry{ | ||||
| 				Config: MountConfig{ | ||||
| 					IdentityTokenKey: "does-not-exist", | ||||
| 				}, | ||||
| 			}, | ||||
| 			wantErr: true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:       "expect error with audience that's not allowed by the key", | ||||
| 			ctx:        ctx, | ||||
| 			mountEntry: secretMountEntry, | ||||
| 			audience:   "not-allowed-audience", | ||||
| 			wantErr:    true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:       "expect valid identity token with secret mount using test key", | ||||
| 			ctx:        ctx, | ||||
| 			mountEntry: secretMountEntry, | ||||
| 			audience:   testAudience, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:       "expect valid identity token with auth mount using default key", | ||||
| 			ctx:        ctx, | ||||
| 			mountEntry: authMountEntry, | ||||
| 			audience:   testAudience, | ||||
| 		}, | ||||
| 	} | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			token, _, err := identityStore.generatePluginIdentityToken(tt.ctx, storage, tt.mountEntry, | ||||
| 				tt.audience, tt.ttl) | ||||
| 			if tt.wantErr { | ||||
| 				require.Error(t, err) | ||||
| 				require.Empty(t, token) | ||||
| 				return | ||||
| 			} | ||||
|  | ||||
| 			require.NoError(t, err) | ||||
| 			require.NotEmpty(t, token) | ||||
|  | ||||
| 			// Verify the signature and claims of the token | ||||
| 			key, err := identityStore.getNamedKey(ctx, storage, tt.mountEntry.Config.IdentityTokenKey) | ||||
| 			require.NoError(t, err) | ||||
| 			keySet, err := capjwt.NewStaticKeySet([]crypto.PublicKey{key.SigningKey.Public()}) | ||||
| 			require.NoError(t, err) | ||||
|  | ||||
| 			validator, err := capjwt.NewValidator(keySet) | ||||
| 			require.NoError(t, err) | ||||
| 			expected := capjwt.Expected{ | ||||
| 				Issuer: fmt.Sprintf("%s/v1/identity/oidc/plugins", identityStore.redirectAddr), | ||||
| 				Subject: fmt.Sprintf("%s:%s:%s:%s", pluginTokenSubjectPrefix, namespace.RootNamespace.ID, | ||||
| 					translateTableClaim(tt.mountEntry.Table), tt.mountEntry.Accessor), | ||||
| 				Audiences:         []string{tt.audience}, | ||||
| 				SigningAlgorithms: []capjwt.Alg{capjwt.RS256}, | ||||
| 			} | ||||
|  | ||||
| 			claims, err := validator.Validate(ctx, token, expected) | ||||
| 			require.NoError(t, err) | ||||
| 			require.Contains(t, claims, pluginTokenPrivateClaimKey) | ||||
| 			require.IsType(t, map[string]interface{}{}, claims[pluginTokenPrivateClaimKey]) | ||||
|  | ||||
| 			vaultSubClaims := claims[pluginTokenPrivateClaimKey].(map[string]interface{}) | ||||
| 			require.Equal(t, namespace.RootNamespace.ID, vaultSubClaims["namespace_id"]) | ||||
| 			require.Equal(t, namespace.RootNamespace.Path, vaultSubClaims["namespace_path"]) | ||||
| 			require.Equal(t, translateTableClaim(tt.mountEntry.Table), vaultSubClaims["class"]) | ||||
| 			require.Equal(t, tt.mountEntry.Type, vaultSubClaims["plugin"]) | ||||
| 			require.Equal(t, tt.mountEntry.RunningVersion, vaultSubClaims["version"]) | ||||
| 			require.Equal(t, tt.mountEntry.Path, vaultSubClaims["path"]) | ||||
| 			require.Equal(t, tt.mountEntry.Accessor, vaultSubClaims["accessor"]) | ||||
| 			require.Equal(t, tt.mountEntry.Local, vaultSubClaims["local"]) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func createMountEntryWithKey(t *testing.T, ctx context.Context, sys *SystemBackend, mountPrefix, mountType, key string) { | ||||
| 	t.Helper() | ||||
|  | ||||
| 	resp, err := sys.HandleRequest(ctx, &logical.Request{ | ||||
| 		Path:      mountPrefix + mountType, | ||||
| 		Operation: logical.UpdateOperation, | ||||
| 		Storage:   new(logical.InmemStorage), | ||||
| 		Data: map[string]interface{}{ | ||||
| 			"type": strings.TrimSuffix(mountType, "/"), | ||||
| 			"config": map[string]interface{}{ | ||||
| 				"identity_token_key": key, | ||||
| 			}, | ||||
| 		}, | ||||
| 	}) | ||||
| 	expectSuccess(t, resp, err) | ||||
| } | ||||
|  | ||||
| // Test_translateTableClaim tests that we convert mount entry table | ||||
| // values to expected claim values. | ||||
| func Test_translateTableClaim(t *testing.T) { | ||||
| 	tests := []struct { | ||||
| 		name  string | ||||
| 		table string | ||||
| 		want  string | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name:  "given mounts table returns secret", | ||||
| 			table: mountTableType, | ||||
| 			want:  secretTableValue, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:  "given auth table returns auth", | ||||
| 			table: "auth", | ||||
| 			want:  "auth", | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:  "given any value returns itself", | ||||
| 			table: "other", | ||||
| 			want:  "other", | ||||
| 		}, | ||||
| 	} | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			assert.Equalf(t, tt.want, translateTableClaim(tt.table), "translateTableClaim(%v)", tt.table) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -102,6 +102,7 @@ type IdentityStore struct { | ||||
| 	groupUpdater  GroupUpdater | ||||
| 	tokenStorer   TokenStorer | ||||
| 	entityCreator EntityCreator | ||||
| 	mountLister   MountLister | ||||
| 	mfaBackend    *LoginMFABackend | ||||
| } | ||||
|  | ||||
| @@ -153,3 +154,10 @@ type EntityCreator interface { | ||||
| } | ||||
|  | ||||
| var _ EntityCreator = &Core{} | ||||
|  | ||||
| type MountLister interface { | ||||
| 	ListMounts() ([]*MountEntry, error) | ||||
| 	ListAuths() ([]*MountEntry, error) | ||||
| } | ||||
|  | ||||
| var _ MountLister = &Core{} | ||||
|   | ||||
| @@ -1619,15 +1619,16 @@ func (b *SystemBackend) handleMount(ctx context.Context, req *logical.Request, d | ||||
| 		config.DelegatedAuthAccessors = apiConfig.DelegatedAuthAccessors | ||||
| 	} | ||||
|  | ||||
| 	if apiConfig.IdentityTokenKey != "" { | ||||
| 	storage := b.Core.router.MatchingStorageByAPIPath(ctx, mountPathIdentity) | ||||
| 	if storage == nil { | ||||
| 		return nil, errors.New("failed to find identity storage") | ||||
| 	} | ||||
|  | ||||
| 	// Ensure that the mount's identity token key exists | ||||
| 	identityStore := b.Core.IdentityStore() | ||||
| 		identityStore.oidcLock.RLock() | ||||
| 		defer identityStore.oidcLock.RUnlock() | ||||
| 	identityStore.oidcLock.Lock() | ||||
| 	defer identityStore.oidcLock.Unlock() | ||||
| 	if apiConfig.IdentityTokenKey != "" { | ||||
| 		k, err := identityStore.getNamedKey(ctx, storage, apiConfig.IdentityTokenKey) | ||||
| 		if err != nil { | ||||
| 			return nil, fmt.Errorf("failed getting key %q: %w", apiConfig.IdentityTokenKey, err) | ||||
| @@ -1639,6 +1640,15 @@ func (b *SystemBackend) handleMount(ctx context.Context, req *logical.Request, d | ||||
| 		config.IdentityTokenKey = apiConfig.IdentityTokenKey | ||||
| 	} | ||||
|  | ||||
| 	// Don't lazily generate the default OIDC key for KV mounts. A default KV mount | ||||
| 	// is enabled in dev and test servers. We don't want to pay the cost of key | ||||
| 	// generation for that KV mount in all tests. | ||||
| 	if config.usingOIDCDefaultKey() && logicalType != mountTypeKV { | ||||
| 		if err := identityStore.lazyGenerateDefaultKey(ctx, storage); err != nil { | ||||
| 			return nil, fmt.Errorf("failed to generate default key: %w", err) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Create the mount entry | ||||
| 	me := &MountEntry{ | ||||
| 		Table:                 mountTableType, | ||||
| @@ -2431,15 +2441,16 @@ func (b *SystemBackend) handleTuneWriteCommon(ctx context.Context, path string, | ||||
| 	if rawVal, ok := data.GetOk("identity_token_key"); ok { | ||||
| 		identityTokenKey := rawVal.(string) | ||||
|  | ||||
| 		if identityTokenKey != "" { | ||||
| 		storage := b.Core.router.MatchingStorageByAPIPath(ctx, mountPathIdentity) | ||||
| 		if storage == nil { | ||||
| 			return nil, errors.New("failed to find identity storage") | ||||
| 		} | ||||
|  | ||||
| 		// Ensure that the mount's identity token key exists | ||||
| 		identityStore := b.Core.IdentityStore() | ||||
| 			identityStore.oidcLock.RLock() | ||||
| 			defer identityStore.oidcLock.RUnlock() | ||||
| 		identityStore.oidcLock.Lock() | ||||
| 		defer identityStore.oidcLock.Unlock() | ||||
| 		if identityTokenKey != "" { | ||||
| 			k, err := identityStore.getNamedKey(ctx, storage, identityTokenKey) | ||||
| 			if err != nil { | ||||
| 				return nil, fmt.Errorf("failed getting key %q: %w", identityTokenKey, err) | ||||
| @@ -2452,6 +2463,13 @@ func (b *SystemBackend) handleTuneWriteCommon(ctx context.Context, path string, | ||||
| 		oldVal := mountEntry.Config.IdentityTokenKey | ||||
| 		mountEntry.Config.IdentityTokenKey = identityTokenKey | ||||
|  | ||||
| 		if mountEntry.Config.usingOIDCDefaultKey() { | ||||
| 			if err := identityStore.lazyGenerateDefaultKey(ctx, storage); err != nil { | ||||
| 				mountEntry.Config.IdentityTokenKey = oldVal | ||||
| 				return nil, fmt.Errorf("failed to generate default key: %w", err) | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		// Update the mount table | ||||
| 		var err error | ||||
| 		switch { | ||||
| @@ -3227,15 +3245,16 @@ func (b *SystemBackend) handleEnableAuth(ctx context.Context, req *logical.Reque | ||||
| 		config.AllowedManagedKeys = apiConfig.AllowedManagedKeys | ||||
| 	} | ||||
|  | ||||
| 	if apiConfig.IdentityTokenKey != "" { | ||||
| 	storage := b.Core.router.MatchingStorageByAPIPath(ctx, mountPathIdentity) | ||||
| 	if storage == nil { | ||||
| 		return nil, errors.New("failed to find identity storage") | ||||
| 	} | ||||
|  | ||||
| 	// Ensure that the mount's identity token key exists | ||||
| 	identityStore := b.Core.IdentityStore() | ||||
| 		identityStore.oidcLock.RLock() | ||||
| 		defer identityStore.oidcLock.RUnlock() | ||||
| 	identityStore.oidcLock.Lock() | ||||
| 	defer identityStore.oidcLock.Unlock() | ||||
| 	if apiConfig.IdentityTokenKey != "" { | ||||
| 		k, err := identityStore.getNamedKey(ctx, storage, apiConfig.IdentityTokenKey) | ||||
| 		if err != nil { | ||||
| 			return nil, fmt.Errorf("failed getting key %q: %w", apiConfig.IdentityTokenKey, err) | ||||
| @@ -3246,6 +3265,11 @@ func (b *SystemBackend) handleEnableAuth(ctx context.Context, req *logical.Reque | ||||
|  | ||||
| 		config.IdentityTokenKey = apiConfig.IdentityTokenKey | ||||
| 	} | ||||
| 	if config.usingOIDCDefaultKey() { | ||||
| 		if err := identityStore.lazyGenerateDefaultKey(ctx, storage); err != nil { | ||||
| 			return nil, fmt.Errorf("failed to generate default key: %w", err) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Create the mount entry | ||||
| 	me := &MountEntry{ | ||||
|   | ||||
| @@ -375,6 +375,10 @@ type MountConfig struct { | ||||
| 	PluginName string `json:"plugin_name,omitempty" structs:"plugin_name,omitempty" mapstructure:"plugin_name"` | ||||
| } | ||||
|  | ||||
| func (c *MountConfig) usingOIDCDefaultKey() bool { | ||||
| 	return c.IdentityTokenKey == "" || c.IdentityTokenKey == defaultKeyName | ||||
| } | ||||
|  | ||||
| type UserLockoutConfig struct { | ||||
| 	LockoutThreshold    uint64        `json:"lockout_threshold,omitempty" structs:"lockout_threshold" mapstructure:"lockout_threshold"` | ||||
| 	LockoutDuration     time.Duration `json:"lockout_duration,omitempty" structs:"lockout_duration" mapstructure:"lockout_duration"` | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Austin Gebauer
					Austin Gebauer