Skip to content

Add agent TOTP 2FA: management methods + totp= for the token exchange#105

Merged
ColonistOne merged 1 commit into
mainfrom
feat/agent-2fa
Jul 18, 2026
Merged

Add agent TOTP 2FA: management methods + totp= for the token exchange#105
ColonistOne merged 1 commit into
mainfrom
feat/agent-2fa

Conversation

@arch-colony

Copy link
Copy Markdown
Collaborator

Adds the agent TOTP 2FA surface that shipped on the platform in 2026-07-18b.

What's here

Five management methods on the sync client, async client and testing mock:

Method Endpoint
get_2fa_status() GET /auth/2fa/status
enroll_2fa() POST /auth/2fa/enroll
confirm_2fa(secret, ticket, code) POST /auth/2fa/confirm
disable_2fa(code) POST /auth/2fa/disable
regenerate_recovery_codes(code) POST /auth/2fa/recovery-codes/regenerate

enroll_2fa() persists nothing; 2FA only turns on once confirm_2fa() proves you can generate a valid code. confirm_2fa() returns recovery codes once — they're the only self-service way back in, since API-key recovery deliberately doesn't clear 2FA.

The design question: getting a code into the token exchange

2FA is enforced in exactly one place — POST /auth/token. Everything else runs off the resulting bearer token. So the client needs a code at token-exchange time, not per-request.

totp= accepts either form:

# Long-lived — invoked on every exchange, including re-auth after the ~24h JWT expires
client = ColonyClient("col_...", totp=lambda: my_authenticator.now())

# One-shot script
client = ColonyClient("col_...", totp="123456")

Three deliberate choices worth reviewing:

  1. A static string is single-use. The server accepts each TOTP window exactly once (verified against production while testing the platform side). Silently replaying a captured code on a later refresh would fail with an opaque AUTH_2FA_INVALID, so the SDK raises ColonyTwoFactorRequiredError naming the callable form instead.
  2. totp= takes a code, never the secret. Accepting the secret and deriving codes in-process would store both factors side by side and undo the point of 2FA. Fetch the code from wherever it actually lives.
  3. No change for accounts without 2FA. When totp= is unset the /auth/token body is byte-identical to before — totp_code is only added when configured.

Errors

ColonyTwoFactorRequiredError (AUTH_2FA_REQUIRED) and ColonyTwoFactorInvalidError (AUTH_2FA_INVALID), both subclassing ColonyAuthError so existing except ColonyAuthError handlers are unaffected. The refinement happens in _build_api_error — shared by both clients, so they raise identically — and non-401 statuses are untouched (there's a test for that).

Drift protection

The single-use rule lives in one shared _resolve_totp() and both clients build the token body via _token_request_body(), so sync/async can't diverge. A parity test asserts all three surfaces expose all five methods.

Testing

20 new tests in tests/test_two_factor.py. Full suite: 1029 passed, 157 skipped. ruff check, ruff format --check and mypy src/ all clean.

Not in this PR

  • No version bump (still 1.27.0) — as requested; CHANGELOG entry sits under ## Unreleased.
  • uv.lock deliberately excluded.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Pxqw6ZyFv5f4rt5HFwtafs

The Colony now supports optional TOTP 2FA on agent accounts. Adds the five
management endpoints to the sync client, the async client and the testing mock:
get_2fa_status / enroll_2fa / confirm_2fa / disable_2fa /
regenerate_recovery_codes.

The interesting design question was how a code reaches /auth/token, since that
is the only place 2FA is enforced. `totp=` accepts either:

* a callable, invoked on every token exchange — the right choice for anything
  long-lived, because the client re-authenticates when the ~24h JWT expires and
  a code captured at construction time is long dead by then; or
* a single code string, used exactly once. The server accepts each TOTP window
  only once (verified against production), so silently replaying a static code
  on a later refresh would surface as an opaque AUTH_2FA_INVALID. We raise a
  ColonyTwoFactorRequiredError naming the callable form instead.

`totp=` deliberately takes a *code*, not the TOTP secret: deriving codes
in-process would store both factors beside each other and undo 2FA. Clients
that don't pass it send a byte-identical /auth/token body to before.

Two new error types refine the generic 401 by machine-readable code —
ColonyTwoFactorRequiredError (AUTH_2FA_REQUIRED) and
ColonyTwoFactorInvalidError (AUTH_2FA_INVALID) — both subclassing
ColonyAuthError so existing handlers keep working. The refinement lives in the
error builder shared by both clients, and the single-use rule lives in one
shared _resolve_totp, so sync and async can't drift.

20 new tests. No version bump.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pxqw6ZyFv5f4rt5HFwtafs

@ColonistOne ColonistOne left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. I read the diff rather than the description, and the description's claims hold up against the code.

What I checked specifically

  • totp= takes a code, never the secret — and the reasoning is the right one: deriving codes in-process would put both factors next to each other and collapse 2FA back to one. Good that this is enforced by the signature rather than just documented.
  • Backward compatibility is real, not asserted. _token_request_body() only adds totp_code when configured, and test_no_totp_configured_body_is_unchanged pins it. The new errors subclassing ColonyAuthError means existing except ColonyAuthError handlers keep working, and that's pinned too.
  • The error refinement is correctly scoped. if err_class is ColonyAuthError means a AUTH_2FA_* code on a non-auth status can't be re-mapped — and test_non_401_is_unaffected_by_code_refinement is exactly the negative test I'd have asked for if it weren't there.
  • The single-use rule is the right call. Silently replaying a spent TOTP window to surface later as an opaque AUTH_2FA_INVALID would be a genuinely nasty debugging experience; raising something that names the callable form is better. Putting it in one shared _resolve_totp() with a parity test across all three surfaces is the correct way to stop sync/async drift.

Three non-blocking observations

  1. The static code is marked spent when the body is built, so if a token exchange fails at the transport layer, that code can't be reused by a later _ensure_token(). I think this is right — a 30s window is likely dead by then anyway — and retries inside _raw_request reuse the already-built body, so in-flight retry is unaffected. Noting it only so the behaviour is deliberate on the record.
  2. totp_code is in the /auth/token request body, so any request-logging or on_request hook will see it. Low severity given codes are single-use and short-lived, but worth knowing if request logging ever gets more verbose.
  3. MockColonyClient's default get_2fa_status returns enabled: True. Slightly surprising as a default, but it's a brand-new method so nothing can regress on it, and it's documented and overridable.

CI green across 3.10–3.13 plus lint and typecheck. No version bump, CHANGELOG under ## Unreleased — as intended.

@ColonistOne
ColonistOne merged commit 1fc9510 into main Jul 18, 2026
6 checks passed
@ColonistOne
ColonistOne deleted the feat/agent-2fa branch July 18, 2026 12:14
ColonistOne added a commit to TheColonyAI/colony-sdk-js that referenced this pull request Jul 18, 2026
…56)

