diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index b7fbedc1..8b14b579 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "kbagent", - "version": "0.83.0", + "version": "0.84.0", "source": "./plugins/kbagent", "description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces", "category": "development" diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 7cde5f79..3ac9a7fc 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -16,6 +16,17 @@ name: E2E # Without those secrets the workflow still succeeds, but emits a warning and # skips the suite (see the credentials guard below). See CONTRIBUTING.md # "E2E tests in CI" for details. +# +# `auth login-password` coverage (test_e2e_auth.py::TestLoginPasswordCommand, +# PR #565) is gated independently on its own dedicated service account, kept +# apart from the E2E_API_TOKEN project above: +# E2E_URL_US_EAST4 (repository VARIABLE, not a secret -- a plain stack +# hostname, non-sensitive) +# E2E_LOGIN_EMAIL / E2E_LOGIN_PASSWORD / E2E_LOGIN_TOTP_SECRET (secrets -- +# the last one is the account's base32 TOTP seed, +# not a live code; omit it if the account has no MFA) +# Missing any of the three required vars just skips that test class, same +# posture as the E2E_API_TOKEN guard below. on: schedule: @@ -68,4 +79,8 @@ jobs: env: E2E_API_TOKEN: ${{ secrets.E2E_API_TOKEN }} E2E_URL: ${{ secrets.E2E_URL }} + E2E_URL_US_EAST4: ${{ vars.E2E_URL_US_EAST4 }} + E2E_LOGIN_EMAIL: ${{ secrets.E2E_LOGIN_EMAIL }} + E2E_LOGIN_PASSWORD: ${{ secrets.E2E_LOGIN_PASSWORD }} + E2E_LOGIN_TOTP_SECRET: ${{ secrets.E2E_LOGIN_TOTP_SECRET }} run: make test-e2e diff --git a/CLAUDE.md b/CLAUDE.md index 810707fb..67550790 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -290,13 +290,31 @@ plugins/kbagent/ # Headless / token-only (0.50.0+): export KBAGENT_PROJECT_FROM_ENV=1 + KBC_TOKEN + KBC_STORAGE_API_URL to synthesize an in-memory `__env__` project (no `project add`, no config.json on disk; token never persisted). Use `--project __env__`. Same env setup also powers `kbagent serve`. kbagent auth login [--stack URL|alias] [--device-code] [--register-projects] +kbagent auth login-password --email EMAIL (--password PASSWORD | --password-stdin) [--totp-secret SECRET] [--stack URL|alias] [--register-projects] kbagent auth status [--stack URL|alias] kbagent auth logout [--stack URL|alias] [--remove-projects] [--yes] kbagent auth register-projects [--stack URL|alias] [--all] [--project-id ID ...] [--alias ID=ALIAS ...] [--yes] +# auth login-password (0.81.0+): the deliberate unattended exception to auth login's "needs a human at +# a browser" rule -- email + password (+ TOTP if the account has MFA) grant, no browser, safe to run +# from a CI secret-backed workflow step. Prefer --password-stdin (or KBC_LOGIN_PASSWORD) over +# --password -- a value on the command line lands in shell history and process listings; +# --password/--password-stdin are mutually exclusive (ConfigError if both given). +# --email/--password/--totp-secret also read from +# KBC_LOGIN_EMAIL/KBC_LOGIN_PASSWORD/KBC_LOGIN_TOTP_SECRET env vars (mirroring KBC_TOKEN's convention), +# so a workflow can set them once in a step's env: block. --totp-secret is the base32 TOTP SEED (not +# a 6-digit code) -- kbagent computes the current code itself (auth/totp.py, stdlib-only RFC 6238), +# so no human ever types a live code. Only the TOTP factor is resolvable this way; a WebAuthn/passkey- +# only account gets AUTH_MFA_INVALID and must use `auth login` (needs a browser) instead. Stores the +# session in auth.json exactly like `auth login` does -- same auth-mode, same "session" column in +# `project list`, same downstream command support. Storing an account's password (and TOTP seed) as +# CI secrets is a bigger blast radius than a single scoped project token: use a dedicated, +# least-privileged service account, never a real human's own credentials. New error code: +# AUTH_MFA_INVALID. # auth (since 0.80.0): browser-based login -- PKCE authorization-code by default (falls back to the # RFC 8628 device flow ONLY on a pre-exchange failure: no loopback browser, callback timeout, or an # SSH/container/WSL heuristic; --device-code forces it). REQUIRES A HUMAN AT A BROWSER -- never attempt -# from an unattended AI agent task; use a static Storage token for CI/headless instead. Issues a +# from an unattended AI agent task; use `auth login-password` or a static Storage token for +# CI/headless instead. Issues a # USER-scoped "programmatic session" (kbc_at_* access token + kbc_rt_* refresh token) stored in # auth.json (0600), a sibling of config.json -- config.json's schema and CURRENT_CONFIG_VERSION are # unchanged. --register-projects writes each accessible project into config.json with the sentinel diff --git a/docs/auth.md b/docs/auth.md index ac4b32bb..33b0351e 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -7,15 +7,21 @@ token (`kbc_rt_*`) that kbagent renews for you (since v0.80.0). > **Read this first: `auth login` needs a human at a browser.** > -> There is **no headless or unattended path**. `auth login` opens a browser -> window, or prints a code you type into a page on another device. An AI agent -> must never run it on its own initiative — if asked to "set up kbagent auth", -> hand the command back to the person and wait for them to finish. +> There is **no headless or unattended path for `auth login`**. It opens a +> browser window, or prints a code you type into a page on another device. An +> AI agent must never run it on its own initiative — if asked to "set up +> kbagent auth", hand the command back to the person and wait for them to +> finish. > -> For CI, containers, cron, or any other unattended context, use a **static -> Storage token**: `kbagent project add --token ...`, or the token-only -> `KBAGENT_PROJECT_FROM_ENV=1` + `KBC_TOKEN` + `KBC_STORAGE_API_URL` path. -> Neither is affected by anything on this page. +> For CI, containers, cron, or any other unattended context, you have two +> options: a **static Storage token** (`kbagent project add --token ...`, or +> the token-only `KBAGENT_PROJECT_FROM_ENV=1` + `KBC_TOKEN` + +> `KBC_STORAGE_API_URL` path -- unaffected by anything on this page), or +> **`kbagent auth login-password`** (since v0.81.0) if you specifically need a +> full USER-scoped session rather than a single project's token -- see +> [section 2b](#2b-auth-login-password-the-unattended-exception) below. It is +> the one deliberate exception to "no unattended path": it needs an account's +> password (and TOTP seed, if MFA is on) as CI secrets, not a browser. ## TL;DR @@ -107,6 +113,74 @@ kbagent auth login [--stack URL|ALIAS] [--device-code] [--register-projects] below (TTY, non-`--json`), or prints a one-line hint pointing at `auth register-projects`. +### 2b. `auth login-password` -- the unattended exception + +```bash +kbagent auth login-password --email EMAIL --password PASSWORD [--totp-secret SECRET] \ + [--stack URL|alias] [--register-projects] +``` + +The one command in this whole page that IS safe for a CI job or an agent to +run non-interactively -- because it needs credentials handed to it, not a +browser. A password grant, straight to the auth service, no loopback +listener, no user interaction of any kind. + +- **`--email` / `--password` / `--totp-secret`** also read from + `KBC_LOGIN_EMAIL` / `KBC_LOGIN_PASSWORD` / `KBC_LOGIN_TOTP_SECRET` env vars + (the exact convention `KBC_TOKEN` already uses), so a workflow sets them + once in a step's `env:` block instead of passing flags: + ```yaml + - name: Sign in + env: + KBC_LOGIN_EMAIL: ${{ secrets.KBC_LOGIN_EMAIL }} + KBC_LOGIN_PASSWORD: ${{ secrets.KBC_LOGIN_PASSWORD }} + KBC_LOGIN_TOTP_SECRET: ${{ secrets.KBC_LOGIN_TOTP_SECRET }} + run: kbagent auth login-password --register-projects + ``` +- **`--totp-secret` is the account's base32 TOTP *seed*** -- the same string + an authenticator app scans from the enrollment QR code, not a live 6-digit + code. kbagent computes the current code itself (`auth/totp.py`, plain + stdlib RFC 6238 -- no dependency added) at the moment it calls the login + endpoint. Nobody types a live code; the seed is the only secret involved. +- **Only TOTP-based MFA can be resolved this way.** If the account's MFA + factor is WebAuthn/passkey instead, there is no shared secret to compute + a response from -- a WebAuthn ceremony is a live cryptographic exchange + that can only run in a real browser holding the actual passkey/security + key, a hard constraint of the protocol, not a missing feature here -- this + command fails fast with `AUTH_MFA_INVALID` naming `auth login` as the + fallback for that account. +- The resulting session is stored in `auth.json` and, from here on, shares + the **same mechanics** as a browser-login session: same bearer dispatch, + same refresh rotation, same `--register-projects` contract, same + `project list` `Auth` column (`session`), same + [section 4](#4-what-works-on-a-session-project) restrictions. Its + **privilege** is not always the same -- see the next point. +- **For an MFA-enabled account, this session carries a live 3-hour "sudo" + window that a browser-login session usually does not.** The password + flow completes MFA and creates the session in one server-side step + (`createSessionAfterMfa`), which stamps the sudo timestamp unconditionally; + PKCE/device instead inherit whatever sudo state the browser session + already had, which is typically stale or absent. Sudo gates exactly the + account-takeover-shaped operations on the Connection UI/API (PAT + create/revoke, TOTP delete, WebAuthn delete/register, recovery-code + regeneration, revoke-all-sessions) -- none of which kbagent itself calls, + but any script holding this session's tokens effectively can for the next + 3 hours. Treat the CI secrets backing `login-password` accordingly. +- **Two CI jobs must not share one MFA-enabled account within the same + 30-second window.** The server accepts each TOTP code exactly once; a + second `login-password` call submitting a code for the same time slice + fails outright, and a 429/5xx retry never resubmits a stale code either + (see the code-level note in `auth/auth_client.py`). Give concurrent + matrix-build legs their own service account, or serialize the login step. +- **Security posture matters here more than for a single project's token.** + A password (+ TOTP seed) is the account's full ambient identity, not a + scoped credential -- whoever holds these CI secrets can do anything that + account can do, everywhere it has access, not just one project's Storage + routes. Use a dedicated, least-privileged service account created + specifically for this pipeline; never a real person's own login. Revoking + access means changing that account's password (and re-enrolling MFA), not + a lightweight per-secret revoke. + ### `auth register-projects` ```bash @@ -331,6 +405,7 @@ message text. Full catalogue: | `AUTH_STATE_MISMATCH` | The PKCE callback's `state` did not match the one issued | Re-run `auth login`; if it repeats, something is intercepting the callback | | `SESSION_EXPIRED` | The refresh token expired or was revoked | `kbagent auth login` again — on the host, if this came from `serve` | | `SESSION_NOT_FOUND` | No session is stored for this stack | `kbagent auth login --stack ` | +| `AUTH_MFA_INVALID` | `auth login-password` hit an MFA factor it cannot resolve (e.g. WebAuthn-only) | Use `kbagent auth login` for that account instead | In a multi-project command, a per-project failure appears in the `errors` array of the result envelope with its own `error_code`, so one session project cannot diff --git a/docs/error-codes.md b/docs/error-codes.md index c2483cdc..e6d4fba0 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -182,3 +182,4 @@ of `ErrorCode` in `src/keboola_agent_cli/errors.py`. | `AUTH_STATE_MISMATCH` | The PKCE callback's `state` parameter did not match the one generated at login start | | `SESSION_EXPIRED` | The programmatic-auth session's refresh token expired or was revoked; run `kbagent auth login` again | | `SESSION_NOT_FOUND` | No programmatic-auth session is persisted for this stack; run `kbagent auth login` | +| `AUTH_MFA_INVALID` | `auth login-password` hit an MFA factor it cannot resolve without a browser (e.g. WebAuthn-only) -- use `kbagent auth login` for that account instead | diff --git a/docs/programmatic-auth-login-plan.md b/docs/programmatic-auth-login-plan.md index 85279d17..35a3bf98 100644 --- a/docs/programmatic-auth-login-plan.md +++ b/docs/programmatic-auth-login-plan.md @@ -758,3 +758,45 @@ because the existing lock is a Windows no-op (§4.4, §7, risk 3). Non-blocking: **NB-3** bearer E2E moved into PR5 (§6), **NB-4** narrowed compat claim + per-consumer fail-fast guards (§4.1, §5), **NB-5** callback timeout aligned under the backend's 120 s (§4.7). The plaintext-storage decision (§4.2) was explicitly not a review finding. + +## 12. Addendum: `auth login-password` (PR #565, v0.81.0) + +This plan's scope (§1) was PKCE + device authorization — both require a human at a +browser. `login-password` (`grantType: password`, `POST /v1/auth/login` + `POST +/v1/auth/mfa`) is the deliberate exception: the RFC (`programmatic-auth.md:56`) lists +the password grant in scope and names use case 5 — "E2E tests need a non-browser path +to obtain user-scoped tokens" — which PKCE/device cannot serve by construction. + +Everything downstream of the token exchange is unchanged from §4.5's `_finalize_login` +tail: same session persistence, same best-effort revoke of the session it replaces, same +introspection, same `--register-projects` contract. What is new: + +- **MFA arrives inline, not as a redirect.** `POST /v1/auth/login` answers HTTP 200 with + `mfaRequired` in the body rather than a 4xx — the CLI resolves it in a second + request (`POST /v1/auth/mfa`) rather than a second browser round trip. Only the TOTP + factor is resolvable this way (`auth/totp.py`, stdlib RFC 6238); WebAuthn/passkey-only + accounts fail fast with `AUTH_MFA_INVALID` naming `auth login` as the fallback. +- **A privilege delta this plan's threat model didn't need to consider.** For an + MFA-enabled account, `createSessionAfterMfa` stamps the session's sudo timestamp + unconditionally, giving it a live 3-hour sudo window that a PKCE/device session + usually does not carry (see `docs/auth.md` and the PR #565 review, finding D1). + `login-password` credentials should be held to at least the same care as the manage + token that convention #12 already default-denies from env. +- **Rate limiting and TOTP replay are new failure surfaces specific to this grant.** + `/v1/auth/login` rate-limits by email and by IP (5/20 per 15 min) and reports the + tightest bucket via `X-RateLimit-*` headers; `POST /v1/auth/mfa` consumes each TOTP + time-slice exactly once, so it is excluded from the shared retry loop the same way + `refresh` and `poll_device_token` already are (§4, `auth/auth_client.py`) — a retried + 429/5xx would otherwise resubmit an already-consumed code. +- **`--password`/`KBC_LOGIN_PASSWORD` were kept, not default-denied like the manage + token.** This is a real, unresolved tension with risk 4 above ("never... put on the + command line, or exported to subprocess environments") and with convention #12's + default-deny-from-env posture — left as an open question for the reviewer rather than + resolved unilaterally in this PR (PR #565 review, finding D2). +- **PATs, not this grant, are the RFC's nominated CI/CD credential** + (`programmatic-auth.md:326`) and are already shipped on Connection master + (`kbc_pat_*`, `PatCreateAction`/`PatExchangeAction`). kbagent has no PAT handling at + all yet. The password grant was chosen here specifically for the E2E-test use case + the RFC calls out, not as a general CI credential recommendation; PAT support is a + natural follow-up given a password change cascade-revokes sessions (this grant + included) while a PAT survives it. diff --git a/docs/web-server.md b/docs/web-server.md index 88ac3a02..0e49e83b 100644 --- a/docs/web-server.md +++ b/docs/web-server.md @@ -299,6 +299,20 @@ a session-backed project from the web UI at all: For a project you would rather not expose this way, register it with a static Storage token (`kbagent project add --token`) — that path has neither property. +### The `auth` command group has no REST router — including `login-password` + +`kbagent auth login` / `login-password` / `status` / `logout` / +`register-projects` have no `server/routers/auth.py` counterpart; this is a +whole-group skip (CONTRIBUTING.md's 1:1 CLI/REST convention), not a per-command +gap. It is a deliberate omission for `login-password` specifically: exposing a +password grant over `serve` would let whoever holds `KBAGENT_SERVE_TOKEN` +submit arbitrary account credentials through this process, which is a strictly +worse blast radius than the existing "serve token borrows a session identity" +tradeoff above — that one requires a session to already exist; this one would +let a caller mint one. Sign in via the CLI directly (`kbagent auth +login-password`, or `auth login` for a human), then register the resulting +session's projects for `serve` to use. + ### Manage tokens are per-request Operations that need a Keboola Manage API token (`org setup`, diff --git a/plugins/kbagent/.claude-plugin/CLAUDE.md b/plugins/kbagent/.claude-plugin/CLAUDE.md index e3f10a99..1df0fd73 100644 --- a/plugins/kbagent/.claude-plugin/CLAUDE.md +++ b/plugins/kbagent/.claude-plugin/CLAUDE.md @@ -89,11 +89,15 @@ a clean slate per task. - User explicitly asks for a raw command (`just show me the curl equivalent`): subagent would refuse; politely decline and point the user at the `kbagent serve` REST API for programmatic integrations. -- User asks to log in / set up auth (`kbagent auth login`): browser - login needs a human at a browser, so no agent -- main context or - subagent -- can complete it. Hand the exact command back to the user - and wait; for unattended contexts point them at a static Storage - token instead. +- User asks to log in / set up auth via a browser (`kbagent auth + login`): browser login needs a human at a browser, so no agent -- + main context or subagent -- can complete it. Hand the exact command + back to the user and wait. For an unattended context, the answer is + NOT automatically a static Storage token: if the user has account + credentials for this purpose, `kbagent auth login-password` + (0.81.0+) is the CI-safe, headless alternative and an agent MAY run + it directly; fall back to a static Storage token only when no such + credentials exist. ### When NOT to delegate (Path B, `kbagent-pr-reviewer`) diff --git a/plugins/kbagent/.claude-plugin/plugin.json b/plugins/kbagent/.claude-plugin/plugin.json index bafaef42..6faf77a1 100644 --- a/plugins/kbagent/.claude-plugin/plugin.json +++ b/plugins/kbagent/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kbagent", - "version": "0.83.0", + "version": "0.84.0", "description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces", "author": { "name": "Keboola", diff --git a/plugins/kbagent/agents/keboola-expert.md b/plugins/kbagent/agents/keboola-expert.md index c397ef5d..d6322f1a 100644 --- a/plugins/kbagent/agents/keboola-expert.md +++ b/plugins/kbagent/agents/keboola-expert.md @@ -72,9 +72,8 @@ a critical failure. **Standalone binaries do not take `kbagent update`** -- if `kbagent --json version` carries `kbagent.install_channel`, quote its `upgrade_command` (or `upgrade_hint` when that is empty) instead (0.79.0+). - The entire `auth` command group (`login`/`status`/`logout`) needs - **0.80.0+** -- below that, refuse and point at a static Storage token - (`project add --token`) instead of attempting a workaround. + `auth` needs **0.80.0+**; `login-password` needs **0.81.0+** -- else + refuse and point at a static Storage token (`project add --token`). 7. **ALWAYS USE `--json`**. Every `kbagent` invocation MUST have `--json` as the first flag after `kbagent`. This makes output @@ -153,6 +152,7 @@ a critical failure. | Bootstrap a model from a set of storage tables | `kbagent semantic-layer build --project P --tables T1,T2,... [--dry-run] [--keep-on-failure]` (0.41.0+) -- **HEURISTIC fallback only** (no AI Service JSON endpoint): synthesises one dataset + one COUNT(*) metric + one glossary entry per table; FQN derived; fields[] role-classified. Response carries `fallback_used: "heuristic"`. Use as a SCAFFOLD, then refine via `add` / `edit`. Rollback on push failure (0.41.10+): every successfully-POSTed child is DELETEd in reverse + model deleted if we created it; pass `--keep-on-failure` to preserve partial state | the `sl-build` skill in `04_AI_Kit/ai-kit` -- full AI-assisted greenfield wizard, schema discovery + SQL analysis + AI generation. Use this when you need richer metrics, relationships, and constraint shapes than the heuristic produces | hand-writing the model JSON from scratch (the `build` heuristic gets you 80% of the way for read-mostly star schemas; only fall back to manual when the heuristic refuses or you need something the skill produces) | | Encrypt the storage token for a transformation `user_properties` (so a Python container can reach the metastore) | `kbagent semantic-layer token --encrypt --project P --component-id C` (0.41.0+) -- builds `{"#metastore_token": }` from the project's already-stored Storage token and delegates to the existing EncryptService; output is the encrypted envelope ready to paste into the transformation's `user_properties` block | `kbagent encrypt values --project P --component-id C --input '{"#metastore_token": ""}'` (works but the operator has to manually fetch the token first -- the wrapper avoids that step) | hand-running the Encryption API and pasting plaintext into `user_properties` (no `#` prefix means it sits in the config in plaintext) | | User asks to "log in" / "authenticate via browser" / set up programmatic auth, or to register a session's projects as aliases | **DO NOT RUN `kbagent auth login` YOURSELF** -- needs a human at the keyboard, no headless path. Tell the user to run `kbagent auth login [--register-projects]` themselves, then continue with `kbagent auth status`. To register projects from an EXISTING session (no re-login), `kbagent auth register-projects --all` or `--project-id ID` (0.80.0+) is non-interactive and agent-safe | -- | attempting `auth login`/the flagless `register-projects` picker from an unattended task; reading the token out of `auth.json`; using the numeric project id as an alias (aliases come from the project NAME) | +| CI task has account creds | `kbagent auth login-password --email E (--password-stdin\|--password P) [--totp-secret SEED]` (0.81.0+), agent-runnable | static token | `auth login` unattended | If the table does not cover the user's task, **ask clarifying questions** instead of guessing. Returning a targeted question is a @@ -332,6 +332,10 @@ read it when a trigger fires. Each `(X.Y.Z+)` tag is the version floor. - `auth login` is **human-only** -- it opens a browser or prints an RFC 8628 device code; never run it from an unattended agent task. Ask the user to run it themselves, then use `auth status`/`auth logout` normally. +- **`auth login-password` (0.81.0+) IS the headless path** -- email + + password (+ TOTP seed), agent-runnable. WebAuthn-only -> `AUTH_MFA_INVALID`, + fall back to `auth login`. MFA accounts get a live 3h sudo window + (`docs/auth.md`). - **Aliases derive from the project NAME, never the numeric id** -- `--project 9840` never resolves. Use `kbagent project list` or `auth register-projects` (0.80.0+, see matrix above) to find/register the diff --git a/plugins/kbagent/skills/kbagent/SKILL.md b/plugins/kbagent/skills/kbagent/SKILL.md index 353039b4..1363289a 100644 --- a/plugins/kbagent/skills/kbagent/SKILL.md +++ b/plugins/kbagent/skills/kbagent/SKILL.md @@ -69,6 +69,7 @@ When working inside a git repository or project directory, run `kbagent init` (o | Remove all permission restrictions | `kbagent permissions reset` | | Check if a specific operation is allowed | `kbagent permissions check <OPERATION>` | | Sign in to a Keboola stack via browser login (PKCE) or device code | `kbagent auth login` | +| Sign in via email + password (+ TOTP if the account has MFA) -- no browser | `kbagent auth login-password --email EMAIL` | | Show the programmatic-auth session health for a stack | `kbagent auth status` | | Revoke and clear the local programmatic-auth session for a stack | `kbagent auth logout` | | Register accessible projects from the current session as local aliases | `kbagent auth register-projects` | diff --git a/plugins/kbagent/skills/kbagent/references/auth-workflow.md b/plugins/kbagent/skills/kbagent/references/auth-workflow.md index 4be1d2bd..de30a99f 100644 --- a/plugins/kbagent/skills/kbagent/references/auth-workflow.md +++ b/plugins/kbagent/skills/kbagent/references/auth-workflow.md @@ -1,21 +1,25 @@ -# Programmatic Auth (Browser Login) workflow +# Programmatic Auth (Browser Login and Unattended Login) workflow > Audience: a human user of kbagent (or an agent relaying instructions to > one) who wants to authenticate via a browser instead of pasting a static -> Storage API token. Goal: sign in once, understand what got stored where, -> and know how to check on / tear down the session later. -> Since v0.80.0. Full command reference: `commands-reference.md` > -> "Programmatic Auth (Browser Login)". Gotchas: `gotchas.md` > "Programmatic -> auth (browser login) is human-only; sentinel tokens; v1 scope". +> Storage API token -- or, since v0.81.0, an agent running unattended with +> real account credentials for CI. Goal: sign in once, understand what got +> stored where, and know how to check on / tear down the session later. +> Since v0.80.0 (browser login), v0.81.0 (unattended `login-password`). +> Full command reference: `commands-reference.md` > "Programmatic Auth +> (Browser Login)". Gotchas: `gotchas.md` > "Programmatic auth (browser +> login) is human-only; sentinel tokens; v1 scope" and > "`auth +> login-password` is the CI-safe, headless exception...". -## Read this first: `auth login` needs a human +## Read this first: `auth login` needs a human -- `auth login-password` does not `kbagent auth login` opens a real browser window (or, on the device flow, prints a short code you type into a page on any device). There is **no** -headless or unattended path -- an AI agent must never run this command on -its own initiative. If an agent is asked to "set up kbagent auth" or -"log me in", the correct behavior is to hand the exact command back to the -user and wait: +headless or unattended path for *this specific command* -- an AI agent must +never run it on its own initiative. If an agent is asked to "set up kbagent +auth" or "log me in" and no account credentials were supplied for an +unattended path, the correct behavior is to hand the exact command back to +the user and wait: ``` Please run this yourself in a terminal where a browser can open: @@ -25,9 +29,12 @@ Please run this yourself in a terminal where a browser can open: Then let me know once it's done and I'll continue with `kbagent auth status`. ``` -For CI, containers, or any other unattended context, keep using a static -Storage token (`kbagent project add --token ...` or -`KBAGENT_PROJECT_FROM_ENV`) -- that path is unchanged by this feature. +For CI, containers, or any other unattended context there are now two +options (since v0.81.0): if the task has account email + password (+ a TOTP +seed for MFA), use `kbagent auth login-password` -- see +"Unattended login" below, an agent MAY run it directly. Otherwise keep using +a static Storage token (`kbagent project add --token ...` or +`KBAGENT_PROJECT_FROM_ENV`) -- that path is unchanged by either feature. ## What `login` actually does @@ -68,6 +75,48 @@ is still chosen the normal way (`--project`, `KBAGENT_PROJECT`, the pinned default) -- the session just supplies the credential, and the CLI adds `X-KBC-ProjectId` per request. +## Unattended login: `auth login-password` (since v0.81.0) + +The CI-safe counterpart to `login` above: never opens a browser, completes +entirely over HTTP, and is safe to run from a secret-backed workflow step -- +or directly by an agent that was given real account credentials for this +purpose. + +``` +kbagent auth login-password --email E (--password-stdin | --password P) + [--totp-secret SEED] [--stack URL|alias] + [--register-projects] +``` + +- `--email` / `KBC_LOGIN_EMAIL`, and the password via `--password-stdin` + (preferred -- hidden prompt on a TTY, reads to EOF on a pipe), + `--password`, or `KBC_LOGIN_PASSWORD` (a CI secret in the step's `env:` + block). +- `--totp-secret` / `KBC_LOGIN_TOTP_SECRET` is the account's **base32 TOTP + seed** from its authenticator enrollment -- **not** a live 6-digit code. + kbagent computes the current code itself (stdlib RFC 6238) immediately + before submitting it, so no human ever types a code into this flow. Only + required if the account has TOTP-based MFA configured. +- An account with **WebAuthn/passkey-only MFA cannot use this command** -- + that ceremony needs a browser. The CLI raises `AUTH_MFA_INVALID` (exit 3); + fall back to `kbagent auth login` for that account. +- Everything after the token exchange -- session persistence, best-effort + revoke of the session it replaces, introspection, `--register-projects` -- + is identical to `login` above; the result is stored in `auth.json` the + same way and follows the same v1 scope restrictions. +- **Security note.** Storing an account's password (and TOTP seed) as CI + secrets is a bigger blast radius than a single scoped Storage token -- + whoever holds them can do anything that account can do, not just what one + project's token allows. Use a dedicated, least-privileged service account, + never a real human's own credentials. For an MFA-enabled account the + resulting session also carries a live 3-hour sudo window that a + browser-login session usually does not (see `docs/auth.md`). +- Two CI jobs calling `login-password` with the **same** service account's + TOTP secret within the same 30-second time slice: the second one fails -- + the server accepts each TOTP code exactly once. Avoid sharing one + MFA-enabled account across concurrent matrix-build legs, or serialize the + login step. + ## Registering projects: `auth register-projects` ``` @@ -146,6 +195,11 @@ that alias rather than offered a second, colliding suggestion. # 1. Sign in (opens a browser; falls back to a device code if needed) kbagent auth login --register-projects +# 1-CI. The unattended equivalent, given real account credentials +# (since v0.81.0) -- no browser, safe from a secret-backed step: +kbagent auth login-password --email "$CI_EMAIL" --password-stdin \ + --totp-secret "$CI_TOTP_SEED" --register-projects <<< "$CI_PASSWORD" + # 2. Register (or top up) local aliases for the projects you want. # Interactive picker; --all or --project-id ID for a non-interactive run. kbagent auth register-projects @@ -265,6 +319,18 @@ browser login only completes on the host, so someone has to run means the auth service was slow or unreachable -- your login is not dead, so do not re-run `auth login`. The refresh is one attempt under a short budget by design; just run the original command again. +- **`login-password` says "Invalid email or password"**: literally that -- + double-check the credentials, not a kbagent bug. **"Invalid or expired TOTP + code"** on the MFA step means the seed or the account's server-side clock + drifted, or the code was already used (see the next point). +- **`login-password` fails intermittently in a CI matrix**: if two jobs + share one MFA-enabled service account and log in within the same + 30-second window, the second submission is rejected -- the server accepts + each TOTP time-slice exactly once. Serialize the login step across that + account, or give each matrix leg its own account. +- **`login-password` raises `AUTH_MFA_INVALID`**: the account's MFA is + WebAuthn/passkey-only, which this grant cannot resolve without a browser. + Fall back to `kbagent auth login` for that account (a human, once). In the human `project list` / `project status` tables the mode shows as an `Auth` column, and in `project info` as an `Auth` row above the Token rows. A @@ -292,8 +358,9 @@ differently on purpose: ## Boundaries (what this surface does NOT own) - It does not replace static Storage tokens -- both coexist indefinitely. - Static tokens remain the only supported path for CI/CD, containers, and - any other unattended context. + Static tokens remain a supported path for CI/CD, containers, and any other + unattended context; `auth login-password` (since v0.81.0) is the other one + when the task has account credentials rather than a token. - It does not manage Manage-API super-admin credentials (`feature`, `org setup`, member administration) -- those keep demanding the existing interactive manage-token prompt; a programmatic session is user-scoped and diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index 2ec571b9..dc51a6de 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -13,14 +13,17 @@ All commands support `--json` for structured output. Multi-project flags (`--pro ## Programmatic Auth (Browser Login) (since v0.80.0) Alternative to a static Storage API token: sign in via a real browser (PKCE -authorization-code) or, when no browser is usable, an RFC 8628 device code. -**Requires a human at a browser/device -- never run `auth login` from an -unattended agent task.** Issues a USER-scoped "programmatic session" +authorization-code) or, when no browser is usable, an RFC 8628 device code -- +or, for CI/automation, a password grant (`auth login-password`, no browser). +**`auth login` requires a human at a browser/device -- never run it from an +unattended agent task; `auth login-password` is the deliberate exception, +safe to run unattended.** Both issue a USER-scoped "programmatic session" (`kbc_at_*` access token + `kbc_rt_*` refresh token) stored in `auth.json` (0600), a sibling of `config.json`; `config.json`'s own schema and `CURRENT_CONFIG_VERSION` are unchanged. - `auth login [--stack URL|alias] [--device-code] [--register-projects]` -- sign in. `--stack` accepts a bare stack URL or an existing project alias (its stack is used); omitted, resolves from the default project's stack. `--device-code` forces the RFC 8628 flow even with a browser available. Without it, PKCE is tried first and falls back to the device flow ONLY on a pre-exchange failure (no loopback browser, callback timeout, or an SSH/container/WSL heuristic) -- once the browser callback succeeds there is no fallback. `--register-projects` writes every project the session can access into `config.json` under the sentinel token `kbc-session://{project_id}` (existing alias for the same project+stack: left alone; pointing elsewhere: skipped with a warning; two accessible projects sharing the same name now get distinct suggested aliases via a project-id suffix instead of the second one being silently skipped). Without `--register-projects`, on a TTY with human output, `login` offers the same picker interactively right after reporting success -- see `auth register-projects` below. +- `auth login-password --email EMAIL (--password PASSWORD | --password-stdin) [--totp-secret SECRET] [--stack URL|alias] [--register-projects]` -- sign in via a password grant, no browser. Prefer `--password-stdin` (or `KBC_LOGIN_PASSWORD`) over `--password` -- a value on the command line lands in shell history and process listings; `--password`/`--password-stdin` are mutually exclusive (`ConfigError` if both are given). `--email`/`--password`/`--totp-secret` also read from `KBC_LOGIN_EMAIL`/`KBC_LOGIN_PASSWORD`/`KBC_LOGIN_TOTP_SECRET` env vars (same convention as `KBC_TOKEN`), so a CI workflow sets them once in a step's `env:` block. `--totp-secret` is the account's base32 TOTP seed (from its authenticator enrollment), NOT a 6-digit code -- kbagent computes the current code itself (`auth/totp.py`, stdlib RFC 6238), so nothing here needs a human typing a live code. Only resolves TOTP-based MFA; a WebAuthn/passkey-only account gets `AUTH_MFA_INVALID` and must use `auth login` instead (that ceremony needs a real browser). The resulting session is stored and used identically to a browser-login session -- same `auth.json`, same `project list` "session" auth-mode, same `--register-projects` contract. Storing an account's password (and TOTP seed) as CI secrets is a bigger blast radius than one scoped project token; use a dedicated, least-privileged service account. - `auth status [--stack URL|alias]` -- show session state (`live`/`refreshed`/`degraded`/`expired`/`missing`), signed-in user, accessible projects, and token expiry. Proactively refreshes the access token if stale before reporting (a healthy session routinely shows an expired 1h access token next to a valid 30-day refresh token) -- `refreshed` means a rotation just happened, `live` means the cached token was still fresh. - `auth logout [--stack URL|alias] [--remove-projects] [--yes]` -- revoke the refresh token server-side and delete the local session from `auth.json`. `--remove-projects` also removes `config.json` aliases pointing at this session (sentinel-token projects only; a static-token project on the same stack is never touched). - `auth register-projects [--stack URL|alias] [--all] [--project-id ID ...] [--alias ID=ALIAS ...] [--yes]` -- register an EXISTING session's accessible projects as `config.json` aliases, without re-running `login`. Fixes two usability gaps in plain `login`: nothing was registered unless `--register-projects` was passed, and the suggested alias was always slugified from the project NAME, so a project id like `9840` from the login table never resolved as `--project 9840`. `--all` selects every accessible project; `--project-id ID` (repeatable) selects specific ones (an inaccessible id raises a `ConfigError`); passing neither starts an interactive arrow-key + spacebar checkbox picker -- every not-yet-registered project preselected, up/down or `j`/`k` move, `space` toggles, `a` selects/deselects all, `enter` accepts, `q`/`esc`/`ctrl-c` cancels -- followed by a single `Edit aliases?` confirm (default no) that opens the old per-project alias prompt only if you opt in (each row already shows its suggested alias), then a final `typer.confirm`. On a piped stdin or a terminal without real interactive capabilities, the picker falls back to the original typed prompt (numbers / ranges `1-3` / `all` / `none`). In a non-TTY or `--json` context with neither `--all` nor `--project-id`, the command fails fast telling the caller to pass `--all` or `--project-id` instead of hanging on a prompt. `--alias ID=ALIAS` (repeatable) overrides the suggested alias for a given project id in every mode, including as the picker's prefilled default. `--yes` skips only the picker's final confirmation. Two collision rules, in both modes: a project already registered under an alias for this project+stack reports `status: "exists"` (no-op -- rename via `project edit --new-alias` instead of re-registering); an alias already claimed by a different project (or a static-token project) reports `status: "skipped"` with a rename-hint note -- an existing `config.json` entry is never overwritten. `auth login` (without `--register-projects`) now also offers this same picker interactively right after a successful login, when stdout is a TTY and `--json` was not used; otherwise it just prints the hint to run this command later, and a failure in that optional follow-up never changes `login`'s own (already-successful) exit code. diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index aecb33be..43b9dae3 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -14,10 +14,13 @@ Versioning convention: ## Programmatic auth (browser login) is human-only; sentinel tokens; v1 scope (since v0.80.0) - **`kbagent auth login` requires a human at a browser (or a device to type a - code into) -- there is no headless/unattended path.** Never invoke it from - an unattended AI agent task; if a user asks an agent to "log in", the agent - must tell the user to run `auth login` themselves in their own terminal. - Session tokens are deliberately not readable through the CLI once issued. + code into) -- there is no headless/unattended path for THIS command.** + Never invoke it from an unattended AI agent task; if a user asks an agent + to "log in", the agent must tell the user to run `auth login` themselves in + their own terminal. Session tokens are deliberately not readable through + the CLI once issued. **`auth login-password` (since v0.81.0, below) is the + headless counterpart** -- it did not exist when this rule was written and + does not fall under it. - **PKCE is the default; the device flow is a fallback, not a mode switch.** The CLI tries the browser (PKCE authorization-code) flow first and falls back to the RFC 8628 device flow ONLY on a *pre-exchange* failure: no @@ -164,14 +167,48 @@ Versioning convention: - **Tokens are plaintext in `auth.json` (0600), a sibling of `config.json`.** Deliberate RFC 8628 deviation, same posture as the static tokens already in `config.json` (see `docs/programmatic-auth-login-plan.md` section 4.2). CI - and any headless/unattended runner should keep using a static Storage token - -- browser login has no non-interactive path by design. + and any headless/unattended runner has two options since v0.81.0: `auth + login-password` (below) if account credentials for this purpose exist, or + a static Storage token otherwise -- browser login (`auth login` itself) + still has no non-interactive path by design. - **Feature-flagged per stack.** A 404 from any `auth` endpoint means browser login is not enabled on that stack (not a wrong URL or a bug) -- the CLI reports it as `AUTH_NOT_SUPPORTED_ON_STACK` and suggests a static token. - See `auth-workflow.md` for the end-to-end login -> register -> status -> logout walkthrough and PKCE-vs-device troubleshooting. +## `auth login-password` is the CI-safe, headless exception to "browser login is human-only" (since v0.81.0) + +- **Password-grant login, no browser, safe for an unattended agent task**: + `kbagent auth login-password --email E (--password-stdin | --password P | + KBC_LOGIN_PASSWORD) [--totp-secret SEED | KBC_LOGIN_TOTP_SECRET] + [--register-projects]`. This is the deliberate carve-out from every "human + at a browser" rule above -- an agent MAY run it directly when the task was + given real account credentials for this purpose, the same way it may run + any other kbagent command with a supplied secret. +- **`--totp-secret` is the base32 TOTP *seed* (the enrollment secret), never + a live 6-digit code.** kbagent computes the current code itself + (stdlib-only RFC 6238); no human ever types a code into this flow. An + account with WebAuthn/passkey-only MFA cannot be resolved here -- the + server rejects it and the CLI raises `AUTH_MFA_INVALID` (new error code, + exit 3, see the table below); fall back to telling the user to run + `kbagent auth login` themselves. +- **A wrong password reports a plain "invalid email or password", not the + generic session-oriented `INVALID_TOKEN` wording** -- there is no + client-level token in this flow, only a submitted credential the server + rejected. A 403 (SSO-enforced account, or an admin whose MFA this grant + cannot resolve) surfaces the server's own message and names `auth login` + as the fallback. A 429 reports when the account's rate-limit window + resets rather than a bare `API error 429`. +- **The session this produces is stored in `auth.json` exactly like a + browser-login session** (same `auth_mode: session`, same + `--register-projects` contract, same v1 scope restrictions above) with one + privilege difference worth knowing: for an MFA-enabled account it carries + a live 3-hour sudo window that a browser-login session usually does not + (see `docs/auth.md`) -- treat the credentials backing it with the same + care as any other long-lived secret, not as a lesser one than a scoped + Storage token. + ## MCP passthrough is DEPRECATED; REMOVED in v0.85.0 (since v0.74.0) - **`tool call` / `tool list` / `agent --type mcp_tool` are on a removal @@ -1792,7 +1829,7 @@ unknown -- do not try to parse a fallback message. | 0 | Success | | 1 | General error | | 2 | Usage error (invalid arguments) | -| 3 | Authentication error (invalid or expired token) -- includes `SESSION_EXPIRED` / `SESSION_NOT_FOUND` / `AUTH_FLOW_DENIED`, whose remedy is `kbagent auth login` (since v0.80.0) | +| 3 | Authentication error (invalid or expired token) -- includes `SESSION_EXPIRED` / `SESSION_NOT_FOUND` / `AUTH_FLOW_DENIED`, whose remedy is `kbagent auth login` (since v0.80.0), and `AUTH_MFA_INVALID` -- `auth login-password` cannot resolve this account's MFA type (WebAuthn/passkey-only), whose remedy is `kbagent auth login` instead (since v0.81.0) | | 4 | Network error (timeout, unreachable) -- includes `QUEUE_JOB_TIMEOUT` (local gave up AND the remote-kill attempt failed; the remote job may still be running), `AUTH_FLOW_TIMEOUT`, and a session refresh that timed out or could not reach the auth service (`TIMEOUT` / `CONNECTION_ERROR`; a slow auth service is NOT a dead login -- re-run, do not re-login) (since v0.80.0) | | 5 | Configuration error (corrupt config, missing alias) | | 6 | Permission denied (blocked by firewall / `--deny-writes` / `--deny-destructive`) | diff --git a/pyproject.toml b/pyproject.toml index 063cfc9e..67b034b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keboola-cli" -version = "0.83.0" +version = "0.84.0" description = "AI-friendly CLI for managing Keboola projects" readme = "README.md" requires-python = ">=3.12" diff --git a/src/keboola_agent_cli/auth/auth_client.py b/src/keboola_agent_cli/auth/auth_client.py index e3afacba..8b38e75f 100644 --- a/src/keboola_agent_cli/auth/auth_client.py +++ b/src/keboola_agent_cli/auth/auth_client.py @@ -10,12 +10,21 @@ token to revoke in its request body. Inherits shared retry/backoff (429/5xx) and error-mapping infrastructure from -:class:`BaseHttpClient`, with two deliberate exceptions that keep the mapping +:class:`BaseHttpClient`, with four deliberate exceptions that keep the mapping but skip the retry loop: `poll_device_token` (a polling 400 is a protocol -state, not a failure) and `refresh` (a blind retry would re-present the refresh -token, and it runs under a wall-clock ceiling the retry loop would outlast). -`refresh` makes one narrow exception of its own for a rotation deadlock, which -the server reports in a form that proves nothing rotated. See their docstrings. +state, not a failure), `refresh` (a blind retry would re-present the refresh +token, and it runs under a wall-clock ceiling the retry loop would outlast), +and `login_password`/`verify_mfa_totp` (a blind retry against `/v1/auth/login` +burns extra requests against the account's rate-limit bucket before the +server's own `X-RateLimit-Reset` guidance is even read, and against +`/v1/auth/mfa` would replay a TOTP code the server already marked consumed -- +burning one of the account's limited MFA attempts for nothing). The last +three share `_request_bypassing_retry`, which maps transport failures +(`httpx.TimeoutException`/`httpx.TransportError`) the way `_do_request` would +have, since bypassing the retry loop must not also mean losing that mapping. +`refresh` makes one further narrow exception of its own for a rotation +deadlock, which the server reports in a form that proves nothing rotated. +See their docstrings. """ from __future__ import annotations @@ -33,6 +42,8 @@ AUTH_CLIENT_ID, AUTH_DEVICE_PATH, AUTH_DEVICE_TOKEN_PATH, + AUTH_LOGIN_PATH, + AUTH_MFA_PATH, AUTH_PKCE_AUTHORIZE_PATH, AUTH_PKCE_TOKEN_PATH, AUTH_REFRESH_CONTENTION_DEFAULT_DELAY, @@ -54,6 +65,7 @@ DevicePollResult, DevicePollStatus, IntrospectResponse, + MfaChallengeResult, RevokeResult, ) @@ -254,6 +266,61 @@ def __enter__(self) -> AuthClient: def __exit__(self, *args: Any) -> None: self.close() + # ------------------------------------------------------------------ + # Password grant + MFA (unattended, CI-safe login) + # ------------------------------------------------------------------ + + def login_password(self, email: str, password: str) -> CliTokenResponse | MfaChallengeResult: + """Password-grant login (``POST /v1/auth/login``). + + The unattended-capable login path -- unlike PKCE/device, this never + opens a browser. Returns a token pair directly, or an + `MfaChallengeResult` if the account has MFA configured (resolve it + with `verify_mfa_totp`). + + Bypasses `_do_request`/the shared retry loop (see the module + docstring): a blind retry here burns extra requests against the + account's rate-limit bucket before `_raise_password_login_error`'s + own `X-RateLimit-Reset` handling is ever consulted, and a retry + after the server already created a session (e.g. a read timeout + while the response was in flight) mints a second live session that + never reaches `AuthService._finalize_login` -- an orphan `auth + logout` has no record of and can never revoke. + """ + response = self._request_bypassing_retry( + "POST", + AUTH_LOGIN_PATH, + json={"grantType": "password", "email": email, "password": password}, + action="Signing in", + ) + if response.status_code >= 400: + self._raise_password_login_error(response, is_mfa=False) + data = response.json() + if data.get("mfaRequired"): + return MfaChallengeResult.model_validate(data) + return CliTokenResponse.model_validate(data) + + def verify_mfa_totp(self, mfa_token: str, code: str) -> CliTokenResponse: + """Resolve a password-login MFA challenge via TOTP (``POST /v1/auth/mfa``). + + Bypasses `_do_request`/the shared retry loop -- the same deliberate + exception `login_password`, `poll_device_token`, and `refresh` + already make (see the module docstring). The server consumes a TOTP + time-slice on its first submission and rejects any resubmission of + it, so a 429/5xx retry here would resend the same code and be + rejected by construction, burning one of the account's limited MFA + attempts for a failure that was never the credential's fault. + """ + response = self._request_bypassing_retry( + "POST", + AUTH_MFA_PATH, + json={"mfaToken": mfa_token, "type": "totp", "code": code}, + action="Verifying the MFA code", + ) + if response.status_code >= 400: + self._raise_password_login_error(response, is_mfa=True) + return CliTokenResponse.model_validate(response.json()) + # ------------------------------------------------------------------ # PKCE # ------------------------------------------------------------------ @@ -491,36 +558,59 @@ def refresh(self, refresh_token: str) -> CliTokenResponse: raise AssertionError("unreachable: the final attempt always returns or raises") - def _post_refresh(self, refresh_token: str) -> httpx.Response: - """Issue one refresh request, mapping transport failures to network codes.""" + def _request_bypassing_retry( + self, + method: str, + path: str, + *, + json: dict[str, Any], + timeout: httpx.Timeout | float | None = None, + action: str, + ) -> httpx.Response: + """Issue one request outside the shared retry loop, still mapping + transport failures the way `_do_request` would have. + + Shared by `login_password`, `verify_mfa_totp`, and `refresh` + (via `_post_refresh`) -- each bypasses the retry loop for its own + reason (see their docstrings and the module docstring), but + bypassing retry must not also mean losing the + `TimeoutException`/`TransportError` -> structured-error mapping + `_do_request` gives every other call; without it, a network blip + would escape as a raw traceback instead of a `--json` error + envelope. `action` names what the caller was doing + ("Signing in", "Refreshing your Keboola login", ...), reused in + both message templates below. + """ + kwargs: dict[str, Any] = {"json": json} + if timeout is not None: + kwargs["timeout"] = timeout try: - return self._client.request( - "POST", - AUTH_TOKEN_REFRESH_PATH, - json={"refreshToken": refresh_token}, - timeout=AUTH_REFRESH_TIMEOUT, - ) + return self._client.request(method, path, **kwargs) except httpx.TimeoutException as exc: raise KeboolaApiError( - message=( - f"Refreshing your Keboola login at {self._base_url} timed out. " - "Run the command again." - ), + message=f"{action} at {self._base_url} timed out. Run the command again.", status_code=0, error_code=ErrorCode.TIMEOUT, retryable=True, ) from exc except httpx.TransportError as exc: raise KeboolaApiError( - message=( - f"Cannot reach {self._base_url} to refresh your Keboola login " - f"({type(exc).__name__})." - ), + message=f"Cannot reach {self._base_url} ({action}: {type(exc).__name__}).", status_code=0, error_code=ErrorCode.CONNECTION_ERROR, retryable=True, ) from exc + def _post_refresh(self, refresh_token: str) -> httpx.Response: + """Issue one refresh request, mapping transport failures to network codes.""" + return self._request_bypassing_retry( + "POST", + AUTH_TOKEN_REFRESH_PATH, + json={"refreshToken": refresh_token}, + timeout=AUTH_REFRESH_TIMEOUT, + action="Refreshing your Keboola login", + ) + def _raise_contention_exhausted(self, response: httpx.Response) -> NoReturn: """Report a rotation deadlock that survived every replay. @@ -694,6 +784,13 @@ def _raise_api_error(self, response: httpx.Response, base_url: str | None = None def _map_auth_error(self, response: httpx.Response) -> NoReturn: """Map a failed auth-endpoint response, escalating 404 to a dedicated code. + `login_password`/`verify_mfa_totp` never reach this method -- both + bypass `_do_request` and call `_raise_password_login_error` directly + (they already know which of the two password-grant endpoints they + are; sniffing it back out of the response would just be re-deriving + what the caller already had), so this stays purely the generic + mapping shared by every other auth endpoint. + A 404 here means programmatic auth (or the specific flow) is not enabled on this stack -- a fail-closed feature flag -- not "wrong URL". Surfacing the generic `NOT_FOUND` code would send the user @@ -721,6 +818,85 @@ def _map_auth_error(self, response: httpx.Response) -> NoReturn: # divergence explicit rather than relax this method's NoReturn type. raise AssertionError("unreachable: BaseHttpClient._raise_api_error always raises") + def _raise_password_login_error(self, response: httpx.Response, *, is_mfa: bool) -> NoReturn: + """Map a failed `/v1/auth/login` or `/v1/auth/mfa` response. + + These two endpoints are the password-grant login path + (`login_password` / `verify_mfa_totp`) and see a different failure + surface than every other auth endpoint: there is no client-level + credential to be "invalid or expired" here, only a submitted email, + password, or TOTP code that the server rejected outright. `is_mfa` + is passed explicitly by the caller -- `login_password` and + `verify_mfa_totp` already know unambiguously which endpoint they + just called, so there is nothing to gain from re-deriving it by + inspecting `response.request.url.path` (which, past being needless, + is also fragile: `httpx.Response.request` raises `RuntimeError` + rather than returning `None` when unset, so a bare + `httpx.Response(...)` built directly -- as tests elsewhere in this + repo do -- would crash this method instead of falling through). + + - 401 -- wrong email/password on `/v1/auth/login`, or a wrong/expired + TOTP code on `/v1/auth/mfa`; the message is picked by which path + failed. Mapped to `AUTH_FLOW_DENIED` (already used for a denied + PKCE/device flow), not the inherited "Invalid or expired token" + wording -- there is no client-level token to be stale here. + - 403 -- the server's own message is surfaced verbatim and + `kbagent auth login` is named, because the two real causes here + (an SSO-enforced account, or a super-admin account whose MFA the + password grant cannot resolve) both require the browser flow, not + a retry of this one. + - 404 -- unlike every other auth endpoint this is *not* browser + login, so the message says "programmatic auth" rather than + "Browser login". + - 429 -- `/v1/auth/login` rate-limits by email and by IP over a + 15-minute window and reports the tightest bucket via + `X-RateLimit-Reset` on every response from these two endpoints + specifically so a client can self-throttle. Surfacing it turns a + generic `API error 429` into "try again at <time>". + """ + status = response.status_code + if status == 401: + message = "Invalid or expired TOTP code." if is_mfa else "Invalid email or password." + raise KeboolaApiError( + message=message, + status_code=401, + error_code=ErrorCode.AUTH_FLOW_DENIED, + retryable=False, + ) + if status == 403: + raise KeboolaApiError( + message=( + f"{self._truncate(self._extract_error_message(response))} If this " + "account requires SSO or its MFA cannot be resolved without a " + "browser, use `kbagent auth login` instead." + ), + status_code=403, + error_code=ErrorCode.ACCESS_DENIED, + retryable=False, + ) + if status == 404: + raise KeboolaApiError( + message=( + f"Programmatic auth is not enabled on this Keboola stack yet " + f"({self._base_url}). Use a static Storage token instead: " + "kbagent project add --project <alias> --url <stack> --token <token>." + ), + status_code=404, + error_code=ErrorCode.AUTH_NOT_SUPPORTED_ON_STACK, + retryable=False, + ) + if status == 429: + reset = response.headers.get("X-RateLimit-Reset") + when = f" Try again after {reset}." if reset else " Try again later." + raise KeboolaApiError( + message=f"Too many failed login attempts.{when}", + status_code=429, + error_code=ErrorCode.API_ERROR, + retryable=False, + ) + super()._raise_api_error(response, self._base_url) + raise AssertionError("unreachable: BaseHttpClient._raise_api_error always raises") + @staticmethod def _truncate(message: str) -> str: """Cap a message to `MAX_API_ERROR_LENGTH`, matching the base client's truncation.""" diff --git a/src/keboola_agent_cli/auth/models.py b/src/keboola_agent_cli/auth/models.py index 935bdaf9..33624b7d 100644 --- a/src/keboola_agent_cli/auth/models.py +++ b/src/keboola_agent_cli/auth/models.py @@ -123,6 +123,29 @@ class DeviceAuthorization(BaseModel): model_config = _WIRE_MODEL_CONFIG +class MfaChallengeResult(BaseModel): + """Response to POST /v1/auth/login when the account has MFA configured. + + Returned instead of a token pair -- resolve it via POST /v1/auth/mfa + (`AuthClient.verify_mfa_totp` for the TOTP factor; WebAuthn/recovery-code + are not wired here, since `login_password` exists specifically for the + no-browser CI path and WebAuthn needs one). + + `allowed_methods` always includes ``"recovery_code"``, even for a + WebAuthn-only account -- the server does not model a `webauthn` method + on this response separately (harmless here under `extra="allow"`; there + is simply no `verify_mfa_webauthn` counterpart to call with it). + """ + + mfa_required: bool = Field(default=True, alias="mfaRequired") + mfa_type: str = Field(default="", alias="mfaType") + mfa_token: str = Field(default="", alias="mfaToken") + expires_in: int = Field(default=0, alias="expiresIn") + allowed_methods: list[str] = Field(default_factory=list, alias="allowedMethods") + + model_config = _WIRE_MODEL_CONFIG + + class AuthProject(BaseModel): """One project accessible to the signed-in session, from introspect.""" diff --git a/src/keboola_agent_cli/auth/totp.py b/src/keboola_agent_cli/auth/totp.py new file mode 100644 index 00000000..da56190f --- /dev/null +++ b/src/keboola_agent_cli/auth/totp.py @@ -0,0 +1,41 @@ +"""RFC 6238 TOTP code computation, stdlib only. + +Used by `login_password` to resolve a TOTP MFA challenge non-interactively +(the whole point of the password-grant login path -- see +`services/auth_service.py`'s `login_password`): given the account's base32 +TOTP seed (stored as a CI secret alongside the password), compute the +current 6-digit code instead of prompting a human for one. +""" + +from __future__ import annotations + +import base64 +import binascii +import hashlib +import hmac +import struct +import time + + +def compute_totp_code(secret_b32: str, *, digits: int = 6, period: int = 30) -> str: + """Compute the current TOTP code for a base32-encoded secret (RFC 6238). + + Raises ``ValueError`` on a blank or malformed secret -- callers should map + that to a structured CLI error rather than letting it propagate raw. + """ + cleaned = secret_b32.strip().upper().replace(" ", "").replace("-", "") + if not cleaned: + raise ValueError("TOTP secret is empty") + # b32decode requires a length that is a multiple of 8; some enrollment + # UIs hand out an unpadded seed, so pad it back rather than reject a + # legitimate secret as "not valid base32". + cleaned += "=" * (-len(cleaned) % 8) + try: + key = base64.b32decode(cleaned, casefold=True) + except binascii.Error as exc: + raise ValueError(f"TOTP secret is not valid base32: {exc}") from exc + counter = int(time.time() // period) + digest = hmac.new(key, struct.pack(">Q", counter), hashlib.sha1).digest() + offset = digest[-1] & 0x0F + code = (struct.unpack(">I", digest[offset : offset + 4])[0] & 0x7FFFFFFF) % (10**digits) + return str(code).zfill(digits) diff --git a/src/keboola_agent_cli/changelog.py b/src/keboola_agent_cli/changelog.py index a233146d..7c0da112 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -24,6 +24,23 @@ # Ordered newest-first. Each value is a list of brief one-line descriptions. CHANGELOG: dict[str, list[str]] = { + "0.84.0": [ + "New: `kbagent auth login-password --email EMAIL (--password PASSWORD | " + "--password-stdin) [--totp-secret SECRET]` -- the deliberate unattended exception " + 'to `auth login`\'s "needs a human at a browser" rule. A password grant (no ' + "browser), safe to run from a CI secret-backed workflow step. `--password-stdin` " + "(hidden prompt on a TTY, reads to EOF on a pipe) is the recommended way to supply " + "the password -- `--password` and `--password-stdin` are mutually exclusive. " + "`--email`/`--password`/`--totp-secret` also read from " + "`KBC_LOGIN_EMAIL`/`KBC_LOGIN_PASSWORD`/`KBC_LOGIN_TOTP_SECRET` env vars (same " + "convention as `KBC_TOKEN`). `--totp-secret` is the account's base32 TOTP seed, " + "not a 6-digit code -- kbagent computes the current code itself (stdlib RFC 6238), " + "so no human ever types a live code. Only resolves TOTP-based MFA; a " + "WebAuthn/passkey-only account gets the new `AUTH_MFA_INVALID` error and must use " + "`auth login` instead. Stores the resulting session in `auth.json` exactly like " + "`auth login` does -- same downstream command support, same " + "`--register-projects` contract.", + ], "0.83.0": [ "Note (#390): there will be NO `kbagent agent migrate-mcp-tasks` command. Migrating " "a scheduled `--type mcp_tool` task to `--type cli_command` before v0.85.0 is a " diff --git a/src/keboola_agent_cli/commands/_helpers.py b/src/keboola_agent_cli/commands/_helpers.py index eb416ad5..e4a8322a 100644 --- a/src/keboola_agent_cli/commands/_helpers.py +++ b/src/keboola_agent_cli/commands/_helpers.py @@ -7,6 +7,7 @@ - Branch resolution for --branch flag """ +import getpass import os import secrets import sys @@ -72,6 +73,23 @@ def resolve_manage_token(*, allow_env: bool = False) -> str: raise typer.Exit(code=2) +def read_password_stdin() -> str: + """Read a password from stdin. + + TTY -> getpass (hidden, line-based, Enter to confirm). + Pipe/redirected -> read to EOF, strip whitespace. + Using `sys.stdin.read()` unconditionally would hang interactively + until the user sent EOF (Ctrl-D); getpass on TTY fixes that. + + Shared by `auth login-password --password-stdin` and `dev-portal + identity add/edit --password-stdin` -- the same input contract, so one + helper rather than a private copy per command module. + """ + if sys.stdin.isatty(): + return getpass.getpass("Password: ").strip() + return sys.stdin.read().strip() + + def get_formatter(ctx: typer.Context) -> OutputFormatter: """Retrieve the OutputFormatter from the Typer context.""" return ctx.obj["formatter"] diff --git a/src/keboola_agent_cli/commands/auth.py b/src/keboola_agent_cli/commands/auth.py index 8b493cb0..cc72ab35 100644 --- a/src/keboola_agent_cli/commands/auth.py +++ b/src/keboola_agent_cli/commands/auth.py @@ -1,4 +1,4 @@ -"""Programmatic browser login -- `kbagent auth login|status|logout|register-projects`. +"""Programmatic browser login -- `kbagent auth login|login-password|status|logout|register-projects`. Thin CLI layer for the `kbagent auth` command group: parses arguments, calls :class:`AuthService`, formats output. No business logic belongs here -- alias @@ -6,13 +6,14 @@ live in `AuthService` / `config_store.py`. The interactive picker itself lives in `_auth_picker.py` (terminal I/O only, same reasoning). -Signs in a user-scoped Keboola session (PKCE authorization-code flow, or a -device-code flow for headless/remote machines) and stores it in `auth.json`. -Requires a human at a browser (or able to visit a URL and type a code) -- -this is not something an AI agent can complete unattended. The resulting -session tokens are never printed or retrievable via the CLI; every result -below is built from a dataclass with no token field, so `--json` output is -safe by construction. +Two ways to sign in, stored the same way in `auth.json`: `login` (PKCE +authorization-code, or a device-code flow for headless/remote machines) +requires a human at a browser -- an AI agent must not attempt it on its own +initiative. `login-password` (email + password + TOTP) is the deliberate +unattended exception, built for CI: it never opens a browser and is safe to +run from a secret-backed workflow step. The resulting session tokens are +never printed or retrievable via the CLI; every result below is built from +a dataclass with no token field, so `--json` output is safe by construction. """ from __future__ import annotations @@ -28,6 +29,7 @@ from rich.table import Table from ..auth.models import DeviceAuthorization +from ..constants import ENV_KBC_LOGIN_EMAIL, ENV_KBC_LOGIN_PASSWORD, ENV_KBC_LOGIN_TOTP_SECRET from ..errors import ConfigError, ErrorCode, KeboolaApiError from ..output import OutputFormatter from ..services.auth_service import ( @@ -46,6 +48,7 @@ get_formatter, get_service, map_error_to_exit_code, + read_password_stdin, ) auth_app = typer.Typer( @@ -382,6 +385,96 @@ def _run_post_login_hook( ) +@auth_app.command("login-password") +def auth_login_password( + ctx: typer.Context, + email: str = typer.Option( + ..., + "--email", + envvar=ENV_KBC_LOGIN_EMAIL, + help="Account email. Also settable via KBC_LOGIN_EMAIL.", + ), + password: str | None = typer.Option( + None, + "--password", + envvar=ENV_KBC_LOGIN_PASSWORD, + help="Account password. Prefer KBC_LOGIN_PASSWORD (a CI secret in the step's " + "env: block) or --password-stdin over typing this flag directly -- it avoids " + "the value landing in shell history or a process listing.", + ), + password_stdin: bool = typer.Option( + False, + "--password-stdin", + help="Read the password from stdin instead of --password/KBC_LOGIN_PASSWORD. " + "On a TTY this is a hidden prompt (Enter to confirm); on a pipe it reads until " + 'EOF (e.g. `echo "$PASS" | kbagent auth login-password --password-stdin ...`).', + ), + totp_secret: str | None = typer.Option( + None, + "--totp-secret", + envvar=ENV_KBC_LOGIN_TOTP_SECRET, + help="Base32 TOTP seed (from the account's authenticator enrollment, NOT a " + "6-digit code) -- required if the account has TOTP-based MFA configured. " + "kbagent computes the current code from this itself, so no human ever " + "types a live code. Also settable via KBC_LOGIN_TOTP_SECRET.", + ), + stack: str | None = typer.Option( + None, "--stack", help="Stack URL or a registered project alias to log into" + ), + register_projects: bool = typer.Option( + False, + "--register-projects", + help="Register every project this session can access under a local alias", + ), +) -> None: + """Sign in via email + password (+ TOTP if the account has MFA) -- no browser. + + This is `auth login`'s unattended counterpart, built for CI: it never + opens a browser and completes entirely over HTTP, so it is safe to run + from a secret-backed workflow step. It does NOT relax `auth login`'s own + "requires a human at a browser" contract for the PKCE/device flows -- + this is a separate, explicitly opt-in command using a different grant + entirely. + + Only TOTP-based MFA can be resolved here (kbagent computes the current + code itself from --totp-secret). An account with WebAuthn/passkey-only + MFA cannot use this command -- that ceremony needs a real browser; use + `kbagent auth login` for such an account instead. + + Storing an account's password (and TOTP seed) as CI secrets is a bigger + blast radius than a single scoped credential: whoever holds them can do + anything that account can do, not just what one project's token allows. + Use a dedicated, least-privileged service account for this, never a + real human's own credentials. + """ + formatter = get_formatter(ctx) + service: AuthService = get_service(ctx, "auth_service") + if password_stdin and password: + _handle_errors( + formatter, + ConfigError( + "--password (or KBC_LOGIN_PASSWORD) and --password-stdin are mutually exclusive." + ), + ) + if password_stdin: + password = read_password_stdin() + if not password: + _handle_errors( + formatter, ConfigError("Pass --password, --password-stdin, or set KBC_LOGIN_PASSWORD.") + ) + try: + result = service.login_password( + stack=stack, + email=email, + password=password, + totp_secret=totp_secret, + register_projects=register_projects, + ) + except (ConfigError, KeboolaApiError) as exc: + _handle_errors(formatter, exc) + formatter.output(result, _format_login_result) + + @auth_app.command("status") def auth_status( ctx: typer.Context, diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index f77f1c49..23607003 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -60,11 +60,14 @@ authorization on SSH/containers/WSL, or with --device-code) as an alternative to a long-lived static Storage API token. - IMPORTANT FOR AI AGENTS: `auth login` REQUIRES A HUMAN AT A BROWSER. - There is no unattended/headless path, and session tokens are deliberately - not readable through the CLI -- do NOT attempt this command from an - unattended agent task. For headless / CI / automation use, keep using a - static Storage token (`project add --token` or `KBAGENT_PROJECT_FROM_ENV`). + IMPORTANT FOR AI AGENTS: `auth login` (PKCE / device flow) REQUIRES A + HUMAN AT A BROWSER. There is no unattended path for THAT command, and + session tokens are deliberately not readable through the CLI -- do NOT + attempt `auth login` from an unattended agent task. `auth login-password` + (below) is the one deliberate, explicit exception: it is safe to run + unattended given the credentials it needs, and is what CI/automation + should use to get a full session (as opposed to a single-project static + Storage token via `project add --token` / `KBAGENT_PROJECT_FROM_ENV`). kbagent auth login [--stack URL|alias] [--device-code] [--register-projects] Opens the Keboola login page in a browser (or falls back to the RFC 8628 @@ -94,6 +97,31 @@ A 404 from any auth endpoint means browser login is not enabled on that stack yet (per-stack feature flag); use a static token instead. + kbagent auth login-password --email EMAIL (--password PASSWORD | --password-stdin) [--totp-secret SECRET] [--stack URL|alias] [--register-projects] + Sign in via a password grant -- no browser, safe to run unattended from + a CI secret-backed workflow. Prefer --password-stdin (or + KBC_LOGIN_PASSWORD) over --password: a value on the command line lands + in shell history and process listings. --password and --password-stdin + are mutually exclusive (ConfigError if both are given). --email/ + --password/--totp-secret also read from + KBC_LOGIN_EMAIL/KBC_LOGIN_PASSWORD/KBC_LOGIN_TOTP_SECRET env vars + (same convention as KBC_TOKEN), so a workflow sets them once in a + step's env: block instead of passing flags. --totp-secret is the + account's base32 TOTP SEED (from its authenticator enrollment), NOT a + 6-digit code -- kbagent computes the current code itself + (auth/totp.py, stdlib RFC 6238) so no human ever types a live code. + Only resolves TOTP-based MFA this way; a WebAuthn/passkey-only account + gets AUTH_MFA_INVALID and must use `auth login` instead (that ceremony + needs a real browser). Stores the resulting session in auth.json + exactly like `auth login` does -- same downstream command support, + same `project list` "session" auth-mode column, same + --register-projects contract. AN AI AGENT MAY run this command when + given real credentials for this purpose (unlike `auth login`), but + must never invent, guess, or reuse credentials from another context. + Storing an account's password (and TOTP seed) as CI secrets is a + bigger blast radius than one scoped project token -- use a dedicated, + least-privileged service account, never a real human's own login. + kbagent auth status [--stack URL|alias] Show the current session's state (live/refreshed/degraded/expired/missing), signed-in user, accessible projects, and token expiry. Proactively diff --git a/src/keboola_agent_cli/commands/dev_portal.py b/src/keboola_agent_cli/commands/dev_portal.py index 92d868d2..b48cf76b 100644 --- a/src/keboola_agent_cli/commands/dev_portal.py +++ b/src/keboola_agent_cli/commands/dev_portal.py @@ -7,7 +7,6 @@ from __future__ import annotations -import getpass import sys from enum import StrEnum from typing import TYPE_CHECKING, Any @@ -25,6 +24,7 @@ get_dev_portal_service, get_formatter, map_error_to_exit_code, + read_password_stdin, resolve_identity_alias, ) @@ -73,19 +73,6 @@ def _split_app(app: str) -> tuple[str, str]: return vendor, app -def _read_password_stdin() -> str: - """Read a password from stdin. - - TTY -> getpass (hidden, line-based, Enter to confirm). - Pipe/redirected -> read to EOF, strip whitespace. - Using `sys.stdin.read()` unconditionally would hang interactively - until the user sent EOF (Ctrl-D); getpass on TTY fixes that. - """ - if sys.stdin.isatty(): - return getpass.getpass("Password: ").strip() - return sys.stdin.read().strip() - - # ----- Identity subcommands ----- @@ -115,7 +102,7 @@ def identity_add( ) -> None: formatter = get_formatter(ctx) if password_stdin: - password = _read_password_stdin() + password = read_password_stdin() if not password: raise typer.BadParameter("Pass --password or --password-stdin.") identity = DeveloperPortalIdentity( @@ -191,7 +178,7 @@ def identity_edit( formatter = get_formatter(ctx) svc = get_dev_portal_service(ctx) if password_stdin: - password = _read_password_stdin() + password = read_password_stdin() try: if new_alias: svc.rename_identity(alias, new_alias) diff --git a/src/keboola_agent_cli/constants.py b/src/keboola_agent_cli/constants.py index 7c80c609..a1d08041 100644 --- a/src/keboola_agent_cli/constants.py +++ b/src/keboola_agent_cli/constants.py @@ -224,6 +224,12 @@ def _resolve_app_name() -> str: ENV_KBC_STORAGE_API_URL: str = "KBC_STORAGE_API_URL" ENV_KBC_MANAGE_API_TOKEN: str = "KBC_MANAGE_API_TOKEN" ENV_KBC_MASTER_TOKEN: str = "KBC_MASTER_TOKEN" +# `auth login-password` (since 0.81.0) -- the unattended, CI-safe login path. +# Mirrors KBC_TOKEN's env-injection convention so a workflow can set these +# once in a step's `env:` block instead of piping secrets through stdin/files. +ENV_KBC_LOGIN_EMAIL: str = "KBC_LOGIN_EMAIL" +ENV_KBC_LOGIN_PASSWORD: str = "KBC_LOGIN_PASSWORD" +ENV_KBC_LOGIN_TOTP_SECRET: str = "KBC_LOGIN_TOTP_SECRET" ENV_MCP_TOOL_TIMEOUT: str = "KBAGENT_MCP_TOOL_TIMEOUT" ENV_MCP_INIT_TIMEOUT: str = "KBAGENT_MCP_INIT_TIMEOUT" ENV_MCP_MAX_SESSIONS: str = "KBAGENT_MCP_MAX_SESSIONS" @@ -645,6 +651,10 @@ def _resolve_app_name() -> str: SESSION_TOKEN_PREFIX: str = "kbc-session://" # Server endpoints, relative to the stack base URL. +# Password-grant login (since 0.81.0) -- the CI-safe, unattended alternative +# to the browser-only PKCE/device flows. See AuthService.login_password. +AUTH_LOGIN_PATH: str = "/v1/auth/login" +AUTH_MFA_PATH: str = "/v1/auth/mfa" AUTH_PKCE_AUTHORIZE_PATH: str = "/admin/auth/pkce/authorize" AUTH_PKCE_TOKEN_PATH: str = "/v1/auth/pkce/token" AUTH_DEVICE_PATH: str = "/v1/auth/device" diff --git a/src/keboola_agent_cli/errors.py b/src/keboola_agent_cli/errors.py index bfe912cb..2b6622a1 100644 --- a/src/keboola_agent_cli/errors.py +++ b/src/keboola_agent_cli/errors.py @@ -137,6 +137,9 @@ class ErrorCode(StrEnum): SESSION_EXPIRED = "SESSION_EXPIRED" SESSION_NOT_FOUND = "SESSION_NOT_FOUND" + # Password-grant login (since 0.81.0) + AUTH_MFA_INVALID = "AUTH_MFA_INVALID" + def mask_token(token: str) -> str: """Mask a Keboola Storage API token for safe display. @@ -317,6 +320,7 @@ def __init__(self, feature: str, *, remedy: str = "") -> None: ErrorCode.AUTH_STATE_MISMATCH: "authentication", ErrorCode.SESSION_EXPIRED: "authentication", ErrorCode.SESSION_NOT_FOUND: "authentication", + ErrorCode.AUTH_MFA_INVALID: "authentication", } diff --git a/src/keboola_agent_cli/permissions.py b/src/keboola_agent_cli/permissions.py index a3d54c67..3f376f5b 100644 --- a/src/keboola_agent_cli/permissions.py +++ b/src/keboola_agent_cli/permissions.py @@ -19,6 +19,7 @@ # read-only local + introspect check. `auth logout --remove-projects` does # touch the registry and is escalated via FLAG_ESCALATIONS below. "auth.login": "write", + "auth.login-password": "write", "auth.logout": "write", "auth.status": "read", # register-projects only ever writes config.json (session-sentinel diff --git a/src/keboola_agent_cli/services/auth_service.py b/src/keboola_agent_cli/services/auth_service.py index 817d319d..033f5f15 100644 --- a/src/keboola_agent_cli/services/auth_service.py +++ b/src/keboola_agent_cli/services/auth_service.py @@ -21,7 +21,7 @@ from ..auth.auth_client import AuthClient from ..auth.device import run_device_flow from ..auth.environment import BrowserEnvironment, detect_browser_environment, open_browser -from ..auth.models import CliTokenResponse, DeviceAuthorization, StackSession +from ..auth.models import CliTokenResponse, DeviceAuthorization, MfaChallengeResult, StackSession from ..auth.pkce import ( PkceAuthorizationError, PkceCallbackServer, @@ -32,6 +32,7 @@ from ..auth.sentinel import is_session_token from ..auth.state_store import AuthStateStore from ..auth.token_provider import SessionTokenProvider, reset_provider_registry +from ..auth.totp import compute_totp_code from ..config_store import ConfigStore from ..errors import ConfigError, ErrorCode, KeboolaApiError from ..models import normalize_stack_url @@ -270,92 +271,178 @@ def login( "Login flow completed without producing a token pair.", error_code=ErrorCode.API_ERROR, ) - now = datetime.now(UTC) - previous = self._state_store.get_session(stack_url) - new_session = StackSession( - stack_url=stack_url, - session_id=tokens.session_id, - user_email=tokens.user.email if tokens.user else "", - user_name=tokens.user.name if tokens.user else "", - access_token=tokens.access_token, - refresh_token=tokens.refresh_token, - access_expires_at=now + timedelta(seconds=tokens.expires_in), - # Read through the shared model helper, not hardcoded None: no - # backend sends a refresh expiry today, but if one starts, - # honouring it here (rather than only on the first refresh) - # keeps login and rotation consistent. See - # `CliTokenResponse.refresh_expiry` for why nothing is guessed - # when the field is absent. - refresh_expires_at=tokens.refresh_expiry(now=now), - created_at=now, - # An orphan is a session this CLI failed to revoke server-side, - # and the only record that it exists. `put_session` replaces the - # whole per-stack row, so a login that did not carry the list - # forward would drop every orphan older than the session it is - # replacing -- leaving a live session no `auth logout` can ever - # reach, while telling the user logout would retry it. - orphaned_session_ids=list(previous.orphaned_session_ids) if previous else [], + return self._finalize_login( + client, + stack_url, + tokens, + method=method, + fallback_reason=fallback_reason, + register_projects=register_projects, + warnings=warnings, ) - # Durable first, always -- never delete the old credentials before - # the new ones are safely on disk (review B-1). - self._state_store.put_session(new_session) - - replaced_session_id = "" - orphaned_session_id = "" - if previous is not None and previous.session_id != new_session.session_id: - replaced_session_id = previous.session_id - revoke_result = client.revoke( - previous.refresh_token, token_type_hint="refreshToken" - ) - if not revoke_result.confirmed: - orphaned_session_id = previous.session_id - self._state_store.record_orphan(stack_url, previous.session_id) - warnings.append( - f"Could not confirm revocation of the previous session " - f"({previous.session_id}); it may still be active on the " - "server. `kbagent auth logout` will retry it." + def login_password( + self, + *, + stack: str | None = None, + email: str, + password: str, + totp_secret: str | None = None, + register_projects: bool = False, + ) -> LoginResult: + """Password-grant login -- the unattended, CI-safe alternative to `login()`. + + Never opens a browser and completes entirely over HTTP, so it is + safe to run from a secret-backed CI workflow (unlike `login()`, + which requires a human at a browser or device). `totp_secret` (the + account's base32 TOTP seed) resolves an MFA challenge for an account + with TOTP-based MFA configured; WebAuthn-only accounts cannot use + this method -- that ceremony needs a browser, which is exactly what + this path exists to avoid. + + The code is computed here, immediately before the MFA request, not + by the caller before `login_password` was even invoked: the login + round trip through `_do_request`'s retry loop can itself take up to + ~90s (3 attempts, 30s read timeout, backoff), and a code computed + before it can drift out of the server's TOTP tolerance window by the + time it would be submitted. See PR #565 review (C3). + + The rest of the algorithm (session persistence, best-effort revoke + of the session it replaces, introspection, optional project + registration) is identical to `login()` -- see `_finalize_login`. + """ + stack_url = self._resolve_stack_url(stack) + warnings: list[str] = [] + with self._auth_client_factory(stack_url) as client: + result = client.login_password(email, password) + if isinstance(result, MfaChallengeResult): + if result.mfa_type != "totp": + raise KeboolaApiError( + f"This account requires MFA type {result.mfa_type!r}, which " + "password-grant login cannot resolve without a browser -- use " + "`kbagent auth login` instead.", + error_code=ErrorCode.AUTH_MFA_INVALID, + retryable=False, ) + if not totp_secret: + raise ConfigError( + "This account requires a TOTP code to sign in -- pass " + "--totp-secret (kbagent computes the code from it)." + ) + try: + totp_code = compute_totp_code(totp_secret) + except ValueError as exc: + raise ConfigError(f"--totp-secret: {exc}") from exc + tokens = client.verify_mfa_totp(result.mfa_token, totp_code) + else: + tokens = result + return self._finalize_login( + client, + stack_url, + tokens, + method="password", + fallback_reason="", + register_projects=register_projects, + warnings=warnings, + ) - introspection = client.introspect(new_session.access_token) - accessible_projects = [ - {"id": project.id, "name": project.name, "role": project.role} - for project in introspection.projects - ] - - registered_projects: list[RegisteredProject] = [] - if register_projects: - # Build candidates from the introspection this call already - # holds -- introspecting a second time (e.g. via - # `register_projects`) would be a redundant network round - # trip against a session that was only just minted. - candidates = self.candidates_from_projects(stack_url, accessible_projects) - selections = [ProjectSelection(project_id=c.project_id) for c in candidates] - registered_projects = apply_selections( - self._config_store, - stack_url, - {c.project_id: c for c in candidates}, - selections, - warnings, + def _finalize_login( + self, + client: AuthClient, + stack_url: str, + tokens: CliTokenResponse, + *, + method: str, + fallback_reason: str, + register_projects: bool, + warnings: list[str], + ) -> LoginResult: + """Shared tail of every login method: persist, best-effort revoke the + session this replaces, introspect, optionally register projects.""" + now = datetime.now(UTC) + previous = self._state_store.get_session(stack_url) + new_session = StackSession( + stack_url=stack_url, + session_id=tokens.session_id, + user_email=tokens.user.email if tokens.user else "", + user_name=tokens.user.name if tokens.user else "", + access_token=tokens.access_token, + refresh_token=tokens.refresh_token, + access_expires_at=now + timedelta(seconds=tokens.expires_in), + # Read through the shared model helper, not hardcoded None: no + # backend sends a refresh expiry today, but if one starts, + # honouring it here (rather than only on the first refresh) + # keeps login and rotation consistent. See + # `CliTokenResponse.refresh_expiry` for why nothing is guessed + # when the field is absent. + refresh_expires_at=tokens.refresh_expiry(now=now), + created_at=now, + # An orphan is a session this CLI failed to revoke server-side, + # and the only record that it exists. `put_session` replaces the + # whole per-stack row, so a login that did not carry the list + # forward would drop every orphan older than the session it is + # replacing -- leaving a live session no `auth logout` can ever + # reach, while telling the user logout would retry it. + orphaned_session_ids=list(previous.orphaned_session_ids) if previous else [], + ) + + # Durable first, always -- never delete the old credentials before + # the new ones are safely on disk (review B-1). + self._state_store.put_session(new_session) + + replaced_session_id = "" + orphaned_session_id = "" + if previous is not None and previous.session_id != new_session.session_id: + replaced_session_id = previous.session_id + revoke_result = client.revoke(previous.refresh_token, token_type_hint="refreshToken") + if not revoke_result.confirmed: + orphaned_session_id = previous.session_id + self._state_store.record_orphan(stack_url, previous.session_id) + warnings.append( + f"Could not confirm revocation of the previous session " + f"({previous.session_id}); it may still be active on the " + "server. `kbagent auth logout` will retry it." ) - return LoginResult( - status="ok", - method=method, - stack_url=stack_url, - session_id=new_session.session_id, - user_email=new_session.user_email, - user_name=new_session.user_name, - access_expires_at=_iso(new_session.access_expires_at), - refresh_expires_at=_iso(new_session.refresh_expires_at), - fallback_reason=fallback_reason, - replaced_session_id=replaced_session_id, - orphaned_session_id=orphaned_session_id, - accessible_projects=accessible_projects, - registered_projects=registered_projects, - warnings=warnings, + introspection = client.introspect(new_session.access_token) + accessible_projects = [ + {"id": project.id, "name": project.name, "role": project.role} + for project in introspection.projects + ] + + registered_projects: list[RegisteredProject] = [] + if register_projects: + # Build candidates from the introspection this call already + # holds -- introspecting a second time (e.g. via + # `register_projects`) would be a redundant network round + # trip against a session that was only just minted. + candidates = self.candidates_from_projects(stack_url, accessible_projects) + selections = [ProjectSelection(project_id=c.project_id) for c in candidates] + registered_projects = apply_selections( + self._config_store, + stack_url, + {c.project_id: c for c in candidates}, + selections, + warnings, ) + return LoginResult( + status="ok", + method=method, + stack_url=stack_url, + session_id=new_session.session_id, + user_email=new_session.user_email, + user_name=new_session.user_name, + access_expires_at=_iso(new_session.access_expires_at), + refresh_expires_at=_iso(new_session.refresh_expires_at), + fallback_reason=fallback_reason, + replaced_session_id=replaced_session_id, + orphaned_session_id=orphaned_session_id, + accessible_projects=accessible_projects, + registered_projects=registered_projects, + warnings=warnings, + ) + def _wrap_device_prompt( self, prompt: Callable[[DeviceAuthorization], None], diff --git a/tests/test_auth_client.py b/tests/test_auth_client.py index 9a7a4d31..5d9aea70 100644 --- a/tests/test_auth_client.py +++ b/tests/test_auth_client.py @@ -28,6 +28,7 @@ DeviceAuthorization, DevicePollStatus, IntrospectResponse, + MfaChallengeResult, RevokeResult, ) from keboola_agent_cli.commands._helpers import map_error_to_exit_code @@ -135,6 +136,277 @@ def test_sends_exact_body_and_parses_response(self, httpx_mock) -> None: } +# ---------------------------------------------------------------------------- +# Password-grant login +# ---------------------------------------------------------------------------- + + +class TestLoginPassword: + def test_no_mfa_returns_token_pair(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{STACK_URL}/v1/auth/login", + method="POST", + status_code=200, + json={ + "accessToken": "kbc_at_abc", + "refreshToken": "kbc_rt_def", + "tokenType": "Bearer", + "expiresIn": 3600, + "sessionId": "sess-1", + "user": {"id": 42, "email": "svc@example.com", "name": "Service"}, + }, + ) + client = _make_client() + try: + result = client.login_password("svc@example.com", "s3cr3t") + finally: + client.close() + + assert isinstance(result, CliTokenResponse) + assert result.access_token == "kbc_at_abc" + + request = httpx_mock.get_requests()[0] + assert json.loads(request.read().decode()) == { + "grantType": "password", + "email": "svc@example.com", + "password": "s3cr3t", + } + assert "Authorization" not in request.headers + + def test_mfa_required_returns_challenge(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{STACK_URL}/v1/auth/login", + method="POST", + status_code=200, + json={ + "mfaRequired": True, + "mfaType": "totp", + "mfaToken": "kbc_mfa_xyz", + "expiresIn": 300, + "allowedMethods": ["totp", "recovery_code"], + }, + ) + client = _make_client() + try: + result = client.login_password("svc@example.com", "s3cr3t") + finally: + client.close() + + assert isinstance(result, MfaChallengeResult) + assert result.mfa_type == "totp" + assert result.mfa_token == "kbc_mfa_xyz" + + def test_404_maps_to_auth_not_supported(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{STACK_URL}/v1/auth/login", method="POST", status_code=404, json={} + ) + client = _make_client() + try: + with pytest.raises(KeboolaApiError) as excinfo: + client.login_password("svc@example.com", "s3cr3t") + finally: + client.close() + assert excinfo.value.error_code == ErrorCode.AUTH_NOT_SUPPORTED_ON_STACK + assert "Programmatic auth" in excinfo.value.message + + def test_401_wrong_password_says_invalid_credentials_not_invalid_token( + self, httpx_mock + ) -> None: + """PR #565 review C2: a wrong password must not surface the generic + `INVALID_TOKEN` "Invalid or expired token (token: ****)" wording -- + there is no client-level token here, only a rejected credential.""" + httpx_mock.add_response( + url=f"{STACK_URL}/v1/auth/login", + method="POST", + status_code=401, + json={"error": "Invalid credentials"}, + ) + client = _make_client() + try: + with pytest.raises(KeboolaApiError) as excinfo: + client.login_password("svc@example.com", "wrong") + finally: + client.close() + assert excinfo.value.error_code == ErrorCode.AUTH_FLOW_DENIED + assert "token" not in excinfo.value.message.lower() + assert "Invalid email or password" in excinfo.value.message + + def test_403_sso_required_names_auth_login(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{STACK_URL}/v1/auth/login", + method="POST", + status_code=403, + json={"error": "SSO login required for this account"}, + ) + client = _make_client() + try: + with pytest.raises(KeboolaApiError) as excinfo: + client.login_password("svc@example.com", "s3cr3t") + finally: + client.close() + assert excinfo.value.error_code == ErrorCode.ACCESS_DENIED + assert "SSO login required for this account" in excinfo.value.message + assert "kbagent auth login" in excinfo.value.message + + def test_429_reports_rate_limit_reset(self, httpx_mock) -> None: + # login_password bypasses the shared retry loop (round-2 review + # finding O001 -- a retried login burns extra requests against the + # rate-limit bucket before this very message is even read), so + # exactly ONE 429 is registered; a second request with no matching + # mock would fail the test outright, proving no retry happened. + httpx_mock.add_response( + url=f"{STACK_URL}/v1/auth/login", + method="POST", + status_code=429, + headers={"X-RateLimit-Reset": "2026-08-11T07:00:00Z"}, + json={"error": "Too many attempts"}, + ) + client = _make_client() + try: + with pytest.raises(KeboolaApiError) as excinfo: + client.login_password("svc@example.com", "wrong") + finally: + client.close() + assert excinfo.value.status_code == 429 + assert "2026-08-11T07:00:00Z" in excinfo.value.message + assert len(httpx_mock.get_requests()) == 1 + + def test_timeout_is_reported_as_a_network_error(self, httpx_mock) -> None: + """Round-2 review O002: bypassing the retry loop must not also mean + losing `_do_request`'s transport-error mapping -- a network blip + must not escape as a raw traceback.""" + httpx_mock.add_exception( + httpx.ReadTimeout("read timed out"), + url=f"{STACK_URL}/v1/auth/login", + method="POST", + ) + client = _make_client() + try: + with pytest.raises(KeboolaApiError) as excinfo: + client.login_password("svc@example.com", "s3cr3t") + finally: + client.close() + assert excinfo.value.error_code == ErrorCode.TIMEOUT + assert map_error_to_exit_code(excinfo.value) == 4 + + def test_connect_failure_is_reported_as_a_network_error(self, httpx_mock) -> None: + httpx_mock.add_exception( + httpx.ConnectError("no route to host"), + url=f"{STACK_URL}/v1/auth/login", + method="POST", + ) + client = _make_client() + try: + with pytest.raises(KeboolaApiError) as excinfo: + client.login_password("svc@example.com", "s3cr3t") + finally: + client.close() + assert excinfo.value.error_code == ErrorCode.CONNECTION_ERROR + assert map_error_to_exit_code(excinfo.value) == 4 + + +class TestVerifyMfaTotp: + def test_sends_type_and_code_returns_tokens(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{STACK_URL}/v1/auth/mfa", + method="POST", + status_code=200, + json={ + "accessToken": "kbc_at_abc", + "refreshToken": "kbc_rt_def", + "expiresIn": 3600, + "sessionId": "sess-1", + }, + ) + client = _make_client() + try: + result = client.verify_mfa_totp("kbc_mfa_xyz", "123456") + finally: + client.close() + + assert isinstance(result, CliTokenResponse) + assert result.access_token == "kbc_at_abc" + + request = httpx_mock.get_requests()[0] + assert json.loads(request.read().decode()) == { + "mfaToken": "kbc_mfa_xyz", + "type": "totp", + "code": "123456", + } + + def test_a_429_is_not_retried(self, httpx_mock) -> None: + """PR #565 review C4: the server consumes a TOTP time-slice on first + submission and rejects any resubmission, so a shared-loop retry would + replay the same code and burn one of the account's MFA attempts for a + failure that was never the code's fault. Only ONE 429 is registered -- + a second request with no matching mock would fail the test outright, + proving no retry was attempted.""" + httpx_mock.add_response( + url=f"{STACK_URL}/v1/auth/mfa", + method="POST", + status_code=429, + json={"error": "Too many attempts"}, + ) + client = _make_client() + try: + with pytest.raises(KeboolaApiError) as excinfo: + client.verify_mfa_totp("kbc_mfa_xyz", "123456") + finally: + client.close() + assert excinfo.value.status_code == 429 + assert len(httpx_mock.get_requests()) == 1 + + def test_invalid_code_is_not_the_generic_invalid_token_message(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{STACK_URL}/v1/auth/mfa", + method="POST", + status_code=401, + json={"error": "Invalid MFA code"}, + ) + client = _make_client() + try: + with pytest.raises(KeboolaApiError) as excinfo: + client.verify_mfa_totp("kbc_mfa_xyz", "000000") + finally: + client.close() + assert excinfo.value.error_code == ErrorCode.AUTH_FLOW_DENIED + assert "token" not in excinfo.value.message.lower() + + def test_timeout_is_reported_as_a_network_error(self, httpx_mock) -> None: + """Round-2 review O002/S001: `verify_mfa_totp` bypasses the retry + loop deliberately (C4), but that must not also mean losing + `_do_request`'s transport-error mapping -- previously a network blip + mid-MFA propagated as a raw, unhandled `httpx` exception.""" + httpx_mock.add_exception( + httpx.ReadTimeout("read timed out"), + url=f"{STACK_URL}/v1/auth/mfa", + method="POST", + ) + client = _make_client() + try: + with pytest.raises(KeboolaApiError) as excinfo: + client.verify_mfa_totp("kbc_mfa_xyz", "123456") + finally: + client.close() + assert excinfo.value.error_code == ErrorCode.TIMEOUT + assert map_error_to_exit_code(excinfo.value) == 4 + + def test_connect_failure_is_reported_as_a_network_error(self, httpx_mock) -> None: + httpx_mock.add_exception( + httpx.ConnectError("no route to host"), + url=f"{STACK_URL}/v1/auth/mfa", + method="POST", + ) + client = _make_client() + try: + with pytest.raises(KeboolaApiError) as excinfo: + client.verify_mfa_totp("kbc_mfa_xyz", "123456") + finally: + client.close() + assert excinfo.value.error_code == ErrorCode.CONNECTION_ERROR + assert map_error_to_exit_code(excinfo.value) == 4 + + # ---------------------------------------------------------------------------- # Device authorization start # ---------------------------------------------------------------------------- @@ -1280,3 +1552,40 @@ def test_404_maps_to_not_supported(self, httpx_mock, path: str) -> None: assert excinfo.value.error_code == ErrorCode.AUTH_NOT_SUPPORTED_ON_STACK assert STACK_URL in excinfo.value.message + + +# ---------------------------------------------------------------------------- +# Round-2 review O003: error mapping must not depend on `response.request` +# ---------------------------------------------------------------------------- + + +class TestMapAuthErrorOnABareResponse: + """`httpx.Response.request` raises `RuntimeError` (never returns `None`) + when the response was never sent through a client -- exactly the shape + `httpx.Response(...)` constructed directly in a test produces (the same + pattern `tests/test_client.py::TestExtractCloudErrorCode` already uses). + Both error-mapping methods below must handle that shape without + crashing; neither reads `.request` since the O001 refactor made + `is_mfa`/endpoint identity an explicit argument instead of something + sniffed from the transport object.""" + + def test_map_auth_error_never_touches_response_request(self) -> None: + client = _make_client() + try: + response = httpx.Response(404, json={"error": "Not Found"}) + with pytest.raises(KeboolaApiError) as excinfo: + client._map_auth_error(response) + finally: + client.close() + assert excinfo.value.error_code == ErrorCode.AUTH_NOT_SUPPORTED_ON_STACK + + def test_raise_password_login_error_never_touches_response_request(self) -> None: + client = _make_client() + try: + response = httpx.Response(401, json={"error": "Invalid credentials"}) + with pytest.raises(KeboolaApiError) as excinfo: + client._raise_password_login_error(response, is_mfa=False) + finally: + client.close() + assert excinfo.value.error_code == ErrorCode.AUTH_FLOW_DENIED + assert "Invalid email or password" in excinfo.value.message diff --git a/tests/test_auth_service.py b/tests/test_auth_service.py index 8b8a02d1..b0a35f11 100644 --- a/tests/test_auth_service.py +++ b/tests/test_auth_service.py @@ -12,6 +12,7 @@ from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any +from unittest.mock import patch import pytest @@ -22,6 +23,7 @@ CliTokenResponse, DeviceAuthorization, IntrospectResponse, + MfaChallengeResult, RevokeResult, ) from keboola_agent_cli.auth.pkce import ( @@ -67,6 +69,10 @@ def __init__(self) -> None: self.refresh_side_effect: Exception | None = None self.revoke_result = RevokeResult(confirmed=True) self.delete_session_result = RevokeResult(confirmed=True) + self.login_password_response: CliTokenResponse | MfaChallengeResult | None = None + self.login_password_side_effect: Exception | None = None + self.verify_mfa_response: CliTokenResponse | None = None + self.verify_mfa_side_effect: Exception | None = None def __enter__(self) -> _FakeAuthClient: return self @@ -102,6 +108,20 @@ def refresh(self, refresh_token: str) -> CliTokenResponse: assert self.refresh_response is not None return self.refresh_response + def login_password(self, email: str, password: str): + self.calls.append(("login_password", (email, password))) + if self.login_password_side_effect is not None: + raise self.login_password_side_effect + assert self.login_password_response is not None + return self.login_password_response + + def verify_mfa_totp(self, mfa_token: str, code: str) -> CliTokenResponse: + self.calls.append(("verify_mfa_totp", (mfa_token, code))) + if self.verify_mfa_side_effect is not None: + raise self.verify_mfa_side_effect + assert self.verify_mfa_response is not None + return self.verify_mfa_response + def revoke(self, token: str, *, token_type_hint: str = "refreshToken") -> RevokeResult: self.calls.append(("revoke", (token, token_type_hint))) return self.revoke_result @@ -1243,3 +1263,87 @@ def delete_session(self, session_id: str, access_token: str) -> RevokeResult: assert outcome.revoked == ["gone"] assert outcome.remaining == ["stuck"] + + +class TestLoginPassword: + def test_no_mfa_finalizes_like_any_other_login(self, store, state_store) -> None: + client = _FakeAuthClient() + client.login_password_response = _tokens(session_id="sess-pw") + client.introspect_response = _introspect() + service = _make_service(store, state_store, client) + + result = service.login_password(stack=STACK_URL, email="svc@example.com", password="s3cr3t") + + assert result.method == "password" + assert result.session_id == "sess-pw" + assert client.calls[0] == ("login_password", ("svc@example.com", "s3cr3t")) + assert not any(c[0] == "verify_mfa_totp" for c in client.calls) + assert state_store.get_session(STACK_URL) is not None + + def test_totp_challenge_resolved_before_finalizing(self, store, state_store) -> None: + client = _FakeAuthClient() + client.login_password_response = MfaChallengeResult( + mfaRequired=True, mfaType="totp", mfaToken="kbc_mfa_xyz", allowedMethods=["totp"] + ) + client.verify_mfa_response = _tokens(session_id="sess-pw") + client.introspect_response = _introspect() + service = _make_service(store, state_store, client) + + # RFC 6238 test-vector seed at T=59s -> 8-digit ref 94287082, so the + # 6-digit code (compute_totp_code's default) is its last 6 digits. + with patch("keboola_agent_cli.auth.totp.time.time", return_value=59): + result = service.login_password( + stack=STACK_URL, + email="svc@example.com", + password="s3cr3t", + totp_secret="GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", + ) + + assert result.method == "password" + assert client.calls[0] == ("login_password", ("svc@example.com", "s3cr3t")) + assert client.calls[1] == ("verify_mfa_totp", ("kbc_mfa_xyz", "287082")) + + def test_totp_challenge_without_code_raises_config_error(self, store, state_store) -> None: + client = _FakeAuthClient() + client.login_password_response = MfaChallengeResult( + mfaRequired=True, mfaType="totp", mfaToken="kbc_mfa_xyz", allowedMethods=["totp"] + ) + service = _make_service(store, state_store, client) + + with pytest.raises(ConfigError): + service.login_password(stack=STACK_URL, email="svc@example.com", password="s3cr3t") + assert not any(c[0] == "verify_mfa_totp" for c in client.calls) + + def test_malformed_totp_secret_raises_config_error_not_value_error( + self, store, state_store + ) -> None: + """A bad --totp-secret must surface as ConfigError, never a raw + ValueError/pydantic traceback (PR #565 review C1).""" + client = _FakeAuthClient() + client.login_password_response = MfaChallengeResult( + mfaRequired=True, mfaType="totp", mfaToken="kbc_mfa_xyz", allowedMethods=["totp"] + ) + service = _make_service(store, state_store, client) + + with pytest.raises(ConfigError, match="--totp-secret"): + service.login_password( + stack=STACK_URL, + email="svc@example.com", + password="s3cr3t", + totp_secret="not-valid-base32!!!", + ) + assert not any(c[0] == "verify_mfa_totp" for c in client.calls) + + def test_webauthn_challenge_raises_mfa_invalid(self, store, state_store) -> None: + client = _FakeAuthClient() + client.login_password_response = MfaChallengeResult( + mfaRequired=True, + mfaType="webauthn", + mfaToken="kbc_mfa_xyz", + allowedMethods=["webauthn"], + ) + service = _make_service(store, state_store, client) + + with pytest.raises(KeboolaApiError) as exc_info: + service.login_password(stack=STACK_URL, email="svc@example.com", password="s3cr3t") + assert exc_info.value.error_code == ErrorCode.AUTH_MFA_INVALID diff --git a/tests/test_auth_totp.py b/tests/test_auth_totp.py new file mode 100644 index 00000000..000a8bfe --- /dev/null +++ b/tests/test_auth_totp.py @@ -0,0 +1,67 @@ +"""Tests for auth/totp.py: RFC 6238 TOTP code computation.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from keboola_agent_cli.auth.totp import compute_totp_code + + +class TestComputeTotpCode: + def test_rfc6238_test_vector(self) -> None: + """RFC 6238 appendix B: seed 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ' (the base32 + encoding of the ASCII string '12345678901234567890') at T=59s -> '94287082' + for SHA1/8-digit. ``digits`` defaults to 6 (the login-password CLI never + overrides it), but the RFC's published vector is 8-digit, so pass digits=8 + here to check against the exact reference value rather than a truncation.""" + with patch("keboola_agent_cli.auth.totp.time.time", return_value=59): + code = compute_totp_code("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", digits=8) + assert code == "94287082" + + def test_default_is_six_digits(self) -> None: + with patch("keboola_agent_cli.auth.totp.time.time", return_value=59): + code = compute_totp_code("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ") + assert len(code) == 6 + assert code.isdigit() + + def test_same_time_window_is_deterministic(self) -> None: + with patch("keboola_agent_cli.auth.totp.time.time", return_value=1000): + first = compute_totp_code("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ") + second = compute_totp_code("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ") + assert first == second + + def test_different_time_window_usually_differs(self) -> None: + with patch("keboola_agent_cli.auth.totp.time.time", return_value=0): + first = compute_totp_code("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ") + with patch("keboola_agent_cli.auth.totp.time.time", return_value=10_000_000): + second = compute_totp_code("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ") + assert first != second + + def test_accepts_lowercase_and_whitespace(self) -> None: + with patch("keboola_agent_cli.auth.totp.time.time", return_value=59): + lower = compute_totp_code("gezdgnbvgy3tqojqgezdgnbvgy3tqojq") + spaced = compute_totp_code("GEZD GNBV GY3T QOJQ GEZD GNBV GY3T QOJQ") + plain = compute_totp_code("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ") + assert lower == plain + assert spaced == plain + + def test_code_rotates_across_period_boundary(self) -> None: + with patch("keboola_agent_cli.auth.totp.time.time", return_value=29): + before = compute_totp_code("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ") + with patch("keboola_agent_cli.auth.totp.time.time", return_value=30): + after = compute_totp_code("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ") + assert before != after + + def test_empty_secret_raises_value_error(self) -> None: + with pytest.raises(ValueError, match="empty"): + compute_totp_code("") + + def test_whitespace_only_secret_raises_value_error(self) -> None: + with pytest.raises(ValueError, match="empty"): + compute_totp_code(" ") + + def test_malformed_base32_raises_value_error(self) -> None: + with pytest.raises(ValueError, match="base32"): + compute_totp_code("not-valid-base32!!") diff --git a/tests/test_cli_auth.py b/tests/test_cli_auth.py index b5e11aee..b3b45ef1 100644 --- a/tests/test_cli_auth.py +++ b/tests/test_cli_auth.py @@ -214,6 +214,184 @@ def test_state_mismatch_exit_code(self, tmp_path: Path) -> None: assert json.loads(result.output)["error"]["code"] == "AUTH_STATE_MISMATCH" +class TestLoginPassword: + def test_success_forwards_totp_secret(self, tmp_path: Path) -> None: + """The CLI forwards the raw base32 seed as-is -- since C3/C4, the code + is computed inside `AuthService.login_password` itself, immediately + before the MFA request, not here (see PR #565 review).""" + config_dir = tmp_path / "c" + config_dir.mkdir() + svc = MagicMock() + svc.login_password.return_value = _login_result(method="password") + result = _invoke( + config_dir, + svc, + [ + "auth", + "login-password", + "--email", + "svc@example.com", + "--password", + "s3cr3t", + "--totp-secret", + "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", + ], + ) + assert result.exit_code == 0, result.output + kwargs = svc.login_password.call_args.kwargs + assert kwargs["email"] == "svc@example.com" + assert kwargs["password"] == "s3cr3t" + assert kwargs["totp_secret"] == "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + + def test_no_totp_secret_passes_none(self, tmp_path: Path) -> None: + config_dir = tmp_path / "c" + config_dir.mkdir() + svc = MagicMock() + svc.login_password.return_value = _login_result(method="password") + result = _invoke( + config_dir, + svc, + ["auth", "login-password", "--email", "svc@example.com", "--password", "s3cr3t"], + ) + assert result.exit_code == 0, result.output + assert svc.login_password.call_args.kwargs["totp_secret"] is None + + def test_password_stdin_reads_piped_password(self, tmp_path: Path) -> None: + config_dir = tmp_path / "c" + config_dir.mkdir() + svc = MagicMock() + svc.login_password.return_value = _login_result(method="password") + result = _invoke( + config_dir, + svc, + ["auth", "login-password", "--email", "svc@example.com", "--password-stdin"], + input_text="s3cr3t\n", + ) + assert result.exit_code == 0, result.output + assert svc.login_password.call_args.kwargs["password"] == "s3cr3t" + + def test_password_and_password_stdin_together_is_config_error(self, tmp_path: Path) -> None: + """Round-2 review S003: --password-stdin previously silently + overrode --password/KBC_LOGIN_PASSWORD with no error, unlike this + codebase's established mutually-exclusive-input pattern + (_metadata_input.py's --text/--file/--stdin).""" + config_dir = tmp_path / "c" + config_dir.mkdir() + svc = MagicMock() + result = _invoke( + config_dir, + svc, + [ + "--json", + "auth", + "login-password", + "--email", + "svc@example.com", + "--password", + "s3cr3t", + "--password-stdin", + ], + input_text="s3cr3t\n", + ) + assert result.exit_code != 0 + assert json.loads(result.stdout)["error"]["code"] == "CONFIG_ERROR" + svc.login_password.assert_not_called() + + def test_missing_password_is_config_error(self, tmp_path: Path) -> None: + config_dir = tmp_path / "c" + config_dir.mkdir() + svc = MagicMock() + result = _invoke( + config_dir, + svc, + ["--json", "auth", "login-password", "--email", "svc@example.com"], + ) + assert result.exit_code != 0 + assert json.loads(result.stdout)["error"]["code"] == "CONFIG_ERROR" + svc.login_password.assert_not_called() + + def test_env_vars_populate_email_and_password(self, tmp_path: Path) -> None: + config_dir = tmp_path / "c" + config_dir.mkdir() + svc = MagicMock() + svc.login_password.return_value = _login_result(method="password") + with ( + patch("keboola_agent_cli.cli.AuthService", return_value=svc), + patch.dict( + "os.environ", + {"KBC_LOGIN_EMAIL": "svc@example.com", "KBC_LOGIN_PASSWORD": "s3cr3t"}, + ), + ): + result = runner.invoke(app, ["--config-dir", str(config_dir), "auth", "login-password"]) + assert result.exit_code == 0, result.output + kwargs = svc.login_password.call_args.kwargs + assert kwargs["email"] == "svc@example.com" + assert kwargs["password"] == "s3cr3t" + + def test_mfa_invalid_error_surfaces(self, tmp_path: Path) -> None: + config_dir = tmp_path / "c" + config_dir.mkdir() + svc = MagicMock() + svc.login_password.side_effect = KeboolaApiError( + "webauthn-only account", error_code=ErrorCode.AUTH_MFA_INVALID + ) + result = _invoke( + config_dir, + svc, + ["--json", "auth", "login-password", "--email", "e", "--password", "p"], + ) + assert result.exit_code != 0 + assert json.loads(result.stdout)["error"]["code"] == "AUTH_MFA_INVALID" + + def test_malformed_totp_secret_raises_config_error(self, tmp_path: Path) -> None: + """A bad --totp-secret is validated inside `AuthService.login_password` + (see `TestLoginPassword.test_malformed_totp_secret_raises_config_error_not_value_error` + in test_auth_service.py) -- here the CLI layer's job is only to map + the ConfigError the (mocked) service raises to exit code 5, same as + `test_missing_password_is_config_error` above.""" + config_dir = tmp_path / "c" + config_dir.mkdir() + svc = MagicMock() + svc.login_password.side_effect = ConfigError("--totp-secret: not valid base32") + result = _invoke( + config_dir, + svc, + [ + "--json", + "auth", + "login-password", + "--email", + "e", + "--password", + "p", + "--totp-secret", + "not-valid-base32!!", + ], + ) + assert result.exit_code != 0 + assert json.loads(result.stdout)["error"]["code"] == "CONFIG_ERROR" + + def test_password_never_appears_in_output(self, tmp_path: Path) -> None: + config_dir = tmp_path / "c" + config_dir.mkdir() + svc = MagicMock() + svc.login_password.return_value = _login_result(method="password") + result = _invoke( + config_dir, + svc, + [ + "--json", + "auth", + "login-password", + "--email", + "svc@example.com", + "--password", + "SuperSecretPassword123", + ], + ) + assert "SuperSecretPassword123" not in result.output + + class TestPostLoginHook: """The optional "register these projects now?" nudge after a plain login.""" diff --git a/tests/test_dev_portal_cli.py b/tests/test_dev_portal_cli.py index ffc2ea44..58c48426 100644 --- a/tests/test_dev_portal_cli.py +++ b/tests/test_dev_portal_cli.py @@ -13,32 +13,11 @@ class TestReadPasswordStdin: - """--password-stdin must work in BOTH TTY mode (hidden getpass prompt, - Enter to confirm) AND pipe mode (read until EOF). The original version - called sys.stdin.read() unconditionally, which hung interactively until - the user sent Ctrl-D.""" - - def test_tty_uses_getpass(self, monkeypatch): - from keboola_agent_cli.commands.dev_portal import _read_password_stdin - - monkeypatch.setattr("sys.stdin.isatty", lambda: True) - monkeypatch.setattr("getpass.getpass", lambda prompt="": "pw-typed\n") - assert _read_password_stdin() == "pw-typed" - - def test_pipe_reads_until_eof(self, monkeypatch): - import io - import sys as _sys - - from keboola_agent_cli.commands.dev_portal import _read_password_stdin - - fake_stdin = io.StringIO("pw-piped\n") - # Use monkeypatch.setattr (not direct attribute assignment) -- ty rejects - # `fake_stdin.isatty = lambda: False` because the slot expects `(self) -> bool` - # and the lambda's signature is `() -> Literal[False]`. monkeypatch handles - # the duck-typed override cleanly without a ty: ignore. - monkeypatch.setattr(fake_stdin, "isatty", lambda: False) - monkeypatch.setattr(_sys, "stdin", fake_stdin) - assert _read_password_stdin() == "pw-piped" + """`--password-stdin` end-to-end through the CLI layer. The TTY/pipe unit + behavior of the underlying helper is covered once, generically, in + tests/test_helpers.py::TestReadPasswordStdin (the helper moved to + commands/_helpers.py -- shared with `auth login-password`, PR #565 + round 2 -- rather than staying a private per-command copy).""" def test_identity_add_password_stdin_end_to_end(self, tmp_config_dir): """End-to-end CliRunner test: --password-stdin in pipe mode (the diff --git a/tests/test_e2e_auth.py b/tests/test_e2e_auth.py index 087aefd3..09101138 100644 --- a/tests/test_e2e_auth.py +++ b/tests/test_e2e_auth.py @@ -25,6 +25,16 @@ this file never needs write access to config/workspace commands (out of scope -- those are covered by `TestFullE2E` in test_e2e.py). +3. Separate gate, only for `TestLoginPasswordCommand` (`auth login-password` + itself, PR #565): E2E_URL_US_EAST4 (a GitHub Actions repo VARIABLE, not a + secret -- it is a plain stack hostname, non-sensitive) plus + E2E_LOGIN_EMAIL / E2E_LOGIN_PASSWORD secrets, and optionally + E2E_LOGIN_TOTP_SECRET if that dedicated service account has TOTP-based + MFA configured. Deliberately its own stack/account, kept apart from + E2E_URL / E2E_SESSION_REFRESH_TOKEN below -- these tests drive real + logins/logouts against it and must never disturb the shared session the + other classes in this file depend on. + How to provision E2E_SESSION_REFRESH_TOKEN without ever typing it on the command line or committing it anywhere: @@ -33,11 +43,19 @@ # cleartext by design -- see docs/programmatic-auth-login-plan.md 4.2) # for that stack, and export it into the CI secret store as # E2E_SESSION_REFRESH_TOKEN. Never echo it into a log or terminal. + # + # Since v0.81.0 the same provisioning step can run fully unattended + # given a service account's credentials, no browser required: + # kbagent auth login-password --stack <stack-url> \\ + # --email "$SVC_EMAIL" --password-stdin <<< "$SVC_PASSWORD" Run: E2E_URL=connection.keboola.com \\ E2E_SESSION_REFRESH_TOKEN=kbc_rt_... \\ E2E_SESSION_PROJECT_ID=12345 \\ + E2E_URL_US_EAST4=connection.us-east4.gcp.keboola.com \\ + E2E_LOGIN_EMAIL=svc@example.com \\ + E2E_LOGIN_PASSWORD=... \\ make test-e2e-auth `make test-e2e-auth` runs this file on its own; the default `make test-e2e` @@ -107,6 +125,28 @@ ), ) +ENV_LOGIN_URL = "E2E_URL_US_EAST4" +ENV_LOGIN_EMAIL = "E2E_LOGIN_EMAIL" +ENV_LOGIN_PASSWORD = "E2E_LOGIN_PASSWORD" +ENV_LOGIN_TOTP_SECRET = "E2E_LOGIN_TOTP_SECRET" + +HAS_LOGIN_CREDENTIALS = bool( + os.environ.get(ENV_LOGIN_URL) + and os.environ.get(ENV_LOGIN_EMAIL) + and os.environ.get(ENV_LOGIN_PASSWORD) +) + +skip_without_login_credentials = pytest.mark.skipif( + not HAS_LOGIN_CREDENTIALS, + reason=( + f"`auth login-password` E2E tests require {ENV_LOGIN_URL}, {ENV_LOGIN_EMAIL} and " + f"{ENV_LOGIN_PASSWORD} (a dedicated, least-privileged service account -- never " + f"a real person's login). {ENV_LOGIN_TOTP_SECRET} is additionally required if " + "that account has TOTP-based MFA configured; when absent, the account must " + "have no MFA (or the login test skips the TOTP-specific assertion)." + ), +) + HAS_WORKSPACE_CREDENTIALS = bool( HAS_SESSION_CREDENTIALS and os.environ.get(ENV_SESSION_WORKSPACE_ID) @@ -461,6 +501,132 @@ def test_status_exits_3_for_a_stack_with_no_session(self, tmp_path: Path) -> Non assert json.loads(result.output)["data"]["status"] == "missing" +# --------------------------------------------------------------------------- +# 4b. `auth login-password` -- the ONE auth command that IS fully unattended +# (PR #565 review, finding B1: unlike the PKCE/device flows above, this one +# has no exemption from CLAUDE.md convention #16 -- it needs no human and no +# browser, so it is the one that can and must be covered end to end). +# --------------------------------------------------------------------------- + + +@skip_without_login_credentials +@pytest.mark.e2e +@pytest.mark.e2e_auth +class TestLoginPasswordCommand: + """Drives the real `kbagent auth login-password` command against a real + stack, each test in its own throwaway `--config-dir` so nothing here + touches the shared `E2E_SESSION_REFRESH_TOKEN` session other tests in + this file depend on. + """ + + def test_login_and_register_projects_succeeds(self, tmp_path: Path) -> None: + """No-MFA or TOTP-MFA login (whichever the service account requires) + with `--register-projects` in the SAME call, followed by `auth + status` reporting it live, then `auth logout --remove-projects` so no + orphaned session or alias accumulates on the real stack across CI + runs. + + Exercises the TOTP path specifically when E2E_LOGIN_TOTP_SECRET is + set -- proving the seed-to-live-code-to-verified-session chain + (C3/C4 in the PR #565 review) actually round-trips against a real + stack, not just the unit-test fakes. + + Deliberately ONE login call, not two: the server consumes a TOTP + time-slice on first submission and rejects any resubmission within + the same ~30s window (the exact C4 constraint this PR documents), so + a second `login-password` call moments later in the same test run + would itself trip the failure it is supposed to guard against. + """ + config_dir = tmp_path / "c" + config_dir.mkdir() + args = [ + "--json", + "--config-dir", + str(config_dir), + "auth", + "login-password", + "--email", + os.environ[ENV_LOGIN_EMAIL], + "--password", + os.environ[ENV_LOGIN_PASSWORD], + "--stack", + os.environ[ENV_LOGIN_URL], + "--register-projects", + ] + totp_secret = os.environ.get(ENV_LOGIN_TOTP_SECRET) + if totp_secret: + args += ["--totp-secret", totp_secret] + + login_result = CliRunner().invoke(app, args) + assert login_result.exit_code == 0, login_result.output + login_data = json.loads(login_result.output)["data"] + assert login_data["method"] == "password" + assert login_data["user_email"] + assert login_data["registered_projects"] + + status_result = CliRunner().invoke( + app, + [ + "--json", + "--config-dir", + str(config_dir), + "auth", + "status", + "--stack", + os.environ[ENV_LOGIN_URL], + ], + ) + assert status_result.exit_code == 0, status_result.output + assert json.loads(status_result.output)["data"]["status"] in {"live", "refreshed"} + + logout_result = CliRunner().invoke( + app, + [ + "--json", + "--config-dir", + str(config_dir), + "auth", + "logout", + "--stack", + os.environ[ENV_LOGIN_URL], + "--remove-projects", + ], + ) + assert logout_result.exit_code == 0, logout_result.output + + def test_wrong_password_reports_invalid_credentials(self, tmp_path: Path) -> None: + """Regression guard for PR #565 review finding C2: a wrong password + must report a plain 'invalid email or password', exit 3 -- never the + generic `INVALID_TOKEN` "Invalid or expired token (token: ****)" + wording, which misdiagnoses the command's single most likely + real-world failure.""" + config_dir = tmp_path / "c" + config_dir.mkdir() + + result = CliRunner().invoke( + app, + [ + "--json", + "--config-dir", + str(config_dir), + "auth", + "login-password", + "--email", + os.environ[ENV_LOGIN_EMAIL], + "--password", + "definitely-the-wrong-password-e2e-probe", + "--stack", + os.environ[ENV_LOGIN_URL], + ], + ) + + assert result.exit_code == 3, result.output + error = json.loads(result.output)["error"] + assert error["code"] == "AUTH_FLOW_DENIED" + assert "invalid email or password" in error["message"].lower() + assert "invalid or expired token" not in error["message"].lower() + + # --------------------------------------------------------------------------- # 5. Device flow -- documented semi-manual scenario (cannot run unattended) # --------------------------------------------------------------------------- diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 5511e215..fbce7524 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -3,10 +3,36 @@ import pytest import typer -from keboola_agent_cli.commands._helpers import map_error_to_exit_code +from keboola_agent_cli.commands._helpers import map_error_to_exit_code, read_password_stdin from keboola_agent_cli.errors import KeboolaApiError, map_error_code_to_type +class TestReadPasswordStdin: + """`--password-stdin` must work in BOTH TTY mode (hidden getpass prompt, + Enter to confirm) AND pipe mode (read until EOF) -- the original version + called `sys.stdin.read()` unconditionally, which hung interactively + until the user sent Ctrl-D. Shared by `auth login-password` and + `dev-portal identity add`/`edit` (PR #565 round 2 dedup).""" + + def test_tty_uses_getpass(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("getpass.getpass", lambda prompt="": "pw-typed\n") + assert read_password_stdin() == "pw-typed" + + def test_pipe_reads_until_eof(self, monkeypatch: pytest.MonkeyPatch) -> None: + import io + import sys as _sys + + fake_stdin = io.StringIO("pw-piped\n") + # Use monkeypatch.setattr (not direct attribute assignment) -- ty rejects + # `fake_stdin.isatty = lambda: False` because the slot expects `(self) -> bool` + # and the lambda's signature is `() -> Literal[False]`. monkeypatch handles + # the duck-typed override cleanly without a ty: ignore. + monkeypatch.setattr(fake_stdin, "isatty", lambda: False) + monkeypatch.setattr(_sys, "stdin", fake_stdin) + assert read_password_stdin() == "pw-piped" + + class TestMapErrorToExitCode: """Tests for map_error_to_exit_code.""" diff --git a/uv.lock b/uv.lock index bd7a4c0e..10228f83 100644 --- a/uv.lock +++ b/uv.lock @@ -590,7 +590,7 @@ wheels = [ [[package]] name = "keboola-cli" -version = "0.83.0" +version = "0.84.0" source = { editable = "." } dependencies = [ { name = "croniter" },