Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
15 changes: 15 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
20 changes: 19 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
91 changes: 83 additions & 8 deletions docs/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <url-or-alias>` |
| `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
Expand Down
1 change: 1 addition & 0 deletions docs/error-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
42 changes: 42 additions & 0 deletions docs/programmatic-auth-login-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
14 changes: 14 additions & 0 deletions docs/web-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
14 changes: 9 additions & 5 deletions plugins/kbagent/.claude-plugin/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down
2 changes: 1 addition & 1 deletion plugins/kbagent/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading
Loading