Skip to content

Commit

Permalink
fix: logout credentials cleanup (#2670)
Browse files Browse the repository at this point in the history
  • Loading branch information
avallete authored Sep 13, 2024
1 parent 92669a8 commit 230be7f
Show file tree
Hide file tree
Showing 13 changed files with 136 additions and 31 deletions.
2 changes: 1 addition & 1 deletion internal/link/link.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func Run(ctx context.Context, projectRef string, fsys afero.Fs, options ...func(
return err
}
// Save database password
if err := credentials.Set(projectRef, config.Password); err != nil {
if err := credentials.StoreProvider.Set(projectRef, config.Password); err != nil {
fmt.Fprintln(os.Stderr, "Failed to save database password:", err)
}
}
Expand Down
4 changes: 2 additions & 2 deletions internal/login/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func TestLoginCommand(t *testing.T) {
Token: token,
Fsys: afero.NewMemMapFs(),
}))
saved, err := credentials.Get(utils.AccessTokenKey)
saved, err := credentials.StoreProvider.Get(utils.AccessTokenKey)
assert.NoError(t, err)
assert.Equal(t, token, saved)
})
Expand Down Expand Up @@ -83,7 +83,7 @@ func TestLoginCommand(t *testing.T) {
expectedBrowserUrl := fmt.Sprintf("%s/cli/login?session_id=%s&token_name=%s&public_key=%s", utils.GetSupabaseDashboardURL(), sessionId, tokenName, publicKey)
assert.Contains(t, out.String(), expectedBrowserUrl)

saved, err := credentials.Get(utils.AccessTokenKey)
saved, err := credentials.StoreProvider.Get(utils.AccessTokenKey)
assert.NoError(t, err)
assert.Equal(t, token, saved)
assert.Empty(t, apitest.ListUnmatchedRequests())
Expand Down
6 changes: 6 additions & 0 deletions internal/logout/logout.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/go-errors/errors"
"github.com/spf13/afero"
"github.com/supabase/cli/internal/utils"
"github.com/supabase/cli/internal/utils/credentials"
)

func Run(ctx context.Context, stdout *os.File, fsys afero.Fs) error {
Expand All @@ -24,6 +25,11 @@ func Run(ctx context.Context, stdout *os.File, fsys afero.Fs) error {
return err
}

// Delete all possible stored project credentials
if err := credentials.StoreProvider.DeleteAll(); err != nil {
fmt.Fprintln(utils.GetDebugLogger(), err)
}

fmt.Fprintln(stdout, "Access token deleted successfully. You are now logged out.")
return nil
}
25 changes: 23 additions & 2 deletions internal/logout/logout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,37 @@ func TestLogoutCommand(t *testing.T) {
assert.Empty(t, saved)
})

t.Run("removes all Supabase CLI credentials", func(t *testing.T) {
t.Cleanup(credentials.MockInit())
require.NoError(t, credentials.StoreProvider.Set(utils.AccessTokenKey, token))
require.NoError(t, credentials.StoreProvider.Set("project1", "password1"))
require.NoError(t, credentials.StoreProvider.Set("project2", "password2"))
t.Cleanup(fstest.MockStdin(t, "y"))
// Run test
err := Run(context.Background(), os.Stdout, afero.NewMemMapFs())
// Check error
assert.NoError(t, err)
// Check that access token has been removed
saved, _ := credentials.StoreProvider.Get(utils.AccessTokenKey)
assert.Empty(t, saved)
// check that project 1 has been removed
saved, _ = credentials.StoreProvider.Get("project1")
assert.Empty(t, saved)
// check that project 2 has been removed
saved, _ = credentials.StoreProvider.Get("project2")
assert.Empty(t, saved)
})

t.Run("skips logout by default", func(t *testing.T) {
keyring.MockInit()
require.NoError(t, credentials.Set(utils.AccessTokenKey, token))
require.NoError(t, credentials.StoreProvider.Set(utils.AccessTokenKey, token))
// Setup in-memory fs
fsys := afero.NewMemMapFs()
// Run test
err := Run(context.Background(), os.Stdout, fsys)
// Check error
assert.ErrorIs(t, err, context.Canceled)
saved, err := credentials.Get(utils.AccessTokenKey)
saved, err := credentials.StoreProvider.Get(utils.AccessTokenKey)
assert.NoError(t, err)
assert.Equal(t, token, saved)
})
Expand Down
2 changes: 1 addition & 1 deletion internal/projects/create/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func Run(ctx context.Context, params api.V1CreateProjectBody, fsys afero.Fs) err

