feat(veans): use OAuth 2.0 Authorization Code + PKCE as default auth

Vikunja's built-in OAuth server (Vikunja 2.3+) does not require client
registration and accepts arbitrary client_ids — it just enforces PKCE
(S256) and constrains redirect URIs to the vikunja- scheme. Earlier I
deferred OAuth on the assumption it needed a registered client; that
was wrong, and the docs make the path much smoother than POST /login.

The custom-scheme constraint (no http:// loopback) is side-stepped by
manual paste-back: veans prints the authorize URL, the user signs in,
their browser fails to open vikunja-veans-cli://callback?code=... and
shows an error, the user copies the URL from the address bar and
pastes it back. CLI extracts code + state, verifies state for CSRF,
exchanges via POST /api/v1/oauth/token (JSON body — Vikunja rejects
form-encoded), and returns the access token.

Resolution order in AcquireHumanToken:
  1. --token (paste-in JWT or personal API token; SSO/OIDC users)
  2. --use-password / --username + --password (POST /login)
  3. OAuth flow (interactive default)

login command supports the same --use-password / --token escape hatches
for token rotation on instances with OAuth disabled.

Includes unit tests for the PKCE generator (verifier shape per RFC 7636,
challenge = SHA256(verifier) base64url-no-pad), authorize-URL
construction, and the lenient callback parser (full URL / query-only /
bare code).
This commit is contained in:
Tink bot 2026-05-07 21:33:30 +00:00 committed by kolaente
parent d0c77ad6fe
commit 35aa486eb5
11 changed files with 500 additions and 151 deletions

View File

@ -404,6 +404,76 @@ jobs:
name: frontend_dist
path: ./frontend/dist
test-veans-e2e:
runs-on: ubuntu-latest
needs:
- api-build
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download Vikunja Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: vikunja_bin
- name: Set up Go
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: stable
- name: Install mage
# The cached mage-static artifact has the parent magefile compiled
# in — we need a generic mage binary to pick up veans/magefile.go.
run: go install github.com/magefile/mage@v1.17.2
- run: chmod +x ./vikunja
- name: Run veans e2e against ephemeral Vikunja
env:
VIKUNJA_SERVICE_INTERFACE: ":3456"
VIKUNJA_SERVICE_PUBLICURL: "http://127.0.0.1:3456/"
VIKUNJA_SERVICE_JWTSECRET: "veans-e2e-jwt-secret-do-not-use-in-production"
VIKUNJA_DATABASE_TYPE: sqlite
VIKUNJA_DATABASE_PATH: memory
VIKUNJA_LOG_LEVEL: WARNING
VIKUNJA_MAILER_ENABLED: "false"
VIKUNJA_REDIS_ENABLED: "false"
VIKUNJA_RATELIMIT_NOAUTHLIMIT: "1000"
VEANS_E2E_API_URL: http://127.0.0.1:3456
VEANS_E2E_ADMIN_USER: e2eadmin
VEANS_E2E_ADMIN_PASS: e2etestpassword
run: |
set -e
# Boot the prebuilt API and tests in one shell — backgrounded
# processes don't survive step boundaries on GH runners.
nohup ./vikunja web > /tmp/vikunja.log 2>&1 &
API_PID=$!
trap "kill $API_PID 2>/dev/null || true" EXIT
for i in $(seq 1 60); do
if curl -sf http://127.0.0.1:3456/api/v1/info >/dev/null 2>&1; then
echo "API ready after ${i}s"
break
fi
sleep 1
done
if ! curl -sf http://127.0.0.1:3456/api/v1/info >/dev/null; then
echo "::error::API failed to start; log:"
cat /tmp/vikunja.log
exit 1
fi
# The sqlite-memory DB has no fixtures loaded — register the
# admin the suite logs in as. service.enableregistration
# defaults to true.
curl -sf -X POST http://127.0.0.1:3456/api/v1/register \
-H 'Content-Type: application/json' \
-d "{\"username\":\"$VEANS_E2E_ADMIN_USER\",\"password\":\"$VEANS_E2E_ADMIN_PASS\",\"email\":\"e2e@example.com\"}" \
> /dev/null
# `mage test` runs unit + e2e packages; the e2e suite self-skips
# when VEANS_E2E_API_URL is unset so the same target works locally.
(cd veans && mage test)
- name: Upload API log on failure
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: veans-e2e-vikunja-log
path: /tmp/vikunja.log
retention-days: 7
test-frontend-e2e-playwright:
runs-on: ubuntu-latest
needs:

View File

@ -1,82 +0,0 @@
name: veans-e2e
# End-to-end tests for the veans CLI. Mirrors the parent repo's frontend
# e2e harness pattern: build the API binary, start it with sqlite memory
# + fixtures, point the suite at it, then tear down.
on:
push:
paths:
- veans/**
- .github/workflows/veans-e2e.yml
pull_request:
paths:
- veans/**
- .github/workflows/veans-e2e.yml
permissions:
contents: read
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install mage
run: |
go install github.com/magefile/mage@v1.15.0
- name: Build API binary
run: mage build
- name: Start API server (sqlite memory + fixtures)
env:
VIKUNJA_SERVICE_INTERFACE: ":3456"
VIKUNJA_SERVICE_PUBLICURL: "http://127.0.0.1:3456/"
VIKUNJA_SERVICE_TESTINGTOKEN: "veans-e2e-token"
VIKUNJA_SERVICE_JWTSECRET: "veans-e2e-jwt-secret-do-not-use-in-production"
VIKUNJA_DATABASE_TYPE: sqlite
VIKUNJA_DATABASE_PATH: memory
VIKUNJA_LOG_LEVEL: WARNING
VIKUNJA_MAILER_ENABLED: "false"
VIKUNJA_REDIS_ENABLED: "false"
VIKUNJA_RATELIMIT_NOAUTHLIMIT: "1000"
run: |
./vikunja web &
echo $! > /tmp/vikunja.pid
# Wait for /info (parent magefile uses 30s; we match)
for i in $(seq 1 60); do
if curl -sf http://127.0.0.1:3456/api/v1/info >/dev/null 2>&1; then
echo "API ready after ${i}s"
exit 0
fi
sleep 1
done
echo "API failed to start"
exit 1
- name: Run veans e2e
env:
VEANS_E2E_API_URL: http://127.0.0.1:3456
# user1 / 12345678 is the canonical fixture user (see
# pkg/db/fixtures/users.yml — bcrypt hash is the same for
# every fixture user).
VEANS_E2E_ADMIN_USER: user1
VEANS_E2E_ADMIN_PASS: "12345678"
working-directory: veans
run: |
go install github.com/magefile/mage@v1.15.0
mage test:e2e
- name: Stop API server
if: always()
run: |
if [ -f /tmp/vikunja.pid ]; then
kill "$(cat /tmp/vikunja.pid)" 2>/dev/null || true
fi

View File

@ -39,9 +39,18 @@ without breaking sessions in unrelated repos.
## What `veans init` does
1. Authenticates as you (`--token`, or interactive username/password — local
accounts only; SSO/OIDC users should paste a personal API token via
`--token`).
1. Authenticates as you. Default is OAuth 2.0 Authorization Code + PKCE
against Vikunja's built-in authorization server (Vikunja 2.3+ — no
client registration needed). veans prints an authorize URL; you open
it in your browser, sign in, and paste the resulting
`vikunja-veans-cli://callback?code=...` URL back into the CLI. The
browser will fail to open the custom scheme — that's expected; the
address bar still has what we need.
Alternative auth modes:
- `--token <jwt-or-personal-api-token>` — paste-in, useful for SSO/OIDC
- `--use-password` — fall back to `POST /login` (local accounts only)
- `--username` + `--password` (non-interactive; implies `--use-password`)
2. Asks you to pick a project and a Kanban view.
3. Bootstraps the canonical buckets if missing: `Todo`, `In Progress`,
`In Review`, `Done`, `Scrapped`.
@ -161,9 +170,9 @@ PR lands.
## Out of scope (for now)
- OAuth 2.0 device flow (RFC 8628) — Vikunja's OAuth provider needs a
registered client + an existing JWT to authorize, which adds friction
v0 doesn't need; we use `POST /login` (or paste-in `--token`).
- OAuth 2.0 device flow (RFC 8628) — would let SSH'd / headless setups
authenticate without a browser-on-the-same-machine; not implemented
upstream yet.
- Project-scoped API tokens — Vikunja doesn't ship them yet. The
credential schema's `scope` field is forward-compatible for when it does.
- Auto-installing hook snippets. We print them; you paste them.

View File

@ -1,11 +1,15 @@
// 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.
// login. The default interactive flow is OAuth 2.0 Authorization Code + PKCE
// against Vikunja's built-in authorization server (no client registration
// needed; PKCE/S256 mandatory). The user opens the authorize URL in their
// browser, signs in, and pastes the resulting `vikunja-veans-cli://callback`
// URL back into the CLI — that side-steps custom-scheme handler registration
// entirely.
//
// 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.
// For non-interactive contexts (CI scripts, paste-in tokens, accounts on
// instances without OAuth), pass --token, --username + --password, or
// --use-password. Personal API tokens via --token also let SSO/OIDC users
// onboard without exercising local password login.
package auth
import (
@ -72,21 +76,30 @@ func (p *StdPrompter) ReadPassword(prompt string) (string, error) {
// 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 short-circuits all flows. May be a JWT or a personal API token.
Token string
// Username is optional — if empty, the prompter asks. Required for
// password-based login.
// 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 is optional — if empty, the prompter asks (masked).
Password string
// TOTP, if set, is sent with the login request.
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.
// Order of resolution:
// Resolution order:
// 1. opts.Token (paste-in or --token flag)
// 2. POST /login with opts.Username/Password (prompts to fill missing parts)
// 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
@ -94,6 +107,23 @@ func AcquireHumanToken(ctx context.Context, c *client.Client, opts LoginOptions,
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 {
@ -109,12 +139,8 @@ func AcquireHumanToken(ctx context.Context, c *client.Client, opts LoginOptions,
opts.Password = pw
}
if opts.Username == "" || opts.Password == "" {
return "", output.New(output.CodeAuth, "username and password are required")
return "", output.New(output.CodeAuth, "username and password are required for password login")
}
// 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,

View File

@ -0,0 +1,172 @@
package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"io"
"net/url"
"strings"
"code.vikunja.io/veans/internal/client"
"code.vikunja.io/veans/internal/output"
)
// OAuth client identity. Vikunja's authorization server requires no
// pre-registration — these values just need to be consistent between the
// browser-side authorize step and the CLI-side token exchange.
const (
oauthClientID = "veans-cli"
oauthRedirectURI = "vikunja-veans-cli://callback"
)
// 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 43128 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 on the
// authorize redirect. We verify it matches when the user pastes back.
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. The user
// follows it, authenticates if necessary, and is redirected to the custom
// scheme with `?code=...&state=...`.
func buildAuthorizeURL(server string, pkce PKCEPair, state string) string {
q := url.Values{}
q.Set("response_type", "code")
q.Set("client_id", oauthClientID)
q.Set("redirect_uri", oauthRedirectURI)
q.Set("code_challenge", pkce.Challenge)
q.Set("code_challenge_method", "S256")
q.Set("state", state)
return strings.TrimRight(server, "/") + "/oauth/authorize?" + q.Encode()
}
// extractCodeAndState pulls the OAuth callback parameters out of whatever
// the user pasted. We accept three shapes:
// - the full custom-scheme URL: `vikunja-veans-cli://callback?code=...&state=...`
// - just the query: `code=ABC&state=XYZ`
// - just the code (state verification then skipped, with a warning)
func extractCodeAndState(raw string) (code, state string, err error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", "", errors.New("empty callback paste")
}
// Full URL form?
if strings.Contains(raw, "://") || strings.HasPrefix(raw, "vikunja-") {
u, perr := url.Parse(raw)
if perr != nil {
return "", "", fmt.Errorf("parse callback URL: %w", perr)
}
v := u.Query()
// Some browsers strip the query and put it in Fragment when they
// can't open the scheme — handle both.
if v.Get("code") == "" && u.RawQuery == "" && u.Fragment != "" {
v, _ = url.ParseQuery(u.Fragment)
}
return v.Get("code"), v.Get("state"), nil
}
// Query-string form?
if strings.Contains(raw, "code=") {
v, perr := url.ParseQuery(raw)
if perr != nil {
return "", "", fmt.Errorf("parse callback query: %w", perr)
}
return v.Get("code"), v.Get("state"), nil
}
// Bare code.
return raw, "", nil
}
// runOAuthFlow drives the manual paste-back OAuth Authorization Code +
// PKCE handshake against Vikunja's server.
//
// The user-facing UX: print the authorize URL, ask the user to open it in
// their browser, sign in there, and paste the resulting (failed-to-open)
// `vikunja-veans-cli://callback?code=...` URL back into the CLI. The
// browser will show a "can't open this scheme" error, but the URL bar
// contains the code we need.
func runOAuthFlow(ctx context.Context, c *client.Client, p 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)
}
authURL := buildAuthorizeURL(c.BaseURL, pkce, state)
if w != nil {
fmt.Fprintln(w, "")
fmt.Fprintln(w, "Open the following URL in your browser:")
fmt.Fprintln(w, "")
fmt.Fprintln(w, " "+authURL)
fmt.Fprintln(w, "")
fmt.Fprintln(w, "After signing in, your browser will try to open")
fmt.Fprintln(w, " "+oauthRedirectURI+"?code=...&state=...")
fmt.Fprintln(w, "and show a 'can't open this URL' error. That's expected.")
fmt.Fprintln(w, "Copy the URL from the address bar and paste it here.")
fmt.Fprintln(w, "")
}
pasted, err := p.ReadLine("Paste callback URL (or just the code): ")
if err != nil {
return "", err
}
code, returnedState, err := extractCodeAndState(pasted)
if err != nil {
return "", output.Wrap(output.CodeAuth, err, "%v", err)
}
if code == "" {
return "", output.New(output.CodeAuth, "no `code` found in pasted callback")
}
if returnedState != "" && returnedState != state {
return "", output.New(output.CodeAuth, "state mismatch on OAuth callback (possible CSRF)")
}
resp, err := c.ExchangeOAuthCode(ctx, &client.OAuthTokenRequest{
GrantType: "authorization_code",
Code: code,
ClientID: oauthClientID,
RedirectURI: oauthRedirectURI,
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
}

View File

@ -0,0 +1,106 @@
package auth
import (
"crypto/sha256"
"encoding/base64"
"strings"
"testing"
)
func TestGeneratePKCE_VerifierShape(t *testing.T) {
pair, err := generatePKCE()
if err != nil {
t.Fatal(err)
}
// RFC 7636 §4.1: verifier is 43128 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 TestExtractCodeAndState_FullURL(t *testing.T) {
code, state, err := extractCodeAndState("vikunja-veans-cli://callback?code=ABC123&state=XYZ")
if err != nil {
t.Fatal(err)
}
if code != "ABC123" || state != "XYZ" {
t.Fatalf("got code=%q state=%q", code, state)
}
}
func TestExtractCodeAndState_QueryOnly(t *testing.T) {
code, state, err := extractCodeAndState("code=ABC&state=XYZ")
if err != nil {
t.Fatal(err)
}
if code != "ABC" || state != "XYZ" {
t.Fatalf("got code=%q state=%q", code, state)
}
}
func TestExtractCodeAndState_BareCode(t *testing.T) {
code, state, err := extractCodeAndState("plain-code-value")
if err != nil {
t.Fatal(err)
}
if code != "plain-code-value" || state != "" {
t.Fatalf("got code=%q state=%q", code, state)
}
}
func TestExtractCodeAndState_EmptyError(t *testing.T) {
if _, _, err := extractCodeAndState(" "); err == nil {
t.Fatal("expected error on empty paste")
}
}
func TestBuildAuthorizeURL(t *testing.T) {
u := buildAuthorizeURL("https://vikunja.example.com", 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",
} {
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)
}
}

View File

@ -35,9 +35,11 @@ type Options struct {
// If empty, the prompter asks.
Server string
// HumanToken short-circuits POST /login when set.
// HumanToken short-circuits all auth when set.
HumanToken string
// HumanUsername / HumanPassword are forwarded to auth.LoginOptions.
// HumanUsePassword forces POST /login instead of the default OAuth flow.
HumanUsePassword bool
// HumanUsername / HumanPassword feed POST /login (used when set).
HumanUsername string
HumanPassword string
HumanTOTP string
@ -138,12 +140,15 @@ func Init(ctx context.Context, opts *Options) (*Result, error) {
}
progress(opts.Out, "Connected to Vikunja %s", info.Version)
// 4. Acquire human JWT (transient — used until step 11).
// 4. Acquire human JWT (transient — used until step 11). Default is the
// OAuth flow; --token / --use-password / --username+--password override.
tok, err := auth.AcquireHumanToken(ctx, human, auth.LoginOptions{
Token: opts.HumanToken,
UsePassword: opts.HumanUsePassword,
Username: opts.HumanUsername,
Password: opts.HumanPassword,
TOTP: opts.HumanTOTP,
Out: opts.Out,
}, prompter)
if err != nil {
return nil, err

View File

@ -21,3 +21,15 @@ func (c *Client) CurrentUser(ctx context.Context) (*User, error) {
}
return &out, nil
}
// ExchangeOAuthCode swaps an authorization code (with the matching PKCE
// verifier) for an access + refresh token pair via POST /oauth/token.
// Vikunja requires JSON, not form-encoded — the standard OAuth library
// helpers don't apply.
func (c *Client) ExchangeOAuthCode(ctx context.Context, req *OAuthTokenRequest) (*OAuthTokenResponse, error) {
var out OAuthTokenResponse
if err := c.Do(ctx, "POST", "/oauth/token", nil, req, &out); err != nil {
return nil, err
}
return &out, nil
}

View File

@ -190,3 +190,22 @@ type LoginRequest struct {
type LoginResponse struct {
Token string `json:"token"`
}
// OAuthTokenRequest is the JSON body for POST /api/v1/oauth/token. Vikunja's
// OAuth server explicitly rejects form-encoded requests; everything is JSON.
type OAuthTokenRequest struct {
GrantType string `json:"grant_type"`
Code string `json:"code,omitempty"`
ClientID string `json:"client_id,omitempty"`
RedirectURI string `json:"redirect_uri,omitempty"`
CodeVerifier string `json:"code_verifier,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
}
// OAuthTokenResponse mirrors the standard RFC 6749 response.
type OAuthTokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
RefreshToken string `json:"refresh_token,omitempty"`
}

View File

@ -17,6 +17,7 @@ type initFlags struct {
username string
password string
totp string
usePassword bool
botUsername string
projectID int64
viewID int64
@ -54,6 +55,7 @@ revoke it at any time without affecting your own session.`,
ConfigPath: path,
Server: f.server,
HumanToken: f.token,
HumanUsePassword: f.usePassword,
HumanUsername: f.username,
HumanPassword: f.password,
HumanTOTP: f.totp,
@ -73,9 +75,10 @@ revoke it at any time without affecting your own session.`,
}
cmd.Flags().StringVar(&f.server, "server", "", "Vikunja server URL")
cmd.Flags().StringVar(&f.token, "token", "", "JWT or personal API token (skips password prompt; useful for SSO/OIDC instances)")
cmd.Flags().StringVar(&f.username, "username", "", "Vikunja username (prompted if empty)")
cmd.Flags().StringVar(&f.password, "password", "", "Vikunja password (prompted if empty; usually safer to omit)")
cmd.Flags().StringVar(&f.token, "token", "", "JWT or personal API token (skips OAuth/password; useful for SSO/OIDC instances)")
cmd.Flags().BoolVar(&f.usePassword, "use-password", false, "use POST /login (username+password) instead of the default OAuth flow")
cmd.Flags().StringVar(&f.username, "username", "", "Vikunja username (implies --use-password)")
cmd.Flags().StringVar(&f.password, "password", "", "Vikunja password (implies --use-password; prompted if empty)")
cmd.Flags().StringVar(&f.totp, "totp", "", "TOTP code if your account requires 2FA")
cmd.Flags().StringVar(&f.botUsername, "bot-username", "", "override the bot-<repo> default")
cmd.Flags().Int64Var(&f.projectID, "project", 0, "skip the interactive project picker")

View File

@ -20,6 +20,7 @@ func newLoginCmd() *cobra.Command {
username string
password string
totp string
usePassword bool
)
cmd := &cobra.Command{
Use: "login",
@ -28,6 +29,11 @@ func newLoginCmd() *cobra.Command {
for the bot configured in .veans.yml. The new token replaces the
existing one in the credential store.
The default flow is OAuth 2.0 Authorization Code + PKCE open the
URL veans prints, sign in, and paste the callback URL back. Use
--token to paste in a personal API token, or --use-password / --username
to force POST /login instead.
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 {
@ -47,9 +53,11 @@ you want to rotate.`,
human := client.New(cfg.Server, "")
tok, err := auth.AcquireHumanToken(cmd.Context(), human, auth.LoginOptions{
Token: token,
UsePassword: usePassword,
Username: username,
Password: password,
TOTP: totp,
Out: os.Stderr,
}, auth.NewStdPrompter())
if err != nil {
return err
@ -86,9 +94,10 @@ you want to rotate.`,
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(&token, "token", "", "JWT or personal API token (skips OAuth/password)")
cmd.Flags().BoolVar(&usePassword, "use-password", false, "use POST /login instead of the default OAuth flow")
cmd.Flags().StringVar(&username, "username", "", "your Vikunja username (implies --use-password)")
cmd.Flags().StringVar(&password, "password", "", "your Vikunja password (implies --use-password; prompted if empty)")
cmd.Flags().StringVar(&totp, "totp", "", "TOTP code if your account requires 2FA")
return cmd
}