diff --git a/changelog/16567.txt b/changelog/16567.txt new file mode 100644 index 000000000..78492e304 --- /dev/null +++ b/changelog/16567.txt @@ -0,0 +1,3 @@ +```release-note:improvement +identity/oidc: Adds support for detailed listing of clients and providers. +``` diff --git a/vault/identity_store_oidc_provider.go b/vault/identity_store_oidc_provider.go index a7422fc6d..fc32017a6 100644 --- a/vault/identity_store_oidc_provider.go +++ b/vault/identity_store_oidc_provider.go @@ -374,7 +374,8 @@ func oidcProviderPaths(i *IdentityStore) []*framework.Path { Type: framework.TypeString, Description: "Filters the list of OIDC providers to those " + "that allow the given client ID in their set of allowed_client_ids.", - Query: true, + Default: "", + Query: true, }, }, Operations: map[logical.Operation]framework.OperationHandler{ @@ -1118,11 +1119,28 @@ func (i *IdentityStore) pathOIDCCreateUpdateClient(ctx context.Context, req *log // pathOIDCListClient is used to list clients func (i *IdentityStore) pathOIDCListClient(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { - clients, err := req.Storage.List(ctx, clientPath) + clients, err := i.listClients(ctx, req.Storage) if err != nil { return nil, err } - return logical.ListResponse(clients), nil + + keys := make([]string, 0, len(clients)) + keyInfo := make(map[string]interface{}) + for _, client := range clients { + keys = append(keys, client.Name) + keyInfo[client.Name] = map[string]interface{}{ + "redirect_uris": client.RedirectURIs, + "assignments": client.Assignments, + "key": client.Key, + "id_token_ttl": int64(client.IDTokenTTL.Seconds()), + "access_token_ttl": int64(client.AccessTokenTTL.Seconds()), + "client_type": client.Type.String(), + "client_id": client.ClientID, + // client_secret is intentionally omitted + } + } + + return logical.ListResponseWithInfo(keys, keyInfo), nil } // pathOIDCReadClient is used to read an existing client @@ -1323,34 +1341,42 @@ func (i *IdentityStore) pathOIDCListProvider(ctx context.Context, req *logical.R return nil, err } + // Build a map from provider name to provider struct + providerMap := make(map[string]*provider) + for _, name := range providers { + provider, err := i.getOIDCProvider(ctx, req.Storage, name) + if err != nil { + return nil, err + } + if provider == nil { + continue + } + providerMap[name] = provider + } + // If allowed_client_id is provided as a query parameter, filter the set of // returned OIDC providers to those that allow the given value in their set // of allowed_client_ids. - if clientIDRaw, ok := d.GetOk("allowed_client_id"); ok { - clientID := clientIDRaw.(string) - if clientID == "" { - return logical.ListResponse(providers), nil - } - - filtered := make([]string, 0) - for _, name := range providers { - provider, err := i.getOIDCProvider(ctx, req.Storage, name) - if err != nil { - return nil, err - } - if provider == nil { - continue - } - - if provider.allowedClientID(clientID) { - filtered = append(filtered, name) + if clientID := d.Get("allowed_client_id").(string); clientID != "" { + for name, provider := range providerMap { + if !provider.allowedClientID(clientID) { + delete(providerMap, name) } } - - providers = filtered } - return logical.ListResponse(providers), nil + keys := make([]string, 0, len(providerMap)) + keyInfo := make(map[string]interface{}) + for name, provider := range providerMap { + keys = append(keys, name) + keyInfo[name] = map[string]interface{}{ + "issuer": provider.effectiveIssuer, + "allowed_client_ids": provider.AllowedClientIDs, + "scopes_supported": provider.ScopesSupported, + } + } + + return logical.ListResponseWithInfo(keys, keyInfo), nil } // pathOIDCReadProvider is used to read an existing provider diff --git a/vault/identity_store_oidc_provider_test.go b/vault/identity_store_oidc_provider_test.go index bb350a91c..24b1b7d36 100644 --- a/vault/identity_store_oidc_provider_test.go +++ b/vault/identity_store_oidc_provider_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "sort" "strings" "testing" "time" @@ -2125,18 +2126,7 @@ func TestOIDC_Path_OIDC_ProviderClient_Update(t *testing.T) { func TestOIDC_Path_OIDC_ProviderClient_List(t *testing.T) { c, _, _ := TestCoreUnsealed(t) ctx := namespace.RootContext(nil) - storage := &logical.InmemStorage{} - - // Create a test key "test-key" - c.identityStore.HandleRequest(ctx, &logical.Request{ - Path: "oidc/key/test-key", - Operation: logical.CreateOperation, - Data: map[string]interface{}{ - "verification_ttl": "2m", - "rotation_period": "2m", - }, - Storage: storage, - }) + storage := c.identityStore.view // Prepare two clients, test-client1 and test-client2 c.identityStore.HandleRequest(ctx, &logical.Request{ @@ -2144,7 +2134,6 @@ func TestOIDC_Path_OIDC_ProviderClient_List(t *testing.T) { Operation: logical.CreateOperation, Storage: storage, Data: map[string]interface{}{ - "key": "test-key", "id_token_ttl": "1m", }, }) @@ -2154,7 +2143,6 @@ func TestOIDC_Path_OIDC_ProviderClient_List(t *testing.T) { Operation: logical.CreateOperation, Storage: storage, Data: map[string]interface{}{ - "key": "test-key", "id_token_ttl": "1m", }, }) @@ -2191,6 +2179,75 @@ func TestOIDC_Path_OIDC_ProviderClient_List(t *testing.T) { expectStrings(t, respListClientAfterDelete.Data["keys"].([]string), expectedStrings) } +func TestOIDC_Path_OIDC_Client_List_KeyInfo(t *testing.T) { + c, _, _ := TestCoreUnsealed(t) + ctx := namespace.RootContext(nil) + + // Create clients with different parameters + clients := map[string]interface{}{ + "c1": map[string]interface{}{ + "id_token_ttl": "5m", + "access_token_ttl": "10m", + "assignments": []string{}, + "redirect_uris": []string{"http://127.0.0.1:8250"}, + "client_type": "confidential", + "key": "default", + }, + "c2": map[string]interface{}{ + "id_token_ttl": "24h", + "access_token_ttl": "5m", + "assignments": []string{allowAllAssignmentName}, + "redirect_uris": []string{"https://localhost:9702/auth/oidc-callback"}, + "client_type": "public", + "key": "default", + }, + } + for name, client := range clients { + input := client.(map[string]interface{}) + resp, err := c.identityStore.HandleRequest(ctx, &logical.Request{ + Path: "oidc/client/" + name, + Operation: logical.CreateOperation, + Storage: c.identityStore.view, + Data: input, + }) + expectSuccess(t, resp, err) + } + + // List clients + req := &logical.Request{ + Path: "oidc/client", + Operation: logical.ListOperation, + Storage: c.identityStore.view, + Data: make(map[string]interface{}), + } + resp, err := c.identityStore.HandleRequest(ctx, req) + expectSuccess(t, resp, err) + require.NotNil(t, resp.Data["key_info"]) + require.NotNil(t, resp.Data["keys"]) + keys := resp.Data["keys"].([]string) + keyInfo := resp.Data["key_info"].(map[string]interface{}) + require.Equal(t, len(keys), len(keyInfo)) + + // Assert the clients returned have additional key info + for name, details := range keyInfo { + actual, _ := details.(map[string]interface{}) + require.NotNil(t, clients[name]) + expected := clients[name].(map[string]interface{}) + require.Contains(t, keys, name) + + idTokenTTL, _ := time.ParseDuration(expected["id_token_ttl"].(string)) + accessTokenTTL, _ := time.ParseDuration(expected["access_token_ttl"].(string)) + require.EqualValues(t, idTokenTTL.Seconds(), actual["id_token_ttl"]) + require.EqualValues(t, accessTokenTTL.Seconds(), actual["access_token_ttl"]) + require.Equal(t, expected["redirect_uris"], actual["redirect_uris"]) + require.Equal(t, expected["assignments"], actual["assignments"]) + require.Equal(t, expected["key"], actual["key"]) + require.Equal(t, expected["client_type"], actual["client_type"]) + require.NotEmpty(t, actual["client_id"]) + require.Empty(t, actual["client_secret"]) + } +} + // TestOIDC_pathOIDCClientExistenceCheck tests pathOIDCClientExistenceCheck func TestOIDC_pathOIDCClientExistenceCheck(t *testing.T) { c, _, _ := TestCoreUnsealed(t) @@ -3352,6 +3409,76 @@ func TestOIDC_Path_OIDC_Provider_List(t *testing.T) { expectStrings(t, respListProvidersAfterDelete.Data["keys"].([]string), expectedStrings) } +func TestOIDC_Path_OIDC_Provider_List_KeyInfo(t *testing.T) { + c, _, _ := TestCoreUnsealed(t) + ctx := namespace.RootContext(nil) + + // Create a custom scope + template := `{ + "groups": {{identity.entity.groups.names}} + }` + resp, err := c.identityStore.HandleRequest(ctx, testScopeReq(c.identityStore.view, + "groups", template)) + expectSuccess(t, resp, err) + + // Create providers with different parameters + providers := map[string]interface{}{ + "default": map[string]interface{}{ + "allowed_client_ids": []string{"*"}, + "scopes_supported": []string{}, + "issuer": "http://127.0.0.1:8200", + }, + "p0": map[string]interface{}{ + "allowed_client_ids": []string{"abc", "def"}, + "scopes_supported": []string{}, + "issuer": "http://10.0.0.1:8200", + }, + "p1": map[string]interface{}{ + "allowed_client_ids": []string{"xyz"}, + "scopes_supported": []string{"groups"}, + "issuer": "https://myvault.com:8200", + }, + } + for name, p := range providers { + input := p.(map[string]interface{}) + resp, err := c.identityStore.HandleRequest(ctx, &logical.Request{ + Path: "oidc/provider/" + name, + Operation: logical.CreateOperation, + Storage: c.identityStore.view, + Data: input, + }) + expectSuccess(t, resp, err) + } + + // List providers + resp, err = c.identityStore.HandleRequest(ctx, &logical.Request{ + Path: "oidc/provider", + Operation: logical.ListOperation, + Storage: c.identityStore.view, + Data: make(map[string]interface{}), + }) + expectSuccess(t, resp, err) + require.NotNil(t, resp.Data["key_info"]) + require.NotNil(t, resp.Data["keys"]) + keys := resp.Data["keys"].([]string) + keyInfo := resp.Data["key_info"].(map[string]interface{}) + require.Equal(t, len(keys), len(keyInfo)) + + // Assert the providers returned have additional key info + for name, details := range keyInfo { + actual, _ := details.(map[string]interface{}) + require.NotNil(t, providers[name]) + expected := providers[name].(map[string]interface{}) + require.Contains(t, keys, name) + + expectedIssuer := fmt.Sprintf("%s%s%s", expected["issuer"], + "/v1/identity/oidc/provider/", name) + require.Equal(t, expectedIssuer, actual["issuer"]) + require.Equal(t, expected["allowed_client_ids"], actual["allowed_client_ids"]) + require.Equal(t, expected["scopes_supported"], actual["scopes_supported"]) + } +} + func TestOIDC_Path_OIDC_Provider_List_Filter(t *testing.T) { c, _, _ := TestCoreUnsealed(t) ctx := namespace.RootContext(nil) @@ -3430,7 +3557,9 @@ func TestOIDC_Path_OIDC_Provider_List_Filter(t *testing.T) { expectSuccess(t, resp, err) // Assert the filtered set of providers is returned - require.Equal(t, tc.expectedProviders, resp.Data["keys"]) + sort.Strings(tc.expectedProviders) + sort.Strings(resp.Data["keys"].([]string)) + require.Equal(t, tc.expectedProviders, resp.Data["keys"].([]string)) }) } } diff --git a/website/content/api-docs/secret/identity/oidc-provider.mdx b/website/content/api-docs/secret/identity/oidc-provider.mdx index e38a36f97..1deffc385 100644 --- a/website/content/api-docs/secret/identity/oidc-provider.mdx +++ b/website/content/api-docs/secret/identity/oidc-provider.mdx @@ -103,10 +103,19 @@ $ curl \ ```json { "data": { - "keys":[ - "test-provider" - ] - } + "key_info": { + "default": { + "allowed_client_ids": [ + "*" + ], + "issuer": "http://127.0.0.1:8200/v1/identity/oidc/provider/default", + "scopes_supported": [] + } + }, + "keys": [ + "default" + ] + } } ``` @@ -359,7 +368,7 @@ This endpoint returns a list of all configured clients. | Method | Path | | :----- | :------------------------------ | -| `LIST` | `/identity/oidc/client` | +| `LIST` | `/identity/oidc/client` | ### Sample Request @@ -375,10 +384,25 @@ $ curl \ ```json { "data": { - "keys":[ - "test-client" - ] - } + "key_info": { + "my-app": { + "access_token_ttl": 86400, + "assignments": [ + "allow_all" + ], + "client_id": "wGr981oYLJbcr4zrUriYxjxSc80JL7HW", + "client_type": "confidential", + "id_token_ttl": 86400, + "key": "default", + "redirect_uris": [ + "http://localhost:5555/callback" + ] + } + }, + "keys": [ + "my-app" + ] + } } ```