flags.ProjectRef = resp.JSON201.Id
viper.Set("DB_PASSWORD", params.DbPass)
if err := credentials.Set(flags.ProjectRef, params.DbPass); err != nil {
if err := credentials.StoreProvider.Set(flags.ProjectRef, params.DbPass); err != nil {
fmt.Fprintln(os.Stderr, "Failed to save database password:", err)
}

Expand Down
2 changes: 1 addition & 1 deletion internal/projects/delete/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func Run(ctx context.Context, ref string, fsys afero.Fs) error {
}

// Unlink project
if err := credentials.Delete(ref); err != nil && !errors.Is(err, keyring.ErrNotFound) {
if err := credentials.StoreProvider.Delete(ref); err != nil && !errors.Is(err, keyring.ErrNotFound) {
fmt.Fprintln(os.Stderr, err)
}
if match, err := afero.FileContainsBytes(fsys, utils.ProjectRefPath, []byte(ref)); match {
Expand Down
2 changes: 1 addition & 1 deletion internal/unlink/unlink.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func Unlink(projectRef string, fsys afero.Fs) error {
allErrors = append(allErrors, wrapped)
}
// Remove linked credentials
if err := credentials.Delete(projectRef); err != nil &&
if err := credentials.StoreProvider.Delete(projectRef); err != nil &&
!errors.Is(err, credentials.ErrNotSupported) &&
!errors.Is(err, keyring.ErrNotFound) {
allErrors = append(allErrors, err)
Expand Down
4 changes: 2 additions & 2 deletions internal/unlink/unlink_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ func TestUnlinkCommand(t *testing.T) {
fsys := afero.NewMemMapFs()
require.NoError(t, afero.WriteFile(fsys, utils.ProjectRefPath, []byte(project), 0644))
// Save database password
require.NoError(t, credentials.Set(project, "test"))
require.NoError(t, credentials.StoreProvider.Set(project, "test"))
// Run test
err := Run(context.Background(), fsys)
// Check error
Expand All @@ -33,7 +33,7 @@ func TestUnlinkCommand(t *testing.T) {
assert.NoError(t, err)
assert.False(t, exists)
// Check credentials does not exist
_, err = credentials.Get(project)
_, err = credentials.StoreProvider.Get(project)
assert.ErrorIs(t, err, keyring.ErrNotFound)
})

Expand Down
8 changes: 4 additions & 4 deletions internal/utils/access_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func loadAccessToken(fsys afero.Fs) (string, error) {
return accessToken, nil
}
// Load from native credentials store
if accessToken, err := credentials.Get(AccessTokenKey); err == nil {
if accessToken, err := credentials.StoreProvider.Get(AccessTokenKey); err == nil {
return accessToken, nil
}
// Fallback to token file
Expand All @@ -68,7 +68,7 @@ func SaveAccessToken(accessToken string, fsys afero.Fs) error {
return errors.New(ErrInvalidToken)
}
// Save to native credentials store
if err := credentials.Set(AccessTokenKey, accessToken); err == nil {
if err := credentials.StoreProvider.Set(AccessTokenKey, accessToken); err == nil {
return nil
}
// Fallback to token file
Expand All @@ -94,13 +94,13 @@ func DeleteAccessToken(fsys afero.Fs) error {
if err := fallbackDeleteToken(fsys); err == nil {
// Typically user system should only have either token file or keyring.
// But we delete from both just in case.
_ = credentials.Delete(AccessTokenKey)
_ = credentials.StoreProvider.Delete(AccessTokenKey)
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
// Fallback not found, delete from native credentials store
err := credentials.Delete(AccessTokenKey)
err := credentials.StoreProvider.Delete(AccessTokenKey)
if errors.Is(err, credentials.ErrNotSupported) || errors.Is(err, keyring.ErrNotFound) {
return errors.New(ErrNotLoggedIn)
} else if err != nil {
Expand Down
4 changes: 2 additions & 2 deletions internal/utils/access_token_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,15 +165,15 @@ func TestSaveTokenFallback(t *testing.T) {
func TestDeleteToken(t *testing.T) {
t.Run("deletes both keyring and fallback", func(t *testing.T) {
token := string(apitest.RandomAccessToken(t))
require.NoError(t, credentials.Set(AccessTokenKey, token))
require.NoError(t, credentials.StoreProvider.Set(AccessTokenKey, token))
// Setup in-memory fs
fsys := afero.NewMemMapFs()
require.NoError(t, fallbackSaveToken(token, fsys))
// Run test
err := DeleteAccessToken(fsys)
// Check error
assert.NoError(t, err)
_, err = credentials.Get(AccessTokenKey)
_, err = credentials.StoreProvider.Get(AccessTokenKey)
assert.ErrorIs(t, err, keyring.ErrNotFound)
path, err := getAccessTokenPath()
assert.NoError(t, err)
Expand Down
38 changes: 24 additions & 14 deletions internal/utils/credentials/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,19 @@ const namespace = "Supabase CLI"

var ErrNotSupported = errors.New("Keyring is not supported on WSL")

// Retrieves the stored password of a project and username
func Get(project string) (string, error) {
type Store interface {
Get(key string) (string, error)
Set(key, value string) error
Delete(project string) error
DeleteAll() error
}

type KeyringStore struct{}

var StoreProvider Store = &KeyringStore{}

// Get retrieves the password for a project from the keyring.
func (ks *KeyringStore) Get(project string) (string, error) {
if err := assertKeyringSupported(); err != nil {
return "", err
}
Expand All @@ -27,34 +38,33 @@ func Get(project string) (string, error) {
return val, nil
}

// Stores the password of a project and username
func Set(project, password string) error {
func (ks *KeyringStore) Set(project, password string) error {
if err := assertKeyringSupported(); err != nil {
return err
}
if err := keyring.Set(namespace, project, password); errors.Is(err, exec.ErrNotFound) {
return errors.New(ErrNotSupported)
} else if err != nil {
if err := keyring.Set(namespace, project, password); err != nil {
if errors.Is(err, exec.ErrNotFound) {
return ErrNotSupported
}
return errors.Errorf("failed to set credentials: %w", err)
}
return nil
}

// Erases the stored password of a project and username
func Delete(project string) error {
func (ks *KeyringStore) Delete(project string) error {
if err := assertKeyringSupported(); err != nil {
return err
}
if err := keyring.Delete(namespace, project); errors.Is(err, exec.ErrNotFound) {
return errors.New(ErrNotSupported)
} else if err != nil {
if err := keyring.Delete(namespace, project); err != nil {
if errors.Is(err, exec.ErrNotFound) {
return ErrNotSupported
}
return errors.Errorf("failed to delete credentials: %w", err)
}
return nil
}

// Deletes all stored credentials for the namespace
func DeleteAll() error {
func (ks *KeyringStore) DeleteAll() error {
return deleteAll(namespace)
}

Expand Down
68 changes: 68 additions & 0 deletions internal/utils/credentials/store_mock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package credentials

import (
"github.com/zalando/go-keyring"
)

type mockProvider struct {
mockStore map[string]map[string]string
mockError error
}

// Get retrieves the password for a project from the mock store.
func (m *mockProvider) Get(project string) (string, error) {
if m.mockError != nil {
return "", m.mockError
}
if pass, ok := m.mockStore[namespace][project]; ok {
return pass, nil
}
return "", keyring.ErrNotFound
}

// Set stores the password for a project in the mock store.
func (m *mockProvider) Set(project, password string) error {
if m.mockError != nil {
return m.mockError
}
if m.mockStore == nil {
m.mockStore = make(map[string]map[string]string)
}
if m.mockStore[namespace] == nil {
m.mockStore[namespace] = make(map[string]string)
}
m.mockStore[namespace][project] = password
return nil
}

// Delete removes the password for a project from the mock store.
func (m *mockProvider) Delete(project string) error {
if m.mockError != nil {
return m.mockError
}
if _, ok := m.mockStore[namespace][project]; ok {
delete(m.mockStore[namespace], project)
return nil
}
return keyring.ErrNotFound
}

// DeleteAll removes all passwords from the mock store.
func (m *mockProvider) DeleteAll() error {
if m.mockError != nil {
return m.mockError
}
delete(m.mockStore, namespace)
return nil
}

func MockInit() func() {
oldStore := StoreProvider
teardown := func() {
StoreProvider = oldStore
}
StoreProvider = &mockProvider{
mockStore: map[string]map[string]string{},
}
return teardown
}
2 changes: 1 addition & 1 deletion internal/utils/flags/db_url.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ func getPassword(projectRef string) string {
if password := viper.GetString("DB_PASSWORD"); len(password) > 0 {
return password
}
if password, err := credentials.Get(projectRef); err == nil {
if password, err := credentials.StoreProvider.Get(projectRef); err == nil {
return password
}
resetUrl := fmt.Sprintf("%s/project/%s/settings/database", utils.GetSupabaseDashboardURL(), projectRef)
Expand Down

0 comments on commit 230be7f

Please sign in to comment.