mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-06-01 19:01:37 +00:00
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:
@@ -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=
|
||||
|
||||
136
veans/internal/auth/auth.go
Normal file
136
veans/internal/auth/auth.go
Normal 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
|
||||
34
veans/internal/auth/auth_test.go
Normal file
34
veans/internal/auth/auth_test.go
Normal 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
|
||||
}
|
||||
27
veans/internal/credentials/env.go
Normal file
27
veans/internal/credentials/env.go
Normal 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 }
|
||||
136
veans/internal/credentials/file.go
Normal file
136
veans/internal/credentials/file.go
Normal 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)
|
||||
}
|
||||
106
veans/internal/credentials/file_test.go
Normal file
106
veans/internal/credentials/file_test.go
Normal 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
|
||||
}
|
||||
54
veans/internal/credentials/keyring.go
Normal file
54
veans/internal/credentials/keyring.go
Normal 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
|
||||
}
|
||||
91
veans/internal/credentials/store.go
Normal file
91
veans/internal/credentials/store.go
Normal 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(""),
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user