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