feat(veans): credential store + transient human auth

Adds the keychain → env → file credential chain (with the env backend
read-only and gated by VEANS_SERVER), and an auth helper that mints a
JWT via POST /login or accepts a paste-in --token to support SSO/OIDC
instances. The plan called for an OAuth loopback flow; Vikunja's OAuth
provider requires a registered client and a pre-existing JWT to
authorize, so v0 takes the simpler password / paste-in path and we'll
revisit when device-flow lands upstream.

Includes round-trip tests for the file backend and the chain fallback
behavior, plus a token-shortcut test that proves auth never dials out
when --token is supplied.
This commit is contained in:
Claude
2026-05-07 21:02:58 +00:00
parent 4920aa6dde
commit 6cb2d61b5b
9 changed files with 612 additions and 1 deletions

View File

@@ -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
)

View File

@@ -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=

136
veans/internal/auth/auth.go Normal file
View File

@@ -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

View File

@@ -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
}

View File

@@ -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 }

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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(""),
},
}
}