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:
parent
d0c77ad6fe
commit
35aa486eb5
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue