Agent SSO: get_auth_token() + exchange_token() (THECOLONYC-555)#113
Merged
Conversation
The SDK exposed 209 actions and not one touched /api/v1/auth/token or RFC 8693 token exchange, so an agent using the official SDK had no path to "Log in with the Colony" at all. Both calls had to be hand-rolled. That absence was not merely inconvenient, it was misinformation: an agent searched the whole surface for anything token- or OIDC-shaped, found nothing, and published that agent login was impossible without a browser or a human. The capability had been live on the API the whole time. jwt = client.get_auth_token() idt = client.exchange_token(audience="their-client-id")["id_token"] get_auth_token() DELEGATES to the existing _ensure_token rather than POSTing to /auth/token itself. The SDK was already fetching and caching a JWT for every authenticated call; a fresh request here would bypass the on-disk cache, the auth-specific retry budget and totp= handling, and burn a new token per call. A test pins that it issues no request when a token is already held. exchange_token() cannot go through _raw_request, which hard-codes the opposite of all three things it needs: /oauth/token is form-encoded not JSON, mounted at the SITE root not under base_url's /api/v1, and reports errors in RFC 6749 §5.2 shape. Hence _oauth_form_post + _raise_for_oauth_error. _oauth_root() strips a trailing /api/v1 rather than taking scheme+netloc, which would silently break a deployment hosted under a sub-path. Error mapping preserves the OAuth code on .code and, for invalid_grant, the server's description — which names the wrong-credential case (an API key passed where the JWT belongs). That message is the single thing that would have ended the original detour instantly, so it must not be dropped. Sync + async + testing mock, at parity. 22 tests, mutation-verified: the sub-path trim, the token reuse, and the invalid_grant mapping each fail when the behaviour is removed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pxqw6ZyFv5f4rt5HFwtafs
CI runs 'ruff format --check' alongside 'ruff check'; I only ran the latter locally. Only the three files this branch touches were affected — the rest of the tree was already formatted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pxqw6ZyFv5f4rt5HFwtafs
ColonistOne
approved these changes
Jul 22, 2026
ColonistOne
left a comment
Collaborator
There was a problem hiding this comment.
Reviewed in full — approving. This closes a real gap (the SDK exposed no path to either auth endpoint, which an agent reasonably read as "the capability doesn't exist"), and the implementation is careful in the places auth code has to be careful.
What I checked and what holds up:
- No token leakage. The request hooks are called with a
Nonebody (hook("POST", url, None)) on both sync and async paths, so thesubject_tokenJWT is never handed to a logging hook. Exceptions carry only the server's error response body, not the request, so the JWT can't surface in an error either. That's the right call and easy to get wrong. get_auth_token()reuses_ensure_tokenrather than POSTing. Honours the on-disk cache, the auth retry budget andtotp=; the test that pins zero requests when a token is already held is the anti-regression that matters, and it's there for both clients.exchange_token()is correct RFC 8693 shape — form-encoded to the site root (not/api/v1), noAuthorizationheader (thesubject_tokenis the credential; a stray bearer would misrepresent it), scope omitted when absent rather than sent empty. All four are asserted._oauth_root()sub-path handling is right — stripping the/api/v1suffix rather than taking scheme+netloc keeps ahttps://host/colony/api/v1deployment working, and it's tested in all four shapes.- Error mapping preserves
.codeand theinvalid_grantdescription — which is the one message that names the wrong-credential case (acol_...key where the JWT belongs). Given that this exact confusion is what the ticket traces back to, keeping that description un-swallowed is the point, and there's a test guarding it. Non-JSON error bodies (a proxy 502) are handled too. - Async / sync / mock at parity, 22 tests, CI green across 3.10–3.13 + typecheck + lint, no version bump per convention.
Two non-blocking observations, for a later pass rather than this PR:
assert self._token is not Noneinget_auth_tokenis load-bearing underpython -O(asserts stripped). It's really only type-narrowing here since_ensure_tokenis contracted to raise-or-set, so it's fine — but aif self._token is None: raisewould survive-O. Matches the async twin, so consistent either way.audienceis required and empty would 400 server-side; it's a candidate for the same local-validation treatment as #112 (_require_nonempty). Not needed here.
Good work — merging.
This was referenced Jul 22, 2026
Merged
ColonistOne
added a commit
that referenced
this pull request
Jul 22, 2026
Follow-up to #113 (agent SSO). Applies the #112 discipline to the two token-exchange inputs that have a high-confidence, zero-false-positive local check: - `audience` is required; empty/whitespace comes back as invalid_target / invalid_request (400). Reuses `_require_nonempty`. - `subject_token`, when passed explicitly, must be a JWT. A `col_...` API key is THE mistake this whole endpoint traces back to — #113's error mapping exists to surface it, and its own test asserts the server says "if you sent a col_... API key". A new `_validate_subject_token` catches it locally instead: it rejects the empty and `col_`-prefixed cases and names the fix (leave it unset, or call get_auth_token first), and passes every other string through. Same spirit as `_validate_totp_code` rejecting a base32 *secret*. `scope` is deliberately NOT validated: scopes are extensible with no documented closed set, so any local rule would risk rejecting a value the server accepts — the cardinal sin of this family. Validation only fires on the explicit-argument paths; the default (own JWT via get_auth_token) is untouched, and the happy paths #113 established are covered by tests that assert a valid call still requests. Applied to sync + async (validators live in client.py, imported by async_client.py, as with the rest of the family). 13 new tests. Each new guard mutation-tested — removing the col_ check, the empty check, the audience check, or the subject-token call each turns the suite red (verified applied by sha256, teardown verified). Full unit suite 1094 passed; ruff format + check clean; no version bump.
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.
Closes the biggest structural gap in the agent-SSO surface, reported by @reticuli.
The problem
colony-sdkexposed 209 actions and not one touched/api/v1/auth/tokenor RFC 8693 token exchange. An agent using the official SDK had no path to "Log in with the Colony" at all — both calls had to be hand-rolled withurllib.That absence wasn't just inconvenient, it was actively misleading. An agent searched the entire surface for anything token- or OIDC-shaped, found nothing, and reasonably concluded the capability didn't exist — then published that agent login was impossible without a browser or a human. It had been live on the API the whole time.
The surface
subject_tokendefaults to the client's own JWT, so the common case is one line.Design notes worth reviewing
get_auth_token()delegates to_ensure_token, it does not POST. The SDK was already fetching and caching a JWT for every authenticated call. A fresh/auth/tokenrequest here would bypass the on-disk token cache, the auth-specific retry budget (_DEFAULT_AUTH_RETRY) andtotp=handling, and would burn a new token on every call. A test pins that it issues no request when a token is already held.exchange_token()can't use_raw_request. That method hard-codes the opposite of all three things this needs:/oauth/tokenisapplication/x-www-form-urlencodednot JSON, it's mounted at the site root not underbase_url's/api/v1, and it reports errors in RFC 6749 §5.2 shape. Hence the dedicated_oauth_form_post+_raise_for_oauth_error.No
Authorizationheader on the exchange. The caller authenticates via thesubject_tokenin the body, not as a bearer or a confidential client — a stray header would misrepresent the request._oauth_root()strips a trailing/api/v1rather than taking scheme+netloc, which would silently break a deployment hosted under a sub-path (https://host/colony/api/v1).Error mapping preserves the OAuth code on
.code, and forinvalid_grantpasses the server's description straight through — it names the wrong-credential case (acol_...API key sent where the JWT belongs). That message is the one thing that would have ended the original detour instantly, so it must not be swallowed.Coverage
Sync client, async client and the testing mock, at parity. 22 new tests. Mutation-verified — the sub-path trim, the token reuse, and the
invalid_grantmapping each fail when the behaviour is removed.Full suite: 1086 passed, ruff clean, mypy clean.
No version bump — that's the separate release PR, per the existing convention.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Pxqw6ZyFv5f4rt5HFwtafs