From bdb2a67ce8f5a3df787ef003ad1c43fb6f58525a Mon Sep 17 00:00:00 2001 From: Zach Kipp Date: Mon, 10 Nov 2025 14:49:02 -0700 Subject: [PATCH 1/2] feat(cli)!: enable keyring usage by default Make keyring usage for session token storage on by default for supported platforms (Windows and macOS), with the ability to opt-out via --use-keyring=false. This change will be a breaking change for any users depending on the session token being stored on disk, though users can restore file usage via the flag above. --- cli/clitest/clitest.go | 12 +- cli/keyring_test.go | 199 ++++++++++++-------- cli/login.go | 6 +- cli/root.go | 12 +- cli/testdata/coder_--help.golden | 7 +- cli/testdata/coder_login_--help.golden | 6 +- docs/reference/cli/index.md | 3 +- docs/reference/cli/login.md | 2 +- enterprise/cli/testdata/coder_--help.golden | 7 +- 9 files changed, 151 insertions(+), 103 deletions(-) diff --git a/cli/clitest/clitest.go b/cli/clitest/clitest.go index 8d1f5302ce7ba..8c23fd39024dd 100644 --- a/cli/clitest/clitest.go +++ b/cli/clitest/clitest.go @@ -66,11 +66,13 @@ func NewWithCommand( Named("cli") i := &serpent.Invocation{ Command: cmd, - Args: append([]string{"--global-config", string(configDir)}, args...), - Stdin: io.LimitReader(nil, 0), - Stdout: (&logWriter{prefix: "stdout", log: logger}), - Stderr: (&logWriter{prefix: "stderr", log: logger}), - Logger: logger, + // Keyring usage is disabled here because many existing tests expect the session token + // to be stored on disk. + Args: append([]string{"--global-config", string(configDir), "--use-keyring=false"}, args...), + Stdin: io.LimitReader(nil, 0), + Stdout: (&logWriter{prefix: "stdout", log: logger}), + Stderr: (&logWriter{prefix: "stderr", log: logger}), + Logger: logger, } t.Logf("invoking command: %s %s", cmd.Name(), strings.Join(i.Args, " ")) diff --git a/cli/keyring_test.go b/cli/keyring_test.go index 646f4ae19a034..618b3579c454d 100644 --- a/cli/keyring_test.go +++ b/cli/keyring_test.go @@ -2,62 +2,76 @@ package cli_test import ( "bytes" + "crypto/rand" + "encoding/binary" + "fmt" "net/url" "os" "path" "runtime" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/cli" "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/cli/sessionstore" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/serpent" ) -// mockKeyring is a mock sessionstore.Backend implementation. -type mockKeyring struct { - credentials map[string]string // service name -> credential +// keyringTestServiceName generates a unique service name for keyring tests +// using the test name and a nanosecond timestamp to prevent collisions. +func keyringTestServiceName(t *testing.T) string { + t.Helper() + var n uint32 + err := binary.Read(rand.Reader, binary.BigEndian, &n) + if err != nil { + t.Fatal(err) + } + return fmt.Sprintf("%s_%v_%d", t.Name(), time.Now().UnixNano(), n) } -const mockServiceName = "mock-service-name" +// instrumentKeyring sets up the CLI invocation to use the actual OS keyring +// with a unique test service name to allow test parallelization. It returns +// the backend and URL for verification of keyring contents in tests. +func instrumentKeyring(t *testing.T, inv *serpent.Invocation, serverURL string) (sessionstore.Backend, *url.URL) { + t.Helper() -func newMockKeyring() *mockKeyring { - return &mockKeyring{credentials: make(map[string]string)} -} + serviceName := keyringTestServiceName(t) + backend := sessionstore.NewKeyringWithService(serviceName) -func (m *mockKeyring) Read(_ *url.URL) (string, error) { - cred, ok := m.credentials[mockServiceName] - if !ok { - return "", os.ErrNotExist - } - return cred, nil -} + srvURL, err := url.Parse(serverURL) + require.NoError(t, err) -func (m *mockKeyring) Write(_ *url.URL, token string) error { - m.credentials[mockServiceName] = token - return nil -} + t.Cleanup(func() { + _ = backend.Delete(srvURL) + }) -func (m *mockKeyring) Delete(_ *url.URL) error { - _, ok := m.credentials[mockServiceName] - if !ok { - return os.ErrNotExist - } - delete(m.credentials, mockServiceName) - return nil + var root cli.RootCmd + cmd, err := root.Command(root.AGPL()) + require.NoError(t, err) + root.WithSessionStorageBackend(backend) + inv.Command = cmd + + return backend, srvURL } func TestUseKeyring(t *testing.T) { - // Verify that the --use-keyring flag opts into using a keyring backend for - // storing session tokens instead of plain text files. + // Verify that the --use-keyring flag default opts into using a keyring backend + // for storing session tokens instead of plain text files. t.Parallel() t.Run("Login", func(t *testing.T) { t.Parallel() + if runtime.GOOS != "windows" && runtime.GOOS != "darwin" { + t.Skip("keyring is not supported on this OS") + } + // Create a test server client := coderdtest.New(t, nil) coderdtest.CreateFirstUser(t, client) @@ -65,24 +79,17 @@ func TestUseKeyring(t *testing.T) { // Create a pty for interactive prompts pty := ptytest.New(t) - // Create CLI invocation with --use-keyring flag + // Create CLI invocation which defaults to using the keyring inv, cfg := clitest.New(t, "login", "--force-tty", - "--use-keyring", "--no-open", client.URL.String(), ) inv.Stdin = pty.Input() inv.Stdout = pty.Output() - // Inject the mock backend before running the command - var root cli.RootCmd - cmd, err := root.Command(root.AGPL()) - require.NoError(t, err) - mockBackend := newMockKeyring() - root.WithSessionStorageBackend(mockBackend) - inv.Command = cmd + backend, srvURL := instrumentKeyring(t, inv, client.URL.String()) // Run login in background doneChan := make(chan struct{}) @@ -100,18 +107,22 @@ func TestUseKeyring(t *testing.T) { // Verify that session file was NOT created (using keyring instead) sessionFile := path.Join(string(cfg), "session") - _, err = os.Stat(sessionFile) + _, err := os.Stat(sessionFile) require.True(t, os.IsNotExist(err), "session file should not exist when using keyring") - // Verify that the credential IS stored in mock keyring - cred, err := mockBackend.Read(nil) - require.NoError(t, err, "credential should be stored in mock keyring") + // Verify that the credential IS stored in OS keyring + cred, err := backend.Read(srvURL) + require.NoError(t, err, "credential should be stored in OS keyring") require.Equal(t, client.SessionToken(), cred, "stored token should match login token") }) t.Run("Logout", func(t *testing.T) { t.Parallel() + if runtime.GOOS != "windows" && runtime.GOOS != "darwin" { + t.Skip("keyring is not supported on this OS") + } + // Create a test server client := coderdtest.New(t, nil) coderdtest.CreateFirstUser(t, client) @@ -119,24 +130,17 @@ func TestUseKeyring(t *testing.T) { // Create a pty for interactive prompts pty := ptytest.New(t) - // First, login with --use-keyring + // First, login with the keyring (default) loginInv, cfg := clitest.New(t, "login", "--force-tty", - "--use-keyring", "--no-open", client.URL.String(), ) loginInv.Stdin = pty.Input() loginInv.Stdout = pty.Output() - // Inject the mock backend - var loginRoot cli.RootCmd - loginCmd, err := loginRoot.Command(loginRoot.AGPL()) - require.NoError(t, err) - mockBackend := newMockKeyring() - loginRoot.WithSessionStorageBackend(mockBackend) - loginInv.Command = loginCmd + backend, srvURL := instrumentKeyring(t, loginInv, client.URL.String()) doneChan := make(chan struct{}) go func() { @@ -150,24 +154,23 @@ func TestUseKeyring(t *testing.T) { pty.ExpectMatch("Welcome to Coder") <-doneChan - // Verify credential exists in mock keyring - cred, err := mockBackend.Read(nil) + // Verify credential exists in OS keyring + cred, err := backend.Read(srvURL) require.NoError(t, err, "read credential should succeed before logout") - require.NotEmpty(t, cred, "credential should exist after logout") + require.NotEmpty(t, cred, "credential should exist before logout") - // Now run logout with --use-keyring + // Now logout logoutInv, _ := clitest.New(t, "logout", - "--use-keyring", "--yes", "--global-config", string(cfg), ) - // Inject the same mock backend + // Instrument logout with the same backend var logoutRoot cli.RootCmd logoutCmd, err := logoutRoot.Command(logoutRoot.AGPL()) require.NoError(t, err) - logoutRoot.WithSessionStorageBackend(mockBackend) + logoutRoot.WithSessionStorageBackend(backend) logoutInv.Command = logoutCmd var logoutOut bytes.Buffer @@ -176,14 +179,18 @@ func TestUseKeyring(t *testing.T) { err = logoutInv.Run() require.NoError(t, err, "logout should succeed") - // Verify the credential was deleted from mock keyring - _, err = mockBackend.Read(nil) + // Verify the credential was deleted from OS keyring + _, err = backend.Read(srvURL) require.ErrorIs(t, err, os.ErrNotExist, "credential should be deleted from keyring after logout") }) - t.Run("OmitFlag", func(t *testing.T) { + t.Run("DefaultFileStorage", func(t *testing.T) { t.Parallel() + if runtime.GOOS != "linux" { + t.Skip("file storage is the default for Linux") + } + // Create a test server client := coderdtest.New(t, nil) coderdtest.CreateFirstUser(t, client) @@ -191,7 +198,6 @@ func TestUseKeyring(t *testing.T) { // Create a pty for interactive prompts pty := ptytest.New(t) - // --use-keyring flag omitted (should use file-based storage) inv, cfg := clitest.New(t, "login", "--force-tty", @@ -216,7 +222,7 @@ func TestUseKeyring(t *testing.T) { // Verify that session file WAS created (not using keyring) sessionFile := path.Join(string(cfg), "session") _, err := os.Stat(sessionFile) - require.NoError(t, err, "session file should exist when NOT using --use-keyring") + require.NoError(t, err, "session file should exist when NOT using --use-keyring on Linux") // Read and verify the token from file content, err := os.ReadFile(sessionFile) @@ -234,7 +240,8 @@ func TestUseKeyring(t *testing.T) { // Create a pty for interactive prompts pty := ptytest.New(t) - // Login using CODER_USE_KEYRING environment variable instead of flag + // Login using CODER_USE_KEYRING environment variable set to disable keyring usage, + // which should have the same behavior on all platforms. inv, cfg := clitest.New(t, "login", "--force-tty", @@ -243,15 +250,49 @@ func TestUseKeyring(t *testing.T) { ) inv.Stdin = pty.Input() inv.Stdout = pty.Output() - inv.Environ.Set("CODER_USE_KEYRING", "true") + inv.Environ.Set("CODER_USE_KEYRING", "false") - // Inject the mock backend - var root cli.RootCmd - cmd, err := root.Command(root.AGPL()) - require.NoError(t, err) - mockBackend := newMockKeyring() - root.WithSessionStorageBackend(mockBackend) - inv.Command = cmd + doneChan := make(chan struct{}) + go func() { + defer close(doneChan) + err := inv.Run() + assert.NoError(t, err) + }() + + pty.ExpectMatch("Paste your token here:") + pty.WriteLine(client.SessionToken()) + pty.ExpectMatch("Welcome to Coder") + <-doneChan + + // Verify that session file WAS created (not using keyring) + sessionFile := path.Join(string(cfg), "session") + _, err := os.Stat(sessionFile) + require.NoError(t, err, "session file should exist when CODER_USE_KEYRING set to false") + + // Read and verify the token from file + content, err := os.ReadFile(sessionFile) + require.NoError(t, err, "should be able to read session file") + require.Equal(t, client.SessionToken(), string(content), "file should contain the session token") + }) + + t.Run("DisableKeyringWithFlag", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + coderdtest.CreateFirstUser(t, client) + pty := ptytest.New(t) + + // Login with --use-keyring=false to explicitly disable keyring usage, which + // should have the same behavior on all platforms. + inv, cfg := clitest.New(t, + "login", + "--use-keyring=false", + "--force-tty", + "--no-open", + client.URL.String(), + ) + inv.Stdin = pty.Input() + inv.Stdout = pty.Output() doneChan := make(chan struct{}) go func() { @@ -265,15 +306,15 @@ func TestUseKeyring(t *testing.T) { pty.ExpectMatch("Welcome to Coder") <-doneChan - // Verify that session file was NOT created (using keyring via env var) + // Verify that session file WAS created (not using keyring) sessionFile := path.Join(string(cfg), "session") - _, err = os.Stat(sessionFile) - require.True(t, os.IsNotExist(err), "session file should not exist when using keyring via env var") + _, err := os.Stat(sessionFile) + require.NoError(t, err, "session file should exist when --use-keyring=false is specified") - // Verify credential is in mock keyring - cred, err := mockBackend.Read(nil) - require.NoError(t, err, "credential should be stored in keyring when CODER_USE_KEYRING=true") - require.NotEmpty(t, cred) + // Read and verify the token from file + content, err := os.ReadFile(sessionFile) + require.NoError(t, err, "should be able to read session file") + require.Equal(t, client.SessionToken(), string(content), "file should contain the session token") }) } @@ -287,7 +328,7 @@ func TestUseKeyringUnsupportedOS(t *testing.T) { t.Skipf("Skipping unsupported OS test on %s where keyring is supported", runtime.GOOS) } - const expMessage = "keyring storage is not supported on this operating system; remove the --use-keyring flag" + const expMessage = "keyring storage is not supported on this operating system; omit --use-keyring to use file-based storage" t.Run("LoginWithUnsupportedKeyring", func(t *testing.T) { t.Parallel() @@ -317,7 +358,7 @@ func TestUseKeyringUnsupportedOS(t *testing.T) { coderdtest.CreateFirstUser(t, client) pty := ptytest.New(t) - // First login without keyring to create a session + // First login without keyring to create a session (default behavior) loginInv, cfg := clitest.New(t, "login", "--force-tty", diff --git a/cli/login.go b/cli/login.go index 1a95d0403588f..d95eb7475dedd 100644 --- a/cli/login.go +++ b/cli/login.go @@ -154,9 +154,9 @@ func (r *RootCmd) login() *serpent.Command { cmd := &serpent.Command{ Use: "login []", Short: "Authenticate with Coder deployment", - Long: "By default, the session token is stored in a plain text file. Use the " + - "--use-keyring flag or set CODER_USE_KEYRING=true to store the token in " + - "the operating system keyring instead.", + Long: "By default, the session token is stored in the operating system keyring on " + + "macOS and Windows and a plain text file on Linux. Use the --use-keyring flag " + + "or CODER_USE_KEYRING environment variable to change the storage mechanism.", Middleware: serpent.RequireRangeArgs(0, 1), Handler: func(inv *serpent.Invocation) error { ctx := inv.Context() diff --git a/cli/root.go b/cli/root.go index fe6d5c4ccd8a9..d64c97effd2c3 100644 --- a/cli/root.go +++ b/cli/root.go @@ -56,7 +56,7 @@ var ( // anything. ErrSilent = xerrors.New("silent error") - errKeyringNotSupported = xerrors.New("keyring storage is not supported on this operating system; remove the --use-keyring flag to use file-based storage") + errKeyringNotSupported = xerrors.New("keyring storage is not supported on this operating system; omit --use-keyring to use file-based storage") ) const ( @@ -483,10 +483,12 @@ func (r *RootCmd) Command(subcommands []*serpent.Command) (*serpent.Command, err Flag: varUseKeyring, Env: envUseKeyring, Description: "Store and retrieve session tokens using the operating system " + - "keyring. Currently only supported on Windows. By default, tokens are " + - "stored in plain text files.", - Value: serpent.BoolOf(&r.useKeyring), - Group: globalGroup, + "keyring. Enabled by default. If the keyring is not supported on the " + + "current platform, file-based storage is used automatically. Set to " + + "false to force file-based storage.", + Default: "true", + Value: serpent.BoolOf(&r.useKeyring), + Group: globalGroup, }, { Flag: "debug-http", diff --git a/cli/testdata/coder_--help.golden b/cli/testdata/coder_--help.golden index e457aca857070..e3cfec70c0f99 100644 --- a/cli/testdata/coder_--help.golden +++ b/cli/testdata/coder_--help.golden @@ -108,10 +108,11 @@ variables or flags. --url url, $CODER_URL URL to a deployment. - --use-keyring bool, $CODER_USE_KEYRING + --use-keyring bool, $CODER_USE_KEYRING (default: true) Store and retrieve session tokens using the operating system keyring. - Currently only supported on Windows. By default, tokens are stored in - plain text files. + Enabled by default. If the keyring is not supported on the current + platform, file-based storage is used automatically. Set to false to + force file-based storage. -v, --verbose bool, $CODER_VERBOSE Enable verbose output. diff --git a/cli/testdata/coder_login_--help.golden b/cli/testdata/coder_login_--help.golden index 8709f60987cc7..96129d8a55c57 100644 --- a/cli/testdata/coder_login_--help.golden +++ b/cli/testdata/coder_login_--help.golden @@ -5,9 +5,9 @@ USAGE: Authenticate with Coder deployment - By default, the session token is stored in a plain text file. Use the - --use-keyring flag or set CODER_USE_KEYRING=true to store the token in the - operating system keyring instead. + By default, the session token is stored in the operating system keyring on + macOS and Windows and a plain text file on Linux. Use the --use-keyring flag + or CODER_USE_KEYRING environment variable to change the storage mechanism. OPTIONS: --first-user-email string, $CODER_FIRST_USER_EMAIL diff --git a/docs/reference/cli/index.md b/docs/reference/cli/index.md index 1005da991dc4f..72fbd608225da 100644 --- a/docs/reference/cli/index.md +++ b/docs/reference/cli/index.md @@ -176,8 +176,9 @@ Disable network telemetry. Network telemetry is collected when connecting to wor |-------------|---------------------------------| | Type | bool | | Environment | $CODER_USE_KEYRING | +| Default | true | -Store and retrieve session tokens using the operating system keyring. Currently only supported on Windows. By default, tokens are stored in plain text files. +Store and retrieve session tokens using the operating system keyring. Enabled by default. If the keyring is not supported on the current platform, file-based storage is used automatically. Set to false to force file-based storage. ### --global-config diff --git a/docs/reference/cli/login.md b/docs/reference/cli/login.md index 459a332e06270..1371ebae1bf2f 100644 --- a/docs/reference/cli/login.md +++ b/docs/reference/cli/login.md @@ -12,7 +12,7 @@ coder login [flags] [] ## Description ```console -By default, the session token is stored in a plain text file. Use the --use-keyring flag or set CODER_USE_KEYRING=true to store the token in the operating system keyring instead. +By default, the session token is stored in the operating system keyring on macOS and Windows and a plain text file on Linux. Use the --use-keyring flag or CODER_USE_KEYRING environment variable to change the storage mechanism. ``` ## Options diff --git a/enterprise/cli/testdata/coder_--help.golden b/enterprise/cli/testdata/coder_--help.golden index 51ee58258f8e4..9c24639db1fd0 100644 --- a/enterprise/cli/testdata/coder_--help.golden +++ b/enterprise/cli/testdata/coder_--help.golden @@ -68,10 +68,11 @@ variables or flags. --url url, $CODER_URL URL to a deployment. - --use-keyring bool, $CODER_USE_KEYRING + --use-keyring bool, $CODER_USE_KEYRING (default: true) Store and retrieve session tokens using the operating system keyring. - Currently only supported on Windows. By default, tokens are stored in - plain text files. + Enabled by default. If the keyring is not supported on the current + platform, file-based storage is used automatically. Set to false to + force file-based storage. -v, --verbose bool, $CODER_VERBOSE Enable verbose output. From ccc35d5f54a263d123f114ded2c26461cb0b67fd Mon Sep 17 00:00:00 2001 From: Zach Kipp Date: Mon, 24 Nov 2025 09:54:39 -0700 Subject: [PATCH 2/2] fix: init file backend for unsupported OS --- cli/clitest/clitest.go | 34 +++++-- cli/keyring_test.go | 160 ++++++++++++++++++------------- cli/root.go | 17 +++- cli/sessionstore/sessionstore.go | 16 +--- 4 files changed, 135 insertions(+), 92 deletions(-) diff --git a/cli/clitest/clitest.go b/cli/clitest/clitest.go index 8c23fd39024dd..20db312101814 100644 --- a/cli/clitest/clitest.go +++ b/cli/clitest/clitest.go @@ -28,7 +28,9 @@ import ( ) // New creates a CLI instance with a configuration pointed to a -// temporary testing directory. +// temporary testing directory. The invocation is set up to use a +// global config directory for the given testing.TB, and keyring +// usage disabled. func New(t testing.TB, args ...string) (*serpent.Invocation, config.Root) { var root cli.RootCmd @@ -59,6 +61,15 @@ func NewWithCommand( t testing.TB, cmd *serpent.Command, args ...string, ) (*serpent.Invocation, config.Root) { configDir := config.Root(t.TempDir()) + // Keyring usage is disabled here because many existing tests expect the session token + // to be stored on disk and is not properly instrumented for parallel testing against + // the actual operating system keyring. + invArgs := append([]string{"--global-config", string(configDir), "--use-keyring=false"}, args...) + return setupInvocation(t, cmd, invArgs...), configDir +} + +func setupInvocation(t testing.TB, cmd *serpent.Command, args ...string, +) *serpent.Invocation { // I really would like to fail test on error logs, but realistically, turning on by default // in all our CLI tests is going to create a lot of flaky noise. logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}). @@ -66,18 +77,21 @@ func NewWithCommand( Named("cli") i := &serpent.Invocation{ Command: cmd, - // Keyring usage is disabled here because many existing tests expect the session token - // to be stored on disk. - Args: append([]string{"--global-config", string(configDir), "--use-keyring=false"}, args...), - Stdin: io.LimitReader(nil, 0), - Stdout: (&logWriter{prefix: "stdout", log: logger}), - Stderr: (&logWriter{prefix: "stderr", log: logger}), - Logger: logger, + Args: args, + Stdin: io.LimitReader(nil, 0), + Stdout: (&logWriter{prefix: "stdout", log: logger}), + Stderr: (&logWriter{prefix: "stderr", log: logger}), + Logger: logger, } t.Logf("invoking command: %s %s", cmd.Name(), strings.Join(i.Args, " ")) + return i +} - // These can be overridden by the test. - return i, configDir +func NewWithDefaultKeyringCommand(t testing.TB, cmd *serpent.Command, args ...string, +) (*serpent.Invocation, config.Root) { + configDir := config.Root(t.TempDir()) + invArgs := append([]string{"--global-config", string(configDir)}, args...) + return setupInvocation(t, cmd, invArgs...), configDir } // SetupConfig applies the URL and SessionToken of the client to the config. diff --git a/cli/keyring_test.go b/cli/keyring_test.go index 618b3579c454d..27b7c12d53cb0 100644 --- a/cli/keyring_test.go +++ b/cli/keyring_test.go @@ -17,6 +17,7 @@ import ( "github.com/coder/coder/v2/cli" "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/cli/config" "github.com/coder/coder/v2/cli/sessionstore" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/pty/ptytest" @@ -35,29 +36,36 @@ func keyringTestServiceName(t *testing.T) string { return fmt.Sprintf("%s_%v_%d", t.Name(), time.Now().UnixNano(), n) } -// instrumentKeyring sets up the CLI invocation to use the actual OS keyring -// with a unique test service name to allow test parallelization. It returns -// the backend and URL for verification of keyring contents in tests. -func instrumentKeyring(t *testing.T, inv *serpent.Invocation, serverURL string) (sessionstore.Backend, *url.URL) { +type keyringTestEnv struct { + serviceName string + keyring sessionstore.Keyring + inv *serpent.Invocation + cfg config.Root + clientURL *url.URL +} + +func setupKeyringTestEnv(t *testing.T, clientURL string, args ...string) keyringTestEnv { t.Helper() + var root cli.RootCmd + + cmd, err := root.Command(root.AGPL()) + require.NoError(t, err) + serviceName := keyringTestServiceName(t) - backend := sessionstore.NewKeyringWithService(serviceName) + root.WithKeyringServiceName(serviceName) + + inv, cfg := clitest.NewWithDefaultKeyringCommand(t, cmd, args...) - srvURL, err := url.Parse(serverURL) + parsedURL, err := url.Parse(clientURL) require.NoError(t, err) + backend := sessionstore.NewKeyringWithService(serviceName) t.Cleanup(func() { - _ = backend.Delete(srvURL) + _ = backend.Delete(parsedURL) }) - var root cli.RootCmd - cmd, err := root.Command(root.AGPL()) - require.NoError(t, err) - root.WithSessionStorageBackend(backend) - inv.Command = cmd - - return backend, srvURL + return keyringTestEnv{serviceName, backend, inv, cfg, parsedURL} } func TestUseKeyring(t *testing.T) { @@ -80,17 +88,15 @@ func TestUseKeyring(t *testing.T) { pty := ptytest.New(t) // Create CLI invocation which defaults to using the keyring - inv, cfg := clitest.New(t, + env := setupKeyringTestEnv(t, client.URL.String(), "login", "--force-tty", "--no-open", - client.URL.String(), - ) + client.URL.String()) + inv := env.inv inv.Stdin = pty.Input() inv.Stdout = pty.Output() - backend, srvURL := instrumentKeyring(t, inv, client.URL.String()) - // Run login in background doneChan := make(chan struct{}) go func() { @@ -106,12 +112,12 @@ func TestUseKeyring(t *testing.T) { <-doneChan // Verify that session file was NOT created (using keyring instead) - sessionFile := path.Join(string(cfg), "session") + sessionFile := path.Join(string(env.cfg), "session") _, err := os.Stat(sessionFile) require.True(t, os.IsNotExist(err), "session file should not exist when using keyring") // Verify that the credential IS stored in OS keyring - cred, err := backend.Read(srvURL) + cred, err := env.keyring.Read(env.clientURL) require.NoError(t, err, "credential should be stored in OS keyring") require.Equal(t, client.SessionToken(), cred, "stored token should match login token") }) @@ -131,17 +137,16 @@ func TestUseKeyring(t *testing.T) { pty := ptytest.New(t) // First, login with the keyring (default) - loginInv, cfg := clitest.New(t, + env := setupKeyringTestEnv(t, client.URL.String(), "login", "--force-tty", "--no-open", client.URL.String(), ) + loginInv := env.inv loginInv.Stdin = pty.Input() loginInv.Stdout = pty.Output() - backend, srvURL := instrumentKeyring(t, loginInv, client.URL.String()) - doneChan := make(chan struct{}) go func() { defer close(doneChan) @@ -155,23 +160,21 @@ func TestUseKeyring(t *testing.T) { <-doneChan // Verify credential exists in OS keyring - cred, err := backend.Read(srvURL) + cred, err := env.keyring.Read(env.clientURL) require.NoError(t, err, "read credential should succeed before logout") require.NotEmpty(t, cred, "credential should exist before logout") - // Now logout - logoutInv, _ := clitest.New(t, - "logout", - "--yes", - "--global-config", string(cfg), - ) - - // Instrument logout with the same backend + // Now logout using the same keyring service name var logoutRoot cli.RootCmd logoutCmd, err := logoutRoot.Command(logoutRoot.AGPL()) require.NoError(t, err) - logoutRoot.WithSessionStorageBackend(backend) - logoutInv.Command = logoutCmd + logoutRoot.WithKeyringServiceName(env.serviceName) + + logoutInv, _ := clitest.NewWithDefaultKeyringCommand(t, logoutCmd, + "logout", + "--yes", + "--global-config", string(env.cfg), + ) var logoutOut bytes.Buffer logoutInv.Stdout = &logoutOut @@ -180,7 +183,7 @@ func TestUseKeyring(t *testing.T) { require.NoError(t, err, "logout should succeed") // Verify the credential was deleted from OS keyring - _, err = backend.Read(srvURL) + _, err = env.keyring.Read(env.clientURL) require.ErrorIs(t, err, os.ErrNotExist, "credential should be deleted from keyring after logout") }) @@ -198,12 +201,13 @@ func TestUseKeyring(t *testing.T) { // Create a pty for interactive prompts pty := ptytest.New(t) - inv, cfg := clitest.New(t, + env := setupKeyringTestEnv(t, client.URL.String(), "login", "--force-tty", "--no-open", client.URL.String(), ) + inv := env.inv inv.Stdin = pty.Input() inv.Stdout = pty.Output() @@ -220,7 +224,7 @@ func TestUseKeyring(t *testing.T) { <-doneChan // Verify that session file WAS created (not using keyring) - sessionFile := path.Join(string(cfg), "session") + sessionFile := path.Join(string(env.cfg), "session") _, err := os.Stat(sessionFile) require.NoError(t, err, "session file should exist when NOT using --use-keyring on Linux") @@ -242,12 +246,13 @@ func TestUseKeyring(t *testing.T) { // Login using CODER_USE_KEYRING environment variable set to disable keyring usage, // which should have the same behavior on all platforms. - inv, cfg := clitest.New(t, + env := setupKeyringTestEnv(t, client.URL.String(), "login", "--force-tty", "--no-open", client.URL.String(), ) + inv := env.inv inv.Stdin = pty.Input() inv.Stdout = pty.Output() inv.Environ.Set("CODER_USE_KEYRING", "false") @@ -265,7 +270,7 @@ func TestUseKeyring(t *testing.T) { <-doneChan // Verify that session file WAS created (not using keyring) - sessionFile := path.Join(string(cfg), "session") + sessionFile := path.Join(string(env.cfg), "session") _, err := os.Stat(sessionFile) require.NoError(t, err, "session file should exist when CODER_USE_KEYRING set to false") @@ -284,13 +289,14 @@ func TestUseKeyring(t *testing.T) { // Login with --use-keyring=false to explicitly disable keyring usage, which // should have the same behavior on all platforms. - inv, cfg := clitest.New(t, + env := setupKeyringTestEnv(t, client.URL.String(), "login", "--use-keyring=false", "--force-tty", "--no-open", client.URL.String(), ) + inv := env.inv inv.Stdin = pty.Input() inv.Stdout = pty.Output() @@ -307,7 +313,7 @@ func TestUseKeyring(t *testing.T) { <-doneChan // Verify that session file WAS created (not using keyring) - sessionFile := path.Join(string(cfg), "session") + sessionFile := path.Join(string(env.cfg), "session") _, err := os.Stat(sessionFile) require.NoError(t, err, "session file should exist when --use-keyring=false is specified") @@ -319,8 +325,8 @@ func TestUseKeyring(t *testing.T) { } func TestUseKeyringUnsupportedOS(t *testing.T) { - // Verify that trying to use --use-keyring on an unsupported operating system produces - // a helpful error message. + // Verify that on unsupported operating systems, file-based storage is used + // automatically even when --use-keyring is set to true (the default). t.Parallel() // Only run this on an unsupported OS. @@ -328,43 +334,60 @@ func TestUseKeyringUnsupportedOS(t *testing.T) { t.Skipf("Skipping unsupported OS test on %s where keyring is supported", runtime.GOOS) } - const expMessage = "keyring storage is not supported on this operating system; omit --use-keyring to use file-based storage" - - t.Run("LoginWithUnsupportedKeyring", func(t *testing.T) { + t.Run("LoginWithDefaultKeyring", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) coderdtest.CreateFirstUser(t, client) + pty := ptytest.New(t) - // Try to login with --use-keyring on an unsupported OS - inv, _ := clitest.New(t, + env := setupKeyringTestEnv(t, client.URL.String(), "login", - "--use-keyring", + "--force-tty", + "--no-open", client.URL.String(), ) + inv := env.inv + inv.Stdin = pty.Input() + inv.Stdout = pty.Output() - // The error should occur immediately, before any prompts - loginErr := inv.Run() + doneChan := make(chan struct{}) + go func() { + defer close(doneChan) + err := inv.Run() + assert.NoError(t, err) + }() - // Verify we got an error about unsupported OS - require.Error(t, loginErr) - require.Contains(t, loginErr.Error(), expMessage) + pty.ExpectMatch("Paste your token here:") + pty.WriteLine(client.SessionToken()) + pty.ExpectMatch("Welcome to Coder") + <-doneChan + + // Verify that session file WAS created (automatic fallback to file storage) + sessionFile := path.Join(string(env.cfg), "session") + _, err := os.Stat(sessionFile) + require.NoError(t, err, "session file should exist due to automatic fallback to file storage") + + content, err := os.ReadFile(sessionFile) + require.NoError(t, err, "should be able to read session file") + require.Equal(t, client.SessionToken(), string(content), "file should contain the session token") }) - t.Run("LogoutWithUnsupportedKeyring", func(t *testing.T) { + t.Run("LogoutWithDefaultKeyring", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) coderdtest.CreateFirstUser(t, client) pty := ptytest.New(t) - // First login without keyring to create a session (default behavior) - loginInv, cfg := clitest.New(t, + // First login to create a session (will use file storage due to automatic fallback) + env := setupKeyringTestEnv(t, client.URL.String(), "login", "--force-tty", "--no-open", client.URL.String(), ) + loginInv := env.inv loginInv.Stdin = pty.Input() loginInv.Stdout = pty.Output() @@ -380,17 +403,22 @@ func TestUseKeyringUnsupportedOS(t *testing.T) { pty.ExpectMatch("Welcome to Coder") <-doneChan - // Now try to logout with --use-keyring on an unsupported OS - logoutInv, _ := clitest.New(t, + // Verify session file exists + sessionFile := path.Join(string(env.cfg), "session") + _, err := os.Stat(sessionFile) + require.NoError(t, err, "session file should exist before logout") + + // Now logout - should succeed and delete the file + logoutEnv := setupKeyringTestEnv(t, client.URL.String(), "logout", - "--use-keyring", "--yes", - "--global-config", string(cfg), + "--global-config", string(env.cfg), ) - err := logoutInv.Run() - // Verify we got an error about unsupported OS - require.Error(t, err) - require.Contains(t, err.Error(), expMessage) + err = logoutEnv.inv.Run() + require.NoError(t, err, "logout should succeed with automatic file storage fallback") + + _, err = os.Stat(sessionFile) + require.True(t, os.IsNotExist(err), "session file should be deleted after logout") }) } diff --git a/cli/root.go b/cli/root.go index d64c97effd2c3..ea1401c3c250a 100644 --- a/cli/root.go +++ b/cli/root.go @@ -540,6 +540,7 @@ type RootCmd struct { noVersionCheck bool noFeatureWarning bool useKeyring bool + keyringServiceName string } // InitClient creates and configures a new client with authentication, telemetry, @@ -720,8 +721,13 @@ func (r *RootCmd) createUnauthenticatedClient(ctx context.Context, serverURL *ur // flag. func (r *RootCmd) ensureTokenBackend() sessionstore.Backend { if r.tokenBackend == nil { - if r.useKeyring { - r.tokenBackend = sessionstore.NewKeyring() + keyringSupported := runtime.GOOS == "windows" || runtime.GOOS == "darwin" + if r.useKeyring && keyringSupported { + serviceName := sessionstore.DefaultServiceName + if r.keyringServiceName != "" { + serviceName = r.keyringServiceName + } + r.tokenBackend = sessionstore.NewKeyringWithService(serviceName) } else { r.tokenBackend = sessionstore.NewFile(r.createConfig) } @@ -729,8 +735,11 @@ func (r *RootCmd) ensureTokenBackend() sessionstore.Backend { return r.tokenBackend } -func (r *RootCmd) WithSessionStorageBackend(backend sessionstore.Backend) { - r.tokenBackend = backend +// WithKeyringServiceName sets a custom keyring service name for testing purposes. +// This allows tests to use isolated keyring storage while still exercising the +// genuine storage backend selection logic in ensureTokenBackend(). +func (r *RootCmd) WithKeyringServiceName(serviceName string) { + r.keyringServiceName = serviceName } type AgentAuth struct { diff --git a/cli/sessionstore/sessionstore.go b/cli/sessionstore/sessionstore.go index 029e86ad7e99b..57f1c269bf8cc 100644 --- a/cli/sessionstore/sessionstore.go +++ b/cli/sessionstore/sessionstore.go @@ -47,9 +47,9 @@ var ( ) const ( - // defaultServiceName is the service name used in keyrings for storing Coder CLI session + // DefaultServiceName is the service name used in keyrings for storing Coder CLI session // tokens. - defaultServiceName = "coder-v2-credentials" + DefaultServiceName = "coder-v2-credentials" ) // keyringProvider represents an operating system keyring. The expectation @@ -108,17 +108,9 @@ type Keyring struct { serviceName string } -// NewKeyring creates a Keyring with the default service name for production use. -func NewKeyring() Keyring { - return Keyring{ - provider: operatingSystemKeyring{}, - serviceName: defaultServiceName, - } -} - // NewKeyringWithService creates a Keyring Backend that stores credentials under the -// specified service name. This is primarily intended for testing to avoid conflicts -// with production credentials and collisions between tests. +// specified service name. Generally, DefaultServiceName should be provided as the service +// name except in tests which may need parameterization to avoid conflicting keyring use. func NewKeyringWithService(serviceName string) Keyring { return Keyring{ provider: operatingSystemKeyring{},