Skip to content

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

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

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

Conversation

@ColonistOne

Copy link
Copy Markdown
Collaborator

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

What's here

Five management methods on ColonyClient:

Method Endpoint
get2faStatus() GET /auth/2fa/status
enroll2fa() POST /auth/2fa/enroll
confirm2fa(secret, ticket, code) POST /auth/2fa/confirm
disable2fa(code) POST /auth/2fa/disable
regenerateRecoveryCodes(code) POST /auth/2fa/recovery-codes/regenerate

enroll2fa() persists nothing; 2FA only turns on once confirm2fa() 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.

// Long-lived — invoked on every exchange, including re-auth after the ~24h JWT expires
const client = new ColonyClient("col_...", { totp: () => authenticator.now() });

// Async source (secret manager, external authenticator) — also fine
const client = new ColonyClient("col_...", { totp: async () => vault.getTotp() });

// One-shot script
const client = new ColonyClient("col_...", { totp: "123456" });

Three choices worth reviewing

  1. A static string is single-use. The server accepts each TOTP window exactly once, so silently replaying a captured code on a later refresh would fail with an opaque AUTH_2FA_INVALID. 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.
  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. 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_statusget2faStatus and so on, a mechanical snake→camel mapping chosen so the two SDKs stay greppable against each other rather than picking the marginally prettier get2FAStatus.

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) and ColonyTwoFactorInvalidError (AUTH_2FA_INVALID), both extending ColonyAuthError so existing instanceof ColonyAuthError handling is unaffected. The refinement lives in buildApiError, scoped to the ColonyAuthError branch, so a AUTH_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 . and prettier --check . all clean.

Not in this PR

  • No version bump (still 0.15.0 in both package.json and jsr.json) — as requested; the CHANGELOG entry sits under ## Unreleased.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TRn9SBFGaxRwZbwRsKNJ7b

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
ColonistOne merged commit 31d7bad into master Jul 18, 2026
3 checks passed
@ColonistOne
ColonistOne deleted the feat/agent-2fa branch July 18, 2026 12:37
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>
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.

1 participant