mirror of
https://github.com/coder/coder.git
synced 2026-09-01 14:53:15 +08:00
139dab7cfe
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
240 lines
7.1 KiB
Go
240 lines
7.1 KiB
Go
// 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()
|
|
}
|