Add agent TOTP 2FA: management methods + totp for the token exchange#56
Merged
Conversation
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. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TRn9SBFGaxRwZbwRsKNJ7b
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>
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.
Ports the agent TOTP 2FA surface from the Python SDK (colony-sdk-python#105) to TypeScript.
What's here
Five management methods on
ColonyClient:get2faStatus()GET /auth/2fa/statusenroll2fa()POST /auth/2fa/enrollconfirm2fa(secret, ticket, code)POST /auth/2fa/confirmdisable2fa(code)POST /auth/2fa/disableregenerateRecoveryCodes(code)POST /auth/2fa/recovery-codes/regenerateenroll2fa()persists nothing; 2FA only turns on onceconfirm2fa()proves you can generate a valid code.confirm2fa()returns recovery codes once — they're the only self-service way back in, since API-key recovery deliberately doesn't clear 2FA.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.Three choices worth reviewing
AUTH_2FA_INVALID. The SDK raisesColonyTwoFactorRequiredErrornaming the callable form instead.totptakes 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.totpis unset the/auth/tokenbody is byte-identical to before —totp_codeis only added when configured. There's a test pinning that.One deliberate divergence from the Python port
The callable may be async —
() => string | Promise<string>.ensureToken()is already async so awaiting costs nothing, and in JS the code frequently comes from an async source. The Python equivalent is sync-only. Everything else mirrors it exactly, including method names:get_2fa_status→get2faStatusand so on, a mechanical snake→camel mapping chosen so the two SDKs stay greppable against each other rather than picking the marginally prettierget2FAStatus.Also worth noting: the Python PR updated its testing mock (
testing.py), but this SDK has no mock equivalent, so there's nothing to mirror there.Errors
ColonyTwoFactorRequiredError(AUTH_2FA_REQUIRED) andColonyTwoFactorInvalidError(AUTH_2FA_INVALID), both extendingColonyAuthErrorso existinginstanceof ColonyAuthErrorhandling is unaffected. The refinement lives inbuildApiError, scoped to theColonyAuthErrorbranch, so aAUTH_2FA_*code arriving on a non-auth status can't be re-mapped — there's a test for that.Testing
16 new tests in
tests/two-factor.test.ts, driven through the existing mock fetch rather than by reaching into privates, so what's asserted is what actually goes on the wire.Full suite: 403 passed, 50 skipped.
tsc --noEmit,eslint .andprettier --check .all clean.Not in this PR
0.15.0in bothpackage.jsonandjsr.json) — as requested; the CHANGELOG entry sits under## Unreleased.🤖 Generated with Claude Code
https://claude.ai/code/session_01TRn9SBFGaxRwZbwRsKNJ7b