Repository: supabase/cli
Pull request #1041 (2023-04-26 17:38:15)

fix: mask access token when login
## What kind of change does this PR introduce?

Bug fix

## What is the new behavior?

Mask access token like password during login

```bash
$ supabase login
You can generate an access token from https://app.supabase.com/account/tokens
Enter your access token:
Error: Invalid access token format. Must be like `sbp_0102...1920`.
Try rerunning the command with --debug to troubleshoot the error.
exit status 1
```

## Additional context

Add any other context or screenshots.


coveralls: ## Pull Request Test Coverage Report for [Build 4815833048](https://coveralls.io/builds/59373101)

* **77** of **87**   **(88.51%)**  changed or added relevant lines in **6** files are covered.
* **2** unchanged lines in **1** file lost coverage.
* Overall coverage increased (+**0.4%**) to **60.998%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [cmd/functions.go](https://coveralls.io/builds/59373101/source?filename=cmd%2Ffunctions.go#L139) | 0 | 1 | 0.0%
| [cmd/login.go](https://coveralls.io/builds/59373101/source?filename=cmd%2Flogin.go#L21) | 2 | 3 | 66.67%
| [internal/link/link.go](https://coveralls.io/builds/59373101/source?filename=internal%2Flink%2Flink.go#L159) | 0 | 2 | 0.0%
| [internal/utils/credentials/input.go](https://coveralls.io/builds/59373101/source?filename=internal%2Futils%2Fcredentials%2Finput.go#L24) | 10 | 16 | 62.5%
<!-- | **Total:** | **77** | **87** | **88.51%** | -->

|  Files with Coverage Reduction | New Missed Lines | % |
| :-----|--------------|--: |
| [cmd/login.go](https://coveralls.io/builds/59373101/source?filename=cmd%2Flogin.go#L22) | 2 | 66.67% |
<!-- | **Total:** | **2** |  | -->

|  Totals | [![Coverage Status](https://coveralls.io/builds/59373101/badge)](https://coveralls.io/builds/59373101) |
| :-- | --: |
| Change from base [Build 4805943059](https://coveralls.io/builds/59330505): |  0.4% |
| Covered Lines: | 4243 |
| Relevant Lines: | 6956 |

---
##### 💛  - [Coveralls](https://coveralls.io)


Patch:
diff --git a/cmd/functions.go b/cmd/functions.go
index 52fc3445..57232f52 100644
--- a/cmd/functions.go
+++ b/cmd/functions.go
@@ -136,9 +136,7 @@ func init() {
 }
 
 func PromptLogin(fsys afero.Fs) error {
-	if _, err := utils.LoadAccessTokenFS(fsys); err == nil {
-		return nil
-	} else if strings.HasPrefix(err.Error(), "Access token not provided. Supply an access token by running") {
+	if _, err := utils.LoadAccessTokenFS(fsys); err == utils.ErrMissingToken {
 		return login.Run(os.Stdin, fsys)
 	} else {
 		return err
diff --git a/cmd/login.go b/cmd/login.go
index 03bad385..e2edc504 100644
--- a/cmd/login.go
+++ b/cmd/login.go
@@ -16,12 +16,10 @@ var (
 		Use:     "login",
 		Short:   "Authenticate using an access token",
 		RunE: func(cmd *cobra.Command, args []string) error {
-			if err := login.Run(os.Stdin, afero.NewOsFs()); err != nil {
-				return err
-			}
-
+			return login.Run(os.Stdin, afero.NewOsFs())
+		},
+		PostRun: func(cmd *cobra.Command, args []string) {
 			fmt.Println("Finished " + utils.Aqua("supabase login") + ".")
-			return nil
 		},
 	}
 )
diff --git a/internal/link/link.go b/internal/link/link.go
index 09ff4846..6c22ce9d 100644
--- a/internal/link/link.go
+++ b/internal/link/link.go
@@ -18,7 +18,6 @@ import (
 	"github.com/supabase/cli/internal/utils"
 	"github.com/supabase/cli/internal/utils/credentials"
 	"github.com/supabase/cli/pkg/api"
-	"golang.org/x/term"
 )
 
 var updatedConfig map[string]interface{} = make(map[string]interface{})
@@ -157,19 +156,10 @@ func updatePostgresConfig(conn *pgx.Conn) {
 
 func PromptPassword(stdin *os.File) string {
 	fmt.Fprint(os.Stderr, "Enter your database password: ")
-	return promptPassword(stdin)
+	return credentials.PromptMasked(stdin)
 }
 
 func PromptPasswordAllowBlank(stdin *os.File) string {
 	fmt.Fprint(os.Stderr, "Enter your database password (or leave blank to skip): ")
-	return promptPassword(stdin)
-}
-
-func promptPassword(stdin *os.File) string {
-	bytepw, err := term.ReadPassword(int(stdin.Fd()))
-	fmt.Println()
-	if err != nil {
-		return ""
-	}
-	return string(bytepw)
+	return credentials.PromptMasked(stdin)
 }
diff --git a/internal/login/login.go b/internal/login/login.go
index 7a61b53d..da053b03 100644
--- a/internal/login/login.go
+++ b/internal/login/login.go
@@ -1,12 +1,8 @@
 package login
 
 import (
-	"bufio"
-	"errors"
 	"fmt"
-	"io"
 	"os"
-	"path/filepath"
 	"strings"
 
 	"github.com/spf13/afero"
@@ -14,41 +10,14 @@ import (
 	"github.com/supabase/cli/internal/utils/credentials"
 )
 
-func Run(stdin io.Reader, fsys afero.Fs) error {
-	fmt.Fprintf(os.Stderr, `You can generate an access token from %s/account/tokens
-Enter your access token: `, utils.GetSupabaseDashboardURL())
-
-	scanner := bufio.NewScanner(stdin)
-	if !scanner.Scan() {
-		fmt.Println("Cancelled " + utils.Aqua("supabase login") + ".")
-		return nil
-	}
-
-	accessToken := strings.TrimSpace(scanner.Text())
-
-	// 1. Validate access token
-	if !utils.AccessTokenPattern.MatchString(accessToken) {
-		return errors.New("Invalid access token format. Must be like `sbp_0102...1920`.")
-	}
-
-	// 2. Save access token
-	if err := credentials.Set(utils.AccessTokenKey, accessToken); err == nil {
-		return nil
-	}
-	return fallbackSaveToken(accessToken, fsys)
+func Run(stdin *os.File, fsys afero.Fs) error {
+	accessToken := PromptAccessToken(stdin)
+	return utils.SaveAccessToken(accessToken, fsys)
 }
 
-func fallbackSaveToken(accessToken string, fsys afero.Fs) error {
-	home, err := os.UserHomeDir()
-	if err != nil {
-		return err
-	}
-
-	configPath := filepath.Join(home, ".supabase")
-	if err := utils.MkdirIfNotExistFS(fsys, configPath); err != nil {
-		return err
-	}
-
-	accessTokenPath := filepath.Join(configPath, utils.AccessTokenKey)
-	return afero.WriteFile(fsys, accessTokenPath, []byte(accessToken), 0600)
+func PromptAccessToken(stdin *os.File) string {
+	fmt.Fprintf(os.Stderr, `You can generate an access token from %s/account/tokens
+Enter your access token: `, utils.GetSupabaseDashboardURL())
+	input := credentials.PromptMasked(stdin)
+	return strings.TrimSpace(input)
 }
diff --git a/internal/utils/access_token.go b/internal/utils/access_token.go
new file mode 100644
index 00000000..26b19e2f
--- /dev/null
+++ b/internal/utils/access_token.go
@@ -0,0 +1,94 @@
+package utils
+
+import (
+	"errors"
+	"os"
+	"path/filepath"
+	"regexp"
+
+	"github.com/spf13/afero"
+	"github.com/supabase/cli/internal/utils/credentials"
+)
+
+var (
+	AccessTokenPattern = regexp.MustCompile(`^sbp_[a-f0-9]{40}$`)
+	ErrInvalidToken    = errors.New("Invalid access token format. Must be like `sbp_0102...1920`.")
+	ErrMissingToken    = errors.New("Access token not provided. Supply an access token by running " + Aqua("supabase login") + " or setting the SUPABASE_ACCESS_TOKEN environment variable.")
+)
+
+const AccessTokenKey = "access-token"
+
+func LoadAccessToken() (string, error) {
+	return LoadAccessTokenFS(afero.NewOsFs())
+}
+
+func LoadAccessTokenFS(fsys afero.Fs) (string, error) {
+	accessToken, err := loadAccessToken(fsys)
+	if err != nil {
+		return "", err
+	}
+	if !AccessTokenPattern.MatchString(accessToken) {
+		return "", ErrInvalidToken
+	}
+	return accessToken, err
+}
+
+func loadAccessToken(fsys afero.Fs) (string, error) {
+	// Env takes precedence
+	if accessToken := os.Getenv("SUPABASE_ACCESS_TOKEN"); accessToken != "" {
+		return accessToken, nil
+	}
+	// Load from native credentials store
+	if accessToken, err := credentials.Get(AccessTokenKey); err == nil {
+		return accessToken, nil
+	}
+	// Fallback to token file
+	return fallbackLoadToken(fsys)
+}
+
+func fallbackLoadToken(fsys afero.Fs) (string, error) {
+	path, err := getAccessTokenPath()
+	if err != nil {
+		return "", err
+	}
+	accessToken, err := afero.ReadFile(fsys, path)
+	if errors.Is(err, os.ErrNotExist) {
+		return "", ErrMissingToken
+	} else if err != nil {
+		return "", err
+	}
+	return string(accessToken), nil
+}
+
+func SaveAccessToken(accessToken string, fsys afero.Fs) error {
+	// Validate access token
+	if !AccessTokenPattern.MatchString(accessToken) {
+		return ErrInvalidToken
+	}
+	// Save to native credentials store
+	if err := credentials.Set(AccessTokenKey, accessToken); err == nil {
+		return nil
+	}
+	// Fallback to token file
+	return fallbackSaveToken(accessToken, fsys)
+}
+
+func fallbackSaveToken(accessToken string, fsys afero.Fs) error {
+	path, err := getAccessTokenPath()
+	if err != nil {
+		return err
+	}
+	if err := MkdirIfNotExistFS(fsys, filepath.Dir(path)); err != nil {
+		return err
+	}
+	return afero.WriteFile(fsys, path, []byte(accessToken), 0600)
+}
+
+func getAccessTokenPath() (string, error) {
+	home, err := os.UserHomeDir()
+	if err != nil {
+		return "", err
+	}
+	// TODO: fallback to workdir
+	return filepath.Join(home, ".supabase", AccessTokenKey), nil
+}
diff --git a/internal/utils/credentials/input.go b/internal/utils/credentials/input.go
new file mode 100644
index 00000000..290b807a
--- /dev/null
+++ b/internal/utils/credentials/input.go
@@ -0,0 +1,30 @@
+package credentials
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"os"
+
+	"golang.org/x/term"
+)
+
+func PromptMasked(stdin *os.File) string {
+	// Start a new line after reading input
+	defer fmt.Println()
+	// Copy if stdin is piped: https://stackoverflow.com/a/26567513
+	if !term.IsTerminal(int(stdin.Fd())) {
+		var buf bytes.Buffer
+		if _, err := io.Copy(&buf, stdin); err != nil {
+			return ""
+		}
+		return buf.String()
+	}
+	// Read with masked tokens
+	bytepw, err := term.ReadPassword(int(stdin.Fd()))
+	if err != nil {
+		fmt.Fprintln(os.Stderr, "Failed to read password:", err)
+		return ""
+	}
+	return string(bytepw)
+}
diff --git a/internal/utils/misc.go b/internal/utils/misc.go
index 31b816c9..a8adfc02 100644
--- a/internal/utils/misc.go
+++ b/internal/utils/misc.go
@@ -20,7 +20,6 @@ import (
 	"time"
 
 	"github.com/spf13/afero"
-	"github.com/supabase/cli/internal/utils/credentials"
 )
 
 // Passed from `-ldflags`: https://stackoverflow.com/q/11354518.
@@ -86,7 +85,6 @@ SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '%[1]s';
 DO 'BEGIN WHILE (
 	SELECT COUNT(*) FROM pg_replication_slots WHERE database = ''%[1]s''
 ) > 0 LOOP END LOOP; END';`
-	AccessTokenKey = "access-token"
 )
 
 var (
@@ -102,7 +100,6 @@ var (
 	//go:embed denos/*
 	denoEmbedDir embed.FS
 
-	AccessTokenPattern = regexp.MustCompile(`^sbp_[a-f0-9]{40}$`)
 	ProjectRefPattern  = regexp.MustCompile(`^[a-z]{20}$`)
 	UUIDPattern        = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
 	ProjectHostPattern = regexp.MustCompile(`^(db\.)[a-z]{20}\.supabase\.(co|red)$`)
@@ -531,37 +528,6 @@ func CopyDenoScripts(ctx context.Context, fsys afero.Fs) (*DenoScriptDir, error)
 	return &sd, nil
 }
 
-func LoadAccessToken() (string, error) {
-	return LoadAccessTokenFS(afero.NewOsFs())
-}
-
-func LoadAccessTokenFS(fsys afero.Fs) (string, error) {
-	// Env takes precedence
-	if accessToken := os.Getenv("SUPABASE_ACCESS_TOKEN"); accessToken != "" {
-		if !AccessTokenPattern.MatchString(accessToken) {
-			return "", errors.New("Invalid access token format. Must be like `sbp_0102...1920`.")
-		}
-		return accessToken, nil
-	}
-	// Load from native credentials store
-	if token, err := credentials.Get(AccessTokenKey); err == nil {
-		return token, nil
-	}
-	// Fallback to home directory
-	home, err := os.UserHomeDir()
-	if err != nil {
-		return "", err
-	}
-	accessTokenPath := filepath.Join(home, ".supabase", AccessTokenKey)
-	accessToken, err := afero.ReadFile(fsys, accessTokenPath)
-	if errors.Is(err, os.ErrNotExist) || string(accessToken) == "" {
-		return "", errors.New("Access token not provided. Supply an access token by running " + Aqua("supabase login") + " or setting the SUPABASE_ACCESS_TOKEN environment variable.")
-	} else if err != nil {
-		return "", err
-	}
-	return string(accessToken), nil
-}
-
 func ValidateFunctionSlug(slug string) error {
 	if !FuncSlugPattern.MatchString(slug) {
 		return errors.New("Invalid Function name. Must start with at least one letter, and only include alphanumeric characters, underscores, and hyphens. (^[A-Za-z][A-Za-z0-9_-]*$)")


Test patch:
diff --git a/internal/login/login_test.go b/internal/login/login_test.go
index f8c50a29..731b295f 100644
--- a/internal/login/login_test.go
+++ b/internal/login/login_test.go
@@ -1,126 +1,36 @@
 package login
 
 import (
-	"bytes"
-	"crypto/rand"
-	"encoding/hex"
-	"io/fs"
 	"os"
-	"path/filepath"
-	"strings"
 	"testing"
 
 	"github.com/spf13/afero"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	"github.com/supabase/cli/internal/testing/apitest"
 	"github.com/supabase/cli/internal/utils"
 	"github.com/supabase/cli/internal/utils/credentials"
 	"github.com/zalando/go-keyring"
 )
 
-func randomToken(t *testing.T) []byte {
-	data := make([]byte, 20)
-	_, err := rand.Read(data)
-	require.NoError(t, err)
-	token := make([]byte, 44)
-	copy(token, "sbp_")
-	hex.Encode(token[4:], data)
-	return token
-}
-
 func TestLoginCommand(t *testing.T) {
 	keyring.MockInit()
 
 	t.Run("prompts and validates api token", func(t *testing.T) {
+		token := apitest.RandomAccessToken(t)
 		// Setup in-memory fs
 		fsys := afero.NewMemMapFs()
-		// Setup input with random token
-		token := randomToken(t)
-		stdin := bytes.NewBuffer(token)
+		// Setup stdin with random token
+		r, w, err := os.Pipe()
+		require.NoError(t, err)
+		_, err = w.Write(token)
+		require.NoError(t, err)
+		require.NoError(t, w.Close())
 		// Run test
-		assert.NoError(t, Run(stdin, fsys))
+		assert.NoError(t, Run(r, fsys))
 		// Validate saved token
 		saved, err := credentials.Get(utils.AccessTokenKey)
 		assert.NoError(t, err)
 		assert.Equal(t, string(token), saved)
 	})
-
-	t.Run("cancels when no input", func(t *testing.T) {
-		// Setup in-memory fs
-		fsys := afero.NewMemMapFs()
-		// Setup dependencies
-		stdin := &bytes.Buffer{}
-		// Run test
-		assert.NoError(t, Run(stdin, fsys))
-	})
-
-	t.Run("throws error on invalid token", func(t *testing.T) {
-		// Setup in-memory fs
-		fsys := afero.NewMemMapFs()
-		// Setup malformed token
-		stdin := bytes.NewBufferString("malformed")
-		// Run test
-		assert.Error(t, Run(stdin, fsys))
-	})
-}
-
-func TestSaveToken(t *testing.T) {
-	const token = "test-token"
-
-	t.Run("fallback saves to file", func(t *testing.T) {
-		// Setup in-memory fs
-		fsys := afero.NewMemMapFs()
-		// Run test
-		assert.NoError(t, fallbackSaveToken(token, fsys))
-		// Validate saved token
-		home, err := os.UserHomeDir()
-		assert.NoError(t, err)
-		path := filepath.Join(home, ".supabase", utils.AccessTokenKey)
-		contents, err := afero.ReadFile(fsys, path)
-		assert.NoError(t, err)
-		assert.Equal(t, []byte(token), contents)
-	})
-
-	t.Run("throws error on home dir failure", func(t *testing.T) {
-		// Setup in-memory fs
-		fsys := afero.NewReadOnlyFs(afero.NewMemMapFs())
-		// Setup empty home directory
-		t.Setenv("HOME", "")
-		// Run test
-		err := fallbackSaveToken(token, fsys)
-		// Check error
-		assert.ErrorContains(t, err, "$HOME is not defined")
-	})
-
-	t.Run("throws error on permission denied", func(t *testing.T) {
-		// Setup in-memory fs
-		fsys := afero.NewReadOnlyFs(afero.NewMemMapFs())
-		// Run test
-		err := fallbackSaveToken(token, fsys)
-		// Check error
-		assert.ErrorContains(t, err, "operation not permitted")
-	})
-
-	t.Run("throws error on write failure", func(t *testing.T) {
-		home, err := os.UserHomeDir()
-		assert.NoError(t, err)
-		// Setup in-memory fs
-		fsys := &MockFs{DenyPath: home}
-		// Run test
-		err = fallbackSaveToken(token, fsys)
-		// Check error
-		assert.ErrorContains(t, err, "permission denied")
-	})
-}
-
-type MockFs struct {
-	afero.MemMapFs
-	DenyPath string
-}
-
-func (m *MockFs) OpenFile(name string, flag int, perm os.FileMode) (afero.File, error) {
-	if strings.HasPrefix(name, m.DenyPath) {
-		return nil, fs.ErrPermission
-	}
-	return m.MemMapFs.OpenFile(name, flag, perm)
 }
diff --git a/internal/utils/access_token_test.go b/internal/utils/access_token_test.go
new file mode 100644
index 00000000..609c7ff0
--- /dev/null
+++ b/internal/utils/access_token_test.go
@@ -0,0 +1,182 @@
+package utils
+
+import (
+	"io/fs"
+	"os"
+	"strings"
+	"testing"
+
+	"github.com/spf13/afero"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/supabase/cli/internal/testing/apitest"
+	"github.com/zalando/go-keyring"
+)
+
+func TestLoadToken(t *testing.T) {
+	keyring.MockInit()
+	token := string(apitest.RandomAccessToken(t))
+
+	t.Run("loads token from env var", func(t *testing.T) {
+		t.Setenv("SUPABASE_ACCESS_TOKEN", token)
+		// Setup in-memory fs
+		fsys := afero.NewMemMapFs()
+		// Run test
+		loaded, err := LoadAccessTokenFS(fsys)
+		// Check error
+		assert.NoError(t, err)
+		assert.Equal(t, token, loaded)
+	})
+
+	t.Run("throws error on invalid token", func(t *testing.T) {
+		t.Setenv("SUPABASE_ACCESS_TOKEN", "invalid")
+		// Setup in-memory fs
+		fsys := afero.NewMemMapFs()
+		// Run test
+		loaded, err := LoadAccessTokenFS(fsys)
+		// Check error
+		assert.ErrorIs(t, err, ErrInvalidToken)
+		assert.Empty(t, loaded)
+	})
+
+	t.Run("throws error on missing token", func(t *testing.T) {
+		// Setup in-memory fs
+		fsys := afero.NewMemMapFs()
+		// Run test
+		loaded, err := LoadAccessTokenFS(fsys)
+		// Check error
+		assert.ErrorIs(t, err, ErrMissingToken)
+		assert.Empty(t, loaded)
+	})
+}
+
+func TestLoadTokenFallback(t *testing.T) {
+	t.Run("fallback loads from file", func(t *testing.T) {
+		path, err := getAccessTokenPath()
+		assert.NoError(t, err)
+		// Setup in-memory fs
+		fsys := afero.NewMemMapFs()
+		require.NoError(t, afero.WriteFile(fsys, path, []byte{}, 0600))
+		// Run test
+		token, err := fallbackLoadToken(fsys)
+		// Check error
+		assert.NoError(t, err)
+		assert.Empty(t, token)
+	})
+
+	t.Run("throws error on home dir failure", func(t *testing.T) {
+		// Setup in-memory fs
+		fsys := afero.NewReadOnlyFs(afero.NewMemMapFs())
+		// Setup empty home directory
+		t.Setenv("HOME", "")
+		// Run test
+		token, err := fallbackLoadToken(fsys)
+		// Check error
+		assert.ErrorContains(t, err, "$HOME is not defined")
+		assert.Empty(t, token)
+	})
+
+	t.Run("throws error on read failure", func(t *testing.T) {
+		path, err := getAccessTokenPath()
+		assert.NoError(t, err)
+		// Setup in-memory fs
+		fsys := &OpenErrorFs{DenyPath: path}
+		// Run test
+		token, err := fallbackLoadToken(fsys)
+		// Check error
+		assert.ErrorContains(t, err, "permission denied")
+		assert.Empty(t, token)
+	})
+}
+
+func TestSaveToken(t *testing.T) {
+	keyring.MockInit()
+	token := string(apitest.RandomAccessToken(t))
+
+	t.Run("saves token to keyring", func(t *testing.T) {
+		// Setup in-memory fs
+		fsys := afero.NewMemMapFs()
+		// Run test
+		assert.NoError(t, SaveAccessToken(token, fsys))
+		// Validate saved token
+		saved, err := LoadAccessTokenFS(fsys)
+		assert.NoError(t, err)
+		assert.Equal(t, token, saved)
+	})
+
+	t.Run("throws error on invalid token", func(t *testing.T) {
+		// Setup in-memory fs
+		fsys := afero.NewMemMapFs()
+		// Run test
+		err := SaveAccessToken("invalid", fsys)
+		// Check error
+		assert.ErrorIs(t, err, ErrInvalidToken)
+	})
+}
+
+func TestSaveTokenFallback(t *testing.T) {
+	token := string(apitest.RandomAccessToken(t))
+
+	t.Run("fallback saves to file", func(t *testing.T) {
+		// Setup in-memory fs
+		fsys := afero.NewMemMapFs()
+		// Run test
+		assert.NoError(t, fallbackSaveToken(token, fsys))
+		// Validate saved token
+		path, err := getAccessTokenPath()
+		assert.NoError(t, err)
+		contents, err := afero.ReadFile(fsys, path)
+		assert.NoError(t, err)
+		assert.Equal(t, []byte(token), contents)
+	})
+
+	t.Run("throws error on home dir failure", func(t *testing.T) {
+		// Setup in-memory fs
+		fsys := afero.NewReadOnlyFs(afero.NewMemMapFs())
+		// Setup empty home directory
+		t.Setenv("HOME", "")
+		// Run test
+		err := fallbackSaveToken(token, fsys)
+		// Check error
+		assert.ErrorContains(t, err, "$HOME is not defined")
+	})
+
+	t.Run("throws error on permission denied", func(t *testing.T) {
+		// Setup in-memory fs
+		fsys := afero.NewReadOnlyFs(afero.NewMemMapFs())
+		// Run test
+		err := fallbackSaveToken(token, fsys)
+		// Check error
+		assert.ErrorContains(t, err, "operation not permitted")
+	})
+
+	t.Run("throws error on write failure", func(t *testing.T) {
+		home, err := os.UserHomeDir()
+		assert.NoError(t, err)
+		// Setup in-memory fs
+		fsys := &OpenErrorFs{DenyPath: home}
+		// Run test
+		err = fallbackSaveToken(token, fsys)
+		// Check error
+		assert.ErrorContains(t, err, "permission denied")
+	})
+}
+
+type OpenErrorFs struct {
+	afero.MemMapFs
+	DenyPath string
+}
+
+func (m *OpenErrorFs) OpenFile(name string, flag int, perm os.FileMode) (afero.File, error) {
+	if strings.HasPrefix(name, m.DenyPath) {
+		return nil, fs.ErrPermission
+	}
+	return m.MemMapFs.OpenFile(name, flag, perm)
+}
+
+func (m *OpenErrorFs) Open(name string) (afero.File, error) {
+	if strings.HasPrefix(name, m.DenyPath) {
+		return nil, fs.ErrPermission
+	}
+	return m.MemMapFs.Open(name)
+}
diff --git a/internal/utils/credentials/input_test.go b/internal/utils/credentials/input_test.go
new file mode 100644
index 00000000..c83d6d3a
--- /dev/null
+++ b/internal/utils/credentials/input_test.go
@@ -0,0 +1,35 @@
+package credentials
+
+import (
+	"os"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestPromptMasked(t *testing.T) {
+	t.Run("reads from piped stdin", func(t *testing.T) {
+		// Setup token
+		r, w, err := os.Pipe()
+		require.NoError(t, err)
+		_, err = w.WriteString("token")
+		require.NoError(t, err)
+		require.NoError(t, w.Close())
+		// Run test
+		input := PromptMasked(r)
+		// Check error
+		assert.Equal(t, "token", input)
+	})
+
+	t.Run("empty string on closed pipe", func(t *testing.T) {
+		// Setup empty stdin
+		r, _, err := os.Pipe()
+		require.NoError(t, err)
+		require.NoError(t, r.Close())
+		// Run test
+		input := PromptMasked(r)
+		// Check error
+		assert.Empty(t, input)
+	})
+}