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
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,
Username: opts.HumanUsername,
Password: opts.HumanPassword,
TOTP: opts.HumanTOTP,
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

@ -12,17 +12,18 @@ import (
)
type initFlags struct {
server string
token string
username string
password string
totp string
botUsername string
projectID int64
viewID int64
yesBuckets bool
skipBuckets bool
configPath string
server string
token string
username string
password string
totp string
usePassword bool
botUsername string
projectID int64
viewID int64
yesBuckets bool
skipBuckets bool
configPath string
}
func newInitCmd() *cobra.Command {
@ -51,18 +52,19 @@ revoke it at any time without affecting your own session.`,
path = filepath.Join(root, config.Filename)
}
res, err := bootstrap.Init(cmd.Context(), &bootstrap.Options{
ConfigPath: path,
Server: f.server,
HumanToken: f.token,
HumanUsername: f.username,
HumanPassword: f.password,
HumanTOTP: f.totp,
BotUsername: f.botUsername,
ProjectID: f.projectID,
ViewID: f.viewID,
AutoApproveBuckets: f.yesBuckets,
SkipBucketBootstrap: f.skipBuckets,
Out: os.Stderr,
ConfigPath: path,
Server: f.server,
HumanToken: f.token,
HumanUsePassword: f.usePassword,
HumanUsername: f.username,
HumanPassword: f.password,
HumanTOTP: f.totp,
BotUsername: f.botUsername,
ProjectID: f.projectID,
ViewID: f.viewID,
AutoApproveBuckets: f.yesBuckets,
SkipBucketBootstrap: f.skipBuckets,
Out: os.Stderr,
})
if err != nil {
return err
@ -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

@ -16,10 +16,11 @@ import (
func newLoginCmd() *cobra.Command {
var (
token string
username string
password string
totp string
token string
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 {
@ -46,10 +52,12 @@ you want to rotate.`,
human := client.New(cfg.Server, "")
tok, err := auth.AcquireHumanToken(cmd.Context(), human, auth.LoginOptions{
Token: token,
Username: username,
Password: password,
TOTP: totp,
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
}