Repository navigation
Description
Background
stack-auth is the Rust crate that gets the bearer tokens CipherStash services need: ZeroKMS, our key service, and CTS, our token service. Its AutoRefresh engine caches a token and refreshes it before or after it expires. Callers share one strategy across every request, and concurrent callers wait on a single refresh rather than starting their own.
Several strategies sit on top of AutoRefresh:
AccessKeyStrategy: exchanges a long-lived access key.OidcFederationStrategy: exchanges a JWT from an identity provider.DeviceSessionStrategy: uses a rotating OAuth refresh token from a device-code login, the~/.cipherstash/auth.jsonprofile.AutoStrategyfalls back to it when there is no access key.
In async Rust, cancelling a call means dropping its future. That happens routinely: tokio::time::timeout, tokio::select!, or a web framework such as axum dropping a request handler when the client disconnects.
Problem
One cancelled refresh locks DeviceSessionStrategy out until the process restarts.
AutoRefresh::get_tokentakes the refresh token out of shared state before it starts the refresh:try_credentialcallstake_refresh_token()(auto_refresh.rs:361,device_session_refresher.rs:57-58).- The refresh token is put back only on the error paths (
restore,auto_refresh.rs:552-556and:609-613). - If the future is dropped at any
.awaitin between, the refresh token is gone from memory. That covers the HTTP call to the token endpoint, saving the new token, and taking the state lock.CancelGuard::drop(auto_refresh.rs:164-171) only clears the in-progress flag and wakes waiters. - After that,
try_credentialreturnsNoneon every call.- On the blocking path (token already expired), every later call returns
Expiredimmediately. - On the non-blocking path (token still usable), callers get the old token until it expires, then
Expired. - It never retries.
DeviceSessionStrategyis built with an in-memory token and no store (device_session_strategy.rs:228,272), so nothing reloadsauth.json.
- On the blocking path (token already expired), every later call returns
Impact. A Rust service that uses device-session credentials, directly or through AutoStrategy, loses authentication for every request and tenant after one cancelled request lands on a refresh. A user closing a tab is enough. Every ZeroKMS call then fails until the service is restarted.
Worse case: the refresh token may already be spent. This one is plausible, not yet tested. If the refresh request reached the server before the drop, the refresh token has rotated upstream, but the new one was never saved: persist_refreshed didn't run, device_session_refresher.rs:65-114. auth.json still holds the old, spent token. A restart replays it, which by the code's own comment trips the identity provider's reuse detection and revokes the whole chain (device_session_refresher.rs:66-72). The user then has to log in again.
Why nothing catches it. The cancellation tests in auto_refresh.rs:1869-2033 (given_cancelled_refresh) abort a refresh but only assert that the next caller doesn't hang. The blocking-path test says so in its comment: "The credential is lost (refresh token was taken before the HTTP call), so the result is Expired" (:1898-1900).
Who is affected
| Consumer | Exposed? |
|---|---|
Rust code calling stack-auth / stack-encrypt with DeviceSessionStrategy or AutoStrategy |
Yes, verified |
AccessKeyStrategy, OidcFederationStrategy |
No. Their credential is (), and the key or JWT is still held, so the next call refreshes again (verified by test) |
@cipherstash/auth (Node, napi-rs) |
No. Calls are plain async fn with no timeout or select!, and JavaScript can't cancel them |
@cipherstash/protect-ffi |
No. Neon spawns each call detached; no timeout or select! in protect-ffi or cipherstash-client |
| Go SDK guests | No. The auth guest drives AutoRefresh under block_on, which always completes. The Go-side problem, where the whole instance is killed on cancel, is #1169 |
Reproduced
In a throwaway test with a mock refresher that takes its credential the same way DeviceSessionRefresher does, the first call was cancelled with tokio::time::timeout:
- Rotating, blocking path: the next three calls each returned
Err(Expired). The refresher was called once only. - Rotating, non-blocking path:
Ok(old token)until the clock passed expiry, thenErr(Expired), with no retry. - Non-rotating (access-key shape): the next call refreshed and returned the new token.
Proposal
The behaviour we want: cancelling a caller cancels only that caller's wait. The refresh it started either finishes, with the new token saved and installed, or fails and puts the credential back.
-
Main fix, native targets. Run the whole refresh (the exchange, saving the new token, installing it, waking waiters) in a spawned task that callers only await. A dropped caller then gives up its wait, and the rotation is still saved. That covers the already-rotated case too. This needs the engine's shared parts behind an
Arc. On wasm, where the engine can't spawn, hosts drive it withblock_on, which already runs to completion. -
Smaller step that could ship first. Have
CancelGuardhold the credential and put it back on drop. This fixes the in-memory lock-out, but not the case where the token already rotated upstream. -
Close the related hang.
CancelGuardwakes waiters without the state lock, so a caller enteringwait_for_in_flight_refreshat the moment of a cancel can wait forever (auto_refresh.rs:148-157, which already calls this out). Notify under the state lock, or bound the wait. -
Tests that check the outcome, not just that nothing hangs. After a cancelled refresh on each path, the next call gets a valid token. That includes a cancel after the token endpoint has answered: the rotated token must be saved and used.
-
Soak tests. Step 4 pins single cases; a soak runs the overlap a real service sees.
- Setup:
- One shared
AutoRefreshon a mock refresher that rotates its refresh token. - A mock identity provider that revokes the whole chain when a refresh token is reused, as the real one does.
- Random latency and errors.
- Hundreds of tokio tasks calling
get_tokenin a loop, each call wrapped in a randomtimeoutor a randomselect!cancel. - Tokio's paused clock and short token lifetimes, so hours of expiry and refresh cycles run in seconds.
- One shared
- Invariants:
- every call that is not cancelled gets a valid token;
- the identity provider never sees a reused refresh token;
- the saved token is always the newest;
- no caller waits past a fixed bound, which also catches the hang in step 3.
- When it runs:
- Every PR: a short seeded run.
- Release time: a long run before a new
stack-authis published. That is a jobrelease-plz.yml'srelease-crateswaits on, plus a run on the Version Packages PR (changeset-release/main), because@cipherstash/authships this crate. - Recommended: the long run on PRs that touch
auto_refresh.rsor the refreshers too, so a regression shows up at the commit that caused it.
- Setup:
Relationship to other work
- Go SDK: one cancelled or timed-out call closes the shared encrypt.Client for every later caller #1169 is the Go SDK's version of the same theme: a single cancelled call breaks a client shared by every caller. The mechanisms are different, so the fixes are separate.
Activity
test(auth): run native guest exchange under Tokio
test(auth): cover token expiry boundaries in refresh
fix(stack-auth)!: finish token refreshes after caller cancellation
test(auth): run native guest exchange under Tokio
test(auth): cover token expiry boundaries in refresh
fix(stack-auth)!: finish token refreshes after caller cancellation
test(auth): run native guest exchange under Tokio
test(auth): cover token expiry boundaries in refresh
fix(stack-auth)!: finish token refreshes after caller cancellation
test(auth): run native guest exchange under Tokio
test(auth): cover token expiry boundaries in refresh