Refuse redirects and bound timeouts on the token exchange path in every SDK - #813
Refuse redirects and bound timeouts on the token exchange path in every SDK#813jeremy wants to merge 2 commits into
Conversation
…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.
9e04730 to
8b5cfb9
Compare
There was a problem hiding this comment.
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.
| with httpx.stream( | ||
| "POST", | ||
| token_endpoint, | ||
| data=params, | ||
| headers={ |
| ever sends — the authorization code, the client secret, or the refresh token — | ||
| and it was the last response-steerable hop that still followed redirects | ||
| anywhere (SPEC §16 "Token-Endpoint Transport Policy"; §14 established the | ||
| rule on the download's signed hop in #805). Go's `Exchanger` followed up to |
There was a problem hiding this comment.
💡 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".
| // A suppressed redirect is refused by status BEFORE any body read, so a | ||
| // 3xx whose body stalls forever cannot degrade into a timeout. Release | ||
| // the unread stream (non-blocking) so refusals don't retain sockets. | ||
| if (REDIRECT_STATUSES.includes(raced.status)) { |
There was a problem hiding this comment.
Handle opaque browser redirects before reading the body
In browser Fetch implementations, redirect: "manual" returns an opaqueredirect response with status 0, so this condition misses every token-endpoint redirect and falls into the body reader, producing a generic missing-length/parse api_error rather than the promised not followed classification. The SDK explicitly recognizes this browser behavior on the analogous path in typescript/src/download.ts; detect response.type === "opaqueredirect" here as a refused redirect as well (while accounting for the browser not exposing the real status).
Useful? React with 👍 / 👎.
| HttpClient(engine) { | ||
| followRedirects = false | ||
| expectSuccess = false | ||
| install(HttpTimeout) { requestTimeoutMillis = TOKEN_REQUEST_TIMEOUT_MS } |
There was a problem hiding this comment.
Reject injected engines that follow redirects internally
When the supplied HttpClient uses an engine that performs redirects internally, rebuilding a client around the same engine does not disable that engine-level behavior; followRedirects = false only prevents Ktor's client redirect plugin from initiating another request after an engine returns a 3xx. Such an engine can therefore follow a 307/308 and re-POST the authorization code, client secret, or refresh token before this code ever sees a response to classify. The injected-client path needs to construct or verify an engine with redirects disabled rather than reusing an opaque engine unchanged; the current MockEngine tests cannot expose this case.
Useful? React with 👍 / 👎.
|
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. |
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
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.Per SDK
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).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.redirect: "manual"+ status-first refusal;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.httpx.posttohttpx.stream— status classified from headers before any body read;follow_redirects=Falsewritten explicitly as the load-bearing control it is.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).Corrections recorded
Appendix F previously claimed Kotlin's exchange followed redirects, reasoned from source absence of
followRedirects = false. It never did (Ktor'sHttpRedirectdefaultscheckHttpMethod = 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 = falseis 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 sHttpTimeoutinstallation is asserted by code with the exception-mapping tested (runTest's virtual clock cannot advanceHttpTimeout'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
Authclassification of 3xx must move toApi.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
Locationis 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) with
followRedirects = falseand a 30 sHttpTimeout. 3xx now surfaces asBasecampException.Api(httpStatus);HttpRequestTimeoutExceptionmaps to retryableNetwork.TypeScript: Sets
redirect: "manual"and classifies redirects status-first.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: Uses
httpx.stream(..., follow_redirects=False)and classifies 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 Extend OAuth endpoint address enforcement beyond Go (SPEC §16 req 5–6): umbrella #818 (per-SDK Ruby: enforce the OAuth address policy by pinning resolved addresses in the default Fetcher transport (SPEC §16 req 5–6) #814–Kotlin: enforce the OAuth address policy, settling the CIO-vs-OkHttp engine question (SPEC §16 req 5–6) #817; upstream
surfguardFix: Use correct bucket-scoped endpoints for timesheet reports #24/Add CardColumns Watch/Unwatch and Checkins UpdateAnswer methods #25).Migration
WithExchangerTimeoutto adjust.Auth; they now surface asApiwith the real status.timeoutMsno longer instant-abort or run unbounded; custom fetches that ignoreAbortSignalno longer extend the exchange beyond its timeout.Written for commit ca4725c. Summary will update on new commits.