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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- **MCP**: the HTTP bind-safety guard now keys on auth posture, not bind interface. The old `_assert_safe_http_bind` refused any non-loopback HTTP bind, which false-positived on the entire hosted deployment (a container binds `0.0.0.0` and is still private) and lived in the run path, so a caller building the ASGI app directly bypassed it. It is replaced by `McpSettings._enforce_bind_safety`, a cross-field validator at the settings boundary that every serving path inherits: the unauthenticated `local` profile still refuses a non-loopback HTTP bind unless `PIPEFY_MCP_ALLOW_INSECURE_HTTP_BIND` is set, while the `remote` profile (which always carries inbound auth) binds any host without a bind-host check. Loopback detection moves to `pipefy_infra.security.is_loopback_host`, covering all of `127.0.0.0/8` and `::1` rather than three hardcoded string literals. Closes #379.
- **CLI / Plugin (Claude Code)**: the CLI install path now installs `pipefy-cli` from PyPI instead of a `git+…#subdirectory=packages/cli` reference with `--with` sibling pins. The `/pipefy:install` slash command runs `uv tool install --force pipefy-cli`, and the documented snippets (root `README.md`, `packages/cli/README.md`, `docs/MIGRATION.md`) use `uvx --from pipefy-cli pipefy …` / `uv tool install pipefy-cli`. PyPI resolves `pipefy` and `pipefy-auth` transitively, so the explicit `--with` flags are gone and installs no longer pay a git clone plus build. This matches the MCP server's PyPI install (#368); do not pass a global `--prerelease allow`, which would let transitive dependencies jump to their own pre-releases. `RELEASE.md` verification snippets move to PyPI accordingly, and the now-unused `latest` moving-tag release step is removed. Closes #234.

### Fixed

- **Auth / CLI / MCP (Windows)**: `pipefy auth login` on Windows failed at the session-store step with `WinError 1783` ("The stub received bad data") from `CredWrite`. The stored session — access + refresh + id tokens plus metadata, UTF-16 encoded by `keyring` — exceeds Windows Credential Manager's `CRED_MAX_CREDENTIAL_BLOB_SIZE` (2560 bytes; the exact size varies with token claims), so the default `auto` backend can't store it. Added `PIPEFY_KEYCHAIN_BACKEND=dpapi` (`keychain_backend = "dpapi"` in TOML) — a DPAPI-encrypted file keyring (`keyrings.alt.Windows.EncryptedKeyring`) that is encrypted at rest and has no blob cap. Read from `config.toml` by both the CLI and MCP server, so a single per-user entry covers both processes. Opt-in; selecting `dpapi` off-Windows raises. The plaintext `file` backend is unchanged.

## [0.3.0-alpha.1] - 2026-07-04

### Added
Expand Down
10 changes: 7 additions & 3 deletions docs/cli/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ PIPEFY_TOKEN="$MY_BEARER" uv run pipefy pipe list
| `PIPEFY_SERVICE_ACCOUNT_CLIENT_SECRET` | Tier 3 | Service-account client secret. |
| `PIPEFY_AUTH_CLIENT_ID` | Tier 4 | Public client id registered for the CLI. Defaults to `pipefy-cli`. |
| `PIPEFY_DISABLE_STORED_SESSION` | Tier 4 | When `1` / `true`, the stored-session tier is skipped end-to-end: tier resolution never probes the keychain, and `pipefy auth login` / `pipefy auth logout` refuse with exit code 2. Use to avoid the keyring backend-discovery cost on cold start (headless Linux, CI) or to opt out of OS-keychain storage entirely. TOML key: `disable_stored_session`. |
| `PIPEFY_KEYCHAIN_BACKEND` | Tier 4 | Active `keyring` backend. `auto` (default) uses OS-keyring discovery; `file` swaps to a plaintext on-disk keyring under `~/.config/pipefy/keyring.cfg` (POSIX) / `%APPDATA%/pipefy/keyring.cfg` (Windows). TOML key: `keychain_backend`. |
| `PIPEFY_KEYCHAIN_BACKEND` | Tier 4 | Active `keyring` backend. `auto` (default) uses OS-keyring discovery; `file` swaps to a plaintext on-disk keyring under `~/.config/pipefy/keyring.cfg` (POSIX) / `%APPDATA%/pipefy/keyring.cfg` (Windows); `dpapi` (Windows only) uses a DPAPI-encrypted keyring that sidesteps the Credential Manager blob cap. TOML key: `keychain_backend`. |

