Skip to content
Draft
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.80.0",
"version": "0.81.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
19 changes: 18 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ plugins/kbagent/

```
# Global options: --json, --verbose, --no-color, --config-dir, --deny-writes, --deny-destructive, --allow-env-manage-token
# 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`.
# 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`. KBC_TOKEN accepts either a real Storage token or a `kbagent auth pat-create`-minted PAT (0.81.0+, see `auth pat-*` below) -- both work identically in this path.

kbagent auth login [--stack URL|alias] [--device-code] [--register-projects]
kbagent auth status [--stack URL|alias]
Expand Down Expand Up @@ -333,6 +333,23 @@ kbagent auth register-projects [--stack URL|alias] [--all] [--project-id ID ...]
# instead of silently skipping the second project). See docs/programmatic-auth-login-plan.md
# section 4.5 for the full design.

kbagent auth pat-create --name NAME [--stack URL|alias] [--totp-code CODE] [--read-only] [--ttl-days N]
kbagent auth pat-revoke PAT_ID [--stack URL|alias] [--yes]
# `auth pat-*` (0.81.0+): mints/revokes a Personal Access Token (`kbc_pat_...`) for one-time CI/CD
# setup -- the sanctioned alternative to a raw Storage token for pipelines, and the only session-auth
# surface meant to run non-interactively downstream of a one-time human step. `pat-create` spends an
# EXISTING `auth login` session's access token (it does not log in itself) to do a TOTP step-up
# (`POST /v1/auth/sudo`) followed by `POST /v1/auth/pat`; --totp-code is prompted interactively when
# omitted, and is REQUIRED (fails fast) under --json or a non-TTY stdin -- there is no unattended path
# to mint a PAT, matching `auth login`'s own "needs a human" boundary. The token is printed exactly
# once and never stored by kbagent. Store it as `KBC_TOKEN` (the `KBAGENT_PROJECT_FROM_ENV=1` headless
# path) or via `project add --token`: `make_client_factory` (services/base.py) recognizes the
# `kbc_pat_` prefix on a plain static token and sends it as `Authorization: Bearer` instead of
# `X-StorageApi-Token` -- those are different auth schemes on the Storage API, not different encodings
# of one, so a PAT dropped into the old header would just fail. A PAT does not rotate (unlike a
# session); replace it (`pat-create` again) instead of expecting a refresh. `pat-revoke` needs no
# step-up. New error codes: AUTH_SUDO_REQUIRED, AUTH_MFA_INVALID.

