mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
bbf7b137da
This fixes a regression that caused the VS code extension to be unable to authenticate after making keyring usage on by default. This is because the VS code extension assumes the CLI will always use the session token stored on disk, specifically in the directory specified by --global-config. This fix makes keyring usage enabled when the --global-config directory is not set. This is a bit wonky but necessary to allow the extension to continue working without modification and without backwards compat concerns. In the future we should modify these extensions to either access the credential in the keyring (like Coder Desktop) or some other approach that doesn't rely on the session token being stored on disk. Tests: `coder login dev.coder.com` -> token stored in keyring `coder login --global-config=/tmp/ dev.coder.com` -> token stored in `/tmp/session`
427 lines
12 KiB
Go
427 lines
12 KiB
Go
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/config"
|
|
"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"
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
|
|
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)
|
|
root.WithKeyringServiceName(serviceName)
|
|
root.UseKeyringWithGlobalConfig()
|
|
|
|
inv, cfg := clitest.NewWithDefaultKeyringCommand(t, cmd, args...)
|
|
|
|
parsedURL, err := url.Parse(clientURL)
|
|
require.NoError(t, err)
|
|
|
|
backend := sessionstore.NewKeyringWithService(serviceName)
|
|
t.Cleanup(func() {
|
|
_ = backend.Delete(parsedURL)
|
|
})
|
|
|
|
return keyringTestEnv{serviceName, backend, inv, cfg, parsedURL}
|
|
}
|
|
|
|
func TestUseKeyring(t *testing.T) {
|
|
// 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)
|
|
|
|
// Create a pty for interactive prompts
|
|
pty := ptytest.New(t)
|
|
|
|
// Create CLI invocation which defaults to using the keyring
|
|
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()
|
|
|
|
// Run login in background
|
|
doneChan := make(chan struct{})
|
|
go func() {
|
|
defer close(doneChan)
|
|
err := inv.Run()
|
|
assert.NoError(t, err)
|
|
}()
|
|
|
|
// Provide the token when prompted
|
|
pty.ExpectMatch("Paste your token here:")
|
|
pty.WriteLine(client.SessionToken())
|
|
pty.ExpectMatch("Welcome to Coder")
|
|
<-doneChan
|
|
|
|
// Verify that session file was NOT created (using keyring instead)
|
|
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 := 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")
|
|
})
|
|
|
|
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)
|
|
|
|
// Create a pty for interactive prompts
|
|
pty := ptytest.New(t)
|
|
|
|
// First, login with the keyring (default)
|
|
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()
|
|
|
|
doneChan := make(chan struct{})
|
|
go func() {
|
|
defer close(doneChan)
|
|
err := loginInv.Run()
|
|
assert.NoError(t, err)
|
|
}()
|
|
|
|
pty.ExpectMatch("Paste your token here:")
|
|
pty.WriteLine(client.SessionToken())
|
|
pty.ExpectMatch("Welcome to Coder")
|
|
<-doneChan
|
|
|
|
// Verify credential exists in OS keyring
|
|
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 using the same keyring service name
|
|
var logoutRoot cli.RootCmd
|
|
logoutCmd, err := logoutRoot.Command(logoutRoot.AGPL())
|
|
require.NoError(t, err)
|
|
logoutRoot.WithKeyringServiceName(env.serviceName)
|
|
logoutRoot.UseKeyringWithGlobalConfig()
|
|
|
|
logoutInv, _ := clitest.NewWithDefaultKeyringCommand(t, logoutCmd,
|
|
"logout",
|
|
"--yes",
|
|
"--global-config", string(env.cfg),
|
|
)
|
|
|
|
var logoutOut bytes.Buffer
|
|
logoutInv.Stdout = &logoutOut
|
|
|
|
err = logoutInv.Run()
|
|
require.NoError(t, err, "logout should succeed")
|
|
|
|
// Verify the credential was deleted from OS keyring
|
|
_, err = env.keyring.Read(env.clientURL)
|
|
require.ErrorIs(t, err, os.ErrNotExist, "credential should be deleted from keyring after logout")
|
|
})
|
|
|
|
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)
|
|
|
|
// Create a pty for interactive prompts
|
|
pty := ptytest.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()
|
|
|
|
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(env.cfg), "session")
|
|
_, err := os.Stat(sessionFile)
|
|
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)
|
|
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("EnvironmentVariable", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Create a test server
|
|
client := coderdtest.New(t, nil)
|
|
coderdtest.CreateFirstUser(t, client)
|
|
|
|
// Create a pty for interactive prompts
|
|
pty := ptytest.New(t)
|
|
|
|
// Login using CODER_USE_KEYRING environment variable set to disable keyring usage,
|
|
// which should have the same behavior on all platforms.
|
|
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")
|
|
|
|
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(env.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.
|
|
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()
|
|
|
|
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(env.cfg), "session")
|
|
_, err := os.Stat(sessionFile)
|
|
require.NoError(t, err, "session file should exist when --use-keyring=false is specified")
|
|
|
|
// 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")
|
|
})
|
|
}
|
|
|
|
func TestUseKeyringUnsupportedOS(t *testing.T) {
|
|
// 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.
|
|
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
|
|
t.Skipf("Skipping unsupported OS test on %s where keyring is supported", runtime.GOOS)
|
|
}
|
|
|
|
t.Run("LoginWithDefaultKeyring", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := coderdtest.New(t, nil)
|
|
coderdtest.CreateFirstUser(t, client)
|
|
pty := ptytest.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()
|
|
|
|
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 (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("LogoutWithDefaultKeyring", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := coderdtest.New(t, nil)
|
|
coderdtest.CreateFirstUser(t, client)
|
|
pty := ptytest.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()
|
|
|
|
doneChan := make(chan struct{})
|
|
go func() {
|
|
defer close(doneChan)
|
|
err := loginInv.Run()
|
|
assert.NoError(t, err)
|
|
}()
|
|
|
|
pty.ExpectMatch("Paste your token here:")
|
|
pty.WriteLine(client.SessionToken())
|
|
pty.ExpectMatch("Welcome to Coder")
|
|
<-doneChan
|
|
|
|
// 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",
|
|
"--yes",
|
|
"--global-config", string(env.cfg),
|
|
)
|
|
|
|
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")
|
|
})
|
|
}
|