The service-account OAuth token URL (Tier 3) and the OIDC issuer URL (Tier 4) are **not** interchangeable: the first is `<base>/oauth/token` for client-credentials, the second is the full OIDC discovery root for the user-login flow.

Expand Down Expand Up @@ -216,7 +216,9 @@ Every `PIPEFY_*` env var is validated against a semantically meaningful regex at

### `Login succeeded but the session could not be stored in your keychain (<backend>)`

The login worked but `keyring` couldn't write the entry. On macOS / Windows this is rare. On headless Linux it usually means no Secret Service daemon is running — install `gnome-keyring` or `kwallet`, set `PIPEFY_KEYCHAIN_BACKEND=file` to use a plaintext file backend under the Pipefy config directory, or fall back to a static `PIPEFY_TOKEN`.
The login worked but `keyring` couldn't write the entry. On headless Linux it usually means no Secret Service daemon is running — install `gnome-keyring` or `kwallet`, set `PIPEFY_KEYCHAIN_BACKEND=file` to use a plaintext file backend under the Pipefy config directory, or fall back to a static `PIPEFY_TOKEN`.

On **Windows** the failure is `WinError 1783` ("The stub received bad data") from `CredWrite`: the stored session (access + refresh + id tokens plus metadata, UTF-16 encoded) exceeds Windows Credential Manager's `CRED_MAX_CREDENTIAL_BLOB_SIZE` (2560 bytes) — the exact size varies with the token claims — so the default `auto` backend can't store it. Set `PIPEFY_KEYCHAIN_BACKEND=dpapi` (`keychain_backend = "dpapi"` in TOML) for a DPAPI-encrypted keyring with no size cap.

When `PIPEFY_KEYCHAIN_BACKEND=file` is active the backend reports as `PlaintextKeyring` and the hint switches to a config-directory writability check (the file backend writes to `keyring.cfg` under the resolved config directory).

Expand Down Expand Up @@ -269,4 +271,6 @@ Two env vars (mirrored as TOML keys) override the default behaviour:

- `PIPEFY_KEYCHAIN_BACKEND=file` swaps the active backend to a plaintext on-disk keyring under `config_dir() / "keyring.cfg"` (`~/.config/pipefy/keyring.cfg` on POSIX, `%APPDATA%/pipefy/keyring.cfg` on Windows). Unblocks headless Linux without Secret Service. **The file is plaintext, not OS-secured**: anyone with read access to the file (including a co-tenant on a shared CI runner) reads the refresh token. Opt-in only.

These are independent: `PIPEFY_DISABLE_STORED_SESSION=1` takes precedence (the file backend is never read or written). `pipefy auth status` will reflect the active backend name regardless (`Keyring`, `PlaintextKeyring`, etc.).
- `PIPEFY_KEYCHAIN_BACKEND=dpapi` (**Windows only**) swaps to `keyrings.alt.Windows.EncryptedKeyring`, a DPAPI-encrypted file keyring. Unlike `file` it is encrypted at rest (per-user, via Windows DPAPI), and unlike the default Credential Manager backend it has no ~2560-byte blob cap — so it is the recommended fix when `pipefy auth login` fails with `WinError 1783`. Selecting it on a non-Windows platform raises.

