Skip to content
Closed
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.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ 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-create --name NAME [--stack URL|alias] [--totp-code CODE | --webauthn] [--read-only] [--ttl-days N] [--project-id ID ...]
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ 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-create --name NAME [--stack URL|alias] [--totp-code CODE | --webauthn] [--read-only] [--ttl-days N] [--project-id ID ...]` -- mint a Personal Access Token (`kbc_pat_...`) from an EXISTING `auth login` session (does not log in itself). Does a step-up 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`; `--webauthn` opens a browser for a passkey ceremony instead (mutually exclusive with `--totp-code`; see `auth/webauthn_browser.py` for the current placeholder ceremony-page contract this assumes). `--project-id` (repeatable) narrows the PAT's scope to an explicit allow-list instead of every project the signed-in user can access -- use it for a one-project-per-CI-secret setup. 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
Expand Down
68 changes: 63 additions & 5 deletions src/keboola_agent_cli/auth/auth_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
AUTH_REFRESH_CONTENTION_STRING_CODE,
AUTH_REFRESH_TIMEOUT,
AUTH_SESSIONS_PATH,
AUTH_SUDO_CHALLENGE_PATH,
AUTH_SUDO_PATH,
AUTH_TOKEN_INTROSPECT_PATH,
AUTH_TOKEN_REFRESH_PATH,
Expand All @@ -58,6 +59,7 @@
IntrospectResponse,
PatCreateResult,
RevokeResult,
SudoChallengeResult,
SudoResult,
)

Expand Down Expand Up @@ -659,10 +661,10 @@ 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).
active sudo window. Password step-up is rejected outright once MFA
is configured (the API's own rule, not a restriction added here).
See `sudo_challenge`/`sudo_webauthn` for the WebAuthn/passkey factor,
which needs a live browser ceremony instead of a typed code.
"""
response = self._do_request(
"POST",
Expand All @@ -677,26 +679,82 @@ def sudo_totp(self, access_token: str, totp_code: str) -> SudoResult:
timeout_seconds=int(data.get("sudoTimeoutSeconds", 0)),
)

def sudo_challenge(self, access_token: str) -> SudoChallengeResult:
"""Start a WebAuthn sudo challenge (``POST /v1/auth/sudo/challenge``).

Returns the `challengeToken` + `PublicKeyCredentialRequestOptions`
the caller hands to a browser ceremony page; complete it with
`sudo_webauthn` once the page redirects back with an assertion.
"""
response = self._do_request(
"POST",
AUTH_SUDO_CHALLENGE_PATH,
json={},
headers={"Authorization": f"Bearer {access_token}"},
)
data = response.json()
return SudoChallengeResult(
challenge_token=str(data.get("challengeToken", "")),
options=data.get("options") or {},
expires_in=int(data.get("expiresIn", 0)),
)

def sudo_webauthn(self, access_token: str, challenge_token: str, assertion: str) -> SudoResult:
"""Complete a WebAuthn sudo step-up (``POST /v1/auth/sudo``).

`challenge_token` and `assertion` are the values `sudo_challenge`
issued and the browser ceremony redirected back with, respectively.
"""
response = self._do_request(
"POST",
AUTH_SUDO_PATH,
json={
"type": "webauthn",
"challengeToken": challenge_token,
"webauthnAssertion": assertion,
},
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,
project_ids: list[str] | 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.

`project_ids` narrows the scope to an explicit allow-list (least
privilege for a CI secret meant to touch exactly one project)
instead of the server's default of every project the signed-in user
can access.
"""
body: dict[str, Any] = {"name": name}
if expires_in is not None:
body["expiresIn"] = expires_in
scope: dict[str, Any] = {}
if project_ids:
scope["projects"] = project_ids
else:
scope["all"] = True
if read_only:
body["scope"] = {"all": True, "readOnly": True}
scope["readOnly"] = True
if scope != {"all": True}:
body["scope"] = scope
response = self._do_request(
"POST",
AUTH_PAT_PATH,
Expand Down
14 changes: 14 additions & 0 deletions src/keboola_agent_cli/auth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,20 @@ class SudoResult:
timeout_seconds: int = 0


@dataclass(frozen=True)
class SudoChallengeResult:
"""Outcome of POST /v1/auth/sudo/challenge -- a WebAuthn ceremony to complete in a browser.

``options`` is the serialized `PublicKeyCredentialRequestOptions` the
ceremony page feeds to `navigator.credentials.get({publicKey: options})`
-- passed through opaquely, this CLI never inspects its contents.
"""

challenge_token: str
options: dict
expires_in: int = 0


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

Expand Down
Loading