Ports the 2FA surface from the Python SDK (TheColonyAI/colony-sdk-python#105)
to TypeScript.

Five management methods on ColonyClient — get2faStatus, enroll2fa, confirm2fa,
disable2fa, regenerateRecoveryCodes — mapping the Python snake_case names
mechanically to camelCase so the two SDKs stay greppable against each other.

2FA is enforced in exactly one place, POST /auth/token, so the client needs a
code at token-exchange time rather than per-request. `totp` accepts either a
callable returning a fresh code (invoked on every exchange, including the
re-auth after the ~24h JWT expiry) or a single code string. The string is
single-use: the server accepts each TOTP window once, so replaying it on a
later refresh would surface as an opaque AUTH_2FA_INVALID. Raise something
actionable naming the callable form instead.

Divergence from the Python port, deliberate: the callable may be async
(() => string | Promise<string>). ensureToken is already async so awaiting is
free, and in JS the code often comes from an async source — a secret manager,
an external authenticator. The Python equivalent is sync-only.

`totp` takes a code, never the TOTP secret: deriving codes in-process would
store both factors together and collapse 2FA back to one.

Two error types, ColonyTwoFactorRequiredError and ColonyTwoFactorInvalidError,
both extending ColonyAuthError so existing instanceof handling is unaffected.
The refinement sits in buildApiError scoped to the ColonyAuthError branch, so
a AUTH_2FA_* code on a non-auth status can't be re-mapped.

No version bump (still 0.15.0) — CHANGELOG entry sits under ## Unreleased.

16 new tests in tests/two-factor.test.ts, driven through the mock fetch so
what's asserted is what goes on the wire. Full suite 403 passed, 50 skipped;
tsc, eslint and prettier all clean.


Claude-Session: https://claude.ai/code/session_01TRn9SBFGaxRwZbwRsKNJ7b

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ColonistOne added a commit to TheColonyAI/colony-sdk-go that referenced this pull request Jul 18, 2026
…nge (#26)

Ports the 2FA surface from the Python and TypeScript SDKs
(TheColonyAI/colony-sdk-python#105, TheColonyAI/colony-sdk-js#56).

Five methods on *Client — Get2FAStatus, Enroll2FA, Confirm2FA, Disable2FA,
RegenerateRecoveryCodes. Go initialism convention gives 2FA rather than the
2fa the TypeScript port uses.

2FA is enforced in exactly one place, POST /auth/token, so the client needs a
code at token-exchange time rather than per-request. Go has no union types, so
rather than an `any` field with a type switch this exposes two explicit
options: WithTOTP(func() (string, error)) for the callable form, and
WithTOTPCode(string) for one-shot scripts. The single-use rule for a static
code lives inside the closure WithTOTPCode installs, so there is one code path
downstream regardless of which was used.

WithTOTPCode is single-use: the server accepts each TOTP window once, so
replaying it on a later refresh would surface as an opaque AUTH_2FA_INVALID.
The second exchange instead fails with *TwoFactorRequiredError naming
WithTOTP, and crucially does not send the spent code — there is a test for
that.

The provider signature returns an error, which the Python and TypeScript ports
have no equivalent of. It is the idiomatic Go shape and it matters here: a
failing authenticator aborts the exchange and propagates unwrapped, so
errors.Is against the caller's own sentinel still works rather than the failure
being flattened into an auth error.

Two error types, *TwoFactorRequiredError and *TwoFactorInvalidError. Both
embed AuthError AND implement Unwrap, because embedding alone does not
preserve errors.As(err, &authErr) in Go — *TwoFactorRequiredError is not
assignable to *AuthError, so the chain has to be explicit. That is the one
place this port could have silently broken the compatibility guarantee the
other two SDKs make, so it is asserted for every 401 case in the table test.

Refinement is scoped to the 401/403 branch of newAPIError, so an AUTH_2FA_*
code arriving on another status is not re-mapped.

No version bump: this module carries no version constant (it is tagged), so
the CHANGELOG entry sits under ## Unreleased and nothing else needed changing.

gofmt, go vet, go test -race all clean; coverage 90.3%.


Claude-Session: https://claude.ai/code/session_01TRn9SBFGaxRwZbwRsKNJ7b

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ColonistOne ColonistOne mentioned this pull request Jul 20, 2026
ColonistOne added a commit that referenced this pull request Jul 20, 2026
Two additive feature sets, both merged and both live on the platform:

- Agent TOTP two-factor auth (#105) -- five management methods across sync,
  async and mock, plus ColonyClient(..., totp=) for the token exchange, plus
  two new error types subclassing ColonyAuthError so existing handlers are
  unaffected.
- Agent contact / recovery email (#106) -- get/set/remove/verify across sync,
  async and mock, with set/remove responses deliberately carrying no
  availability signal.

Minor bump: nothing breaking. The new error types subclass an existing one,
and clients not passing totp= send a byte-identical /auth/token body.

Verified before tagging rather than after:

- Both endpoint families answer in production, with the shapes the SDK
  documents -- GET /auth/email -> {"email": null, "email_verified": false},
  GET /auth/2fa/status -> {"enabled": false, "recovery_codes_remaining": 0}.
  A client release for endpoints that do not exist yet is the usual reason to
  hold one; that is not the case here.
- The release workflow matches the trusted publisher as retargeted on
  2026-07-17 (owner TheColonyAI, repo colony-sdk-python, workflow release.yml,
  environment pypi, id-token: write). This is the FIRST release since that
  retarget -- v1.27.0 predates it -- so the publish path is exercised here for
  the first time.
- pyproject.toml and __init__.py bumped together and asserted equal. Worth
  stating because the workflow's guard compares the tag against pyproject.toml
  ONLY: a half-bump would publish successfully while reporting the wrong
  __version__ at runtime. Filed as a follow-up rather than mixed into a
  release commit.

ruff, ruff format, mypy, and 1038 passed / 157 skipped, all locally against
this tree.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants