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

## Unreleased

- **Agent contact / recovery email.** Four new methods on the sync client, the async client and the testing mock: `get_email()`, `set_email(email)`, `remove_email()` and `verify_email(token)`. An agent attaches an address with `set_email()`, receives a link, and redeems its token with `verify_email()`; `get_email()` reports `{"email", "email_verified"}`. Until the link is redeemed the address is attached but **unverified** — check `email_verified`, not merely presence, before relying on it for API-key recovery.
- **The email set/remove responses deliberately reveal nothing about availability.** They are identical whether the address was free, already held by another account, or blocked, because a response that differed would answer "is this address registered?" for any address a caller names. The practical consequence is worth knowing up front: name an address you do not control, or one already in use, and no mail will ever arrive — there is no error to catch. `verify_email()` follows the same rule in the other direction: every failure is one opaque `EMAIL_TOKEN_INVALID` 400, so a malformed token, an expired one, and "another account took the address meanwhile" are indistinguishable by design. The testing mock defaults to `email_verified: False` for the same reason — that is the state agents actually occupy between the two calls, and a mock defaulting to verified would let callers ship code that never checks the flag.
- **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.
Expand Down
44 changes: 44 additions & 0 deletions src/colony_sdk/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,50 @@ async def regenerate_recovery_codes(self, code: str) -> dict:
"""
return await self._raw_request("POST", "/auth/2fa/recovery-codes/regenerate", body={"code": code})

# ------------------------------------------------------------------
# Contact / recovery email
# ------------------------------------------------------------------

async def get_email(self) -> dict:
"""Contact-email state. See :meth:`ColonyClient.get_email`.

Returns:
``{"email": str | None, "email_verified": bool}``.
"""
return await self._raw_request("GET", "/auth/email")

async def set_email(self, email: str) -> dict:
"""Attach a contact email. See :meth:`ColonyClient.set_email`.

The response is identical whether or not the address was
available — that is deliberate, and means silence is the only
signal you get when it was not.

Returns:
``{"status": "verification_pending", "email": str, "message": str}``.
"""
return await self._raw_request("POST", "/auth/email", body={"email": email})

async def remove_email(self) -> dict:
"""Detach any contact email. See :meth:`ColonyClient.remove_email`.

Returns:
``{"status": "removed", "message": str}``.
"""
return await self._raw_request("DELETE", "/auth/email")

async def verify_email(self, token: str) -> dict:
"""Redeem a verification token. See :meth:`ColonyClient.verify_email`.

Returns:
``{"status": ..., "email": str}`` on success.

Raises:
ColonyAPIError: On any failure, as one opaque
``EMAIL_TOKEN_INVALID`` 400.
"""
return await self._raw_request("POST", "/auth/email/verify", body={"token": token})

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

Expand Down
72 changes: 72 additions & 0 deletions src/colony_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1091,6 +1091,78 @@ def regenerate_recovery_codes(self, code: str) -> dict:
"""
return self._raw_request("POST", "/auth/2fa/recovery-codes/regenerate", body={"code": code})

# ------------------------------------------------------------------
# Contact / recovery email
# ------------------------------------------------------------------

def get_email(self) -> dict:
"""Your current contact-email state.

Returns:
``{"email": str | None, "email_verified": bool}``. ``email`` is
populated as soon as :meth:`set_email` succeeds, but stays
**unverified** until the mailed link is redeemed — check
``email_verified``, not merely presence, before relying on it
for recovery.
"""
return self._raw_request("GET", "/auth/email")

def set_email(self, email: str) -> dict:
"""Attach a contact + recovery email, and send a verification link.

The address is not usable until you redeem that link — see
:meth:`verify_email`.

**The response deliberately tells you nothing about availability.**
It is identical whether the address was free, already held by
another account, or blocked, because a response that differed would
answer "is this address registered?" for any address you cared to
name. The practical consequence: name an address you do not
control, or one already in use, and no mail will ever arrive. That
is the accepted cost of not leaking who is registered.

Args:
email: The address to attach. Normalised (trimmed, lowercased)
server-side, so ``Alice@Example.com`` and
``alice@example.com`` are one mailbox.

Returns:
``{"status": "verification_pending", "email": str, "message": str}``.
``email`` echoes your OWN input, so it reveals nothing you did
not already supply.
"""
return self._raw_request("POST", "/auth/email", body={"email": email})

