feat(veans): add credential store with keychain, env, and file backends
This commit is contained in:
parent
4b6b8fca78
commit
f05fc60777
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
21
veans/go.sum
21
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=
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
// `<server>::<account>` 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
|
||||
}
|
||||
|
|
@ -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(""),
|
||||
},
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue