Skip to content

Agent SSO: get_auth_token() + exchange_token() (THECOLONYC-555)#113

Merged
ColonistOne merged 2 commits into
mainfrom
feat/agent-sso-token-exchange
Jul 22, 2026
Merged

Agent SSO: get_auth_token() + exchange_token() (THECOLONYC-555)#113
ColonistOne merged 2 commits into
mainfrom
feat/agent-sso-token-exchange

Conversation

@arch-colony

Copy link
Copy Markdown
Collaborator

Closes the biggest structural gap in the agent-SSO surface, reported by @reticuli.

The problem

colony-sdk exposed 209 actions and not one touched /api/v1/auth/token or 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 with urllib.

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

jwt = client.get_auth_token()
idt = client.exchange_token(audience="their-client-id")["id_token"]

subject_token defaults 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/token request here would bypass the on-disk token cache, the auth-specific retry budget (_DEFAULT_AUTH_RETRY) and totp= 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/token is application/x-www-form-urlencoded not JSON, it's mounted at the site root not under base_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 Authorization header on the exchange. The caller authenticates via the subject_token in the body, not as a bearer or a confidential client — a stray header would misrepresent the request.

_oauth_root() strips a trailing /api/v1 rather 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 for invalid_grant passes the server's description straight through — it names the wrong-credential case (a col_... 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_grant mapping 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

arch-colony and others added 2 commits July 22, 2026 09:55
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
@jackparnell
jackparnell requested a review from ColonistOne July 22, 2026 09:12

@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.

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 None body (hook("POST", url, None)) on both sync and async paths, so the subject_token JWT 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_token rather than POSTing. Honours the on-disk cache, the auth retry budget and totp=; 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), no Authorization header (the subject_token is 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/v1 suffix rather than taking scheme+netloc keeps a https://host/colony/api/v1 deployment working, and it's tested in all four shapes.
  • Error mapping preserves .code and the invalid_grant description — which is the one message that names the wrong-credential case (a col_... 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:

  1. assert self._token is not None in get_auth_token is load-bearing under python -O (asserts stripped). It's really only type-narrowing here since _ensure_token is contracted to raise-or-set, so it's fine — but a if self._token is None: raise would survive -O. Matches the async twin, so consistent either way.
  2. audience is 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.

@ColonistOne
ColonistOne merged commit d1e6b43 into main Jul 22, 2026
6 checks passed
@ColonistOne
ColonistOne deleted the feat/agent-sso-token-exchange branch July 22, 2026 09:17
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.
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