From 35aa486eb54b70cac4b8e99b2d4772982c1c46cc Mon Sep 17 00:00:00 2001 From: Tink bot Date: Thu, 7 May 2026 21:33:30 +0000 Subject: [PATCH] feat(veans): use OAuth 2.0 Authorization Code + PKCE as default auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .github/workflows/test.yml | 70 +++++++++++ .github/workflows/veans-e2e.yml | 82 ------------ veans/README.md | 21 +++- veans/internal/auth/auth.go | 64 +++++++--- veans/internal/auth/oauth.go | 172 ++++++++++++++++++++++++++ veans/internal/auth/oauth_test.go | 106 ++++++++++++++++ veans/internal/bootstrap/bootstrap.go | 19 +-- veans/internal/client/auth.go | 12 ++ veans/internal/client/types.go | 19 +++ veans/internal/commands/init.go | 55 ++++---- veans/internal/commands/login.go | 31 +++-- 11 files changed, 500 insertions(+), 151 deletions(-) delete mode 100644 .github/workflows/veans-e2e.yml create mode 100644 veans/internal/auth/oauth.go create mode 100644 veans/internal/auth/oauth_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 557fbb57f..f0fec1164 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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: diff --git a/.github/workflows/veans-e2e.yml b/.github/workflows/veans-e2e.yml deleted file mode 100644 index bf0e850e4..000000000 --- a/.github/workflows/veans-e2e.yml +++ /dev/null @@ -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 diff --git a/veans/README.md b/veans/README.md index 54839c07e..d59929d1b 100644 --- a/veans/README.md +++ b/veans/README.md @@ -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 ` — 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. diff --git a/veans/internal/auth/auth.go b/veans/internal/auth/auth.go index c11883290..15b4d6b28 100644 --- a/veans/internal/auth/auth.go +++ b/veans/internal/auth/auth.go @@ -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, diff --git a/veans/internal/auth/oauth.go b/veans/internal/auth/oauth.go new file mode 100644 index 000000000..ba76d91eb --- /dev/null +++ b/veans/internal/auth/oauth.go @@ -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 43–128 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 +} diff --git a/veans/internal/auth/oauth_test.go b/veans/internal/auth/oauth_test.go new file mode 100644 index 000000000..b227fde48 --- /dev/null +++ b/veans/internal/auth/oauth_test.go @@ -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 43–128 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) + } +} diff --git a/veans/internal/bootstrap/bootstrap.go b/veans/internal/bootstrap/bootstrap.go index 4f957410d..881c8d856 100644 --- a/veans/internal/bootstrap/bootstrap.go +++ b/veans/internal/bootstrap/bootstrap.go @@ -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 diff --git a/veans/internal/client/auth.go b/veans/internal/client/auth.go index 7cea36559..6c5e3ddd1 100644 --- a/veans/internal/client/auth.go +++ b/veans/internal/client/auth.go @@ -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 +} diff --git a/veans/internal/client/types.go b/veans/internal/client/types.go index c8e54c709..14142e430 100644 --- a/veans/internal/client/types.go +++ b/veans/internal/client/types.go @@ -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"` +} diff --git a/veans/internal/commands/init.go b/veans/internal/commands/init.go index 04f718110..564c36d28 100644 --- a/veans/internal/commands/init.go +++ b/veans/internal/commands/init.go @@ -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- default") cmd.Flags().Int64Var(&f.projectID, "project", 0, "skip the interactive project picker") diff --git a/veans/internal/commands/login.go b/veans/internal/commands/login.go index 8665d49eb..aa971d2f0 100644 --- a/veans/internal/commands/login.go +++ b/veans/internal/commands/login.go @@ -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 }