feat(cli): optionally store session token in OS keyring (#20256)

This change implements optional secure storage of the CLI token using the operating system
 keyring for Windows, with groundwork laid for macOS in a future change. Previously, the
 Coder CLI stored authentication tokens in plaintext configuration files, which posed a
 security risk because users' tokens are stored unencrypted and can be easily accessed by
 other processes or users with file system access.

The keyring is opt-in to preserve compatibility with applications (like the JetBrains
Toolbox plugin, VS code plugin, etc). Users can opt into keyring use with a new
`--use-keyring` flag.

The secure storage is platform dependent. Windows Credential Manager API is used on Windows.
The session token continues to be stored in plain text on macOS and Linux. macOS is omitted
for now while we figure out the best path forward for compatibility with apps like Coder Desktop.

https://www.notion.so/coderhq/CLI-Session-Token-in-OS-Keyring-293d579be592808b8b7fd235304e50d5

https://github.com/coder/coder/issues/19403
This commit is contained in:
Zach
2025-10-30 23:41:08 +00:00
committed by GitHub
parent d306a2d7e5
commit 139dab7cfe
17 changed files with 1383 additions and 15 deletions
+355
View File
@@ -0,0 +1,355 @@
package cli_test
import (
"bytes"
"net/url"
"os"
"path"
"runtime"
"testing"
"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/coderd/coderdtest"
"github.com/coder/coder/v2/pty/ptytest"
)
// mockKeyring is a mock sessionstore.Backend implementation.
type mockKeyring struct {
credentials map[string]string // service name -> credential
}
const mockServiceName = "mock-service-name"
func newMockKeyring() *mockKeyring {
return &mockKeyring{credentials: make(map[string]string)}
}
func (m *mockKeyring) Read(_ *url.URL) (string, error) {
cred, ok := m.credentials[mockServiceName]
if !ok {
return "", os.ErrNotExist
}
return cred, nil
}
func (m *mockKeyring) Write(_ *url.URL, token string) error {
m.credentials[mockServiceName] = token
return nil
}
func (m *mockKeyring) Delete(_ *url.URL) error {
_, ok := m.credentials[mockServiceName]
if !ok {
return os.ErrNotExist
}
delete(m.credentials, mockServiceName)
return nil
}
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.
t.Parallel()
t.Run("Login", 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)
// Create CLI invocation with --use-keyring flag
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
// 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(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 mock keyring
cred, err := mockBackend.Read(nil)
require.NoError(t, err, "credential should be stored in mock keyring")
require.Equal(t, client.SessionToken(), cred, "stored token should match login token")
})
t.Run("Logout", 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)
// First, login with --use-keyring
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
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 mock keyring
cred, err := mockBackend.Read(nil)
require.NoError(t, err, "read credential should succeed before logout")
require.NotEmpty(t, cred, "credential should exist after logout")
// Now run logout with --use-keyring
logoutInv, _ := clitest.New(t,
"logout",
"--use-keyring",
"--yes",
"--global-config", string(cfg),
)
// Inject the same mock backend
var logoutRoot cli.RootCmd
logoutCmd, err := logoutRoot.Command(logoutRoot.AGPL())
require.NoError(t, err)
logoutRoot.WithSessionStorageBackend(mockBackend)
logoutInv.Command = logoutCmd
var logoutOut bytes.Buffer
logoutInv.Stdout = &logoutOut
err = logoutInv.Run()
require.NoError(t, err, "logout should succeed")
// Verify the credential was deleted from mock keyring
_, err = mockBackend.Read(nil)
require.ErrorIs(t, err, os.ErrNotExist, "credential should be deleted from keyring after logout")
})
t.Run("OmitFlag", 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)
// --use-keyring flag omitted (should use file-based storage)
inv, cfg := clitest.New(t,
"login",
"--force-tty",
"--no-open",
client.URL.String(),
)
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(cfg), "session")
_, err := os.Stat(sessionFile)
require.NoError(t, err, "session file should exist when NOT using --use-keyring")
// 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 instead of flag
inv, cfg := clitest.New(t,
"login",
"--force-tty",
"--no-open",
client.URL.String(),
)
inv.Stdin = pty.Input()
inv.Stdout = pty.Output()
inv.Environ.Set("CODER_USE_KEYRING", "true")
// 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 NOT created (using keyring via env var)
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")
// 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)
})
}
func TestUseKeyringUnsupportedOS(t *testing.T) {
// Verify that trying to use --use-keyring on an unsupported operating system produces
// a helpful error message.
t.Parallel()
// Skip on Windows since the keyring is actually supported.
if runtime.GOOS == "windows" {
t.Skip("Skipping unsupported OS test on Windows where keyring is supported")
}
const expMessage = "keyring storage is not supported on this operating system; remove the --use-keyring flag"
t.Run("LoginWithUnsupportedKeyring", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)
// Try to login with --use-keyring on an unsupported OS
inv, _ := clitest.New(t,
"login",
"--use-keyring",
client.URL.String(),
)
// The error should occur immediately, before any prompts
loginErr := inv.Run()
// Verify we got an error about unsupported OS
require.Error(t, loginErr)
require.Contains(t, loginErr.Error(), expMessage)
})
t.Run("LogoutWithUnsupportedKeyring", 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
loginInv, cfg := clitest.New(t,
"login",
"--force-tty",
"--no-open",
client.URL.String(),
)
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
// Now try to logout with --use-keyring on an unsupported OS
logoutInv, _ := clitest.New(t,
"logout",
"--use-keyring",
"--yes",
"--global-config", string(cfg),
)
err := logoutInv.Run()
// Verify we got an error about unsupported OS
require.Error(t, err)
require.Contains(t, err.Error(), expMessage)
})
}
+24 -5
View File
@@ -19,6 +19,7 @@ import (
"github.com/coder/pretty"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/cli/sessionstore"
"github.com/coder/coder/v2/coderd/userpassword"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
@@ -114,9 +115,11 @@ func (r *RootCmd) loginWithPassword(
}
sessionToken := resp.SessionToken
config := r.createConfig()
err = config.Session().Write(sessionToken)
err = r.ensureTokenBackend().Write(client.URL, sessionToken)
if err != nil {
if xerrors.Is(err, sessionstore.ErrNotImplemented) {
return errKeyringNotSupported
}
return xerrors.Errorf("write session token: %w", err)
}
@@ -149,11 +152,15 @@ func (r *RootCmd) login() *serpent.Command {
useTokenForSession bool
)
cmd := &serpent.Command{
Use: "login [<url>]",
Short: "Authenticate with Coder deployment",
Use: "login [<url>]",
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.",
Middleware: serpent.RequireRangeArgs(0, 1),
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
rawURL := ""
var urlSource string
@@ -198,6 +205,15 @@ func (r *RootCmd) login() *serpent.Command {
return err
}
// Check keyring availability before prompting the user for a token to fail fast.
if r.useKeyring {
backend := r.ensureTokenBackend()
_, err := backend.Read(client.URL)
if err != nil && xerrors.Is(err, sessionstore.ErrNotImplemented) {
return errKeyringNotSupported
}
}
hasFirstUser, err := client.HasFirstUser(ctx)
if err != nil {
return xerrors.Errorf("Failed to check server %q for first user, is the URL correct and is coder accessible from your browser? Error - has initial user: %w", serverURL.String(), err)
@@ -394,8 +410,11 @@ func (r *RootCmd) login() *serpent.Command {
}
config := r.createConfig()
err = config.Session().Write(sessionToken)
err = r.ensureTokenBackend().Write(client.URL, sessionToken)
if err != nil {
if xerrors.Is(err, sessionstore.ErrNotImplemented) {
return errKeyringNotSupported
}
return xerrors.Errorf("write session token: %w", err)
}
err = config.URL().Write(serverURL.String())
+8 -3
View File
@@ -8,6 +8,7 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/cli/sessionstore"
"github.com/coder/serpent"
)
@@ -46,11 +47,15 @@ func (r *RootCmd) logout() *serpent.Command {
errors = append(errors, xerrors.Errorf("remove URL file: %w", err))
}
err = config.Session().Delete()
err = r.ensureTokenBackend().Delete(client.URL)
// Only throw error if the session configuration file is present,
// otherwise the user is already logged out, and we proceed
if err != nil && !os.IsNotExist(err) {
errors = append(errors, xerrors.Errorf("remove session file: %w", err))
if err != nil && !xerrors.Is(err, os.ErrNotExist) {
if xerrors.Is(err, sessionstore.ErrNotImplemented) {
errors = append(errors, errKeyringNotSupported)
} else {
errors = append(errors, xerrors.Errorf("remove session token: %w", err))
}
}
err = config.Organization().Delete()
+50 -7
View File
@@ -37,6 +37,7 @@ import (
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/cli/config"
"github.com/coder/coder/v2/cli/gitauth"
"github.com/coder/coder/v2/cli/sessionstore"
"github.com/coder/coder/v2/cli/telemetry"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
@@ -54,6 +55,8 @@ var (
// ErrSilent is a sentinel error that tells the command handler to just exit with a non-zero error, but not print
// 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")
)
const (
@@ -68,12 +71,14 @@ const (
varVerbose = "verbose"
varDisableDirect = "disable-direct-connections"
varDisableNetworkTelemetry = "disable-network-telemetry"
varUseKeyring = "use-keyring"
notLoggedInMessage = "You are not logged in. Try logging in using '%s login <url>'."
envNoVersionCheck = "CODER_NO_VERSION_WARNING"
envNoFeatureWarning = "CODER_NO_FEATURE_WARNING"
envSessionToken = "CODER_SESSION_TOKEN"
envUseKeyring = "CODER_USE_KEYRING"
//nolint:gosec
envAgentToken = "CODER_AGENT_TOKEN"
//nolint:gosec
@@ -474,6 +479,15 @@ func (r *RootCmd) Command(subcommands []*serpent.Command) (*serpent.Command, err
Value: serpent.BoolOf(&r.disableNetworkTelemetry),
Group: globalGroup,
},
{
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,
},
{
Flag: "debug-http",
Description: "Debug codersdk HTTP requests.",
@@ -508,6 +522,7 @@ func (r *RootCmd) Command(subcommands []*serpent.Command) (*serpent.Command, err
type RootCmd struct {
clientURL *url.URL
token string
tokenBackend sessionstore.Backend
globalConfig string
header []string
headerCommand string
@@ -522,6 +537,7 @@ type RootCmd struct {
disableNetworkTelemetry bool
noVersionCheck bool
noFeatureWarning bool
useKeyring bool
}
// InitClient creates and configures a new client with authentication, telemetry,
@@ -549,14 +565,19 @@ func (r *RootCmd) InitClient(inv *serpent.Invocation) (*codersdk.Client, error)
return nil, err
}
}
// Read the token stored on disk.
if r.token == "" {
r.token, err = conf.Session().Read()
tok, err := r.ensureTokenBackend().Read(r.clientURL)
// Even if there isn't a token, we don't care.
// Some API routes can be unauthenticated.
if err != nil && !os.IsNotExist(err) {
if err != nil && !xerrors.Is(err, os.ErrNotExist) {
if xerrors.Is(err, sessionstore.ErrNotImplemented) {
return nil, errKeyringNotSupported
}
return nil, err
}
if tok != "" {
r.token = tok
}
}
// Configure HTTP client with transport wrappers
@@ -588,7 +609,6 @@ func (r *RootCmd) InitClient(inv *serpent.Invocation) (*codersdk.Client, error)
// This allows commands to run without requiring authentication, but still use auth if available.
func (r *RootCmd) TryInitClient(inv *serpent.Invocation) (*codersdk.Client, error) {
conf := r.createConfig()
var err error
// Read the client URL stored on disk.
if r.clientURL == nil || r.clientURL.String() == "" {
rawURL, err := conf.URL().Read()
@@ -605,14 +625,19 @@ func (r *RootCmd) TryInitClient(inv *serpent.Invocation) (*codersdk.Client, erro
}
}
}
// Read the token stored on disk.
if r.token == "" {
r.token, err = conf.Session().Read()
tok, err := r.ensureTokenBackend().Read(r.clientURL)
// Even if there isn't a token, we don't care.
// Some API routes can be unauthenticated.
if err != nil && !os.IsNotExist(err) {
if err != nil && !xerrors.Is(err, os.ErrNotExist) {
if xerrors.Is(err, sessionstore.ErrNotImplemented) {
return nil, errKeyringNotSupported
}
return nil, err
}
if tok != "" {
r.token = tok
}
}
// Only configure the client if we have a URL
@@ -688,6 +713,24 @@ func (r *RootCmd) createUnauthenticatedClient(ctx context.Context, serverURL *ur
return client, nil
}
// ensureTokenBackend returns the session token storage backend, creating it if necessary.
// This must be called after flags are parsed so we can respect the value of the --use-keyring
// flag.
func (r *RootCmd) ensureTokenBackend() sessionstore.Backend {
if r.tokenBackend == nil {
if r.useKeyring {
r.tokenBackend = sessionstore.NewKeyring()
} else {
r.tokenBackend = sessionstore.NewFile(r.createConfig)
}
}
return r.tokenBackend
}
func (r *RootCmd) WithSessionStorageBackend(backend sessionstore.Backend) {
r.tokenBackend = backend
}
type AgentAuth struct {
// Agent Client config
agentToken string
+239
View File
@@ -0,0 +1,239 @@
// Package sessionstore provides CLI session token storage mechanisms.
// Operating system keyring storage is intended to have compatibility with other Coder
// applications (e.g. Coder Desktop, Coder provider for JetBrains Toolbox, etc) so that
// applications can read/write the same credential stored in the keyring.
//
// Note that we aren't using an existing Go package zalando/go-keyring here for a few
// reasons. 1) It prescribes the format of the target credential name in the OS keyrings,
// which makes our life difficult for compatibility with other Coder applications. 2)
// It uses init functions that make it difficult to test with. As a result, the OS
// keyring implementations may be adapted from zalando/go-keyring source (i.e. Windows).
package sessionstore
import (
"encoding/json"
"errors"
"net/url"
"os"
"strings"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/config"
)
// Backend is a storage backend for session tokens.
type Backend interface {
// Read returns the session token for the given server URL or an error, if any. It
// will return os.ErrNotExist if no token exists for the given URL.
Read(serverURL *url.URL) (string, error)
// Write stores the session token for the given server URL.
Write(serverURL *url.URL, token string) error
// Delete removes the session token for the given server URL or an error, if any.
// It will return os.ErrNotExist error if no token exists to delete.
Delete(serverURL *url.URL) error
}
var (
// ErrSetDataTooBig is returned if `keyringProvider.Set` was called with too much data.
// On macOS: The combination of service, username & password should not exceed ~3000 bytes
// On Windows: The service is limited to 32KiB while the password is limited to 2560 bytes
ErrSetDataTooBig = xerrors.New("data passed to Set was too big")
// ErrNotImplemented represents when keyring usage is not implemented on the current
// operating system.
ErrNotImplemented = xerrors.New("not implemented")
)
// keyringProvider represents an operating system keyring. The expectation
// is these methods operate on the user/login keyring.
type keyringProvider interface {
// Set stores the given credential for a service name in the operating system
// keyring.
Set(service, credential string) error
// Get retrieves the credential from the keyring. It must return os.ErrNotExist
// if the credential is not found.
Get(service string) ([]byte, error)
// Delete deletes the credential from the keyring. It must return os.ErrNotExist
// if the credential is not found.
Delete(service string) error
}
// credential represents a single credential entry.
type credential struct {
CoderURL string `json:"coder_url"`
APIToken string `json:"api_token"`
}
// credentialsMap represents the JSON structure stored in the operating system keyring.
// It supports storing multiple credentials for different server URLs.
type credentialsMap map[string]credential
// normalizeHost returns a normalized version of the URL host for use as a map key.
func normalizeHost(u *url.URL) (string, error) {
if u == nil || u.Host == "" {
return "", xerrors.New("nil server URL")
}
return strings.TrimSpace(strings.ToLower(u.Host)), nil
}
// parseCredentialsJSON parses the JSON from the keyring into a credentialsMap.
func parseCredentialsJSON(jsonData []byte) (credentialsMap, error) {
if len(jsonData) == 0 {
return make(credentialsMap), nil
}
var creds credentialsMap
if err := json.Unmarshal(jsonData, &creds); err != nil {
return nil, xerrors.Errorf("unmarshal credentials: %w", err)
}
return creds, nil
}
// Keyring is a Backend that exclusively stores the session token in the operating
// system keyring. Happy path usage of this type should start with NewKeyring.
// It stores a JSON object in the keyring that supports multiple credentials for
// different server URLs, providing compatibility with Coder Desktop and other Coder
// applications.
type Keyring struct {
provider keyringProvider
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.
func NewKeyringWithService(serviceName string) Keyring {
return Keyring{
provider: operatingSystemKeyring{},
serviceName: serviceName,
}
}
func (o Keyring) Read(serverURL *url.URL) (string, error) {
host, err := normalizeHost(serverURL)
if err != nil {
return "", err
}
credJSON, err := o.provider.Get(o.serviceName)
if err != nil {
return "", err
}
if len(credJSON) == 0 {
return "", os.ErrNotExist
}
creds, err := parseCredentialsJSON(credJSON)
if err != nil {
return "", xerrors.Errorf("read: parse existing credentials: %w", err)
}
// Return the credential for the specified URL
cred, ok := creds[host]
if !ok {
return "", os.ErrNotExist
}
return cred.APIToken, nil
}
func (o Keyring) Write(serverURL *url.URL, token string) error {
host, err := normalizeHost(serverURL)
if err != nil {
return err
}
existingJSON, err := o.provider.Get(o.serviceName)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return xerrors.Errorf("read existing credentials: %w", err)
}
creds, err := parseCredentialsJSON(existingJSON)
if err != nil {
return xerrors.Errorf("write: parse existing credentials: %w", err)
}
// Upsert the credential for this URL.
creds[host] = credential{
CoderURL: host,
APIToken: token,
}
credsJSON, err := json.Marshal(creds)
if err != nil {
return xerrors.Errorf("marshal credentials: %w", err)
}
err = o.provider.Set(o.serviceName, string(credsJSON))
if err != nil {
return xerrors.Errorf("write credentials to keyring: %w", err)
}
return nil
}
func (o Keyring) Delete(serverURL *url.URL) error {
host, err := normalizeHost(serverURL)
if err != nil {
return err
}
existingJSON, err := o.provider.Get(o.serviceName)
if err != nil {
return err
}
creds, err := parseCredentialsJSON(existingJSON)
if err != nil {
return xerrors.Errorf("failed to parse existing credentials: %w", err)
}
if _, ok := creds[host]; !ok {
return os.ErrNotExist
}
delete(creds, host)
// Delete the entire keyring entry when no credentials remain.
if len(creds) == 0 {
return o.provider.Delete(o.serviceName)
}
// Write back the updated credentials map.
credsJSON, err := json.Marshal(creds)
if err != nil {
return xerrors.Errorf("failed to marshal credentials: %w", err)
}
return o.provider.Set(o.serviceName, string(credsJSON))
}
// File is a Backend that exclusively stores the session token in a file on disk.
type File struct {
config func() config.Root
}
func NewFile(f func() config.Root) *File {
return &File{config: f}
}
func (f *File) Read(_ *url.URL) (string, error) {
return f.config().Session().Read()
}
func (f *File) Write(_ *url.URL, token string) error {
return f.config().Session().Write(token)
}
func (f *File) Delete(_ *url.URL) error {
return f.config().Session().Delete()
}
@@ -0,0 +1,121 @@
package sessionstore
import (
"encoding/json"
"net/url"
"testing"
"github.com/stretchr/testify/require"
)
func TestNormalizeHost(t *testing.T) {
t.Parallel()
tests := []struct {
name string
url *url.URL
want string
wantErr bool
}{
{
name: "StandardHost",
url: &url.URL{Host: "coder.example.com"},
want: "coder.example.com",
},
{
name: "HostWithPort",
url: &url.URL{Host: "coder.example.com:8080"},
want: "coder.example.com:8080",
},
{
name: "UppercaseHost",
url: &url.URL{Host: "CODER.EXAMPLE.COM"},
want: "coder.example.com",
},
{
name: "HostWithWhitespace",
url: &url.URL{Host: " coder.example.com "},
want: "coder.example.com",
},
{
name: "NilURL",
url: nil,
want: "",
wantErr: true,
},
{
name: "EmptyHost",
url: &url.URL{Host: ""},
want: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := normalizeHost(tt.url)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tt.want, got)
})
}
}
func TestParseCredentialsJSON(t *testing.T) {
t.Parallel()
t.Run("Empty", func(t *testing.T) {
t.Parallel()
creds, err := parseCredentialsJSON(nil)
require.NoError(t, err)
require.NotNil(t, creds)
require.Empty(t, creds)
})
t.Run("NewFormat", func(t *testing.T) {
t.Parallel()
jsonData := []byte(`{
"coder1.example.com": {"coder_url": "coder1.example.com", "api_token": "token1"},
"coder2.example.com": {"coder_url": "coder2.example.com", "api_token": "token2"}
}`)
creds, err := parseCredentialsJSON(jsonData)
require.NoError(t, err)
require.Len(t, creds, 2)
require.Equal(t, "token1", creds["coder1.example.com"].APIToken)
require.Equal(t, "token2", creds["coder2.example.com"].APIToken)
})
t.Run("InvalidJSON", func(t *testing.T) {
t.Parallel()
jsonData := []byte(`{invalid json}`)
_, err := parseCredentialsJSON(jsonData)
require.Error(t, err)
})
}
func TestCredentialsMap_RoundTrip(t *testing.T) {
t.Parallel()
creds := credentialsMap{
"coder1.example.com": {
CoderURL: "coder1.example.com",
APIToken: "token1",
},
"coder2.example.com:8080": {
CoderURL: "coder2.example.com:8080",
APIToken: "token2",
},
}
jsonData, err := json.Marshal(creds)
require.NoError(t, err)
parsed, err := parseCredentialsJSON(jsonData)
require.NoError(t, err)
require.Equal(t, creds, parsed)
}
+19
View File
@@ -0,0 +1,19 @@
//go:build !windows
package sessionstore
const defaultServiceName = "not-implemented"
type operatingSystemKeyring struct{}
func (operatingSystemKeyring) Set(_, _ string) error {
return ErrNotImplemented
}
func (operatingSystemKeyring) Get(_ string) ([]byte, error) {
return nil, ErrNotImplemented
}
func (operatingSystemKeyring) Delete(_ string) error {
return ErrNotImplemented
}
+342
View File
@@ -0,0 +1,342 @@
package sessionstore_test
import (
"errors"
"fmt"
"net/url"
"os"
"path"
"runtime"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/config"
"github.com/coder/coder/v2/cli/sessionstore"
)
// Generate a test service name for use with the OS keyring. It uses a combination
// of the test name and a nanosecond timestamp to prevent collisions.
func keyringTestServiceName(t *testing.T) string {
t.Helper()
return t.Name() + "_" + fmt.Sprintf("%v", time.Now().UnixNano())
}
func TestKeyring(t *testing.T) {
t.Parallel()
if runtime.GOOS != "windows" {
t.Skip("linux and darwin are not supported yet")
}
// This test exercises use of the operating system keyring. As a result,
// the operating system keyring is expected to be available.
const (
testURL = "http://127.0.0.1:1337"
testURL2 = "http://127.0.0.1:1338"
)
t.Run("ReadNonExistent", func(t *testing.T) {
t.Parallel()
backend := sessionstore.NewKeyringWithService(keyringTestServiceName(t))
srvURL, err := url.Parse(testURL)
require.NoError(t, err)
t.Cleanup(func() { _ = backend.Delete(srvURL) })
_, err = backend.Read(srvURL)
require.Error(t, err)
require.True(t, os.IsNotExist(err), "expected os.ErrNotExist when reading non-existent token")
})
t.Run("DeleteNonExistent", func(t *testing.T) {
t.Parallel()
backend := sessionstore.NewKeyringWithService(keyringTestServiceName(t))
srvURL, err := url.Parse(testURL)
require.NoError(t, err)
t.Cleanup(func() { _ = backend.Delete(srvURL) })
err = backend.Delete(srvURL)
require.Error(t, err)
require.True(t, errors.Is(err, os.ErrNotExist), "expected os.ErrNotExist when deleting non-existent token")
})
t.Run("WriteAndRead", func(t *testing.T) {
t.Parallel()
backend := sessionstore.NewKeyringWithService(keyringTestServiceName(t))
srvURL, err := url.Parse(testURL)
require.NoError(t, err)
t.Cleanup(func() { _ = backend.Delete(srvURL) })
dir := t.TempDir()
expSessionFile := path.Join(dir, "session")
const inputToken = "test-keyring-token-12345"
err = backend.Write(srvURL, inputToken)
require.NoError(t, err)
// Verify no session file was created (keyring stores in OS keyring, not file)
_, err = os.Stat(expSessionFile)
require.True(t, errors.Is(err, os.ErrNotExist), "expected session token file to not exist when using keyring")
token, err := backend.Read(srvURL)
require.NoError(t, err)
require.Equal(t, inputToken, token)
// Clean up
err = backend.Delete(srvURL)
require.NoError(t, err)
})
t.Run("WriteAndDelete", func(t *testing.T) {
t.Parallel()
backend := sessionstore.NewKeyringWithService(keyringTestServiceName(t))
srvURL, err := url.Parse(testURL)
require.NoError(t, err)
t.Cleanup(func() { _ = backend.Delete(srvURL) })
const inputToken = "test-keyring-token-67890"
err = backend.Write(srvURL, inputToken)
require.NoError(t, err)
token, err := backend.Read(srvURL)
require.NoError(t, err)
require.Equal(t, inputToken, token)
err = backend.Delete(srvURL)
require.NoError(t, err)
_, err = backend.Read(srvURL)
require.Error(t, err)
require.True(t, os.IsNotExist(err), "expected os.ErrNotExist after deleting token")
})
t.Run("OverwriteToken", func(t *testing.T) {
t.Parallel()
backend := sessionstore.NewKeyringWithService(keyringTestServiceName(t))
srvURL, err := url.Parse(testURL)
require.NoError(t, err)
t.Cleanup(func() { _ = backend.Delete(srvURL) })
// Write first token
const firstToken = "first-keyring-token"
err = backend.Write(srvURL, firstToken)
require.NoError(t, err)
token, err := backend.Read(srvURL)
require.NoError(t, err)
require.Equal(t, firstToken, token)
// Overwrite with second token
const secondToken = "second-keyring-token"
err = backend.Write(srvURL, secondToken)
require.NoError(t, err)
token, err = backend.Read(srvURL)
require.NoError(t, err)
require.Equal(t, secondToken, token)
// Clean up
err = backend.Delete(srvURL)
require.NoError(t, err)
})
t.Run("MultipleServers", func(t *testing.T) {
t.Parallel()
backend := sessionstore.NewKeyringWithService(keyringTestServiceName(t))
srvURL, err := url.Parse(testURL)
require.NoError(t, err)
srvURL2, err := url.Parse(testURL2)
require.NoError(t, err)
t.Cleanup(func() {
_ = backend.Delete(srvURL)
_ = backend.Delete(srvURL2)
})
// Write token for server 1
const token1 = "token-for-server-1"
err = backend.Write(srvURL, token1)
require.NoError(t, err)
// Write token for server 2 (should NOT overwrite server 1)
const token2 = "token-for-server-2"
err = backend.Write(srvURL2, token2)
require.NoError(t, err)
// Read server 1's credential
token, err := backend.Read(srvURL)
require.NoError(t, err)
require.Equal(t, token1, token)
// Read server 2's credential
token, err = backend.Read(srvURL2)
require.NoError(t, err)
require.Equal(t, token2, token)
// Delete server 1's credential
err = backend.Delete(srvURL)
require.NoError(t, err)
// Verify server 1's credential is gone
_, err = backend.Read(srvURL)
require.Error(t, err)
require.True(t, os.IsNotExist(err))
// Verify server 2's credential still exists
token, err = backend.Read(srvURL2)
require.NoError(t, err)
require.Equal(t, token2, token)
// Clean up remaining credentials
err = backend.Delete(srvURL2)
require.NoError(t, err)
})
}
func TestFile(t *testing.T) {
const (
testURL = "http://127.0.0.1:1337"
testURL2 = "http://127.0.0.1:1338"
)
t.Parallel()
t.Run("ReadNonExistent", func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
backend := sessionstore.NewFile(func() config.Root { return config.Root(dir) })
srvURL, err := url.Parse(testURL)
require.NoError(t, err)
_, err = backend.Read(srvURL)
require.Error(t, err)
require.True(t, os.IsNotExist(err))
})
t.Run("WriteAndRead", func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
backend := sessionstore.NewFile(func() config.Root { return config.Root(dir) })
srvURL, err := url.Parse(testURL)
require.NoError(t, err)
// Write a token
const inputToken = "test-token-12345"
err = backend.Write(srvURL, inputToken)
require.NoError(t, err)
// Verify the session file was created
sessionFile := config.Root(dir).Session()
require.True(t, sessionFile.Exists())
// Read the token back
token, err := backend.Read(srvURL)
require.NoError(t, err)
require.Equal(t, inputToken, token)
})
t.Run("WriteAndDelete", func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
backend := sessionstore.NewFile(func() config.Root { return config.Root(dir) })
srvURL, err := url.Parse(testURL)
require.NoError(t, err)
// Write a token
const inputToken = "test-token-67890"
err = backend.Write(srvURL, inputToken)
require.NoError(t, err)
// Verify the token was written
token, err := backend.Read(srvURL)
require.NoError(t, err)
require.Equal(t, inputToken, token)
// Delete the token
err = backend.Delete(srvURL)
require.NoError(t, err)
// Verify the token is gone
_, err = backend.Read(srvURL)
require.Error(t, err)
require.True(t, os.IsNotExist(err))
})
t.Run("DeleteNonExistent", func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
backend := sessionstore.NewFile(func() config.Root { return config.Root(dir) })
srvURL, err := url.Parse(testURL)
require.NoError(t, err)
// Attempt to delete a non-existent token
err = backend.Delete(srvURL)
require.Error(t, err)
require.True(t, os.IsNotExist(err))
})
t.Run("OverwriteToken", func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
backend := sessionstore.NewFile(func() config.Root { return config.Root(dir) })
srvURL, err := url.Parse(testURL)
require.NoError(t, err)
// Write first token
const firstToken = "first-token"
err = backend.Write(srvURL, firstToken)
require.NoError(t, err)
token, err := backend.Read(srvURL)
require.NoError(t, err)
require.Equal(t, firstToken, token)
// Overwrite with second token
const secondToken = "second-token"
err = backend.Write(srvURL, secondToken)
require.NoError(t, err)
token, err = backend.Read(srvURL)
require.NoError(t, err)
require.Equal(t, secondToken, token)
})
t.Run("WriteIgnoresURL", func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
backend := sessionstore.NewFile(func() config.Root { return config.Root(dir) })
srvURL, err := url.Parse(testURL)
require.NoError(t, err)
srvURL2, err := url.Parse(testURL2)
require.NoError(t, err)
//nolint:gosec // Write with first URL test token
const firstToken = "token-for-url1"
err = backend.Write(srvURL, firstToken)
require.NoError(t, err)
//nolint:gosec // Write with second URL - should overwrite
const secondToken = "token-for-url2"
err = backend.Write(srvURL2, secondToken)
require.NoError(t, err)
// Should have the second token (File backend doesn't differentiate by URL)
token, err := backend.Read(srvURL)
require.NoError(t, err)
require.Equal(t, secondToken, token)
})
}
+66
View File
@@ -0,0 +1,66 @@
//go:build windows
package sessionstore
import (
"errors"
"os"
"syscall"
"github.com/danieljoos/wincred"
)
const (
// defaultServiceName is the service name used in the Windows Credential Manager
// for storing Coder CLI session tokens.
defaultServiceName = "coder-v2-credentials"
)
// operatingSystemKeyring implements keyringProvider and uses Windows Credential Manager.
// It is largely adapted from the zalando/go-keyring package.
type operatingSystemKeyring struct{}
func (operatingSystemKeyring) Set(service, credential string) error {
// password may not exceed 2560 bytes (https://github.com/jaraco/keyring/issues/540#issuecomment-968329967)
if len(credential) > 2560 {
return ErrSetDataTooBig
}
// service may not exceed 512 bytes (might need more testing)
if len(service) >= 512 {
return ErrSetDataTooBig
}
// service may not exceed 32k but problems occur before that
// so we limit it to 30k
if len(service) > 1024*30 {
return ErrSetDataTooBig
}
cred := wincred.NewGenericCredential(service)
cred.CredentialBlob = []byte(credential)
cred.Persist = wincred.PersistLocalMachine
return cred.Write()
}
func (operatingSystemKeyring) Get(service string) ([]byte, error) {
cred, err := wincred.GetGenericCredential(service)
if err != nil {
if errors.Is(err, syscall.ERROR_NOT_FOUND) {
return nil, os.ErrNotExist
}
return nil, err
}
return cred.CredentialBlob, nil
}
func (operatingSystemKeyring) Delete(service string) error {
cred, err := wincred.GetGenericCredential(service)
if err != nil {
if errors.Is(err, syscall.ERROR_NOT_FOUND) {
return os.ErrNotExist
}
return err
}
return cred.Delete()
}
@@ -0,0 +1,127 @@
//go:build windows
package sessionstore_test
import (
"encoding/json"
"net/url"
"os"
"testing"
"github.com/danieljoos/wincred"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/sessionstore"
)
func TestWindowsKeyring_WriteReadDelete(t *testing.T) {
t.Parallel()
const testURL = "http://127.0.0.1:1337"
srvURL, err := url.Parse(testURL)
require.NoError(t, err)
serviceName := keyringTestServiceName(t)
backend := sessionstore.NewKeyringWithService(serviceName)
t.Cleanup(func() { _ = backend.Delete(srvURL) })
// Verify no token exists initially
_, err = backend.Read(srvURL)
require.ErrorIs(t, err, os.ErrNotExist)
// Write a token
const inputToken = "test-token-12345"
err = backend.Write(srvURL, inputToken)
require.NoError(t, err)
// Verify the credential is stored in Windows Credential Manager with correct format
winCred, err := wincred.GetGenericCredential(serviceName)
require.NoError(t, err, "getting windows credential")
var storedCreds map[string]struct {
CoderURL string `json:"coder_url"`
APIToken string `json:"api_token"`
}
err = json.Unmarshal(winCred.CredentialBlob, &storedCreds)
require.NoError(t, err, "unmarshalling stored credentials")
// Verify the stored values
require.Len(t, storedCreds, 1)
cred, ok := storedCreds[srvURL.Host]
require.True(t, ok, "credential for URL should exist")
require.Equal(t, inputToken, cred.APIToken)
require.Equal(t, srvURL.Host, cred.CoderURL)
// Read the token back
token, err := backend.Read(srvURL)
require.NoError(t, err)
require.Equal(t, inputToken, token)
// Delete the token
err = backend.Delete(srvURL)
require.NoError(t, err)
// Verify token is deleted
_, err = backend.Read(srvURL)
require.ErrorIs(t, err, os.ErrNotExist)
}
func TestWindowsKeyring_MultipleServers(t *testing.T) {
t.Parallel()
const testURL1 = "http://127.0.0.1:1337"
srv1URL, err := url.Parse(testURL1)
require.NoError(t, err)
const testURL2 = "http://127.0.0.1:1338"
srv2URL, err := url.Parse(testURL2)
require.NoError(t, err)
serviceName := keyringTestServiceName(t)
backend := sessionstore.NewKeyringWithService(serviceName)
t.Cleanup(func() {
_ = backend.Delete(srv1URL)
_ = backend.Delete(srv2URL)
})
// Write token for server 1
const token1 = "token-server-1"
err = backend.Write(srv1URL, token1)
require.NoError(t, err)
// Write token for server 2 (should NOT overwrite server 1's token)
const token2 = "token-server-2"
err = backend.Write(srv2URL, token2)
require.NoError(t, err)
// Verify both credentials are stored in Windows Credential Manager
winCred, err := wincred.GetGenericCredential(serviceName)
require.NoError(t, err, "getting windows credential")
var storedCreds map[string]struct {
CoderURL string `json:"coder_url"`
APIToken string `json:"api_token"`
}
err = json.Unmarshal(winCred.CredentialBlob, &storedCreds)
require.NoError(t, err, "unmarshalling stored credentials")
// Both credentials should exist
require.Len(t, storedCreds, 2)
require.Equal(t, token1, storedCreds[srv1URL.Host].APIToken)
require.Equal(t, token2, storedCreds[srv2URL.Host].APIToken)
// Read individual credentials
token, err := backend.Read(srv1URL)
require.NoError(t, err)
require.Equal(t, token1, token)
token, err = backend.Read(srv2URL)
require.NoError(t, err)
require.Equal(t, token2, token)
// Cleanup
err = backend.Delete(srv1URL)
require.NoError(t, err)
err = backend.Delete(srv2URL)
require.NoError(t, err)
}
+5
View File
@@ -108,6 +108,11 @@ variables or flags.
--url url, $CODER_URL
URL to a deployment.
--use-keyring bool, $CODER_USE_KEYRING
Store and retrieve session tokens using the operating system keyring.
Currently only supported on Windows. By default, tokens are stored in
plain text files.
-v, --verbose bool, $CODER_VERBOSE
Enable verbose output.
+4
View File
@@ -5,6 +5,10 @@ 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.
OPTIONS:
--first-user-email string, $CODER_FIRST_USER_EMAIL
Specifies an email address to use if creating the first user for the
+9
View File
@@ -170,6 +170,15 @@ Disable direct (P2P) connections to workspaces.
Disable network telemetry. Network telemetry is collected when connecting to workspaces using the CLI, and is forwarded to the server. If telemetry is also enabled on the server, it may be sent to Coder. Network telemetry is used to measure network quality and detect regressions.
### --use-keyring
| | |
|-------------|---------------------------------|
| Type | <code>bool</code> |
| Environment | <code>$CODER_USE_KEYRING</code> |
Store and retrieve session tokens using the operating system keyring. Currently only supported on Windows. By default, tokens are stored in plain text files.
### --global-config
| | |
+6
View File
@@ -9,6 +9,12 @@ Authenticate with Coder deployment
coder login [flags] [<url>]
```
## 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.
```
## Options
### --first-user-email
+5
View File
@@ -68,6 +68,11 @@ variables or flags.
--url url, $CODER_URL
URL to a deployment.
--use-keyring bool, $CODER_USE_KEYRING
Store and retrieve session tokens using the operating system keyring.
Currently only supported on Windows. By default, tokens are stored in
plain text files.
-v, --verbose bool, $CODER_VERBOSE
Enable verbose output.
+1
View File
@@ -480,6 +480,7 @@ require (
github.com/coder/aisdk-go v0.0.9
github.com/coder/boundary v1.0.1-0.20250925154134-55a44f2a7945
github.com/coder/preview v1.0.4
github.com/danieljoos/wincred v1.2.3
github.com/dgraph-io/ristretto/v2 v2.3.0
github.com/fsnotify/fsnotify v1.9.0
github.com/go-git/go-git/v5 v5.16.2
+2
View File
@@ -987,6 +987,8 @@ github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/dave/dst v0.27.2 h1:4Y5VFTkhGLC1oddtNwuxxe36pnyLxMFXT51FOzH8Ekc=
github.com/dave/dst v0.27.2/go.mod h1:jHh6EOibnHgcUW3WjKHisiooEkYwqpHLBSX1iOBhEyc=
github.com/dave/jennifer v1.6.1 h1:T4T/67t6RAA5AIV6+NP8Uk/BIsXgDoqEowgycdQQLuk=