Skip to content

Refuse redirects and bound timeouts on the token exchange path in every SDK - #813

Merged
jeremy merged 5 commits into
mainfrom
harden-token-exchange
Aug 31, 2026
Merged

Refuse redirects and bound timeouts on the token exchange path in every SDK#813
jeremy merged 5 commits into
mainfrom
harden-token-exchange

Conversation

@jeremy

@jeremy jeremy commented Aug 23, 2026

Copy link
Copy Markdown
Member

The token-exchange/refresh POST carries the highest-value credentials the SDK ever sends — the authorization code, the client secret, or the refresh token — and after #809 established "no response-steered hop follows redirects" (SPEC §14) and #810 policed the endpoint addresses, it remained the one response-steerable hop that still followed redirects (Go: 10 hops, 307/308 re-POSTing the form; TS: 20) and ran unbounded in Go. This PR makes the exchange path uniform with the device flow and states the contract as SPEC §16 Token-Endpoint Transport Policy [static].

The contract, in every SDK with an exchange path

  • A 301/302/303/307/308 from the token endpoint surfaces as the SDK's typed API error carrying that status, message redirect {status} on the token endpoint is not followed (substring contract: not followed, same as §14), and the Location is never dialled. Any other 3xx (304 above all) stays the generic non-2xx failure.
  • Classification precedes the body read on default transports: a 302 whose body stalls forever is the typed refusal, never a timeout. The one narrowed lane is Ruby's injected-Faraday client (buffered by nature) — it keeps the injected-client fidelity tier's wall-clock-bounded, completed-response classification, stated in the SPEC subsection.
  • Timeouts converge on 30 s default / 3600 s ceiling / invalid-normalizes-to-default, the numbers every other credential POST already uses.
  • Suppression rides injected clients too (device-flow precedent); the §16 address policy deliberately does not — the distinction is stated in SPEC.

