",
+ cfg.Bot.Username,
+ "Refs:",
+ "veans claim",
+ "Todo",
+ "In Progress",
+ "In Review",
+ "Done",
+ "Scrapped",
+ }
+ for _, s := range mustContain {
+ if !strings.Contains(out, s) {
+ t.Errorf("prime output missing %q", s)
+ }
+ }
+}
+
+// TestPrime_SilentOutsideWorkspace verifies the safe-globally-installed
+// hook contract: no .veans.yml ⇒ silent + exit 0.
+func TestPrime_SilentOutsideWorkspace(t *testing.T) {
+ h := New(t)
+
+ // A workspace with no .veans.yml — just a temp dir.
+ ws := h.NewWorkspace(t)
+
+ stdout, stderr, code := h.Run(t, ws, "prime")
+ if code != 0 {
+ t.Fatalf("prime exit %d (expected 0)\n%s\n%s", code, stdout, stderr)
+ }
+ if stdout != "" {
+ t.Fatalf("expected silent stdout outside workspace, got: %s", stdout)
+ }
+}
diff --git a/veans/e2e/shared_test.go b/veans/e2e/shared_test.go
new file mode 100644
index 000000000..dba95a3a0
--- /dev/null
+++ b/veans/e2e/shared_test.go
@@ -0,0 +1,74 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package e2e
+
+import (
+ "os/exec"
+ "strconv"
+ "strings"
+ "testing"
+
+ "code.vikunja.io/veans/internal/config"
+)
+
+// provisionWorkspace runs `veans init` against a fresh project and returns
+// the workspace + harness primed for command-level e2e tests. Each test that
+// needs a working .veans.yml calls this at the top.
+func provisionWorkspace(t *testing.T) (*Workspace, *Harness) {
+ t.Helper()
+ h := New(t)
+ suffix := uniqueSuffix()
+ project := h.CreateProject(t, "veans-e2e-"+suffix, identifier(suffix))
+ view := h.FindKanbanView(t, project.ID)
+
+ ws := h.NewWorkspace(t)
+ ws.BotUsername = "bot-veans-e2e-" + suffix
+
+ _, stderr, code := h.Run(t, ws,
+ "init",
+ "--server", h.APIURL,
+ "--token", h.AdminToken,
+ "--project", strconv.FormatInt(project.ID, 10),
+ "--view", strconv.FormatInt(view.ID, 10),
+ "--bot-username", ws.BotUsername,
+ "--yes-buckets",
+ )
+ if code != 0 {
+ t.Fatalf("provision init failed: %s", stderr)
+ }
+ return ws, h
+}
+
+// loadConfig reads .veans.yml out of a workspace.
+func loadConfig(t *testing.T, ws *Workspace) *config.Config {
+ t.Helper()
+ c, err := config.Load(ws.ConfigPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return c
+}
+
+// gitInWorkspace runs git inside the workspace and fails the test on error.
+func gitInWorkspace(t *testing.T, ws *Workspace, args ...string) {
+ t.Helper()
+ cmd := exec.CommandContext(t.Context(), "git", args...)
+ cmd.Dir = ws.Dir
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
+ }
+}
diff --git a/veans/e2e/tasks_test.go b/veans/e2e/tasks_test.go
new file mode 100644
index 000000000..4e2dee52b
--- /dev/null
+++ b/veans/e2e/tasks_test.go
@@ -0,0 +1,161 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package e2e
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+ "testing"
+
+ "code.vikunja.io/veans/internal/client"
+)
+
+// TestCreateShowList_RoundTrip verifies the read+write path against a real
+// Vikunja: provision a workspace via init, create a task, show it, list it
+// (with --filter), and confirm the JSON shapes are unwrapped raw object/array.
+func TestCreateShowList_RoundTrip(t *testing.T) {
+ ws, h := provisionWorkspace(t)
+
+ // Create a task with a description and a label.
+ out, errOut, code := h.Run(t, ws,
+ "create", "Test bug fix",
+ "-d", "## Repro\n- [ ] step 1\n- [ ] step 2",
+ "--label", "bug",
+ "--priority", "3",
+ )
+ if code != 0 {
+ t.Fatalf("create exit %d\n%s\n%s", code, out, errOut)
+ }
+ var created client.Task
+ if err := json.Unmarshal([]byte(out), &created); err != nil {
+ t.Fatalf("decode create: %v\n%s", err, out)
+ }
+ if created.Title != "Test bug fix" {
+ t.Fatalf("created title = %q", created.Title)
+ }
+ if created.Priority != 3 {
+ t.Fatalf("priority = %d", created.Priority)
+ }
+ if !taskHasLabelTitle(&created, "veans:bug") {
+ t.Fatalf("expected veans:bug label on created task; got %+v", created.Labels)
+ }
+
+ // Show with --json — should be a raw object, not enveloped.
+ id := fmt.Sprintf("%d", created.Index)
+ showOut, _, code := h.Run(t, ws, "show", id)
+ if code != 0 {
+ t.Fatalf("show exit %d", code)
+ }
+ var shown client.Task
+ if err := json.Unmarshal([]byte(showOut), &shown); err != nil {
+ t.Fatalf("decode show: %v\n%s", err, showOut)
+ }
+ if shown.ID != created.ID {
+ t.Fatalf("show returned wrong task: %d vs %d", shown.ID, created.ID)
+ }
+
+ // List with --json — should be a raw array.
+ listOut, _, code := h.Run(t, ws, "list")
+ if code != 0 {
+ t.Fatalf("list exit %d", code)
+ }
+ var listed []*client.Task
+ if err := json.Unmarshal([]byte(listOut), &listed); err != nil {
+ t.Fatalf("decode list: %v\n%s", err, listOut)
+ }
+ if len(listed) == 0 {
+ t.Fatalf("list returned empty array; expected at least our created task")
+ }
+
+ // --filter passthrough: only items with priority > 2.
+ filterOut, _, code := h.Run(t, ws, "list", "--filter", "priority > 2")
+ if code != 0 {
+ t.Fatalf("list --filter exit %d\n%s", code, filterOut)
+ }
+ var filtered []*client.Task
+ if err := json.Unmarshal([]byte(filterOut), &filtered); err != nil {
+ t.Fatalf("decode filter list: %v", err)
+ }
+ for _, ft := range filtered {
+ if ft.Priority <= 2 {
+ t.Fatalf("filter leaked priority=%d task into result", ft.Priority)
+ }
+ }
+
+ // Empty result set must encode as `[]`, not `null` — JSON-parsing agents
+ // can't reliably branch on the latter. Use a filter that matches nothing.
+ emptyOut, _, code := h.Run(t, ws, "list", "--filter", "priority > 10")
+ if code != 0 {
+ t.Fatalf("list (empty) exit %d\n%s", code, emptyOut)
+ }
+ if got := strings.TrimSpace(emptyOut); got != "[]" {
+ t.Fatalf("empty list should print []; got %q", got)
+ }
+}
+
+// TestUpdate_DescriptionReplaceUniqueness pins the agent-friendly Edit-tool
+// behavior: the "old" string must match exactly once, otherwise the update
+// errors and nothing changes.
+func TestUpdate_DescriptionReplaceUniqueness(t *testing.T) {
+ ws, h := provisionWorkspace(t)
+
+ out, errOut, code := h.Run(t, ws, "create", "checkbox task",
+ "-d", "- [ ] step 1\n- [ ] step 1 (again)",
+ )
+ if code != 0 {
+ t.Fatalf("create exit %d\n%s\n%s", code, out, errOut)
+ }
+ var created client.Task
+ if err := json.Unmarshal([]byte(out), &created); err != nil {
+ t.Fatal(err)
+ }
+ id := fmt.Sprintf("%d", created.Index)
+
+ // Non-unique replace should fail with a validation error and a
+ // non-zero exit code.
+ _, stderr, code := h.Run(t, ws,
+ "update", id,
+ "--description-replace-old", "step 1",
+ "--description-replace-new", "step 1 [x]",
+ )
+ if code == 0 {
+ t.Fatalf("expected non-zero exit on non-unique replace")
+ }
+ if !strings.Contains(stderr, "VALIDATION_ERROR") {
+ t.Fatalf("expected VALIDATION_ERROR in stderr, got: %s", stderr)
+ }
+
+ // Disambiguate and try again — should succeed.
+ _, _, code = h.Run(t, ws,
+ "update", id,
+ "--description-replace-old", "- [ ] step 1\n",
+ "--description-replace-new", "- [x] step 1\n",
+ )
+ if code != 0 {
+ t.Fatalf("disambiguated update should succeed; exit %d", code)
+ }
+}
+
+func taskHasLabelTitle(t *client.Task, title string) bool {
+ for _, l := range t.Labels {
+ if l != nil && l.Title == title {
+ return true
+ }
+ }
+ return false
+}
diff --git a/veans/go.mod b/veans/go.mod
new file mode 100644
index 000000000..c025994dc
--- /dev/null
+++ b/veans/go.mod
@@ -0,0 +1,21 @@
+module code.vikunja.io/veans
+
+go 1.25.0
+
+require (
+ github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b
+ github.com/magefile/mage v1.17.2
+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
+ github.com/spf13/cobra v1.10.2
+ github.com/zalando/go-keyring v0.2.8
+ golang.org/x/sys v0.43.0
+ 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
+)
diff --git a/veans/go.sum b/veans/go.sum
new file mode 100644
index 000000000..3e0c9d612
--- /dev/null
+++ b/veans/go.sum
@@ -0,0 +1,38 @@
+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/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b h1:qZ21OofI7zneC9dOEqul4FmIWz/YjJJMrf6fL7jrFYQ=
+github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc=
+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/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
+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.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+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..60706aaf7
--- /dev/null
+++ b/veans/internal/auth/auth.go
@@ -0,0 +1,165 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// Package auth handles the human's transient authentication during init and
+// login. The default interactive flow is OAuth 2.0 Authorization Code + PKCE
+// against Vikunja's built-in authorization server. The OAuth dance opens a
+// browser at the authorize URL; the user signs in and lands on a localhost
+// callback this CLI ran. --token / --use-password / --username + --password
+// are escape hatches for non-interactive contexts.
+package auth
+
+import (
+ "bufio"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+
+ "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 writes prompts to os.Stderr; uses
+// term.ReadPassword for masked input when on a TTY. The bufio.Reader is
+// reused across ReadLine calls — a new reader on each call would read-
+// ahead a buffer, discard the rest on return, and starve later prompts.
+type StdPrompter struct {
+ stdin *bufio.Reader
+}
+
+func NewStdPrompter() *StdPrompter {
+ return &StdPrompter{stdin: bufio.NewReader(os.Stdin)}
+}
+
+func (p *StdPrompter) ReadLine(prompt string) (string, error) {
+ if _, err := fmt.Fprint(os.Stderr, prompt); err != nil {
+ return "", err
+ }
+ line, err := p.stdin.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(os.Stderr, prompt); err != nil {
+ return "", err
+ }
+ if term.IsTerminal(int(os.Stdin.Fd())) {
+ buf, err := term.ReadPassword(int(os.Stdin.Fd()))
+ fmt.Fprintln(os.Stderr)
+ if err != nil {
+ return "", err
+ }
+ return string(buf), nil
+ }
+ // Non-TTY (CI, scripted test) — read a plain line.
+ return p.ReadLine("")
+}
+
+// LoginOptions controls how AcquireHumanToken obtains a JWT.
+type LoginOptions struct {
+ // Token short-circuits all flows. May be a JWT or a personal API token.
+ Token string
+ // UsePassword forces the legacy POST /login flow even when no password
+ // is set yet (the prompter will ask for it). Useful on instances where
+ // OAuth is disabled or the user prefers entering a password.
+ UsePassword bool
+ // Username / Password / TOTP feed POST /login. If both Username and
+ // Password are non-empty, AcquireHumanToken uses /login non-interactively
+ // regardless of UsePassword.
+ Username string
+ Password string
+ TOTP string
+ // Out is where progress / OAuth instructions are written. Defaults to
+ // os.Stderr in production via NewStdPrompter; tests can pass any writer.
+ Out io.Writer
+}
+
+// AcquireHumanToken returns a bearer token to act as the human during init.
+// Resolution order:
+// 1. opts.Token (paste-in or --token flag)
+// 2. POST /login with Username + Password (used non-interactively when both
+// are set, or when --use-password is passed)
+// 3. OAuth Authorization Code + PKCE flow with manual callback paste-back
+// (the default for interactive use)
+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()
+ }
+ w := opts.Out
+ if w == nil {
+ w = os.Stderr
+ }
+
+ usePassword := opts.UsePassword || (opts.Username != "" && opts.Password != "")
+ if usePassword {
+ return loginWithPassword(ctx, c, opts, p)
+ }
+
+ return runOAuthFlow(ctx, c, p, w)
+}
+
+// loginWithPassword runs the legacy POST /login path. Kept for instances
+// that have OAuth disabled or for non-interactive `--username` + `--password`
+// invocations in CI.
+func loginWithPassword(ctx context.Context, c *client.Client, opts LoginOptions, p Prompter) (string, error) {
+ 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 for password login")
+ }
+ 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
+}
diff --git a/veans/internal/auth/auth_test.go b/veans/internal/auth/auth_test.go
new file mode 100644
index 000000000..0315cc256
--- /dev/null
+++ b/veans/internal/auth/auth_test.go
@@ -0,0 +1,50 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+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/auth/oauth.go b/veans/internal/auth/oauth.go
new file mode 100644
index 000000000..281bbabce
--- /dev/null
+++ b/veans/internal/auth/oauth.go
@@ -0,0 +1,279 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package auth
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "fmt"
+ "html"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/pkg/browser"
+
+ "code.vikunja.io/veans/internal/client"
+ "code.vikunja.io/veans/internal/output"
+)
+
+// oauthClientID is what veans presents to Vikunja's authorization server.
+// Vikunja's OAuth provider doesn't require client registration — the value
+// just needs to be consistent across the authorize and token-exchange steps.
+const oauthClientID = "veans-cli"
+
+// loopbackTimeout caps how long we wait for the user to complete the
+// browser-side handshake before giving up.
+const loopbackTimeout = 5 * time.Minute
+
+// PKCEPair holds the challenge sent to /oauth/authorize and the verifier
+// kept locally until token exchange.
+type PKCEPair struct {
+ Verifier string
+ Challenge string
+}
+
+// generatePKCE produces a fresh (verifier, challenge) pair per RFC 7636.
+// The verifier is 64 random bytes, base64url-encoded without padding (~86
+// characters — comfortably inside the 43–128 range Vikunja accepts). The
+// challenge is the SHA-256 of the verifier, also base64url-no-pad.
+func generatePKCE() (PKCEPair, error) {
+ buf := make([]byte, 64)
+ if _, err := rand.Read(buf); err != nil {
+ return PKCEPair{}, err
+ }
+ verifier := base64.RawURLEncoding.EncodeToString(buf)
+ sum := sha256.Sum256([]byte(verifier))
+ challenge := base64.RawURLEncoding.EncodeToString(sum[:])
+ return PKCEPair{Verifier: verifier, Challenge: challenge}, nil
+}
+
+// generateState returns a random opaque string for CSRF protection.
+func generateState() (string, error) {
+ buf := make([]byte, 24)
+ if _, err := rand.Read(buf); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(buf), nil
+}
+
+// buildAuthorizeURL renders the browser-side redirect target.
+func buildAuthorizeURL(server, redirectURI string, pkce PKCEPair, state string) string {
+ q := url.Values{}
+ q.Set("response_type", "code")
+ q.Set("client_id", oauthClientID)
+ q.Set("redirect_uri", redirectURI)
+ q.Set("code_challenge", pkce.Challenge)
+ q.Set("code_challenge_method", "S256")
+ q.Set("state", state)
+ return strings.TrimRight(server, "/") + "/oauth/authorize?" + q.Encode()
+}
+
+// callbackResult carries the parsed query parameters from the loopback
+// callback request, or any error that prevented a clean handshake.
+type callbackResult struct {
+ code string
+ state string
+ err error
+}
+
+// runOAuthFlow drives an OAuth Authorization Code + PKCE handshake against
+// Vikunja's server using a localhost loopback listener (RFC 8252):
+// bind 127.0.0.1:0, open the authorize URL in the browser, capture the
+// callback, exchange the code for a token.
+//
+// The prompter is retained on the signature for symmetry with the
+// password flow but isn't called — the loopback handshake completes
+// without further user input beyond the in-browser sign-in.
+func runOAuthFlow(ctx context.Context, c *client.Client, _ Prompter, w io.Writer) (string, error) {
+ pkce, err := generatePKCE()
+ if err != nil {
+ return "", output.Wrap(output.CodeUnknown, err, "generate PKCE: %v", err)
+ }
+ state, err := generateState()
+ if err != nil {
+ return "", output.Wrap(output.CodeUnknown, err, "generate state: %v", err)
+ }
+
+ listener, redirectURI, err := bindLoopbackListener(ctx)
+ if err != nil {
+ return "", err
+ }
+
+ server, resultCh := newCallbackServer(listener)
+ go func() { _ = server.Serve(listener) }()
+ // Shutdown uses a detached context derived from ctx so cancellation
+ // at the outer scope still allows the graceful-stop to drain.
+ shutdownParent := context.WithoutCancel(ctx)
+ defer func() {
+ shutdownCtx, cancel := context.WithTimeout(shutdownParent, 2*time.Second)
+ defer cancel()
+ _ = server.Shutdown(shutdownCtx)
+ }()
+
+ authURL := buildAuthorizeURL(c.BaseURL, redirectURI, pkce, state)
+ announceBrowserStep(w, authURL)
+ // Best-effort browser launch — the URL is also printed so the user
+ // can paste it manually if their environment can't auto-open one
+ // (SSH session, container without DISPLAY, etc.).
+ _ = browser.OpenURL(authURL)
+
+ result, err := waitForCallback(ctx, resultCh)
+ if err != nil {
+ return "", err
+ }
+ if result.state != state {
+ return "", output.New(output.CodeAuth,
+ "state mismatch on OAuth callback (possible CSRF)")
+ }
+
+ resp, err := c.ExchangeOAuthCode(ctx, &client.OAuthTokenRequest{
+ GrantType: "authorization_code",
+ Code: result.code,
+ ClientID: oauthClientID,
+ RedirectURI: redirectURI,
+ CodeVerifier: pkce.Verifier,
+ })
+ if err != nil {
+ return "", err
+ }
+ if resp.AccessToken == "" {
+ return "", output.New(output.CodeAuth, "OAuth token exchange returned empty access_token")
+ }
+ return resp.AccessToken, nil
+}
+
+// bindLoopbackListener picks a free port on 127.0.0.1 and returns a
+// listener + the corresponding `http://127.0.0.1:NNN/callback` URI for
+// the OAuth `redirect_uri` parameter.
+func bindLoopbackListener(ctx context.Context) (net.Listener, string, error) {
+ var lc net.ListenConfig
+ listener, err := lc.Listen(ctx, "tcp", "127.0.0.1:0")
+ if err != nil {
+ return nil, "", output.Wrap(output.CodeUnknown, err,
+ "bind loopback port for OAuth callback: %v", err)
+ }
+ port := listener.Addr().(*net.TCPAddr).Port
+ return listener, fmt.Sprintf("http://127.0.0.1:%d/callback", port), nil
+}
+
+// newCallbackServer returns an http.Server bound to `listener` whose
+// /callback handler parses the authorization-server redirect query and
+// pushes the result onto the returned channel.
+func newCallbackServer(listener net.Listener) (*http.Server, <-chan callbackResult) {
+ resultCh := make(chan callbackResult, 1)
+ server := &http.Server{
+ Addr: listener.Addr().String(),
+ ReadHeaderTimeout: 5 * time.Second,
+ WriteTimeout: 10 * time.Second,
+ IdleTimeout: 10 * time.Second,
+ Handler: http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/callback" {
+ http.NotFound(rw, r)
+ return
+ }
+ // Pin to GET so a third-party page can't POST a forged
+ // (code, state) into the loopback handler. State binding
+ // already defends, but cheap belt-and-braces.
+ if r.Method != http.MethodGet {
+ rw.Header().Set("Allow", "GET")
+ http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ q := r.URL.Query()
+ res := callbackResult{code: q.Get("code"), state: q.Get("state")}
+ if errCode := q.Get("error"); errCode != "" {
+ desc := q.Get("error_description")
+ if desc == "" {
+ desc = errCode
+ }
+ res.err = fmt.Errorf("authorization failed: %s", desc)
+ }
+ renderCallbackPage(rw, res.err)
+ select {
+ case resultCh <- res:
+ default:
+ }
+ }),
+ }
+ return server, resultCh
+}
+
+// waitForCallback blocks until the loopback handler fires, ctx cancels,
+// or loopbackTimeout elapses.
+func waitForCallback(ctx context.Context, resultCh <-chan callbackResult) (callbackResult, error) {
+ timer := time.NewTimer(loopbackTimeout)
+ defer timer.Stop()
+ select {
+ case result := <-resultCh:
+ if result.err != nil {
+ return result, output.Wrap(output.CodeAuth, result.err, "%v", result.err)
+ }
+ if result.code == "" {
+ return result, output.New(output.CodeAuth, "no `code` returned from OAuth callback")
+ }
+ return result, nil
+ case <-timer.C:
+ return callbackResult{}, output.New(output.CodeAuth,
+ "OAuth flow timed out after %s — re-run init with --use-password or --token", loopbackTimeout)
+ case <-ctx.Done():
+ return callbackResult{}, ctx.Err()
+ }
+}
+
+func announceBrowserStep(w io.Writer, authURL string) {
+ if w == nil {
+ return
+ }
+ fmt.Fprintln(w)
+ fmt.Fprintln(w, "Opening your browser to authorize veans:")
+ fmt.Fprintln(w, " "+authURL)
+ fmt.Fprintln(w)
+ fmt.Fprintln(w, "If the browser doesn't open, paste the URL above manually.")
+ fmt.Fprintln(w)
+}
+
+// renderCallbackPage writes a minimal HTML response to the user's browser
+// after the loopback callback fires. We don't ship any framework — a few
+// lines of inlined HTML are enough to confirm completion.
+func renderCallbackPage(w http.ResponseWriter, err error) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ if err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ // HTML-escape the authorization-server's error_description — it
+ // arrives unsanitized from a remote source and we render it
+ // straight into the loopback page.
+ _, _ = fmt.Fprintf(w, `
+veans: authorization failed
+%s
+You can close this tab and re-run veans init.
+`, html.EscapeString(err.Error()))
+ return
+ }
+ _, _ = w.Write([]byte(`
+veans is authorized
+You can close this tab and return to the terminal.
+`))
+}
+
+// silence the unused-import linter when errors isn't referenced elsewhere.
diff --git a/veans/internal/auth/oauth_test.go b/veans/internal/auth/oauth_test.go
new file mode 100644
index 000000000..9af726bc2
--- /dev/null
+++ b/veans/internal/auth/oauth_test.go
@@ -0,0 +1,286 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package auth
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/base64"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestGeneratePKCE_VerifierShape(t *testing.T) {
+ pair, err := generatePKCE()
+ if err != nil {
+ t.Fatal(err)
+ }
+ // RFC 7636 §4.1: verifier is 43–128 chars, [A-Za-z0-9-._~].
+ if len(pair.Verifier) < 43 || len(pair.Verifier) > 128 {
+ t.Fatalf("verifier length %d out of [43,128]", len(pair.Verifier))
+ }
+ for _, r := range pair.Verifier {
+ switch {
+ case r >= 'A' && r <= 'Z',
+ r >= 'a' && r <= 'z',
+ r >= '0' && r <= '9',
+ r == '-', r == '.', r == '_', r == '~':
+ default:
+ t.Fatalf("verifier contains illegal rune %q", r)
+ }
+ }
+ // Challenge must be SHA256(verifier) base64url-no-pad.
+ want := sha256.Sum256([]byte(pair.Verifier))
+ got, err := base64.RawURLEncoding.DecodeString(pair.Challenge)
+ if err != nil {
+ t.Fatalf("challenge isn't base64url-no-pad: %v", err)
+ }
+ if string(got) != string(want[:]) {
+ t.Fatal("challenge != SHA256(verifier)")
+ }
+}
+
+func TestGeneratePKCE_Unique(t *testing.T) {
+ a, _ := generatePKCE()
+ b, _ := generatePKCE()
+ if a.Verifier == b.Verifier {
+ t.Fatal("two consecutive verifiers are identical — entropy is broken")
+ }
+}
+
+func TestBuildAuthorizeURL(t *testing.T) {
+ u := buildAuthorizeURL(
+ "https://vikunja.example.com",
+ "http://127.0.0.1:54321/callback",
+ PKCEPair{Challenge: "CHL"},
+ "S",
+ )
+ if !strings.HasPrefix(u, "https://vikunja.example.com/oauth/authorize?") {
+ t.Fatalf("unexpected prefix: %s", u)
+ }
+ for _, want := range []string{
+ "response_type=code",
+ "client_id=" + oauthClientID,
+ "code_challenge=CHL",
+ "code_challenge_method=S256",
+ "state=S",
+ // redirect_uri carried through (URL-encoded)
+ "redirect_uri=http%3A%2F%2F127.0.0.1%3A54321%2Fcallback",
+ } {
+ if !strings.Contains(u, want) {
+ t.Errorf("authorize URL missing %q: %s", want, u)
+ }
+ }
+ // Server URL with trailing slash should still produce a single slash
+ // before the path.
+ u2 := buildAuthorizeURL("https://vikunja.example.com/", "", PKCEPair{}, "")
+ if strings.Contains(u2, "//oauth") {
+ t.Errorf("trailing slash leaked into URL: %s", u2)
+ }
+}
+
+// newCallbackHandler returns just the http.Handler portion of
+// newCallbackServer so tests can drive it directly with httptest.NewRecorder
+// without binding a real loopback socket.
+func newCallbackHandler(t *testing.T) (http.Handler, <-chan callbackResult) {
+ t.Helper()
+ var lc net.ListenConfig
+ listener, err := lc.Listen(context.Background(), "tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("listen: %v", err)
+ }
+ t.Cleanup(func() { _ = listener.Close() })
+ server, ch := newCallbackServer(listener)
+ return server.Handler, ch
+}
+
+func TestNewCallbackServer_HappyPath(t *testing.T) {
+ handler, ch := newCallbackHandler(t)
+ req := httptest.NewRequest(http.MethodGet, "/callback?code=abc&state=xyz", nil)
+ rec := httptest.NewRecorder()
+
+ handler.ServeHTTP(rec, req)
+
+ select {
+ case res := <-ch:
+ if res.code != "abc" {
+ t.Errorf("code = %q, want abc", res.code)
+ }
+ if res.state != "xyz" {
+ t.Errorf("state = %q, want xyz", res.state)
+ }
+ if res.err != nil {
+ t.Errorf("err = %v, want nil", res.err)
+ }
+ default:
+ t.Fatal("no result pushed to channel")
+ }
+
+ if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
+ t.Errorf("Content-Type = %q, want text/html…", ct)
+ }
+ if rec.Code != http.StatusOK {
+ t.Errorf("status = %d, want 200", rec.Code)
+ }
+}
+
+func TestNewCallbackServer_AuthzServerError(t *testing.T) {
+ handler, ch := newCallbackHandler(t)
+ req := httptest.NewRequest(http.MethodGet,
+ "/callback?error=access_denied&error_description=user+declined", nil)
+ rec := httptest.NewRecorder()
+
+ handler.ServeHTTP(rec, req)
+
+ select {
+ case res := <-ch:
+ if res.err == nil {
+ t.Fatal("err = nil, want non-nil")
+ }
+ // renderCallbackPage uses error_description when present; the
+ // handler also stuffs it into res.err. "user declined" comes
+ // straight from error_description.
+ if !strings.Contains(res.err.Error(), "user declined") {
+ t.Errorf("err = %q, want it to mention error_description", res.err.Error())
+ }
+ default:
+ t.Fatal("no result pushed to channel")
+ }
+
+ if rec.Code != http.StatusBadRequest {
+ t.Errorf("status = %d, want 400", rec.Code)
+ }
+}
+
+func TestNewCallbackServer_AuthzServerErrorOnlyCode(t *testing.T) {
+ // When error_description is empty, the handler falls back to the
+ // `error` code in the user-visible message.
+ handler, ch := newCallbackHandler(t)
+ req := httptest.NewRequest(http.MethodGet, "/callback?error=access_denied", nil)
+ rec := httptest.NewRecorder()
+
+ handler.ServeHTTP(rec, req)
+
+ select {
+ case res := <-ch:
+ if res.err == nil {
+ t.Fatal("err = nil, want non-nil")
+ }
+ if !strings.Contains(res.err.Error(), "access_denied") {
+ t.Errorf("err = %q, want it to mention error code", res.err.Error())
+ }
+ default:
+ t.Fatal("no result pushed to channel")
+ }
+}
+
+func TestNewCallbackServer_EmptyCode(t *testing.T) {
+ // Empty `code` without an `error` parameter is the handler's job
+ // only to forward verbatim — waitForCallback is the one that
+ // upgrades that to an error.
+ handler, ch := newCallbackHandler(t)
+ req := httptest.NewRequest(http.MethodGet, "/callback?state=xyz", nil)
+ rec := httptest.NewRecorder()
+
+ handler.ServeHTTP(rec, req)
+
+ select {
+ case res := <-ch:
+ if res.code != "" {
+ t.Errorf("code = %q, want empty", res.code)
+ }
+ if res.state != "xyz" {
+ t.Errorf("state = %q, want xyz", res.state)
+ }
+ if res.err != nil {
+ t.Errorf("err = %v, want nil", res.err)
+ }
+ default:
+ t.Fatal("no result pushed to channel")
+ }
+}
+
+func TestNewCallbackServer_MethodNotAllowed(t *testing.T) {
+ handler, ch := newCallbackHandler(t)
+ req := httptest.NewRequest(http.MethodPost, "/callback?code=abc&state=xyz", nil)
+ rec := httptest.NewRecorder()
+
+ handler.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusMethodNotAllowed {
+ t.Errorf("status = %d, want 405", rec.Code)
+ }
+ if a := rec.Header().Get("Allow"); a != "GET" {
+ t.Errorf("Allow header = %q, want GET", a)
+ }
+ select {
+ case res := <-ch:
+ t.Fatalf("nothing should be pushed for a rejected method, got %+v", res)
+ default:
+ }
+}
+
+func TestNewCallbackServer_WrongPath(t *testing.T) {
+ handler, ch := newCallbackHandler(t)
+ req := httptest.NewRequest(http.MethodGet, "/something-else", nil)
+ rec := httptest.NewRecorder()
+
+ handler.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusNotFound {
+ t.Errorf("status = %d, want 404", rec.Code)
+ }
+ select {
+ case res := <-ch:
+ t.Fatalf("nothing should be pushed for a 404, got %+v", res)
+ default:
+ }
+}
+
+func TestRenderCallbackPage_HTMLEscapesError(t *testing.T) {
+ rec := httptest.NewRecorder()
+ renderCallbackPage(rec, &fakeError{msg: ``})
+
+ body := rec.Body.String()
+ if strings.Contains(body, "