These are independent: `PIPEFY_DISABLE_STORED_SESSION=1` takes precedence (the file backend is never read or written). `pipefy auth status` will reflect the active backend name regardless (`Keyring`, `PlaintextKeyring`, `EncryptedKeyring`, etc.).
2 changes: 1 addition & 1 deletion docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ Credential variables reject leading and trailing whitespace; `PIPEFY_ORG_ID` (be
| `PIPEFY_ORG_ID` | unset | Convenience: pins a default org for CLI and MCP tools that take an optional `org_id` argument. |
| `PIPEFY_PORTAL_ORG_UUID` | unset | SDK portal integration tests only (`pytest -m integration -k portal`). Set in local `.env` to an organization **UUID** (or numeric org id string) where the active token has **`manage_portals`** (and usually `create_portal`). Never committed; runtime MCP/CLI do not read this. Many default orgs return `PERMISSION_DENIED` on portal writes — pick an org with portal admin scope. See [`mcp/tools/portal.md`](mcp/tools/portal.md#testing). |
| `PIPEFY_DISABLE_STORED_SESSION` | `0` | Set to `1` (or `disable_stored_session = true` in TOML) to skip the keychain-backed stored-session tier entirely. `pipefy auth login` / `auth logout` refuse with exit code 2 when set. |
| `PIPEFY_KEYCHAIN_BACKEND` | `auto` | Set to `file` (or `keychain_backend = "file"` in TOML) to use a file-backed plaintext keyring under `~/.config/pipefy/keyring.cfg` (`%APPDATA%\pipefy\keyring.cfg` on Windows). Unblocks headless Linux and CI runners. Plaintext on disk; opt-in only. |
| `PIPEFY_KEYCHAIN_BACKEND` | `auto` | Set to `file` (or `keychain_backend = "file"` in TOML) to use a file-backed plaintext keyring under `~/.config/pipefy/keyring.cfg` (`%APPDATA%\pipefy\keyring.cfg` on Windows). Unblocks headless Linux and CI runners. Plaintext on disk; opt-in only. On Windows, set to `dpapi` (`keychain_backend = "dpapi"`) for a DPAPI-encrypted keyring that sidesteps the Credential Manager blob cap (2560 bytes) the Pipefy session exceeds — the default `auto` backend fails such a login with `WinError 1783`. Selecting `dpapi` off-Windows raises. |
| `PIPEFY_ALLOW_INSECURE_URLS` | `false` | Disables the SSRF host check on URL variables. Local development only. |
| `PIPEFY_CONFIG_FILE` | unset | Overrides the default `config.toml` path. See [File path](#file-path) above. |

Expand Down
11 changes: 7 additions & 4 deletions packages/auth/src/pipefy_auth/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,8 @@ def _strip_str(cls, value: object) -> object:
@field_validator("keychain_backend", mode="before")
@classmethod
def _normalize_keychain_backend(cls, value: object) -> object:
# ``keychain_backend`` is ``Literal["auto", "file"]``; copy-pasted env
# values like ``PIPEFY_KEYCHAIN_BACKEND=' AUTO '`` should normalize to
# ``keychain_backend`` is ``Literal["auto", "file", "dpapi"]``; copy-pasted
# env values like ``PIPEFY_KEYCHAIN_BACKEND=' AUTO '`` should normalize to
# ``"auto"`` rather than fail Literal validation with a cryptic enum
# error. Case is meaningful for credential fields (kept strict via
# ``_strip_str``), so the lowering applies only here.
Expand Down Expand Up @@ -301,15 +301,18 @@ def _normalize_keychain_backend(cls, value: object) -> object:
),
)

keychain_backend: Literal["auto", "file"] = Field(
keychain_backend: Literal["auto", "file", "dpapi"] = Field(
default="auto",
description=(
"Active ``keyring`` backend (env: PIPEFY_KEYCHAIN_BACKEND). ``auto`` "
"uses the default OS keyring discovery; ``file`` swaps to "
"``keyrings.alt.file.PlaintextKeyring`` writing under "
"``config_dir()/keyring.cfg``. The file backend stores credentials "
"in plaintext on disk; opt-in only, intended for headless Linux "
"without Secret Service or for CI runners."
"without Secret Service or for CI runners. ``dpapi`` (Windows only) "
"swaps to a DPAPI-encrypted file keyring, sidestepping the Windows "
"Credential Manager blob cap (2560 bytes) that the Pipefy session "
"exceeds; selecting it off-Windows raises."
),
)

Expand Down
26 changes: 25 additions & 1 deletion packages/auth/src/pipefy_auth/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from __future__ import annotations

import sys
import time
from typing import Annotated, Any, Literal
from urllib.parse import urlparse
Expand All @@ -35,7 +36,7 @@
_TOKEN_FIELDS: frozenset[str] = frozenset(TokenResponse.model_fields.keys())


def configure_keychain_backend(choice: Literal["auto", "file"]) -> None:
def configure_keychain_backend(choice: Literal["auto", "file", "dpapi"]) -> None:
"""Apply the requested keyring backend before any session read or write.

Idempotent: safe to call multiple times; the second ``"file"`` call
Expand All @@ -48,10 +49,33 @@ def configure_keychain_backend(choice: Literal["auto", "file"]) -> None:
``pipefy_infra.config.config_dir() / "keyring.cfg"``; the file stores
credentials in plaintext on disk and is intended for headless
Linux or CI runners where the OS keychain is unavailable.
``"dpapi"`` (Windows only) swaps to
:class:`keyrings.alt.Windows.EncryptedKeyring`, a DPAPI-encrypted
file keyring. It sidesteps the Windows Credential Manager
``CredWrite`` blob cap (``CRED_MAX_CREDENTIAL_BLOB_SIZE`` = 2560
bytes). The session blob (access + refresh + id tokens plus
metadata, UTF-16 encoded) exceeds that in practice — its exact
size varies with the token claims — so the ``auto`` backend fails
the write with ``WinError 1783``.

Raises:
RuntimeError: When ``"dpapi"`` is selected on a non-Windows platform.
"""
if choice == "auto":
return
import keyring

if choice == "dpapi":
if sys.platform != "win32":
raise RuntimeError(
"keychain_backend='dpapi' is Windows-only (DPAPI via CRYPT32.DLL); "
"use 'auto' or 'file' on this platform."
)
from keyrings.alt.Windows import EncryptedKeyring

keyring.set_keyring(EncryptedKeyring())
return

from keyrings.alt.file import PlaintextKeyring
from pipefy_infra.config import config_dir

Expand Down
32 changes: 32 additions & 0 deletions packages/auth/tests/test_storage_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from __future__ import annotations

import json
import sys
import types
from collections.abc import Iterator
from pathlib import Path

Expand Down Expand Up @@ -82,6 +84,36 @@ def test_file_backend_is_idempotent(
assert first.file_path == second.file_path


@pytest.mark.unit
def test_dpapi_backend_rejected_off_windows(
_isolated_keyring: None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``dpapi`` is Windows-only; selecting it elsewhere fails fast with guidance."""
monkeypatch.setattr("pipefy_auth.storage.sys.platform", "linux")
with pytest.raises(RuntimeError, match="dpapi.*Windows"):
configure_keychain_backend("dpapi")


@pytest.mark.unit
def test_dpapi_backend_installs_encrypted_keyring_on_windows(
_isolated_keyring: None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""On Windows, ``dpapi`` installs the DPAPI-encrypted keyring.

``keyrings.alt.Windows`` can't be imported off-Windows (it binds CRYPT32.DLL),
so stub the module to exercise the branch on any CI platform.
"""
monkeypatch.setattr("pipefy_auth.storage.sys.platform", "win32")
fake_module = types.ModuleType("keyrings.alt.Windows")
fake_module.EncryptedKeyring = InMemoryKeyring # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "keyrings.alt.Windows", fake_module)

configure_keychain_backend("dpapi")
assert isinstance(keyring.get_keyring(), InMemoryKeyring)


@pytest.mark.unit
def test_file_backend_round_trip_writes_under_config_dir(
_isolated_keyring: None,
Expand Down
9 changes: 6 additions & 3 deletions packages/cli/src/pipefy_cli/commands/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,9 +203,12 @@ def _open(url: str) -> bool:
else:
hint = (
"On headless Linux, ensure a Secret Service daemon "
"(gnome-keyring, kwallet) is running, set "
"PIPEFY_KEYCHAIN_BACKEND=file to use a plaintext file backend, "
"or use a static PIPEFY_TOKEN."
"(gnome-keyring, kwallet) is running, or set "
"PIPEFY_KEYCHAIN_BACKEND=file for a plaintext file backend. "
"On Windows, if the session is too large for Credential Manager "
"(WinError 1783), set PIPEFY_KEYCHAIN_BACKEND=dpapi for a "
"DPAPI-encrypted keyring with no size cap. "
"Or use a static PIPEFY_TOKEN."
)
typer.echo(
f"Login succeeded but the session could not be stored in your "
Expand Down