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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## Unreleased

`identity.create_api_key` takes `connected_app` on both clients. A key made with `connected_app=True` is bound by the auto-send setting like an OAuth connection.
The field is sent only when given, so existing calls send the same body. Needs API 1.12.0.

## 0.3.1 (2026-11-10)

`faxes.list` takes `idempotency_key`, a filter that finds the send made with that key.
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,15 @@ if req.status == "approved":
An agent's own API key can never approve. `ffa.policies.evaluate(quote_id=...)` is a dry run that says
whether a policy would allow the send.

A key made for a connected app used over stdio, such as the Gemini CLI extension, is created with
`connected_app=True`. The organization's auto-send setting (`ffa.policies.get_auto_send`) then binds it like
an OAuth connection. An agent's key cannot carry the flag.

```python
key = ffa.identity.create_api_key("gemini", scopes=["fax:send"], connected_app=True)
key.connected_app # True, also on ffa.identity.list_api_keys()
```

## Webhooks

```python
Expand Down
11 changes: 9 additions & 2 deletions src/faxforagents/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,22 @@ async def create_api_key(
environment: str = "test",
scopes: list[str] | None = None,
expires_in_days: int | None = None,
connected_app: bool | None = None,
idempotency_key: str | None = None,
) -> m.ApiKeyWithSecret:
"""Create a key. The secret is in the answer once. Scope: key:admin."""
body = {
"""Create a key. The secret is in the answer once. Scope: key:admin.

``connected_app=True`` marks the key as one a connected app uses over stdio, such as the Gemini CLI
extension: the organization's auto-send setting binds it like an OAuth connection. The answer and
``list_api_keys`` carry the flag. Needs API 1.12.0; older servers refuse the field."""
body: dict[str, Any] = {
"label": label,
"environment": environment,
"scopes": scopes,
"expires_in_days": expires_in_days,
}
if connected_app is not None:
body["connected_app"] = connected_app
return await self._h.request(
"POST", "/v1/api-keys", model=m.ApiKeyWithSecret, json=body, idempotency_key=idempotency_key
)
Expand Down
11 changes: 9 additions & 2 deletions src/faxforagents/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,15 +69,22 @@ def create_api_key(
environment: str = "test",
scopes: list[str] | None = None,
expires_in_days: int | None = None,
connected_app: bool | None = None,
idempotency_key: str | None = None,
) -> m.ApiKeyWithSecret:
"""Create a key. The secret is in the answer once. Scope: key:admin."""
body = {
"""Create a key. The secret is in the answer once. Scope: key:admin.

``connected_app=True`` marks the key as one a connected app uses over stdio, such as the Gemini CLI
extension: the organization's auto-send setting binds it like an OAuth connection. The answer and
``list_api_keys`` carry the flag. Needs API 1.12.0; older servers refuse the field."""
body: dict[str, Any] = {
"label": label,
"environment": environment,
"scopes": scopes,
"expires_in_days": expires_in_days,
}
if connected_app is not None:
body["connected_app"] = connected_app
return self._h.request(
"POST", "/v1/api-keys", model=m.ApiKeyWithSecret, json=body, idempotency_key=idempotency_key
)
Expand Down
19 changes: 19 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,22 @@ def error(code: str, message: str = "refused", **extra: Any) -> dict[str, Any]:

def live_key() -> str | None:
return os.environ.get("FAXFORAGENTS_API_KEY") or None


def api_key_with_secret(**over: Any) -> dict[str, Any]:
base = {
"id": "key_" + "5" * 32,
"label": "gemini",
"prefix": "ffa_test_aaaaaaaa",
"environment": "test",
"project_id": "prj_1",
"agent_id": None,
"scopes": ["fax:send"],
"created_at": "2026-11-10T10:00:00Z",
"last_used_at": None,
"expires_at": None,
"revoked_at": None,
"rotated_from_id": None,
"secret": "ffa_test_" + "a" * 43,
}
return {**base, **over}
24 changes: 23 additions & 1 deletion tests/test_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import respx

from faxforagents import AsyncClient
from tests.conftest import fax_job, quote
from tests.conftest import api_key_with_secret, fax_job, quote


async def test_async_send_and_wait(aclient: AsyncClient, api: respx.MockRouter) -> None:
Expand Down Expand Up @@ -85,3 +85,25 @@ async def test_async_receipt_pdf_and_auto_send(aclient: AsyncClient, api: respx.
async def test_async_oauth_grants_iter(aclient: AsyncClient, api: respx.MockRouter) -> None:
api.get("/v1/oauth/grants").respond(200, json={"items": [], "next_cursor": None})
assert [g.id async for g in aclient.oauth.iter_grants()] == []


async def test_async_create_api_key_sends_connected_app_only_when_given(
aclient: AsyncClient, api: respx.MockRouter
) -> None:
import json

route = api.post("/v1/api-keys")
route.side_effect = [
httpx.Response(201, json=api_key_with_secret(connected_app=True)),
httpx.Response(201, json=api_key_with_secret()),
]
made = await aclient.identity.create_api_key("gemini", connected_app=True, idempotency_key="k1")
assert made.connected_app is True
assert json.loads(route.calls.last.request.content) == {
"label": "gemini",
"environment": "test",
"connected_app": True,
}
assert route.calls.last.request.headers["idempotency-key"] == "k1"
assert (await aclient.identity.create_api_key("plain")).connected_app is False
assert "connected_app" not in json.loads(route.calls.last.request.content)
31 changes: 30 additions & 1 deletion tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
ScopeError,
ServerError,
)
from tests.conftest import BASE, KEY, document, error, fax_job, quote
from tests.conftest import BASE, KEY, api_key_with_secret, document, error, fax_job, quote


def test_bearer_header_and_user_agent(client: Client, api: respx.MockRouter) -> None:
Expand Down Expand Up @@ -343,3 +343,32 @@ def test_effective_policies_can_ask_about_another_principal(client: Client, api:
with pytest.raises(Exception): # noqa: B017, PT011
Client(api_key=KEY, base_url=BASE, max_retries=0).policies.effective()
assert str(route.calls.last.request.url) == f"{BASE}/v1/policies/effective"


def test_create_api_key_sends_connected_app_only_when_given(client: Client, api: respx.MockRouter) -> None:
route = api.post("/v1/api-keys")
route.side_effect = [
httpx.Response(201, json=api_key_with_secret(connected_app=True)),
httpx.Response(201, json=api_key_with_secret(connected_app=False)),
httpx.Response(201, json=api_key_with_secret()),
]
made = client.identity.create_api_key("gemini", scopes=["fax:send"], connected_app=True)
assert made.connected_app is True
assert json.loads(route.calls.last.request.content) == {
"label": "gemini",
"environment": "test",
"scopes": ["fax:send"],
"connected_app": True,
}
assert client.identity.create_api_key("plain", connected_app=False).connected_app is False
assert json.loads(route.calls.last.request.content)["connected_app"] is False
# Left out, the field is not sent and an answer without it reads as a plain key.
assert client.identity.create_api_key("older").connected_app is False
assert json.loads(route.calls.last.request.content) == {"label": "older", "environment": "test"}


def test_list_api_keys_reads_connected_app(client: Client, api: respx.MockRouter) -> None:
flagged = {k: v for k, v in api_key_with_secret(connected_app=True).items() if k != "secret"}
plain = {k: v for k, v in api_key_with_secret(id="key_plain").items() if k != "secret"}
api.get("/v1/api-keys").respond(200, json={"items": [flagged, plain]})
assert [k.connected_app for k in client.identity.list_api_keys().items] == [True, False]
Loading