feat(veans): add transient human auth flow

This commit is contained in:
Tink bot 2026-05-26 22:38:48 +02:00 committed by kolaente
parent f05fc60777
commit 878233f758
2 changed files with 170 additions and 0 deletions

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
}