diff --git a/api/dashboard/client.go b/api/dashboard/client.go index 98b29512..89cdd8e2 100644 --- a/api/dashboard/client.go +++ b/api/dashboard/client.go @@ -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) diff --git a/api/dashboard/client_test.go b/api/dashboard/client_test.go index 3ea40d68..bded1e2f 100644 --- a/api/dashboard/client_test.go +++ b/api/dashboard/client_test.go @@ -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) { diff --git a/pkg/cmd/apikeys/apikeys.go b/pkg/cmd/apikeys/apikeys.go index 273ade05..832268ff 100644 --- a/pkg/cmd/apikeys/apikeys.go +++ b/pkg/cmd/apikeys/apikeys.go @@ -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" ) @@ -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 } diff --git a/pkg/cmd/apikeys/rotate/rotate.go b/pkg/cmd/apikeys/rotate/rotate.go new file mode 100644 index 00000000..7ceed2b2 --- /dev/null +++ b/pkg/cmd/apikeys/rotate/rotate.go @@ -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 +} diff --git a/pkg/cmd/apikeys/rotate/rotate_test.go b/pkg/cmd/apikeys/rotate/rotate_test.go new file mode 100644 index 00000000..b3792848 --- /dev/null +++ b/pkg/cmd/apikeys/rotate/rotate_test.go @@ -0,0 +1,137 @@ +package rotate + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zalando/go-keyring" + + "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/test" +) + +func seedToken(t *testing.T) { + t.Helper() + keyring.MockInit() + require.NoError(t, auth.SaveToken(&dashboard.OAuthTokenResponse{ + AccessToken: "test-token", + ExpiresIn: 3600, + CreatedAt: time.Now().Unix(), + })) +} + +// rotateServer stubs the dashboard rotate endpoint at wantPath, returning +// newValue as the rotated key's value. +func rotateServer(t *testing.T, wantPath, newValue string) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc(wantPath, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + require.NoError(t, json.NewEncoder(w).Encode(dashboard.CreateAPIKeyResponse{ + Data: dashboard.APIKeyResource{ + ID: "key-uuid-123", + Type: "api_key", + Attributes: dashboard.APIKeyAttributes{Value: newValue}, + }, + })) + }) + return httptest.NewServer(mux) +} + +func newRotateOpts( + t *testing.T, + srv *httptest.Server, + cfg config.IConfig, + isTTY bool, +) (*RotateOptions, *bytes.Buffer) { + t.Helper() + seedToken(t) + + io, _, stdout, _ := iostreams.Test() + io.SetStdoutTTY(isTTY) + + opts := &RotateOptions{ + IO: io, + Config: cfg, + NewDashboardClient: func(string) *dashboard.Client { + c := dashboard.NewClientWithHTTPClient("test", srv.Client()) + c.APIURL = srv.URL + return c + }, + } + return opts, stdout +} + +func TestNewRotateCmd(t *testing.T) { + io, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{IOStreams: io} + cmd := NewRotateCmd(f, nil) + + assert.Equal(t, "rotate", cmd.Name()) + assert.Equal(t, "true", cmd.Annotations["skipAuthCheck"]) + assert.Nil(t, cmd.Flags().Lookup("key-id")) +} + +func Test_runRotateCmd_ResolutionErrors(t *testing.T) { + tests := []struct { + name string + cfg *test.ConfigStub + wantErr string + }{ + { + name: "no active application", + cfg: &test.ConfigStub{ActiveAppID: ""}, + wantErr: "no current application selected", + }, + { + name: "active application without a stored UUID", + cfg: &test.ConfigStub{ + ActiveAppID: "APP1", + SavedApps: map[string]test.SavedApplication{ + "APP1": {Alias: "prod", APIKey: "old-key"}, + }, + }, + wantErr: "no CLI-managed API key found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + opts := &RotateOptions{IO: io, Config: tt.cfg} + + err := runRotateCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +func Test_runRotateCmd_PersistsCLIManagedKey(t *testing.T) { + srv := rotateServer(t, "/1/applications/APP1/api-keys/key-uuid-123/rotate", "rotated-key") + defer srv.Close() + + cfg := &test.ConfigStub{ + ActiveAppID: "APP1", + SavedApps: map[string]test.SavedApplication{ + "APP1": {Alias: "prod", APIKeyUUID: "key-uuid-123", APIKey: "old-key"}, + }, + } + opts, stdout := newRotateOpts(t, srv, cfg, true) + + require.NoError(t, runRotateCmd(opts)) + + assert.Contains(t, stdout.String(), "rotated-key") + // The CLI-managed key was rotated, so the new value replaces the stored one. + assert.Equal(t, "rotated-key", cfg.SavedApps["APP1"].APIKey) +} diff --git a/pkg/cmd/application/selectapp/select.go b/pkg/cmd/application/selectapp/select.go index eac0389c..a0c56979 100644 --- a/pkg/cmd/application/selectapp/select.go +++ b/pkg/cmd/application/selectapp/select.go @@ -121,8 +121,12 @@ func runSelectCmd(opts *SelectOptions) (*dashboard.Application, error) { } // Reuse a key already stored for this application (keychain, then legacy - // config.toml) before creating a new one on the dashboard. - if !apputil.ReuseExistingAPIKey(opts.Config, chosen) { + // config.toml) before creating a new one on the dashboard. A migrated + // application may have a usable key but no stored UUID (legacy config.toml + // never recorded one); without a UUID, commands like `apikeys rotate` can't + // target it, so regenerate a fresh CLI-managed key whenever none is on record. + _, hasUUID := opts.Config.APIKeyUUID(chosen.ID) + if !hasUUID || !apputil.ReuseExistingAPIKey(opts.Config, chosen) { if err := apputil.EnsureAPIKey(opts.IO, client, accessToken, chosen); err != nil { return nil, err } diff --git a/pkg/cmd/application/selectapp/select_test.go b/pkg/cmd/application/selectapp/select_test.go new file mode 100644 index 00000000..bc42da94 --- /dev/null +++ b/pkg/cmd/application/selectapp/select_test.go @@ -0,0 +1,124 @@ +package selectapp + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zalando/go-keyring" + + "github.com/algolia/cli/api/dashboard" + "github.com/algolia/cli/pkg/auth" + "github.com/algolia/cli/pkg/iostreams" + "github.com/algolia/cli/pkg/keychain" + "github.com/algolia/cli/test" +) + +func seedToken(t *testing.T) { + t.Helper() + keyring.MockInit() + require.NoError(t, auth.SaveToken(&dashboard.OAuthTokenResponse{ + AccessToken: "test-token", + ExpiresIn: 3600, + CreatedAt: time.Now().Unix(), + })) +} + +// selectServer stubs the dashboard endpoints select uses: listing applications +// and creating an API key. createHit records whether the key-creation endpoint +// was called. +func selectServer(t *testing.T, createHit *bool) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/1/applications", func(w http.ResponseWriter, _ *http.Request) { + require.NoError(t, json.NewEncoder(w).Encode(dashboard.ApplicationsResponse{ + Data: []dashboard.ApplicationResource{{ + ID: "APP1", + Type: "application", + Attributes: dashboard.ApplicationAttributes{ + ApplicationID: "APP1", + Name: "My App", + }, + }}, + Meta: dashboard.PaginationMeta{CurrentPage: 1, TotalPages: 1}, + })) + }) + mux.HandleFunc( + "/1/applications/APP1/api-keys", + func(w http.ResponseWriter, _ *http.Request) { + *createHit = true + w.WriteHeader(http.StatusCreated) + require.NoError(t, json.NewEncoder(w).Encode(dashboard.CreateAPIKeyResponse{ + Data: dashboard.APIKeyResource{ + ID: "new-uuid", + Attributes: dashboard.APIKeyAttributes{Value: "new-key"}, + }, + })) + }, + ) + return httptest.NewServer(mux) +} + +func newSelectOpts(t *testing.T, srv *httptest.Server, cfg *test.ConfigStub) *SelectOptions { + t.Helper() + seedToken(t) + io, _, _, _ := iostreams.Test() + return &SelectOptions{ + IO: io, + Config: cfg, + AppName: "My App", // bypasses the interactive picker + NewDashboardClient: func(string) *dashboard.Client { + c := dashboard.NewClientWithHTTPClient("test", srv.Client()) + c.APIURL = srv.URL + return c + }, + } +} + +func Test_runSelectCmd_RegeneratesKeyWhenNoUUID(t *testing.T) { + createHit := false + srv := selectServer(t, &createHit) + defer srv.Close() + + // Migrated application: present in state with an alias, but no UUID. + cfg := &test.ConfigStub{ + SavedApps: map[string]test.SavedApplication{ + "APP1": {Alias: "my app", APIKey: "old-key"}, + }, + } + opts := newSelectOpts(t, srv, cfg) + + app, err := runSelectCmd(opts) + require.NoError(t, err) + require.NotNil(t, app) + + assert.True(t, createHit, "expected a fresh key to be generated when no UUID is stored") + assert.Equal(t, "new-uuid", cfg.SavedApps["APP1"].APIKeyUUID) + assert.Equal(t, "new-key", cfg.SavedApps["APP1"].APIKey) +} + +func Test_runSelectCmd_ReusesKeyWhenUUIDPresent(t *testing.T) { + createHit := false + srv := selectServer(t, &createHit) + defer srv.Close() + + cfg := &test.ConfigStub{ + SavedApps: map[string]test.SavedApplication{ + "APP1": {Alias: "my app", APIKeyUUID: "existing-uuid", APIKey: "old-key"}, + }, + } + opts := newSelectOpts(t, srv, cfg) + // A key in the keychain lets ReuseExistingAPIKey succeed. + require.NoError(t, keychain.SaveAppSecrets("APP1", keychain.AppSecrets{APIKey: "kc-key"})) + + app, err := runSelectCmd(opts) + require.NoError(t, err) + require.NotNil(t, app) + + assert.False(t, createHit, "expected no new key when a UUID is already stored") + assert.Equal(t, "existing-uuid", cfg.SavedApps["APP1"].APIKeyUUID) +} diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index 9bcb4a04..1b7de0eb 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -168,7 +168,11 @@ func runOAuthFlowSteps( } appDetails = app - if !apputil.ReuseExistingAPIKey(opts.Config, appDetails) { + // A migrated application may have a usable key but no stored UUID, which + // commands like `apikeys rotate` need; regenerate a fresh CLI-managed key + // whenever none is on record (mirrors `application select`). + _, hasUUID := opts.Config.APIKeyUUID(appDetails.ID) + if !hasUUID || !apputil.ReuseExistingAPIKey(opts.Config, appDetails) { if err := apputil.EnsureAPIKey(opts.IO, client, accessToken, appDetails); err != nil { return err } diff --git a/pkg/config/config.go b/pkg/config/config.go index 097725f5..90e4aa63 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -32,6 +32,7 @@ type IConfig interface { // New model (state.toml + OS keychain). ActiveApplicationID() string + APIKeyUUID(appID string) (string, bool) ApplicationInState(appID string) bool ApplicationIDByAlias(alias string) (string, bool) SaveApplication(appID, alias, apiKeyUUID, apiKey string, setCurrent bool) error diff --git a/pkg/config/write.go b/pkg/config/write.go index 23a8c5f5..f3bffe7c 100644 --- a/pkg/config/write.go +++ b/pkg/config/write.go @@ -11,6 +11,17 @@ func (c *Config) ActiveApplicationID() string { return c.activeApplicationID() } +// APIKeyUUID returns the CLI-managed API key UUID recorded in state.toml for +// the given application, and whether one is stored. A configured application +// with no UUID (a legacy setup) returns ("", false). +func (c *Config) APIKeyUUID(appID string) (string, bool) { + app, ok := c.loadState().Applications[appID] + if !ok || app.APIKeyUUID == "" { + return "", false + } + return app.APIKeyUUID, true +} + // ApplicationIDByAlias returns the application ID carrying the given alias in // state.toml, and whether one was found. func (c *Config) ApplicationIDByAlias(alias string) (string, bool) { diff --git a/pkg/config/write_test.go b/pkg/config/write_test.go index 360be9f9..76462999 100644 --- a/pkg/config/write_test.go +++ b/pkg/config/write_test.go @@ -123,3 +123,32 @@ func TestConfig_ActiveApplicationIDAndAliasAccessors(t *testing.T) { assert.True(t, ok) assert.Equal(t, "APP1", appID) } + +func TestConfig_APIKeyUUID(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.toml") + require.NoError(t, os.WriteFile( + path, + []byte("[applications.APP1]\napi_key_uuid = \"uuid-1\"\nalias = \"prod\"\n\n[applications.APP2]\nalias = \"legacy\"\n"), + 0o600, + )) + cfg := &Config{StateFile: path} + + tests := []struct { + name string + appID string + wantUUID string + wantOK bool + }{ + {"stored UUID", "APP1", "uuid-1", true}, + {"present but no UUID (legacy)", "APP2", "", false}, + {"absent application", "APP3", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + uuid, ok := cfg.APIKeyUUID(tt.appID) + assert.Equal(t, tt.wantOK, ok) + assert.Equal(t, tt.wantUUID, uuid) + }) + } +} diff --git a/test/config.go b/test/config.go index 65a77ada..8d1a507f 100644 --- a/test/config.go +++ b/test/config.go @@ -152,6 +152,14 @@ func (c *ConfigStub) ApplicationInState(appID string) bool { return ok } +func (c *ConfigStub) APIKeyUUID(appID string) (string, bool) { + app, ok := c.SavedApps[appID] + if !ok || app.APIKeyUUID == "" { + return "", false + } + return app.APIKeyUUID, true +} + func (c *ConfigStub) ApplicationIDByAlias(alias string) (string, bool) { for appID, app := range c.SavedApps { if app.Alias == alias {