Per SDK

  • Go: doTokenRequest carries the POST on a noRedirectClient copy (injected clients included), classifies redirects before the body read, and runs under a child-context deadline — 30 s default, new WithExchangerTimeout (ceiling = the shared device ceiling). AuthManager.refreshLocked gains suppression + classification but no timeout (it runs on the operator-configured API client).
  • Kotlin: postTokenRequest now wraps every client (injected engines re-wrapped, the hardenedDeviceClient pattern) with followRedirects = false + 30 s HttpTimeout; a 3xx is BasecampException.Api with the real status where it previously fell into the generic Auth branch with the status lost; HttpRequestTimeoutException maps to retryable Network.
  • TypeScript: redirect: "manual" + status-first refusal (a browser's opaqueredirect is refused by type, status unavailable); timeoutMs now clamps through the shared resolver (NaN/Infinity no longer instant-abort or unbound); the whole round trip races the abort signal, so a custom fetch that ignores AbortSignal cannot hold the exchange past its deadline. raceAbort/the timeout resolver moved to oauth/limits.ts; the device flow delegates.
  • Python: exchange moved from httpx.post to the shared request_bounded transport — total wall-clock deadline (httpx's own timeout is per I/O phase), streaming 1 MiB cap, redirect statuses skipped via read_body so they classify from the headers with the body unread.
  • Ruby: the default lane moved to the headers-first Fetcher.stream_http transport; an injected Faraday client is vetted by ensure_redirects_suppressed! and bounded by the device flow's full wall-clock discipline; the constructor timeout normalizes (3600 s ceiling). The legacy OauthTokenProvider.perform_refresh, previously a bare unbounded Faraday.post, gets the full contract (stream_http, 30 s, redirect refusal as ApiError with the status).
  • Swift: no exchange path — no change (SPEC already records this).

Corrections recorded

Appendix F previously claimed Kotlin's exchange followed redirects, reasoned from source absence of followRedirects = false. It never did (Ktor's HttpRedirect defaults checkHttpMethod = true; CIO doesn't follow at engine level) — the appendix now retracts that explicitly and names Kotlin's real defects, all closed here. The non-uniformity paragraph collapses to the uniform state.

Conformance scope

Per-SDK unit tests only; the subsection is [static]. The oauth-token corpus schema is resource-semantics-scoped (a response is status + body); one redirect case would need headers/redirect/never-dialled vocabulary — instrument stretching, stated in one SPEC sentence. (The corpus has no 3xx fixtures, so no expected classification changed.)

Tests

Five-status table over BOTH exchange and refresh in every SDK, plus: 304-stays-generic, attacker-Location-never-dialled counters (the attacker serves a usable token, proving refusal stopped the chain), stalled-body-302 headers-first classification (Go, Python, Ruby live-socket), timeout normalization tables, live-timeout bounds, TS's never-settling signal-ignoring fetch, Ruby injected-follower refusal at construction + narrowed-lane pins, and the legacy provider's redirect/no-mutation-on-refusal tests. Every refusal was mutation-verified (suppression/classification reverted → tests fail; restored → green).

Two honest gaps, on the record: Kotlin's followRedirects = false is defense-in-depth no in-repo engine can falsify (Ktor never follows POSTs regardless — the status-first classification is what the tests kill); and Kotlin's 30 s HttpTimeout installation is asserted by code with the exception-mapping tested (runTest's virtual clock cannot advance HttpTimeout's real-dispatcher timer).

Migration

MIGRATING.md "Unreleased" entry: no knob to re-enable following; Go's ctx-less callers now get a 30 s default; Kotlin catch-sites keyed to the old Auth classification of 3xx must move to Api.


Summary by cubic

Refuses token-endpoint redirects and bounds exchange/refresh timeouts across all SDKs. Previously Go/TS followed redirects (Go up to 10 hops, TS up to 20) and Go's exchange could run unbounded; now 301/302/303/307/308 return a typed API error containing "not followed," classification happens before any body read, defaults are 30 s with a 3600 s ceiling (invalid values normalize), and injected clients also refuse redirects (the Location is never dialed). 304 stays on the generic non-2xx path.

Migration

  • There is no knob to re-enable redirect following; configure token endpoints that answer directly.
  • Go: Calls without a context deadline now time out at 30 s; use WithExchangerTimeout to adjust.
  • Kotlin: Update catch-sites that treated 3xx as Auth; they now surface as Api with the real status.
  • TypeScript: Invalid timeoutMs no longer instant-abort or run unbounded; custom fetches that ignore AbortSignal no longer extend the exchange beyond its timeout.
  • Ruby: Injected Faraday connections must be adapter-only (no redirect middleware); oversized exchange responses now raise OauthError instead of ApiError.

Written for commit b3e398e. Summary will update on new commits.

Review in cubic

Copilot AI balanced review requested due to automatic review settings August 23, 2026 02:30
…ry SDK

The token-exchange/refresh POST carries the authorization code, the client
secret, or the refresh token, and it was the last response-steerable hop
that still followed redirects (Go: ten hops, a 307/308 re-POSTing the form;
TS: twenty) and the last credential POST unbounded in Go. Every SDK with an
exchange path now refuses 301/302/303/307/308 with the typed api_error
carrying the real status and the SPEC §14 "not followed" message, classified
before any body read, on injected clients too; timeouts converge on the
shared 30 s default / 3600 s ceiling / normalize-to-default rule (new Go
WithExchangerTimeout; AuthManager refresh gains suppression and
classification but keeps the operator-configured client's timeout). Ruby's
default lane moves to the headers-first Fetcher.stream_http transport and
its legacy OauthTokenProvider gets the full contract; the injected-Faraday
lane keeps the injected-client fidelity tier, stated in the new SPEC §16
"Token-Endpoint Transport Policy" subsection. Appendix F retracts the wrong
claim that Kotlin's exchange followed redirects (Ktor never follows POSTs)
and records Kotlin's real defects — a 3xx thrown as Auth with the status
lost, no HttpTimeout, injected clients used verbatim — all closed here.
@github-actions github-actions Bot added typescript Pull requests that update TypeScript code ruby Pull requests that update the Ruby SDK go kotlin python Pull requests that update the Python SDK labels Aug 23, 2026
@jeremy
jeremy force-pushed the harden-token-exchange branch from 9e04730 to 8b5cfb9 Compare August 23, 2026 02:30
The cross-SDK enforcement work now has an umbrella (#818), per-SDK issues
(#814-#817), and upstream surfguard issues (surfguard#24/#25); Appendix F's
requirement-5/6 sections name them so the follow-ups are discoverable from
the normative record.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Standardizes token-endpoint redirect refusal and timeout handling across Go, Kotlin, TypeScript, Python, and Ruby.

Changes:

  • Refuses credential-bearing redirects with typed errors.
  • Adds or normalizes 30-second request bounds.
  • Documents and tests the cross-SDK transport policy.

Tip

If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

Reviewed changes

Copilot reviewed 22 out of 22 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
SPEC.md Defines the token-endpoint transport policy.
MIGRATING.md Documents behavior and migration impacts.
go/pkg/basecamp/oauth/exchange.go Adds redirect refusal and request deadlines.
go/pkg/basecamp/oauth/oauth_test.go Tests exchange transport policy.
go/pkg/basecamp/auth.go Refuses redirects during managed refresh.
go/pkg/basecamp/auth_test.go Tests managed-refresh refusal.
kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Exchange.kt Hardens token clients and error mapping.
kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/OAuthTest.kt Tests redirects and timeout classification.
typescript/src/oauth/limits.ts Centralizes timeout and abort helpers.
typescript/src/oauth/exchange.ts Adds manual redirects and bounded awaiting.
typescript/src/oauth/device.ts Reuses shared OAuth limits.
typescript/tests/oauth/oauth.test.ts Tests redirects and timeout normalization.
python/src/basecamp/oauth/exchange.py Adds headers-first redirect classification.
python/tests/oauth/test_exchange.py Tests Python redirect refusal.
ruby/lib/basecamp/oauth/fetcher.rb Generalizes injected-client validation text.
ruby/lib/basecamp/oauth/exchange.rb Introduces hardened exchange transports.
ruby/lib/basecamp/oauth_token_provider.rb Hardens legacy refresh requests.
ruby/test/basecamp/security_test.rb Updates body-limit error expectations.
ruby/test/basecamp/oauth_transport_test.rb Tests headers-first classification.
ruby/test/basecamp/oauth_test.rb Tests timeout normalization.
ruby/test/basecamp/oauth_ssrf_test.rb Tests redirect and injected-client handling.
ruby/test/basecamp/auth_test.rb Tests legacy refresh transport policy.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread python/src/basecamp/oauth/exchange.py Outdated
Comment thread MIGRATING.md Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9e04730bdc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread typescript/src/oauth/exchange.ts
Comment thread kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/Exchange.kt
@jeremy

jeremy commented Aug 23, 2026

Copy link
Copy Markdown
Member Author

Consumer-side counterpart is up: basecamp/basecamp-cli#656 makes the #804/#810 address policy live in the CLI (pinned to v0.15.0; its skipped redirect-status-survival test un-skips at the re-pin past this PR). Cross-SDK enforcement tracking: #818.

…aqueredirects

Review round on #813:

- Python: the exchange posted through a bare httpx.stream, whose timeout is
  per I/O phase and whose read() buffers the whole body before the 1 MiB
  check. Route it through the shared request_bounded transport instead —
  total wall-clock deadline, streaming size cap, redirect statuses skipped
  via read_body so they still classify from the headers with the body
  unread. _parse_token_response now takes (status, body).
- TypeScript: a browser fetch answers redirect: "manual" with an
  opaqueredirect (type "opaqueredirect", status 0), which the status table
  could not see — refuse it by type, without a status to carry.
- MIGRATING: the signed-hop rule shipped in #809, not issue #805.
- Kotlin/SPEC/MIGRATING: state that the engine re-wrap governs Ktor's
  client-level redirect plugin only; every Ktor engine defaults engine-level
  following off, so an injected engine must not opt in (the Kotlin
  counterpart of Ruby's adapter-only rule).
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-08-31T18:38:49.429443Z b3e398e New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@jeremy
jeremy requested a balanced review from Copilot August 29, 2026 06:15
@jeremy

jeremy commented Aug 29, 2026

Copy link
Copy Markdown
Member Author

@codex review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated 1 comment.

Suppressed comments (3)

Previously missed (3) — in code that hasn't changed since the last review.

go/pkg/basecamp/oauth/exchange.go:28

  • This public type comment says every 3xx is a typed api_error, but the implementation and SPEC intentionally keep 304 and other non-redirect 3xx on generic handling. Narrow the wording to the five refused redirect statuses.
// [NewExchanger] for the overrides. It never follows a redirect from the
// token endpoint — a 3xx surfaces as a typed api_error — and bounds each
// request at [WithExchangerTimeout]'s deadline (30 s by default).

go/pkg/basecamp/oauth/exchange.go:82

  • The sentence immediately above says http.DefaultClient restores pre-policy behavior “outright,” which now contradicts this new contract: only address enforcement is disabled; redirect suppression and the exchanger timeout remain active. Update the constructor documentation so callers do not expect the old redirect/timeout behavior.
// Redirect suppression is not the address policy and rides every lane: the
// token endpoint's redirects are refused on an injected client too, via a
// per-request shallow copy that never mutates the caller's client — the same
// contract as the device flow's POSTs.

MIGRATING.md:63

  • The accompanying test records another Ruby migration: oversized exchange responses now raise Basecamp::Oauth::OauthError instead of Basecamp::ApiError. Because existing rescue Basecamp::ApiError handlers no longer catch this failure, document that exception-taxonomy break in the Ruby migration bullet.
- **Ruby**: the `Exchange` constructor's `timeout:` is normalized (ceiling
  3600 s), the default lane moved to the headers-first `Fetcher.stream_http`
  transport, and an injected Faraday client is now vetted for redirect
  middleware and bounded by a wall-clock deadline — a slow-drip body can no
  longer hold the request open. The legacy `OauthTokenProvider` refresh,

Comment thread python/src/basecamp/oauth/exchange.py
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

Reviewed commit: b62c5f639d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

…s overclaiming

request_bounded's oversized-body OAuthError dropped the http_status the
buffered pre-transport path used to carry, even though the cap can only
trip on a status read_body already admitted — restore it (discovery and
the device flow inherit the enrichment) and pin it in the exchange cap
test. Two doc corrections from review: the Exchanger type comment claimed
every 3xx is a typed api_error where only the five refused statuses are
(304 stays generic), and NewExchanger's http.DefaultClient sentence
promised the pre-policy behavior "outright" when redirect refusal and the
timeout survive every client choice. MIGRATING's Ruby bullet now records
the taxonomy break the streaming cap introduced: an oversized Exchange
response raises Oauth::OauthError where it raised ApiError.
@jeremy

jeremy commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

Fixed the three suppressed review comments in 0f2d31e: the Exchanger type comment now names the five refused statuses instead of claiming every 3xx (304 and the rest stay on the generic non-200 path, matching SPEC and the implementation); NewExchanger's http.DefaultClient sentence no longer promises the pre-policy behavior "outright" — it now says only the address policy is switched off while redirect refusal and the request timeout survive every client choice; and MIGRATING's Ruby bullet records the exception-taxonomy break for oversized Exchange responses (streaming cap raises Basecamp::Oauth::OauthError where the post-hoc check raised Basecamp::ApiError).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0f2d31eac9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread SPEC.md
…ntract

SPEC's Token-Endpoint Transport Policy promised every refused redirect a
typed error carrying its status, but a browser Fetch answers
redirect: "manual" with an opaqueredirect whose status the browser hides —
TypeScript's browser branch deliberately throws without httpStatus. Name
that carrier exception in the contract and in MIGRATING's TypeScript
bullet: the "not followed" substring is the portable contract, the status
field is not, so cross-runtime consumers must not key redirect handling
on it.
@jeremy
jeremy merged commit 5294568 into main Aug 31, 2026
49 checks passed
@jeremy
jeremy deleted the harden-token-exchange branch August 31, 2026 18:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

go kotlin python Pull requests that update the Python SDK ruby Pull requests that update the Ruby SDK typescript Pull requests that update TypeScript code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants