Single-flight and order the Fjord Account token refresh #273
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/272-fjord-refresh-lock"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Paragon rotates the Fjord Account refresh token on every exchange and treats a replay as theft, so it revokes the whole token family.
exchange_refreshdocuments that contract but nothing defended either failure mode it introduces, and both end with the user signed out whilehosts.tomland the token store still look healthy.Concurrent processes raced. Every invocation refreshes reactively on a 401, so two commands running at once both loaded the same refresh token and both POSTed it; the second was a replay that killed the first one's freshly minted token. A shell prompt hook plus an interactive command is enough to hit it. Refreshes now take an advisory file lock (
src/fjord/lock.rs,std::fs::File::try_lock) keyed per platform URL and held across the exchange and the persist. A process that waited re-reads the bearer and returns whatever the winner stored rather than spending a token that is already gone. The lock file name collapses every non-alphanumeric byte of the platform URL to-, so it can contribute neither a separator nor a..hop.Persistence was unordered and did not abort. The new access bearer could land while the rotated refresh token did not, leaving a session that works until it expires and then cannot be renewed at all.
store_tokensnow writes the refresh record first and returns before touching the bearer if that write fails, so an interruption between the two leaves the previous, still-valid pair intact and costs one wasted exchange. The decision of what to write (store the rotation, keep a record that still belongs to this account, or drop one left by another) is split into a pureplan_refresh_write, and the two writes are injected intostore_tokens_with, which is how the ordering is tested without a keychain.The issue proposed two-slot rotation for this. It does not fit a rotating OP: the old token is spent the moment the exchange returns, so there is no "promote once used successfully" state to reach, and holding it is exactly the replay risk. Ordering is what closes the window. The issue also said a keychain write failure loses the token, which is wrong,
store_token_with_entry_and_file_pathalready falls back to a 0600 file. Both corrections are on #272.Last, a spent grant reported itself by dumping the raw JSON body.
invalid_grantis now a typed terminal error,fjord::UnrenewableGrant, and sincemainprints only the outermost error unless--debugis set,error_headlinelifts its message out of the chain. Callers keep their own.context()for the debug trace. Before, the sentence the user needed was the thirdcaused by:line and invisible by default.Verified against a mock OP that rotates and detects replay: two concurrent processes on an expired access token produce 2 token-endpoint exchanges on 0.5.0 and 1 here, and a dead grant now prints the recovery as the first line with no flags.
Refs #272.
Verified on macOS:
cargo fmt --allclean,cargo clippy --all-targets --all-features -- -D warningsclean,cargo test --allgreen (817 + 5 + 1),cargo auditclean after the rustls bump.make coverageputs the touched files at 89% (lock.rs) and 93% (oidc.rs) lines.Two notes outside the fix. The second commit bumps rustls 0.23.40 to 0.23.45 for RUSTSEC-2026-0285, lockfile only; the pre-push audit gate fails without it. And
make coverage-strictreports 2.5% on my machine undernightly-2026-08-07while the tests all run and stablemake coveragereports 66%, so the nightly tier looks broken independently of this branch and I could not use it as a gate here.