kbagent project add --project NAME --url URL --token TOKEN
kbagent project list
kbagent project remove --project NAME
Expand Down
2 changes: 2 additions & 0 deletions docs/error-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,3 +182,5 @@ 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_SUDO_REQUIRED` | `auth pat-create` could not activate the sudo (step-up) window -- check the TOTP code and try again |
| `AUTH_MFA_INVALID` | An MFA verification code (TOTP or recovery code) was rejected as invalid or expired |
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.80.0",
"version": "0.81.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
2 changes: 2 additions & 0 deletions plugins/kbagent/skills/kbagent/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ When working inside a git repository or project directory, run `kbagent init` (o
| 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` |
| Mint a Personal Access Token from the current session, for one-time CI/CD setup | `kbagent auth pat-create --name NAME` |
| Revoke a Personal Access Token. | `kbagent auth pat-revoke <PAT-ID>` |
| Add a new Keboola project connection | `kbagent project add --project ALIAS` |
| List all connected Keboola projects | `kbagent project list` |
| Remove a Keboola project connection | `kbagent project remove --project ALIAS` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ unattended agent task.** Issues a USER-scoped "programmatic session"
- `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.

- `auth pat-create --name NAME [--stack URL|alias] [--totp-code CODE] [--read-only] [--ttl-days N]` -- mint a Personal Access Token (`kbc_pat_...`) from an EXISTING `auth login` session (does not log in itself). Does a TOTP step-up (`POST /v1/auth/sudo`) then mints the token (`POST /v1/auth/pat`); `--totp-code` is prompted interactively when omitted and is REQUIRED under `--json` or a non-TTY stdin -- no unattended path to mint a PAT, same boundary as `auth login`. Prints the token exactly once (`access_token`); kbagent never stores it. Intended use: one-time CI/CD setup -- store the result as `KBC_TOKEN` (`KBAGENT_PROJECT_FROM_ENV=1`) or via `project add --token`. `make_client_factory` (`services/base.py`) recognizes the `kbc_pat_` prefix on a plain static token and routes it to `Authorization: Bearer` instead of `X-StorageApi-Token` (distinct auth schemes on the Storage API), so a PAT works everywhere a session project already does (`sync`, `storage`, `config`, ...) -- it just doesn't rotate, so replace it (`pat-create` again) instead of expecting a refresh. New error codes: `AUTH_SUDO_REQUIRED`, `AUTH_MFA_INVALID`.
- `auth pat-revoke PAT_ID [--stack URL|alias] [--yes]` -- revoke a Personal Access Token. No sudo step-up needed. Idempotent: revoking an already-revoked id is not an error.

v1 scope: the Storage + Manage paths. `serve` reaches them too (it delegates to
the same already-guarded services), so a session project works over the REST API
and web UI -- but whoever holds `KBAGENT_SERVE_TOKEN` then acts as the signed-in
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "keboola-cli"
version = "0.80.0"
version = "0.81.0"
description = "AI-friendly CLI for managing Keboola projects"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
85 changes: 85 additions & 0 deletions src/keboola_agent_cli/auth/auth_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
AUTH_CLIENT_ID,
AUTH_DEVICE_PATH,
AUTH_DEVICE_TOKEN_PATH,
AUTH_PAT_PATH,
AUTH_PKCE_AUTHORIZE_PATH,
AUTH_PKCE_TOKEN_PATH,
AUTH_REFRESH_CONTENTION_DEFAULT_DELAY,
Expand All @@ -41,6 +42,7 @@
AUTH_REFRESH_CONTENTION_STRING_CODE,
AUTH_REFRESH_TIMEOUT,
AUTH_SESSIONS_PATH,
AUTH_SUDO_PATH,
AUTH_TOKEN_INTROSPECT_PATH,
AUTH_TOKEN_REFRESH_PATH,
AUTH_TOKEN_REVOKE_PATH,
Expand All @@ -54,7 +56,9 @@
DevicePollResult,
DevicePollStatus,
IntrospectResponse,
PatCreateResult,
RevokeResult,
SudoResult,
)

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -647,6 +651,87 @@ def delete_session(self, session_id: str, access_token: str) -> RevokeResult:
message=self._truncate(self._extract_error_message(response)),
)

# ------------------------------------------------------------------
# Sudo step-up + Personal Access Tokens (since 0.81.0)
# ------------------------------------------------------------------

def sudo_totp(self, access_token: str, totp_code: str) -> SudoResult:
"""Activate the sudo window on the current session via TOTP (``POST /v1/auth/sudo``).

Required before `create_pat` -- a PAT cannot be minted outside an
active sudo window. Only the TOTP factor is wired here: WebAuthn
step-up needs a live browser ceremony this CLI has nowhere to host,
and password step-up is rejected outright once MFA is configured
(the API's own rule, not a restriction added here).
"""
response = self._do_request(
"POST",
AUTH_SUDO_PATH,
json={"type": "totp", "totpCode": totp_code},
headers={"Authorization": f"Bearer {access_token}"},
)
data = response.json()
return SudoResult(
verified=bool(data.get("sudoVerified")),
expires_at=str(data.get("sudoExpiresAt", "")),
timeout_seconds=int(data.get("sudoTimeoutSeconds", 0)),
)

def create_pat(
self,
access_token: str,
*,
name: str,
read_only: bool = False,
expires_in: int | None = None,
) -> PatCreateResult:
"""Mint a Personal Access Token (``POST /v1/auth/pat``).

Requires an active sudo window (`sudo_totp`) on the same session;
the server answers 403 otherwise. The returned `PatCreateResult`
carries the bearer value exactly once -- the caller must print it
and never persist it.
"""
body: dict[str, Any] = {"name": name}
if expires_in is not None:
body["expiresIn"] = expires_in
if read_only:
body["scope"] = {"all": True, "readOnly": True}
response = self._do_request(
"POST",
AUTH_PAT_PATH,
json=body,
headers={"Authorization": f"Bearer {access_token}"},
)
return PatCreateResult.model_validate(response.json())

def revoke_pat(self, access_token: str, pat_id: str) -> RevokeResult:
"""Revoke a Personal Access Token (``DELETE /v1/auth/pat/{id}``).

Idempotent server-side (a second call against an already-revoked id
still returns 204) and, like `revoke`/`delete_session`, never raises
-- a failed revoke must be reported distinctly, not thrown, so a
caller can still tell the operator exactly what to check.
"""
try:
response = self._client.request(
"DELETE",
f"{AUTH_PAT_PATH}/{pat_id}",
headers={"Authorization": f"Bearer {access_token}"},
)
except httpx.HTTPError as exc:
return RevokeResult(
confirmed=False,
message=self._truncate(f"{type(exc).__name__}: {exc}"),
)

if response.status_code < 300 or response.status_code == 404:
return RevokeResult(confirmed=True)
return RevokeResult(
confirmed=False,
message=self._truncate(self._extract_error_message(response)),
)

@staticmethod
def _extract_error_message(response: httpx.Response) -> str:
"""Best-effort human message from a failed response body.
Expand Down
49 changes: 47 additions & 2 deletions src/keboola_agent_cli/auth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
Two families of model live here:

- Wire models (`AuthUser`, `CliTokenResponse`, `DeviceAuthorization`,
`AuthProject`, `IntrospectResponse`, `DevicePollResult`, `RevokeResult`):
shaped after the Keboola auth-service JSON responses, never persisted.
`AuthProject`, `IntrospectResponse`, `DevicePollResult`, `RevokeResult`,
`SudoResult`, `PatItem`, `PatCreateResult`): shaped after the Keboola
auth-service JSON responses, never persisted.
- Persisted state (`StackSession`, `AuthState`): the exact shape written to
and read from ``auth.json`` by `AuthStateStore`.

Expand Down Expand Up @@ -188,6 +189,50 @@ class RevokeResult:
message: str = ""


@dataclass(frozen=True)
class SudoResult:
"""Outcome of POST /v1/auth/sudo (step-up authentication).

Carries no token -- the sudo window is server-side state on the existing
session, not a new credential. ``expires_at`` is the raw RFC 3339 string
from the response, kept as-is since it is only ever displayed, never
computed on.
"""

verified: bool
expires_at: str = ""
timeout_seconds: int = 0


class PatItem(BaseModel):
"""A Personal Access Token's metadata. Never carries the secret value."""

id: str
name: str
read_only: bool = Field(default=False, alias="readOnly")
expires_at: datetime | None = Field(default=None, alias="expiresAt")
created_at: datetime | None = Field(default=None, alias="createdAt")

model_config = _WIRE_MODEL_CONFIG


class PatCreateResult(BaseModel):
"""Response to POST /v1/auth/pat.

``access_token`` is the PAT's bearer value, shown exactly once by this
response and never retrievable again -- callers must print it and not
persist it (mirrors the "no token value is ever logged" rule the session
login flow already follows).
"""

access_token: str = Field(alias="accessToken")
token_type: str = Field(default="Bearer", alias="tokenType")
expires_in: int = Field(default=0, alias="expiresIn")
pat: PatItem

model_config = _WIRE_MODEL_CONFIG


class StackSession(BaseModel):
"""One persisted programmatic-auth session, keyed by normalized stack URL.

Expand Down
19 changes: 19 additions & 0 deletions src/keboola_agent_cli/auth/token_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,25 @@ def _stamp(self, request: httpx.Request, token: str) -> None:
request.headers["X-KBC-ProjectId"] = str(self._project_id)


class StaticBearerAuth(httpx.Auth):
"""httpx auth hook that stamps a fixed `Authorization: Bearer` value.

The PAT counterpart of `BearerAuth`: a Personal Access Token has no
refresh token and does not rotate, so it needs none of `BearerAuth`'s
`TokenProvider`/401-retry machinery -- it behaves like a static Storage
token that happens to go on a different header. When it expires or is
revoked, the fix is the same as for a stale static token: mint a new one
and update the secret, not an automatic refresh.
"""

def __init__(self, token: str) -> None:
self._token = token

def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
request.headers["Authorization"] = f"Bearer {self._token}"
yield request


def get_session_token_provider(stack_url: str, state_store: AuthStateStore) -> SessionTokenProvider:
"""Return the process-wide provider for (state_store.state_path, normalized stack_url).

Expand Down
20 changes: 20 additions & 0 deletions src/keboola_agent_cli/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,26 @@

# Ordered newest-first. Each value is a list of brief one-line descriptions.
CHANGELOG: dict[str, list[str]] = {
"0.81.0": [
"New: `kbagent auth pat-create --name NAME [--totp-code CODE] [--read-only] "
"[--ttl-days N]` -- mint a Personal Access Token (`kbc_pat_...`) from an EXISTING "
"`auth login` session, for one-time CI/CD setup. Does a TOTP step-up "
"(`POST /v1/auth/sudo`) then mints the token (`POST /v1/auth/pat`); `--totp-code` is "
"prompted interactively when omitted and is REQUIRED under `--json` or a non-TTY "
"stdin -- there is no unattended path to mint a PAT, the same human-required boundary "
"as `auth login` itself. The token is printed exactly once and never stored by kbagent.",
"New: `kbagent auth pat-revoke PAT_ID [--yes]` -- revoke a Personal Access Token. No "
"sudo step-up needed; idempotent (revoking an already-revoked id is not an error).",
"New: a `kbc_pat_...` token now works as a drop-in for `KBC_TOKEN` "
"(`KBAGENT_PROJECT_FROM_ENV=1`) or `project add --token` -- `make_client_factory` "
"recognizes the prefix on a plain static token and sends it as `Authorization: Bearer` "
"instead of `X-StorageApi-Token` (the Storage API treats these as distinct auth "
"schemes, not interchangeable encodings of one), so a PAT reaches every command that "
"already works on a session project (`sync`, `storage`, `config`, ...). Unlike a "
"session, a PAT does not rotate -- replace it (`pat-create` again) rather than "
"expecting an automatic refresh.",
"New error codes: `AUTH_SUDO_REQUIRED`, `AUTH_MFA_INVALID`.",
],
"0.80.0": [
"New: `kbagent auth login|status|logout` -- browser-based programmatic authentication as "
"an alternative to a long-lived static Storage API token. `login` signs in via PKCE "
Expand Down
Loading
Loading