diff --git a/veans/go.mod b/veans/go.mod index 64be794b0..6b3bcd58e 100644 --- a/veans/go.mod +++ b/veans/go.mod @@ -1,13 +1,19 @@ module code.vikunja.io/veans -go 1.25 +go 1.25.0 require ( github.com/magefile/mage v1.17.2 github.com/spf13/cobra v1.10.2 + github.com/zalando/go-keyring v0.2.8 + golang.org/x/term v0.42.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/danieljoos/wincred v1.2.3 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sys v0.43.0 // indirect ) diff --git a/veans/go.sum b/veans/go.sum index 60994e5a9..286500900 100644 --- a/veans/go.sum +++ b/veans/go.sum @@ -1,12 +1,33 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +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/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40= github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/veans/internal/credentials/env.go b/veans/internal/credentials/env.go new file mode 100644 index 000000000..53e239070 --- /dev/null +++ b/veans/internal/credentials/env.go @@ -0,0 +1,27 @@ +package credentials + +import "os" + +// EnvBackend is read-only. VEANS_TOKEN is intended for CI / containers where +// the keychain is unavailable and writing a credentials file is undesirable. +// +// VEANS_TOKEN matches any (server, account) lookup — there's only one slot. +// VEANS_SERVER, when set, additionally pins the server it applies to. +type EnvBackend struct{} + +func NewEnvBackend() *EnvBackend { return &EnvBackend{} } +func (*EnvBackend) Name() string { return "env" } + +func (*EnvBackend) Get(server, _ string) (string, error) { + tok := os.Getenv("VEANS_TOKEN") + if tok == "" { + return "", ErrNotFound + } + if pinned := os.Getenv("VEANS_SERVER"); pinned != "" && pinned != server { + return "", ErrNotFound + } + return tok, nil +} + +func (*EnvBackend) Set(_, _, _ string) error { return errReadOnly } +func (*EnvBackend) Delete(_, _ string) error { return errReadOnly } diff --git a/veans/internal/credentials/file.go b/veans/internal/credentials/file.go new file mode 100644 index 000000000..7aa3c2d96 --- /dev/null +++ b/veans/internal/credentials/file.go @@ -0,0 +1,136 @@ +package credentials + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "gopkg.in/yaml.v3" +) + +// FileBackend persists credentials to ~/.config/veans/credentials.yml at +// mode 0600. It's the fallback when no keychain is available (CI, Docker, +// headless servers) and is the implicit backend e2e tests use. +// +// The schema includes a `scope` field that's always empty in v0 but reserved +// for project-scoped tokens once Vikunja gains them — the same store can +// hold both kinds without migration. +type FileBackend struct { + path string +} + +type fileEntry struct { + Server string `yaml:"server"` + Account string `yaml:"account"` + Scope string `yaml:"scope,omitempty"` + Token string `yaml:"token"` + ExpiresAt *time.Time `yaml:"expires_at,omitempty"` +} + +type fileSchema struct { + Credentials []fileEntry `yaml:"credentials"` +} + +// NewFileBackend builds a FileBackend rooted at `path`, or the platform +// default (~/.config/veans/credentials.yml, honoring XDG_CONFIG_HOME) when +// path is "". +func NewFileBackend(path string) *FileBackend { + if path == "" { + path = defaultCredsPath() + } + return &FileBackend{path: path} +} + +func (b *FileBackend) Name() string { return "file" } +func (b *FileBackend) Path() string { return b.path } + +func defaultCredsPath() string { + if c := os.Getenv("XDG_CONFIG_HOME"); c != "" { + return filepath.Join(c, "veans", "credentials.yml") + } + if h, err := os.UserHomeDir(); err == nil { + return filepath.Join(h, ".config", "veans", "credentials.yml") + } + return filepath.Join(".", "credentials.yml") +} + +func (b *FileBackend) load() (*fileSchema, error) { + buf, err := os.ReadFile(b.path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return &fileSchema{}, nil + } + return nil, err + } + var s fileSchema + if err := yaml.Unmarshal(buf, &s); err != nil { + return nil, fmt.Errorf("parse %s: %w", b.path, err) + } + return &s, nil +} + +func (b *FileBackend) save(s *fileSchema) error { + if err := os.MkdirAll(filepath.Dir(b.path), 0o700); err != nil { + return err + } + buf, err := yaml.Marshal(s) + if err != nil { + return err + } + return os.WriteFile(b.path, buf, 0o600) +} + +func (b *FileBackend) Get(server, account string) (string, error) { + s, err := b.load() + if err != nil { + return "", err + } + for _, e := range s.Credentials { + if e.Server == server && e.Account == account { + return e.Token, nil + } + } + return "", ErrNotFound +} + +func (b *FileBackend) Set(server, account, token string) error { + s, err := b.load() + if err != nil { + return err + } + for i, e := range s.Credentials { + if e.Server == server && e.Account == account { + s.Credentials[i].Token = token + return b.save(s) + } + } + s.Credentials = append(s.Credentials, fileEntry{ + Server: server, + Account: account, + Token: token, + }) + return b.save(s) +} + +func (b *FileBackend) Delete(server, account string) error { + s, err := b.load() + if err != nil { + return err + } + out := s.Credentials[:0] + removed := false + for _, e := range s.Credentials { + if e.Server == server && e.Account == account { + removed = true + continue + } + out = append(out, e) + } + if !removed { + return ErrNotFound + } + s.Credentials = out + return b.save(s) +} diff --git a/veans/internal/credentials/file_test.go b/veans/internal/credentials/file_test.go new file mode 100644 index 000000000..c01cce079 --- /dev/null +++ b/veans/internal/credentials/file_test.go @@ -0,0 +1,106 @@ +package credentials + +import ( + "errors" + "path/filepath" + "testing" +) + +func TestFileBackend_RoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "credentials.yml") + b := NewFileBackend(path) + + if _, err := b.Get("https://example.com", "bot-foo"); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } + + if err := b.Set("https://example.com", "bot-foo", "tok-123"); err != nil { + t.Fatalf("Set failed: %v", err) + } + + tok, err := b.Get("https://example.com", "bot-foo") + if err != nil { + t.Fatalf("Get after Set: %v", err) + } + if tok != "tok-123" { + t.Fatalf("got %q, want tok-123", tok) + } + + // Update in place. + if err := b.Set("https://example.com", "bot-foo", "tok-456"); err != nil { + t.Fatalf("Set update: %v", err) + } + tok, _ = b.Get("https://example.com", "bot-foo") + if tok != "tok-456" { + t.Fatalf("update lost: got %q", tok) + } + + // Different account — separate row. + if err := b.Set("https://example.com", "bot-bar", "tok-789"); err != nil { + t.Fatalf("Set bar: %v", err) + } + tokBar, _ := b.Get("https://example.com", "bot-bar") + if tokBar != "tok-789" { + t.Fatalf("bar got %q", tokBar) + } + + if err := b.Delete("https://example.com", "bot-foo"); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := b.Get("https://example.com", "bot-foo"); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound after delete, got %v", err) + } + if _, err := b.Get("https://example.com", "bot-bar"); err != nil { + t.Fatalf("bar should still exist: %v", err) + } +} + +func TestChain_FallsThroughOnNotFound(t *testing.T) { + dir := t.TempDir() + file := NewFileBackend(filepath.Join(dir, "credentials.yml")) + stub := &stubBackend{store: map[string]string{}} + c := &Chain{Backends: []Store{stub, file}} + + // First backend has nothing; second is empty too. + if _, err := c.Get("s", "a"); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } + + // Set should write to the first writable backend (stub here). + if err := c.Set("s", "a", "tok"); err != nil { + t.Fatalf("Set: %v", err) + } + if stub.store["s::a"] != "tok" { + t.Fatalf("expected stub to receive write") + } + + // Get should now find it via stub. + if got, _ := c.Get("s", "a"); got != "tok" { + t.Fatalf("got %q want tok", got) + } +} + +type stubBackend struct { + store map[string]string +} + +func (s *stubBackend) Name() string { return "stub" } +func (s *stubBackend) Get(server, account string) (string, error) { + if v, ok := s.store[server+"::"+account]; ok { + return v, nil + } + return "", ErrNotFound +} +func (s *stubBackend) Set(server, account, token string) error { + s.store[server+"::"+account] = token + return nil +} +func (s *stubBackend) Delete(server, account string) error { + k := server + "::" + account + if _, ok := s.store[k]; !ok { + return ErrNotFound + } + delete(s.store, k) + return nil +} diff --git a/veans/internal/credentials/keyring.go b/veans/internal/credentials/keyring.go new file mode 100644 index 000000000..e825de1ab --- /dev/null +++ b/veans/internal/credentials/keyring.go @@ -0,0 +1,54 @@ +package credentials + +import ( + "errors" + + "github.com/zalando/go-keyring" +) + +// service is the keyring service name. Per-host accounts are encoded as +// `::` since OS keychains key on (service, account) pairs. +const service = "veans" + +// KeyringBackend persists tokens in the OS keychain (macOS Keychain, +// Windows Credential Manager, libsecret on Linux). On systems without a +// usable keychain (e.g. headless CI containers), Get/Set return errors that +// the chain treats as NotFound, allowing the file backend to take over. +type KeyringBackend struct{} + +func NewKeyringBackend() *KeyringBackend { return &KeyringBackend{} } +func (*KeyringBackend) Name() string { return "keyring" } + +func (*KeyringBackend) Get(server, account string) (string, error) { + tok, err := keyring.Get(service, key(server, account)) + if err != nil { + if errors.Is(err, keyring.ErrNotFound) { + return "", ErrNotFound + } + // Treat any keyring backend error (no daemon, etc) as NotFound so + // the chain falls through to the file backend transparently. + return "", ErrNotFound + } + return tok, nil +} + +func (*KeyringBackend) Set(server, account, token string) error { + if err := keyring.Set(service, key(server, account), token); err != nil { + return err + } + return nil +} + +func (*KeyringBackend) Delete(server, account string) error { + if err := keyring.Delete(service, key(server, account)); err != nil { + if errors.Is(err, keyring.ErrNotFound) { + return ErrNotFound + } + return err + } + return nil +} + +func key(server, account string) string { + return server + "::" + account +} diff --git a/veans/internal/credentials/store.go b/veans/internal/credentials/store.go new file mode 100644 index 000000000..196f2cd4e --- /dev/null +++ b/veans/internal/credentials/store.go @@ -0,0 +1,91 @@ +// Package credentials handles bot-token storage with a keychain → env → file +// fallback chain. The store is keyed by (server, account); `account` is the +// bot's username — the human's token is never persisted. +package credentials + +import ( + "errors" + "fmt" +) + +// ErrNotFound is returned when no backend has the requested credential. +var ErrNotFound = errors.New("credential not found") + +// Store is the read/write contract every backend implements. +type Store interface { + Get(server, account string) (string, error) + Set(server, account, token string) error + Delete(server, account string) error + // Name is used in error messages. + Name() string +} + +// Chain queries each backend in order on Get; writes go to the first writable +// backend. Env (read-only) is skipped on writes. The order is keychain → +// env → file, matching the plan. +type Chain struct { + Backends []Store +} + +func (c *Chain) Name() string { return "chain" } + +// Get returns the first non-NotFound result from any backend. +func (c *Chain) Get(server, account string) (string, error) { + var lastErr error + for _, b := range c.Backends { + tok, err := b.Get(server, account) + if err == nil { + return tok, nil + } + if !errors.Is(err, ErrNotFound) { + lastErr = fmt.Errorf("%s: %w", b.Name(), err) + } + } + if lastErr != nil { + return "", lastErr + } + return "", ErrNotFound +} + +// Set writes to the first backend that accepts a write. Env is read-only. +func (c *Chain) Set(server, account, token string) error { + for _, b := range c.Backends { + if _, ok := b.(*EnvBackend); ok { + continue + } + if err := b.Set(server, account, token); err == nil { + return nil + } else if !errors.Is(err, errReadOnly) { + return fmt.Errorf("%s: %w", b.Name(), err) + } + } + return errors.New("no writable backend available") +} + +// Delete removes from every writable backend (best-effort). +func (c *Chain) Delete(server, account string) error { + var firstErr error + for _, b := range c.Backends { + if _, ok := b.(*EnvBackend); ok { + continue + } + if err := b.Delete(server, account); err != nil && !errors.Is(err, ErrNotFound) && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +// errReadOnly is sentinel for backends that refuse writes (env). +var errReadOnly = errors.New("read-only backend") + +// Default builds the standard keychain → env → file chain. +func Default() *Chain { + return &Chain{ + Backends: []Store{ + NewKeyringBackend(), + NewEnvBackend(), + NewFileBackend(""), + }, + } +}