From e7b4504fabc0e8227c1f28682bfc5e1796ce6ec3 Mon Sep 17 00:00:00 2001 From: Duane May Date: Thu, 11 Jun 2026 18:18:17 -0400 Subject: [PATCH] Add `change-client-secret` command for authenticated client self-service secret updates --- cmd/change_client_secret.go | 93 +++++++++++++++++++ cmd/change_client_secret_test.go | 128 ++++++++++++++++++++++++++ docs/commands.md | 1 + docs/commands/change-client-secret.md | 62 +++++++++++++ docs/migrating-from-uaac.md | 2 +- 5 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 cmd/change_client_secret.go create mode 100644 cmd/change_client_secret_test.go create mode 100644 docs/commands/change-client-secret.md diff --git a/cmd/change_client_secret.go b/cmd/change_client_secret.go new file mode 100644 index 0000000..12465fc --- /dev/null +++ b/cmd/change_client_secret.go @@ -0,0 +1,93 @@ +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + + "code.cloudfoundry.org/uaa-cli/cli" + "code.cloudfoundry.org/uaa-cli/config" + "code.cloudfoundry.org/uaa-cli/utils" + "github.com/cloudfoundry-community/go-uaa" + "github.com/spf13/cobra" +) + +var oldSecret string + +func ChangeClientSecretValidation(cfg config.Config, oldSecret, newSecret string) error { + if err := cli.EnsureContextInConfig(cfg); err != nil { + return err + } + + context := cfg.GetActiveContext() + if context.GrantType != config.CLIENT_CREDENTIALS { + return errors.New("You must have a client_credentials token in your context to perform this command.") + } + + if oldSecret == "" { + return cli.MissingArgumentError("old_secret") + } + if newSecret == "" { + return cli.MissingArgumentError("secret") + } + return nil +} + +func ChangeClientSecretCmd(api *uaa.API, log cli.Logger, cfg config.Config, oldSecret, newSecret string) error { + context := cfg.GetActiveContext() + clientId := context.ClientId + + // Prepare the request body for the secret change + requestBody := map[string]interface{}{ + "oldSecret": oldSecret, + "secret": newSecret, + } + + requestBodyJSON, err := json.Marshal(requestBody) + if err != nil { + return err + } + + // Make the API call to change the client secret + path := fmt.Sprintf("/oauth/clients/%s/secret", clientId) + headers := []string{"Content-Type: application/json"} + + // Add zone header if specified + if cfg.ZoneSubdomain != "" { + headers = append(headers, fmt.Sprintf("X-Identity-Zone-Id: %s", cfg.ZoneSubdomain)) + } + + _, _, status, err := api.Curl(path, "PUT", string(requestBodyJSON), headers) + if err != nil { + return err + } + + if status >= 400 { + return errors.New("The secret for client " + clientId + " was not updated.") + } + + log.Infof("The secret for client %v has been successfully updated.", utils.Emphasize(clientId)) + return nil +} + +var changeClientSecretCmd = &cobra.Command{ + Use: "change-client-secret --old_secret OLD_SECRET --secret NEW_SECRET", + Short: "Change secret for authenticated client", + PreRun: func(cmd *cobra.Command, args []string) { + cli.NotifyValidationErrors(ChangeClientSecretValidation(GetSavedConfig(), oldSecret, clientSecret), cmd, log) + }, + Run: func(cmd *cobra.Command, args []string) { + cfg := GetSavedConfig() + api := GetAPIFromSavedTokenInContext() + cli.NotifyErrorsWithRetry(ChangeClientSecretCmd(api, log, cfg, oldSecret, clientSecret), log, cfg) + }, +} + +func init() { + RootCmd.AddCommand(changeClientSecretCmd) + changeClientSecretCmd.Annotations = make(map[string]string) + changeClientSecretCmd.Annotations[CLIENT_CRUD_CATEGORY] = "true" + changeClientSecretCmd.Flags().StringVar(&oldSecret, "old_secret", "", "current client secret") + changeClientSecretCmd.Flags().StringVarP(&clientSecret, "secret", "s", "", "new client secret") + changeClientSecretCmd.Flags().StringVarP(&zoneSubdomain, "zone", "z", "", "the identity zone subdomain where the client resides") +} diff --git a/cmd/change_client_secret_test.go b/cmd/change_client_secret_test.go new file mode 100644 index 0000000..5218c45 --- /dev/null +++ b/cmd/change_client_secret_test.go @@ -0,0 +1,128 @@ +package cmd_test + +import ( + "net/http" + + "code.cloudfoundry.org/uaa-cli/config" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + . "github.com/onsi/gomega/gbytes" + . "github.com/onsi/gomega/gexec" + . "github.com/onsi/gomega/ghttp" +) + +var _ = Describe("ChangeClientSecret", func() { + BeforeEach(func() { + c := config.NewConfigWithServerURL(server.URL()) + // Create a client context with client_credentials grant type + ctx := config.NewContextWithToken("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ") + ctx.GrantType = config.CLIENT_CREDENTIALS + ctx.ClientId = "myclient" + c.AddContext(ctx) + config.WriteConfig(c) + }) + + It("successfully changes client secret", func() { + server.RouteToHandler("PUT", "/oauth/clients/myclient/secret", CombineHandlers( + VerifyRequest("PUT", "/oauth/clients/myclient/secret"), + VerifyJSON(`{"oldSecret":"oldsecret","secret":"newsecret"}`), + VerifyHeaderKV("Authorization", "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ"), + VerifyHeaderKV("Content-Type", "application/json"), + RespondWith(http.StatusOK, `{"status":"ok","message":"Secret is updated"}`), + )) + + session := runCommand("change-client-secret", "--old_secret", "oldsecret", "--secret", "newsecret") + + Expect(session.Out).To(Say("The secret for client myclient has been successfully updated.")) + Eventually(session).Should(Exit(0)) + }) + + It("displays error when API request fails", func() { + server.RouteToHandler("PUT", "/oauth/clients/myclient/secret", CombineHandlers( + VerifyRequest("PUT", "/oauth/clients/myclient/secret"), + VerifyJSON(`{"oldSecret":"wrongsecret","secret":"newsecret"}`), + RespondWith(http.StatusBadRequest, `{"error":"invalid_secret","error_description":"The old secret is incorrect"}`), + )) + + session := runCommand("change-client-secret", "--old_secret", "wrongsecret", "--secret", "newsecret") + + Expect(session.Err).To(Say("The secret for client myclient was not updated.")) + Expect(session.Out).To(Say("Retry with --verbose for more information.")) + Eventually(session).Should(Exit(1)) + }) + + It("complains when there is no active target", func() { + config.WriteConfig(config.NewConfig()) + session := runCommand("change-client-secret", "--old_secret", "old", "--secret", "new") + + Expect(session.Err).To(Say("You must set a target in order to use this command.")) + Eventually(session).Should(Exit(1)) + }) + + It("complains when there is no active context", func() { + c := config.NewConfig() + t := config.NewTarget() + c.AddTarget(t) + config.WriteConfig(c) + session := runCommand("change-client-secret", "--old_secret", "old", "--secret", "new") + + Expect(session.Err).To(Say("You must have a token in your context to perform this command.")) + Eventually(session).Should(Exit(1)) + }) + + It("complains when context is not client_credentials", func() { + c := config.NewConfigWithServerURL(server.URL()) + // Create a password grant context (user context) + ctx := config.NewContextWithToken("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ") + ctx.GrantType = config.PASSWORD + ctx.Username = "testuser" + c.AddContext(ctx) + config.WriteConfig(c) + + session := runCommand("change-client-secret", "--old_secret", "old", "--secret", "new") + + Expect(session.Err).To(Say("You must have a client_credentials token in your context to perform this command.")) + Eventually(session).Should(Exit(1)) + }) + + It("supports zone switching", func() { + server.RouteToHandler("PUT", "/oauth/clients/myclient/secret", CombineHandlers( + VerifyRequest("PUT", "/oauth/clients/myclient/secret"), + VerifyJSON(`{"oldSecret":"oldsecret","secret":"newsecret"}`), + VerifyHeaderKV("Authorization", "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ"), + VerifyHeaderKV("X-Identity-Zone-Id", "twilight-zone"), + RespondWith(http.StatusOK, `{"status":"ok","message":"Secret is updated"}`), + )) + + session := runCommand("change-client-secret", "--old_secret", "oldsecret", "--secret", "newsecret", "--zone", "twilight-zone") + + Expect(session.Out).To(Say("The secret for client myclient has been successfully updated.")) + Eventually(session).Should(Exit(0)) + }) + + It("shows verbose output when requested", func() { + server.RouteToHandler("PUT", "/oauth/clients/myclient/secret", CombineHandlers( + VerifyRequest("PUT", "/oauth/clients/myclient/secret"), + RespondWith(http.StatusOK, `{"status":"ok","message":"Secret is updated"}`), + )) + + session := runCommand("change-client-secret", "--old_secret", "oldsecret", "--secret", "newsecret", "--verbose") + + Expect(session.Out).To(Say("The secret for client myclient has been successfully updated.")) + Eventually(session).Should(Exit(0)) + }) + + It("complains when no old secret is provided", func() { + session := runCommand("change-client-secret", "--secret", "newsecret") + + Expect(session.Err).To(Say("Missing argument `old_secret` must be specified.")) + Eventually(session).Should(Exit(1)) + }) + + It("complains when no new secret is provided", func() { + session := runCommand("change-client-secret", "--old_secret", "oldsecret") + + Expect(session.Err).To(Say("Missing argument `secret` must be specified.")) + Eventually(session).Should(Exit(1)) + }) +}) diff --git a/docs/commands.md b/docs/commands.md index 48a8cdc..d547afa 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -37,6 +37,7 @@ Each command name below links to a page with a full description, including all a | [`get-client`](commands/get-client.md) | View a client registration | | [`list-clients`](commands/list-clients.md) | See all clients in the targeted UAA | | [`set-client-secret`](commands/set-client-secret.md) | Update the secret for a client | +| [`change-client-secret`](commands/change-client-secret.md) | Change secret for authenticated client | ## Managing Users diff --git a/docs/commands/change-client-secret.md b/docs/commands/change-client-secret.md new file mode 100644 index 0000000..c22c60e --- /dev/null +++ b/docs/commands/change-client-secret.md @@ -0,0 +1,62 @@ +# uaa change-client-secret + +## Overview + +Change the secret for the currently authenticated client. This command allows a client to change its own secret by providing both the old secret and the new secret. + +## Usage + +``` +uaa change-client-secret --old_secret OLD_SECRET --secret NEW_SECRET [flags] +``` + +## Required Authentication + +This command requires an active client context obtained via the `client_credentials` grant type. + +## Arguments + +| Argument | Description | +|----------|-------------| +| `--old_secret` | The current secret for the client | +| `--secret`, `-s` | The new secret for the client | + +## Options + +| Option | Description | +|--------|-------------| +| `--zone`, `-z` | Identity zone subdomain where the client resides | +| `--verbose`, `-v` | Display verbose output including HTTP request/response details | + +## Examples + +### Change client secret with explicit values + +```bash +uaa change-client-secret --old_secret currentsecret --secret newsecret +``` + +### Change client secret in a specific zone + +```bash +uaa change-client-secret --old_secret currentsecret --secret newsecret --zone myzone +``` + +### Change client secret with verbose output + +```bash +uaa change-client-secret --old_secret currentsecret --secret newsecret --verbose +``` + +## Prerequisites + +1. You must have targeted a UAA server using `uaa target` +2. You must have an active client context with `client_credentials` grant type (obtained via `uaa get-client-credentials-token`) +3. The client must have the necessary permissions to change its own secret + +## Notes + +- This command is for self-service secret changes where a client changes its own secret +- Both the old and new secrets must be provided for security reasons +- After changing the secret, you will need to re-authenticate with the new secret +- Use `--verbose` to see the actual HTTP request being made to the UAA \ No newline at end of file diff --git a/docs/migrating-from-uaac.md b/docs/migrating-from-uaac.md index 83b08fc..2adc55f 100644 --- a/docs/migrating-from-uaac.md +++ b/docs/migrating-from-uaac.md @@ -86,7 +86,7 @@ The `uaa` CLI outputs a combination of human-readable status messages and JSON d | `uaac client update [id]` | [`uaa update-client CLIENT_ID ...`](commands/update-client.md) | uaac supports `--interactive` / `-i`; uaa-cli does not | | `uaac client delete [id]` | [`uaa delete-client CLIENT_ID`](commands/delete-client.md) | | | `uaac secret set [id]` | [`uaa set-client-secret CLIENT_ID -s SECRET`](commands/set-client-secret.md) | | -| `uaac secret change` | *(no equivalent)* | Use `uaa curl /oauth/clients/CLIENT_ID/secret -X PUT -d '{"oldSecret":"OLD","secret":"NEW"}'` | +| `uaac secret change` | [`uaa change-client-secret --old_secret OLD --secret NEW`](commands/change-client-secret.md) | Client must be authenticated with client_credentials token | | `uaac client jwt add [id]` | *(no equivalent)* | Use `uaa curl /oauth/clients/CLIENT_ID/clientjwt -X PUT -d '{...}'` | | `uaac client jwt update [id]` | *(no equivalent)* | Use `uaa curl /oauth/clients/CLIENT_ID/clientjwt -X PUT -d '{...}'` | | `uaac client jwt delete [id]` | *(no equivalent)* | Use `uaa curl /oauth/clients/CLIENT_ID/clientjwt -X DELETE` |