diff --git a/solitaire_data/src/sync_client.rs b/solitaire_data/src/sync_client.rs index d2a5122..a3cbd80 100644 --- a/solitaire_data/src/sync_client.rs +++ b/solitaire_data/src/sync_client.rs @@ -77,6 +77,11 @@ pub struct SolitaireServerClient { username: String, /// Shared `reqwest` client (keeps connection pools alive across calls). client: reqwest::Client, + /// Serialises token refreshes. The server rotates refresh tokens and each + /// is single-use, so two overlapping 401-retries must not both spend one: + /// the loser's (already-consumed) token would be rejected and surface as + /// a spurious "session expired" to the player. See [`Self::refresh_token`]. + refresh_lock: tokio::sync::Mutex<()>, } #[cfg(not(target_arch = "wasm32"))] @@ -90,6 +95,7 @@ impl SolitaireServerClient { base_url: base_url.into().trim_end_matches('/').to_owned(), username: username.into(), client: reqwest::Client::new(), + refresh_lock: tokio::sync::Mutex::new(()), } } @@ -172,7 +178,27 @@ impl SolitaireServerClient { /// The server rotates refresh tokens on each call: the response includes a /// new refresh token that replaces the old one. Both tokens are persisted /// to the OS keychain on success. - async fn refresh_token(&self) -> Result<(), SyncError> { + /// + /// `stale_access` is the access token that just earned the caller a 401. + /// Refreshes are serialised behind [`Self::refresh_lock`]: with single-use + /// rotated refresh tokens, two overlapping 401-retries (e.g. a replay + /// upload racing a manual sync) must not both call `/api/auth/refresh` — + /// the second call would present an already-consumed token, get rejected, + /// and force a re-login for no user-visible reason. Whoever loses the lock + /// race checks whether the stored access token has already moved past + /// `stale_access`; if so the refresh already happened and there is nothing + /// left to do. + async fn refresh_token(&self, stale_access: &str) -> Result<(), SyncError> { + let _guard = self.refresh_lock.lock().await; + + if let Ok(current) = load_access_token(&self.username) + && current != stale_access + { + // Another task refreshed while we waited on the lock; retry with + // the token it stored rather than spending the new refresh token. + return Ok(()); + } + let old_refresh = load_refresh_token(&self.username).map_err(|e| SyncError::Auth(e.to_string()))?; @@ -231,7 +257,7 @@ impl SyncProvider for SolitaireServerClient { if resp.status() == reqwest::StatusCode::UNAUTHORIZED { // Token expired — refresh and retry once. - self.refresh_token().await?; + self.refresh_token(&token).await?; let new_token = self.access_token()?; let resp = self .client @@ -264,7 +290,7 @@ impl SyncProvider for SolitaireServerClient { if resp.status() == reqwest::StatusCode::UNAUTHORIZED { // Token expired — refresh and retry once. - self.refresh_token().await?; + self.refresh_token(&token).await?; let new_token = self.access_token()?; let resp = self .client @@ -331,7 +357,7 @@ impl SyncProvider for SolitaireServerClient { .map_err(|e| SyncError::Network(e.to_string()))?; if resp.status() == reqwest::StatusCode::UNAUTHORIZED { - self.refresh_token().await?; + self.refresh_token(&token).await?; let new_token = self.access_token()?; let resp = self .client @@ -366,7 +392,7 @@ impl SyncProvider for SolitaireServerClient { .map_err(|e| SyncError::Network(e.to_string()))?; if resp.status() == reqwest::StatusCode::UNAUTHORIZED { - self.refresh_token().await?; + self.refresh_token(&token).await?; let new_token = self.access_token()?; let resp = self .client @@ -406,7 +432,7 @@ impl SyncProvider for SolitaireServerClient { .map_err(|e| SyncError::Network(e.to_string()))?; if resp.status() == reqwest::StatusCode::UNAUTHORIZED { - self.refresh_token().await?; + self.refresh_token(&token).await?; let new_token = self.access_token()?; let resp = self .client @@ -446,7 +472,7 @@ impl SyncProvider for SolitaireServerClient { .map_err(|e| SyncError::Network(e.to_string()))?; if resp.status() == reqwest::StatusCode::UNAUTHORIZED { - self.refresh_token().await?; + self.refresh_token(&token).await?; let new_token = self.access_token()?; let resp = self .client @@ -480,7 +506,7 @@ impl SyncProvider for SolitaireServerClient { .map_err(|e| SyncError::Network(e.to_string()))?; if resp.status() == reqwest::StatusCode::UNAUTHORIZED { - self.refresh_token().await?; + self.refresh_token(&token).await?; let new_token = self.access_token()?; let resp = self .client @@ -542,7 +568,7 @@ impl SolitaireServerClient { .map_err(|e| SyncError::Network(e.to_string()))?; if resp.status() == reqwest::StatusCode::UNAUTHORIZED { - self.refresh_token().await?; + self.refresh_token(&token).await?; let new_token = self.access_token()?; let resp = self .client