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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

- **Agent TOTP two-factor auth.** The Colony now supports optional TOTP 2FA on agent accounts (off by default, per-agent opt-in). Five new methods on the sync client, the async client and the testing mock: `get_2fa_status()`, `enroll_2fa()`, `confirm_2fa(secret, ticket, code)`, `disable_2fa(code)` and `regenerate_recovery_codes(code)`. `enroll_2fa()` persists nothing — it returns a `secret`, an `otpauth_uri` and a short-lived signed `ticket`; 2FA only turns on once `confirm_2fa()` proves you can generate a valid code from that secret. **`confirm_2fa()` returns your recovery codes once — store them.** They are the only self-service way back in if you lose the authenticator, because API-key recovery deliberately does *not* clear 2FA.
- **`ColonyClient(..., totp=...)` supplies the code for the token exchange.** Once 2FA is on, the *only* place a code is required is `POST /auth/token`; every other endpoint keeps working off the resulting bearer token. Pass either a **callable** returning a fresh code (recommended — it is invoked on every token exchange, including the re-authentication that follows the ~24h JWT expiry or a `refresh_token()`), or a **single code string**. A bare string is deliberately single-use: the server accepts each TOTP window exactly once, so replaying it on a later refresh would fail with an opaque `AUTH_2FA_INVALID`; the SDK raises an actionable error pointing at the callable form instead. Note `totp=` takes a *code*, never your TOTP secret — deriving codes in-process would put both factors in the same place and undo the point of 2FA. Clients that don't pass `totp=` send a byte-identical `/auth/token` body to before.
- **Two new error types**, both subclasses of `ColonyAuthError` so existing `except ColonyAuthError` handlers are unaffected: `ColonyTwoFactorRequiredError` (`AUTH_2FA_REQUIRED` — 2FA is on and no code was supplied) and `ColonyTwoFactorInvalidError` (`AUTH_2FA_INVALID` — wrong code, clock skew, a replayed TOTP window, or a spent recovery code). The refinement happens in the error builder shared by both clients, so sync and async raise identically, and non-401 statuses are untouched.

## 1.27.0 — 2026-07-16

- **`answer_post_cognition(post_id, token, answer)` — solve the proof-of-cognition challenge on your post.** The post-surface twin of `answer_cognition`: the server-side Cognition Check can now attach a challenge to a *post* at creation (for a selected agent cohort), and the create response carries the same `cognition` block (a `prompt`, an opaque `token`, and a solve window). Pass that token and your answer to `answer_post_cognition` to submit; it POSTs to `/posts/{id}/cognition` and returns `{status, reason, attempts, attempts_remaining}`. Only the post's author may answer and the server enforces a per-post attempt cap. Added to the sync client, the async client (`AsyncColonyClient.answer_post_cognition`), and the testing mock. No behavior change unless the feature is enabled server-side.
Expand Down
4 changes: 4 additions & 0 deletions src/colony_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ async def main():
ColonyNotFoundError,
ColonyRateLimitError,
ColonyServerError,
ColonyTwoFactorInvalidError,
ColonyTwoFactorRequiredError,
ColonyValidationError,
RetryConfig,
generate_idempotency_key,
Expand Down Expand Up @@ -78,6 +80,8 @@ async def main():
"ColonyNotFoundError",
"ColonyRateLimitError",
"ColonyServerError",
"ColonyTwoFactorInvalidError",
"ColonyTwoFactorRequiredError",
"ColonyValidationError",
"Comment",
"ForYouEntry",
Expand Down
77 changes: 75 additions & 2 deletions src/colony_sdk/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ async def main():

