Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions api/dashboard/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,65 @@ func (c *Client) CreateAPIKey(
return CreatedAPIKey{Value: key, UUID: keyResp.Data.ID}, nil
}

// RotateAPIKey regenerates the secret value of the API key identified by keyUUID
// for the given application, via the Public API. The key keeps its UUID; only
// its value changes. Returns the new value and the (unchanged) UUID.
func (c *Client) RotateAPIKey(accessToken, appID, keyUUID string) (CreatedAPIKey, error) {
endpoint := fmt.Sprintf(
"%s/1/applications/%s/api-keys/%s/rotate",
c.APIURL,
url.PathEscape(appID),
url.PathEscape(keyUUID),
)
req, err := http.NewRequest(http.MethodPost, endpoint, nil)
if err != nil {
return CreatedAPIKey{}, err
}
c.setAPIHeaders(req, accessToken)

resp, err := c.client.Do(req)
if err != nil {
return CreatedAPIKey{}, fmt.Errorf("rotate API key request failed: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode == http.StatusUnauthorized {
return CreatedAPIKey{}, ErrSessionExpired
}

respBody, err := io.ReadAll(resp.Body)
if err != nil {
return CreatedAPIKey{}, fmt.Errorf("failed to read API key response: %w", err)
}

if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return CreatedAPIKey{}, fmt.Errorf(
"rotate API key failed with status %d: %s",
resp.StatusCode,
string(respBody),
)
}

var keyResp CreateAPIKeyResponse
if err := json.Unmarshal(respBody, &keyResp); err != nil {
return CreatedAPIKey{}, fmt.Errorf(
"failed to parse API key response: %w (body: %s)",
err,
string(respBody),
)
}

key := keyResp.Data.Attributes.Value
if key == "" {
return CreatedAPIKey{}, fmt.Errorf(
"API key rotation succeeded but no key was returned in the response: %s",
string(respBody),
)
}

return CreatedAPIKey{Value: key, UUID: keyResp.Data.ID}, nil
}

// GetCrawlerUser gets the crawler API user data for the current authenticated user
func (c *Client) GetCrawlerUser(accessToken string) (*DashboardCrawlerUserData, error) {
req, err := http.NewRequest(http.MethodGet, c.APIURL+"/1/crawler/user", nil)
Expand Down
61 changes: 61 additions & 0 deletions api/dashboard/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,67 @@ func TestCreateAPIKey_EmptyValueReturnsError(t *testing.T) {
assert.Contains(t, err.Error(), "no key was returned")
}

func TestRotateAPIKey_ReturnsNewValue(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc(
"/1/applications/APP1/api-keys/key-uuid-123/rotate",
func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))

require.NoError(t, json.NewEncoder(w).Encode(CreateAPIKeyResponse{
Data: APIKeyResource{
ID: "key-uuid-123",
Type: "api_key",
Attributes: APIKeyAttributes{Value: "rotated-key"},
},
}))
},
)

ts, client := newTestClient(mux)
defer ts.Close()

created, err := client.RotateAPIKey("test-token", "APP1", "key-uuid-123")
require.NoError(t, err)
assert.Equal(t, "rotated-key", created.Value)
assert.Equal(t, "key-uuid-123", created.UUID)
}

func TestRotateAPIKey_Unauthorized(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc(
"/1/applications/APP1/api-keys/key-uuid-123/rotate",
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
},
)

ts, client := newTestClient(mux)
defer ts.Close()

_, err := client.RotateAPIKey("test-token", "APP1", "key-uuid-123")
assert.ErrorIs(t, err, ErrSessionExpired)
}

func TestRotateAPIKey_ErrorStatus(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc(
"/1/applications/APP1/api-keys/key-uuid-123/rotate",
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte("not found"))
},
)

ts, client := newTestClient(mux)
defer ts.Close()

_, err := client.RotateAPIKey("test-token", "APP1", "key-uuid-123")
require.Error(t, err)
assert.Contains(t, err.Error(), "404")
}

