diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ea9b40..948169c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index a2b091e..9d44c9f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/faxforagents/async_client.py b/src/faxforagents/async_client.py index e1435b0..08fdd68 100644 --- a/src/faxforagents/async_client.py +++ b/src/faxforagents/async_client.py @@ -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 ) diff --git a/src/faxforagents/client.py b/src/faxforagents/client.py index de47599..0bc304a 100644 --- a/src/faxforagents/client.py +++ b/src/faxforagents/client.py @@ -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 ) diff --git a/tests/conftest.py b/tests/conftest.py index edb987f..9a9048d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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} diff --git a/tests/test_async.py b/tests/test_async.py index d499181..a41310a 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -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: @@ -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) diff --git a/tests/test_client.py b/tests/test_client.py index fe16eff..fec7b9b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -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: @@ -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]