From df7a60d13778a2a4ee5b257b25b476c0fb78b777 Mon Sep 17 00:00:00 2001 From: Tink bot Date: Tue, 26 May 2026 22:41:21 +0200 Subject: [PATCH] feat(veans): add login command for token rotation --- veans/internal/commands/login.go | 94 ++++++++++++++++++++++++++++++++ veans/internal/commands/root.go | 1 + 2 files changed, 95 insertions(+) create mode 100644 veans/internal/commands/login.go diff --git a/veans/internal/commands/login.go b/veans/internal/commands/login.go new file mode 100644 index 000000000..8665d49eb --- /dev/null +++ b/veans/internal/commands/login.go @@ -0,0 +1,94 @@ +package commands + +import ( + "errors" + "fmt" + "os" + + "github.com/spf13/cobra" + + "code.vikunja.io/veans/internal/auth" + "code.vikunja.io/veans/internal/client" + "code.vikunja.io/veans/internal/config" + "code.vikunja.io/veans/internal/credentials" + "code.vikunja.io/veans/internal/output" +) + +func newLoginCmd() *cobra.Command { + var ( + token string + username string + password string + totp string + ) + cmd := &cobra.Command{ + Use: "login", + Short: "Mint a fresh API token for the bot user (rotation)", + Long: `Re-authenticates as you (the bot's owner) and mints a new API token +for the bot configured in .veans.yml. The new token replaces the +existing one in the credential store. + +Use this after revoking the bot's token in Vikunja's UI, or any time +you want to rotate.`, + RunE: func(cmd *cobra.Command, args []string) error { + path, err := config.Find("") + if err != nil { + if errors.Is(err, config.ErrNotFound) { + return output.Wrap(output.CodeNotConfigured, err, + "no .veans.yml found — run `veans init` first") + } + return err + } + cfg, err := config.Load(path) + if err != nil { + return err + } + + human := client.New(cfg.Server, "") + tok, err := auth.AcquireHumanToken(cmd.Context(), human, auth.LoginOptions{ + Token: token, + Username: username, + Password: password, + TOTP: totp, + }, auth.NewStdPrompter()) + if err != nil { + return err + } + human.Token = tok + + routes, err := human.Routes(cmd.Context()) + if err != nil { + return output.Wrap(output.CodeUnknown, err, "fetch /routes: %v", err) + } + perms := client.PermissionsForBot(routes) + if len(perms) == 0 { + return output.New(output.CodeUnknown, "no API token permissions available") + } + + minted, err := human.CreateToken(cmd.Context(), &client.APIToken{ + Title: "veans (rotated)", + Permissions: perms, + ExpiresAt: client.FarFuture, + OwnerID: cfg.Bot.UserID, + }) + if err != nil { + return err + } + if minted.Token == "" { + return output.New(output.CodeUnknown, "PUT /tokens did not return token plaintext") + } + + if err := credentials.Default().Set(cfg.Server, cfg.Bot.Username, minted.Token); err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "Rotated token for %s on %s\n", cfg.Bot.Username, cfg.Server) + return nil + }, + } + cmd.Flags().StringVar(&token, "token", "", "JWT or personal API token (skips password prompt)") + cmd.Flags().StringVar(&username, "username", "", "your Vikunja username") + cmd.Flags().StringVar(&password, "password", "", "your Vikunja password (prompted if empty)") + cmd.Flags().StringVar(&totp, "totp", "", "TOTP code if your account requires 2FA") + return cmd +} diff --git a/veans/internal/commands/root.go b/veans/internal/commands/root.go index 165da1fd9..e5f8471c2 100644 --- a/veans/internal/commands/root.go +++ b/veans/internal/commands/root.go @@ -42,6 +42,7 @@ func Root(version string) *cobra.Command { root.AddCommand(newClaimCmd()) root.AddCommand(newPrimeCmd()) root.AddCommand(newAPICmd()) + root.AddCommand(newLoginCmd()) return root }