Refuse redirects and bound timeouts on the token exchange path in every SDK
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 theLocationis 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:
doTokenRequestcarries the POST on anoRedirectClientcopy (injected clients included), classifies redirects before the body read, and runs under a child-context deadline — 30 s default, newWithExchangerTimeout(ceiling = the shared device ceiling).AuthManager.refreshLockedgains suppression + classification but no timeout (it runs on the operator-configured API client). - Kotlin:
postTokenRequestnow wraps every client (injected engines re-wrapped, thehardenedDeviceClientpattern) withfollowRedirects = false+ 30 sHttpTimeout; a 3xx isBasecampException.Apiwith the real status where it previously fell into the genericAuthbranch with the status lost;HttpRequestTimeoutExceptionmaps to retryableNetwork. - TypeScript:
redirect: "manual"+ status-first refusal (a browser'sopaqueredirectis refused by type, status unavailable);timeoutMsnow 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 ignoresAbortSignalcannot hold the exchange past its deadline.raceAbort/the timeout resolver moved tooauth/limits.ts; the device flow delegates. - Python: exchange moved from
httpx.postto the sharedrequest_boundedtransport — total wall-clock deadline (httpx's own timeout is per I/O phase), streaming 1 MiB cap, redirect statuses skipped viaread_bodyso they classify from the headers with the body unread. - Ruby: the default lane moved to the headers-first
Fetcher.stream_httptransport; an injected Faraday client is vetted byensure_redirects_suppressed!and bounded by the device flow's full wall-clock discipline; the constructor timeout normalizes (3600 s ceiling). The legacyOauthTokenProvider.perform_refresh, previously a bare unboundedFaraday.post, gets the full contract (stream_http, 30 s, redirect refusal asApiErrorwith 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.
- Go:
Exchangersuppresses redirects via a shallow no-redirect client and adds a child-context timeout (30 s default;WithExchangerTimeout, ceiling 3600 s). Redirects classify before reads.AuthManager.Refreshnow refuses redirects but keeps the operator client's timeout. - Kotlin: Wraps all clients (injected engines included, governing Ktor's client-level redirect plugin) with
followRedirects = falseand a 30 sHttpTimeout. 3xx now surfaces asBasecampException.Api(httpStatus);HttpRequestTimeoutExceptionmaps to retryableNetwork. - TypeScript: Sets
redirect: "manual", classifies redirects status-first, and refuses a browser's status-hiddenopaqueredirectby type; "not followed" — not the status field — is the portable contract across runtimes.timeoutMsclamps via shared helpers; the request races anAbortSignalso custom fetches cannot hold past the deadline. Shared helpers live inoauth/limits.ts; device flow imports them. - Python: Exchange rides the shared
request_boundedtransport — total wall-clock deadline, 1 MiB streaming cap — withfollow_redirects=False, classifying redirects from headers before any body read. - Ruby: Default lane uses headers-first
Fetcher.stream_http; injected Faraday clients are vetted for redirect suppression and run under a wall-clock deadline; constructor timeout normalizes. LegacyOauthTokenProviderrefresh now uses the same transport, 30 s bound, and redirect refusal. - SPEC: Adds §16 Token-Endpoint Transport Policy. Appendix F retracts the earlier claim that Kotlin followed redirects and records the closed defects. Tracks cross-SDK enforcement in #818 (per-SDK #814–#817; upstream
surfguard#24/#25).
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
WithExchangerTimeoutto adjust. - Kotlin: Update catch-sites that treated 3xx as
Auth; they now surface asApiwith the real status. - TypeScript: Invalid
timeoutMsno longer instant-abort or run unbounded; custom fetches that ignoreAbortSignalno longer extend the exchange beyond its timeout. - Ruby: Injected Faraday connections must be adapter-only (no redirect middleware); oversized exchange responses now raise
OauthErrorinstead ofApiError.