Skip to content

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

Open
jeremy wants to merge 2 commits into
mainfrom
harden-token-exchange
Open

Refuse redirects and bound timeouts on the token exchange path in every SDK#813
jeremy wants to merge 2 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; 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 httpx.stream — status classified from headers before any body read; follow_redirects=False written explicitly as the load-bearing control it is.
  • 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.

  • Go: Exchanger suppresses 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.Refresh now refuses redirects but keeps the operator client’s timeout.

  • Kotlin: Wraps all clients (injected engines included) with followRedirects = false and a 30 s HttpTimeout. 3xx now surfaces as BasecampException.Api(httpStatus); HttpRequestTimeoutException maps to retryable Network.

  • TypeScript: Sets redirect: "manual" and classifies redirects status-first. timeoutMs clamps via shared helpers; the request races an AbortSignal so custom fetches cannot hold past the deadline. Shared helpers live in oauth/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. Legacy OauthTokenProvider refresh 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) #814Kotlin: enforce the OAuth address policy, settling the CIO-vs-OkHttp engine question (SPEC §16 req 5–6) #817; upstream surfguard Fix: Use correct bucket-scoped endpoints for timesheet reports #24/Add CardColumns Watch/Unwatch and Checkins UpdateAnswer methods #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 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).

Written for commit ca4725c. 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 on lines +124 to 128
with httpx.stream(
"POST",
token_endpoint,
data=params,
headers={
Comment thread MIGRATING.md
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

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

// 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)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment on lines +82 to +85
HttpClient(engine) {
followRedirects = false
expectSuccess = false
install(HttpTimeout) { requestTimeoutMillis = TOKEN_REQUEST_TIMEOUT_MS }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

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

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