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/auth/auth.go b/veans/internal/auth/auth.go new file mode 100644 index 000000000..c11883290 --- /dev/null +++ b/veans/internal/auth/auth.go @@ -0,0 +1,136 @@ +// Package auth handles the human's transient authentication during init and +// login. v0 uses POST /login (username + password) to mint a JWT we hold in +// memory only — Vikunja's OAuth provider flow requires a registered client +// and an existing JWT to authorize, which adds friction we don't need yet. +// +// Pre-existing JWTs and personal API tokens may be passed via --token, which +// short-circuits the prompt entirely; this is the path SSO/OIDC users take +// since they cannot log in with a local password. +package auth + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "strings" + "syscall" + + "golang.org/x/term" + + "code.vikunja.io/veans/internal/client" + "code.vikunja.io/veans/internal/output" +) + +// Prompter abstracts stdin / TTY reads so tests can inject scripted answers. +type Prompter interface { + ReadLine(prompt string) (string, error) + ReadPassword(prompt string) (string, error) +} + +// StdPrompter reads from os.Stdin and uses term.ReadPassword for masked +// input. It's the production default. +type StdPrompter struct { + In io.Reader + Out io.Writer +} + +func NewStdPrompter() *StdPrompter { + return &StdPrompter{In: os.Stdin, Out: os.Stderr} +} + +func (p *StdPrompter) ReadLine(prompt string) (string, error) { + if _, err := fmt.Fprint(p.Out, prompt); err != nil { + return "", err + } + r := bufio.NewReader(p.In) + line, err := r.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "", err + } + return strings.TrimRight(line, "\r\n"), nil +} + +func (p *StdPrompter) ReadPassword(prompt string) (string, error) { + if _, err := fmt.Fprint(p.Out, prompt); err != nil { + return "", err + } + if f, ok := p.In.(*os.File); ok && term.IsTerminal(int(f.Fd())) { + buf, err := term.ReadPassword(int(f.Fd())) + fmt.Fprintln(p.Out) + if err != nil { + return "", err + } + return string(buf), nil + } + // Non-TTY (CI, scripted test) — read a plain line. + line, err := p.ReadLine("") + return line, err +} + +// LoginOptions controls how AcquireHumanToken obtains a JWT. +type LoginOptions struct { + // Token short-circuits the prompt. May be a JWT or a personal API token. + Token string + // Username is optional — if empty, the prompter asks. Required for + // password-based login. + Username string + // Password is optional — if empty, the prompter asks (masked). + Password string + // TOTP, if set, is sent with the login request. + TOTP string +} + +// AcquireHumanToken returns a bearer token to act as the human during init. +// Order of resolution: +// 1. opts.Token (paste-in or --token flag) +// 2. POST /login with opts.Username/Password (prompts to fill missing parts) +func AcquireHumanToken(ctx context.Context, c *client.Client, opts LoginOptions, p Prompter) (string, error) { + if opts.Token != "" { + return opts.Token, nil + } + if p == nil { + p = NewStdPrompter() + } + if opts.Username == "" { + u, err := p.ReadLine("Vikunja username: ") + if err != nil { + return "", output.Wrap(output.CodeAuth, err, "read username: %v", err) + } + opts.Username = strings.TrimSpace(u) + } + if opts.Password == "" { + pw, err := p.ReadPassword("Vikunja password: ") + if err != nil { + return "", output.Wrap(output.CodeAuth, err, "read password: %v", err) + } + opts.Password = pw + } + if opts.Username == "" || opts.Password == "" { + return "", output.New(output.CodeAuth, "username and password are required") + } + + // Vikunja's local /login takes either a username or an email; we let the + // server decide. LongToken=true requests a longer-lived JWT, useful since + // init may take a few seconds. + resp, err := c.Login(ctx, &client.LoginRequest{ + Username: opts.Username, + Password: opts.Password, + TOTPPasscode: opts.TOTP, + LongToken: true, + }) + if err != nil { + return "", err + } + if resp.Token == "" { + return "", output.New(output.CodeAuth, "login returned empty token") + } + return resp.Token, nil +} + +// silenceLinter suppresses the unused syscall import on platforms where +// term.ReadPassword inlines its own platform call. We keep the import to +// document that masked input is expected to use POSIX-level terminal modes. +var _ = syscall.Stdin diff --git a/veans/internal/auth/auth_test.go b/veans/internal/auth/auth_test.go new file mode 100644 index 000000000..abe91f99e --- /dev/null +++ b/veans/internal/auth/auth_test.go @@ -0,0 +1,34 @@ +package auth + +import ( + "context" + "testing" + + "code.vikunja.io/veans/internal/client" +) + +func TestAcquireHumanToken_TokenShortCircuit(t *testing.T) { + // When opts.Token is set, no prompts and no HTTP calls happen — the + // nil client confirms that nothing tries to dial out. + tok, err := AcquireHumanToken(context.Background(), (*client.Client)(nil), LoginOptions{Token: "abc"}, &recordingPrompter{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tok != "abc" { + t.Fatalf("got %q, want abc", tok) + } +} + +type recordingPrompter struct { + calls []string +} + +func (r *recordingPrompter) ReadLine(p string) (string, error) { + r.calls = append(r.calls, "line:"+p) + return "", nil +} + +func (r *recordingPrompter) ReadPassword(p string) (string, error) { + r.calls = append(r.calls, "pw:"+p) + return "", nil +} 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(""), + }, + } +}