func TestUpdateApplication_Success(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/1/applications/APP1", func(w http.ResponseWriter, r *http.Request) {
Expand Down
2 changes: 2 additions & 0 deletions pkg/cmd/apikeys/apikeys.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"github.com/algolia/cli/pkg/cmd/apikeys/delete"
"github.com/algolia/cli/pkg/cmd/apikeys/get"
"github.com/algolia/cli/pkg/cmd/apikeys/list"
"github.com/algolia/cli/pkg/cmd/apikeys/rotate"
"github.com/algolia/cli/pkg/cmdutil"
)

Expand All @@ -22,6 +23,7 @@ func NewAPIKeysCmd(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(create.NewCreateCmd(f, nil))
cmd.AddCommand(delete.NewDeleteCmd(f, nil))
cmd.AddCommand(get.NewGetCmd(f, nil))
cmd.AddCommand(rotate.NewRotateCmd(f, nil))

return cmd
}
120 changes: 120 additions & 0 deletions pkg/cmd/apikeys/rotate/rotate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package rotate

import (
"fmt"

"github.com/MakeNowJust/heredoc"
"github.com/spf13/cobra"

"github.com/algolia/cli/api/dashboard"
"github.com/algolia/cli/pkg/auth"
"github.com/algolia/cli/pkg/cmdutil"
"github.com/algolia/cli/pkg/config"
"github.com/algolia/cli/pkg/iostreams"
"github.com/algolia/cli/pkg/validators"
)

// RotateOptions represents the options for the rotate command.
type RotateOptions struct {
IO *iostreams.IOStreams
Config config.IConfig

NewDashboardClient func(clientID string) *dashboard.Client
}

// NewRotateCmd returns a new instance of the rotate command.
func NewRotateCmd(f *cmdutil.Factory, runF func(*RotateOptions) error) *cobra.Command {
opts := &RotateOptions{
IO: f.IOStreams,
Config: f.Config,
NewDashboardClient: func(clientID string) *dashboard.Client {
return dashboard.NewClient(clientID)
},
}

cmd := &cobra.Command{
Use: "rotate",
Short: "Rotate the CLI-managed API key for the current application",
Long: heredoc.Doc(`
Rotate (regenerate) the CLI-managed API key for the current application.

The previous key is invalidated and replaced by a new one, which is then
stored for the current application.
`),
Example: heredoc.Doc(`
# Rotate the current application's CLI-managed key
$ algolia apikeys rotate
`),
Args: validators.NoArgs(),
Annotations: map[string]string{
"skipAuthCheck": "true",
},
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
}

return runRotateCmd(opts)
},
}

return cmd
}

// runRotateCmd executes the rotate command.
func runRotateCmd(opts *RotateOptions) error {
cs := opts.IO.ColorScheme()

appID := opts.Config.ActiveApplicationID()
if appID == "" {
return fmt.Errorf(
"no current application selected; run %s first",
cs.Bold("algolia application select"),
)
}

keyUUID, hasStored := opts.Config.APIKeyUUID(appID)
if !hasStored {
return fmt.Errorf(
"no CLI-managed API key found for application %s; run %s to regenerate one",
cs.Bold(appID),
cs.Bold("algolia application select"),
)
}

client := opts.NewDashboardClient(auth.OAuthClientID())

accessToken, err := auth.EnsureAuthenticated(opts.IO, client)
if err != nil {
return err
}

opts.IO.StartProgressIndicatorWithLabel("Rotating API key")
created, err := client.RotateAPIKey(accessToken, appID, keyUUID)
opts.IO.StopProgressIndicator()
if err != nil {
newToken, reAuthErr := auth.ReauthenticateIfExpired(opts.IO, client, err)
if reAuthErr != nil {
return reAuthErr
}
accessToken = newToken
opts.IO.StartProgressIndicatorWithLabel("Rotating API key")
created, err = client.RotateAPIKey(accessToken, appID, keyUUID)
opts.IO.StopProgressIndicator()
if err != nil {
return err
}
}

// We rotated the application's own CLI-managed key, so the previous value is
// now invalid: persist the new one for the current application.
if err := opts.Config.SaveApplication(appID, "", keyUUID, created.Value, false); err != nil {
return fmt.Errorf("API key rotated but could not be saved locally: %w", err)
}

if opts.IO.IsStdoutTTY() {
fmt.Fprintf(opts.IO.Out, "%s API key rotated: %s\n", cs.SuccessIcon(), created.Value)
}

return nil
}
Loading
Loading