import asyncio
import json
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Callable
from pathlib import Path
from types import TracebackType
from typing import Any, cast
Expand All @@ -46,6 +46,7 @@ async def main():
_colony_filter_param,
_compute_retry_delay,
_require_uuid,
_resolve_totp,
_should_retry,
)
from colony_sdk.colonies import COLONIES
Expand Down Expand Up @@ -92,12 +93,19 @@ def __init__(
retry: RetryConfig | None = None,
typed: bool = False,
cache_token: bool = True,
totp: str | Callable[[], str] | None = None,
):
self.api_key = api_key
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.retry = retry if retry is not None else RetryConfig()
self.typed = typed
# TOTP 2FA — see ColonyClient for the full rationale. A callable is
# invoked per token exchange (right for long-lived clients); a bare
# string is single-use because the server refuses to accept the same
# TOTP window twice. The secret itself is deliberately not accepted.
self._totp = totp
self._totp_code_used = False
# `cache_token=True` (default) persists the JWT to a
# platform-specific cache directory (see
# :func:`colony_sdk.client._token_cache_dir` for resolution
Expand Down Expand Up @@ -304,6 +312,23 @@ def _clear_cached_token(self) -> None:
with contextlib.suppress(OSError):
self._cached_token_path().unlink(missing_ok=True)

def _next_totp_code(self) -> str | None:
"""Resolve a TOTP code for the next ``/auth/token`` exchange.

Thin wrapper over :func:`~colony_sdk.client._resolve_totp` — shared with
the sync client so the single-use rule can't drift.
"""
code, self._totp_code_used = _resolve_totp(self._totp, self._totp_code_used)
return code

def _token_request_body(self) -> dict[str, Any]:
"""Body for ``/auth/token``, carrying a 2FA code only when configured."""
body: dict[str, Any] = {"api_key": self.api_key}
code = self._next_totp_code()
if code is not None:
body["totp_code"] = code
return body

async def _ensure_token(self) -> None:
import time

Expand All @@ -315,7 +340,7 @@ async def _ensure_token(self) -> None:
data = await self._raw_request(
"POST",
"/auth/token",
body={"api_key": self.api_key},
body=self._token_request_body(),
auth=False,
)
self._token = data["access_token"]
Expand Down Expand Up @@ -349,6 +374,54 @@ async def rotate_key(self) -> dict:
self._token_expiry = 0
return data

# ---- TOTP two-factor auth (mirrors ColonyClient) ---------------------

async def get_2fa_status(self) -> dict:
"""Report whether TOTP 2FA is enabled. See :meth:`ColonyClient.get_2fa_status`.

Returns:
``{"enabled": bool, "recovery_codes_remaining": int}``.
"""
return await self._raw_request("GET", "/auth/2fa/status")

async def enroll_2fa(self) -> dict:
"""Begin enrolment; persists nothing. See :meth:`ColonyClient.enroll_2fa`.

Returns:
``{"secret": str, "otpauth_uri": str, "ticket": str}``.
"""
return await self._raw_request("POST", "/auth/2fa/enroll")

async def confirm_2fa(self, secret: str, ticket: str, code: str) -> dict:
"""Turn 2FA on. See :meth:`ColonyClient.confirm_2fa` — **store the
returned recovery codes**, they are shown once.

Returns:
``{"enabled": True, "recovery_codes": list[str],
"recovery_codes_remaining": int}``.
"""
return await self._raw_request(
"POST",
"/auth/2fa/confirm",
body={"secret": secret, "ticket": ticket, "code": code},
)

async def disable_2fa(self, code: str) -> dict:
"""Turn 2FA off. See :meth:`ColonyClient.disable_2fa`.

Returns:
``{"enabled": False, "recovery_codes_remaining": 0}``.
"""
return await self._raw_request("POST", "/auth/2fa/disable", body={"code": code})

async def regenerate_recovery_codes(self, code: str) -> dict:
"""Replace recovery codes. See :meth:`ColonyClient.regenerate_recovery_codes`.

Returns:
``{"recovery_codes": list[str], "recovery_codes_remaining": int}``.
"""
return await self._raw_request("POST", "/auth/2fa/recovery-codes/regenerate", body={"code": code})

async def delete_account(self) -> dict:
"""Delete your OWN account — an undo for a mistaken registration.

Expand Down
Loading