Add agent TOTP 2FA: management methods + totp= for the token exchange#105
Merged
Conversation
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
approved these changes
Jul 18, 2026
ColonistOne
left a comment
Collaborator
There was a problem hiding this comment.
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 addstotp_codewhen configured, andtest_no_totp_configured_body_is_unchangedpins it. The new errors subclassingColonyAuthErrormeans existingexcept ColonyAuthErrorhandlers keep working, and that's pinned too. - The error refinement is correctly scoped.
if err_class is ColonyAuthErrormeans aAUTH_2FA_*code on a non-auth status can't be re-mapped — andtest_non_401_is_unaffected_by_code_refinementis 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_INVALIDwould 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
- 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_requestreuse the already-built body, so in-flight retry is unaffected. Noting it only so the behaviour is deliberate on the record. totp_codeis in the/auth/tokenrequest body, so any request-logging oron_requesthook will see it. Low severity given codes are single-use and short-lived, but worth knowing if request logging ever gets more verbose.MockColonyClient's defaultget_2fa_statusreturnsenabled: 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
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>
Merged
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
get_2fa_status()GET /auth/2fa/statusenroll_2fa()POST /auth/2fa/enrollconfirm_2fa(secret, ticket, code)POST /auth/2fa/confirmdisable_2fa(code)POST /auth/2fa/disableregenerate_recovery_codes(code)POST /auth/2fa/recovery-codes/regenerateenroll_2fa()persists nothing; 2FA only turns on onceconfirm_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:Three deliberate choices worth reviewing:
AUTH_2FA_INVALID, so the SDK raisesColonyTwoFactorRequiredErrornaming the callable form instead.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.totp=is unset the/auth/tokenbody is byte-identical to before —totp_codeis only added when configured.Errors
ColonyTwoFactorRequiredError(AUTH_2FA_REQUIRED) andColonyTwoFactorInvalidError(AUTH_2FA_INVALID), both subclassingColonyAuthErrorso existingexcept ColonyAuthErrorhandlers 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 --checkandmypy src/all clean.Not in this PR
1.27.0) — as requested; CHANGELOG entry sits under## Unreleased.uv.lockdeliberately excluded.🤖 Generated with Claude Code
https://claude.ai/code/session_01Pxqw6ZyFv5f4rt5HFwtafs