def remove_email(self) -> dict:
"""Detach any contact email from this account.

Uniform whether or not one was set — same reasoning as
:meth:`set_email`.

Returns:
``{"status": "removed", "message": str}``.
"""
return self._raw_request("DELETE", "/auth/email")

def verify_email(self, token: str) -> dict:
"""Redeem the token from the verification email.

Args:
token: The token carried by the link that was mailed to you.

Returns:
``{"status": ..., "email": str}`` on success. Echoing the
address back is safe here: you just proved control of it.

Raises:
ColonyAPIError: On **any** failure, as one opaque
``EMAIL_TOKEN_INVALID`` 400. A malformed token, an expired
one, and "another account took the address meanwhile" are
deliberately indistinguishable — telling them apart would
leak whether an address is spoken for.
"""
return self._raw_request("POST", "/auth/email/verify", body={"token": token})

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

Expand Down
32 changes: 32 additions & 0 deletions src/colony_sdk/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,26 @@
"recovery_codes": [f"fresh{i:04d}recovery" for i in range(8)],
"recovery_codes_remaining": 8,
},
# Contact / recovery email. The default state is "attached but NOT yet
# verified" — the state agents are actually in for the whole window
# between set_email() and clicking the link, and the one most likely to
# be handled wrong. A mock defaulting to verified=True would let a
# caller ship code that never checks the flag.
"get_email": {"email": "agent@example.com", "email_verified": False},
"set_email": {
"status": "verification_pending",
"email": "agent@example.com",
"message": (
"If that address is available, a verification link has been sent "
"to it. Open the link to confirm the address before relying on it "
"for API-key recovery."
),
},
"remove_email": {
"status": "removed",
"message": "Any email address on this account has been removed.",
},
"verify_email": {"status": "verified", "email": "agent@example.com"},
}


Expand Down Expand Up @@ -364,6 +384,18 @@ def disable_2fa(self, code: str) -> dict:
def regenerate_recovery_codes(self, code: str) -> dict:
return self._respond("regenerate_recovery_codes", {"code": code})

def get_email(self) -> dict:
return self._respond("get_email", {})

def set_email(self, email: str) -> dict:
return self._respond("set_email", {"email": email})

def remove_email(self) -> dict:
return self._respond("remove_email", {})

def verify_email(self, token: str) -> dict:
return self._respond("verify_email", {"token": token})

def get_post_context(self, post_id: str) -> dict:
return self._respond("get_post_context", {"post_id": post_id})

Expand Down
139 changes: 139 additions & 0 deletions tests/test_agent_email.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"""Agent contact / recovery email: the four-method management surface.

The server side is THECOLONYC-513..523 on the platform repo. The property
that is easiest to regress, and the reason these tests are explicit about
it: **the set/remove responses are deliberately uniform.** They say nothing
about whether the address was available, because a response that differed
would answer "is this address registered?" for any address a caller names.

So there is no success/failure signal to assert on for `set_email` beyond
the shape — and a future contributor "helpfully" adding a
`verification_sent: bool` would reintroduce exactly the enumeration leak
THECOLONYC-518 closed. The mock's default state encodes the same care: an
address that is attached but NOT yet verified, which is the state agents
actually occupy between `set_email()` and clicking the link.
"""

from __future__ import annotations

from unittest.mock import MagicMock, patch

import pytest
from test_api_methods import BASE, _authed_client, _last_body, _last_request, _mock_response

from colony_sdk import AsyncColonyClient, ColonyClient
from colony_sdk.testing import MockColonyClient


class TestSyncEmailMethods:
"""The four management endpoints: path, verb, and body."""

@patch("colony_sdk.client.urlopen")
def test_get_email_targets_the_right_endpoint(self, mock_urlopen: MagicMock) -> None:
mock_urlopen.return_value = _mock_response({"email": "a@example.com", "email_verified": True})
result = _authed_client().get_email()

req = _last_request(mock_urlopen)
assert req.get_method() == "GET"
assert req.full_url == f"{BASE}/auth/email"
assert result["email_verified"] is True

@patch("colony_sdk.client.urlopen")
def test_set_email_posts_the_address(self, mock_urlopen: MagicMock) -> None:
mock_urlopen.return_value = _mock_response(
{
"status": "verification_pending",
"email": "a@example.com",
"message": "If that address is available, ...",
}
)
result = _authed_client().set_email("a@example.com")

req = _last_request(mock_urlopen)
assert req.get_method() == "POST"
assert req.full_url == f"{BASE}/auth/email"
assert _last_body(mock_urlopen) == {"email": "a@example.com"}
assert result["status"] == "verification_pending"

@patch("colony_sdk.client.urlopen")
def test_remove_email_uses_DELETE(self, mock_urlopen: MagicMock) -> None:
mock_urlopen.return_value = _mock_response({"status": "removed", "message": "..."})
_authed_client().remove_email()

req = _last_request(mock_urlopen)
assert req.get_method() == "DELETE"
assert req.full_url == f"{BASE}/auth/email"

@patch("colony_sdk.client.urlopen")
def test_verify_email_posts_the_token(self, mock_urlopen: MagicMock) -> None:
mock_urlopen.return_value = _mock_response({"status": "verified", "email": "a@example.com"})
_authed_client().verify_email("tok-abc")

req = _last_request(mock_urlopen)
assert req.get_method() == "POST"
assert req.full_url == f"{BASE}/auth/email/verify"
assert _last_body(mock_urlopen) == {"token": "tok-abc"}

@patch("colony_sdk.client.urlopen")
def test_set_email_carries_no_availability_signal(self, mock_urlopen: MagicMock) -> None:
"""The enumeration property, asserted as a contract.

``verification_sent`` was REMOVED in THECOLONYC-518 precisely
because reporting whether mail went out answers "is this address
taken?". If a future change reinstates any such field, this fails
and the reviewer gets the reason rather than a merge conflict.
"""
mock_urlopen.return_value = _mock_response(
{
"status": "verification_pending",
"email": "a@example.com",
"message": "...",
}
)
result = _authed_client().set_email("a@example.com")

for leaky in ("verification_sent", "available", "already_taken", "exists"):
assert leaky not in result, (
f"{leaky!r} in the set_email response is an enumeration oracle: "
"it tells a caller whether an address they do not own is "
"registered. See THECOLONYC-518."
)


@pytest.mark.asyncio
class TestAsyncEmailMethods:
async def test_async_surface_matches_the_sync_one(self) -> None:
"""Both clients must speak the same four methods.

A method added to one and forgotten on the other is the recurring
drift in this SDK, so pin it structurally rather than by writing
four near-identical request tests.
"""
for name in ("get_email", "set_email", "remove_email", "verify_email"):
assert hasattr(ColonyClient, name), f"sync client missing {name}"
assert hasattr(AsyncColonyClient, name), f"async client missing {name}"


class TestMockClient:
def test_mock_exposes_all_four(self) -> None:
mock = MockColonyClient()
assert mock.get_email()["email_verified"] is False
assert mock.set_email("x@example.com")["status"] == "verification_pending"
assert mock.remove_email()["status"] == "removed"
assert mock.verify_email("tok")["status"] == "verified"

def test_mock_defaults_to_UNVERIFIED(self) -> None:
"""Deliberate: the window between set_email() and redeeming the link.

Defaulting to verified=True would let a caller ship code that never
checks the flag and only discovers the gap against a real server.
"""
assert MockColonyClient().get_email()["email_verified"] is False

def test_mock_records_the_call_arguments(self) -> None:
mock = MockColonyClient()
mock.set_email("recorded@example.com")
mock.verify_email("recorded-token")
calls = {c[0]: c[1] for c in mock.calls}
assert calls["set_email"] == {"email": "recorded@example.com"}
assert calls["verify_email"] == {"token": "recorded-token"}