diff --git a/src-tauri/src/device_activation.rs b/src-tauri/src/device_activation.rs index b2015aeb..de42c95c 100644 --- a/src-tauri/src/device_activation.rs +++ b/src-tauri/src/device_activation.rs @@ -66,12 +66,15 @@ pub struct ActivatedResult { pub device_id: String, pub limit: u32, pub used: u32, - /// Device-bound 30-day lease — persisted into the session store. - /// The backend keeps `access_token`-style snake_case for token fields; - /// `refreshToken` is aliased because camelCase responses were observed - /// in the wild and a silent None here is what kills session renewal. - #[serde(rename = "refresh_token", alias = "refreshToken", default)] + /// Accept the API's snake_case token and expose camelCase over IPC. + #[serde(alias = "refresh_token", default)] pub refresh_token: Option, + #[serde( + alias = "access_token", + default, + skip_serializing_if = "Option::is_none" + )] + pub access_token: Option, } fn device_limit_error(info: &DeviceLimitInfo) -> String { @@ -196,6 +199,7 @@ pub async fn activate_device( } let payload = state.payload(); + let mut rotated_access = None; let mut attempt = post_activate(&token, &payload, replace_device_id.as_deref()).await; if let ActivateAttempt::Unauthorized = attempt { let lease = refresh_token.as_deref().unwrap_or("").trim(); @@ -206,6 +210,7 @@ pub async fn activate_device( json!({ "accessToken": refreshed.access_token, "refreshToken": refreshed.refresh_token, + "previousRefreshToken": refreshed.previous_refresh_token, }), ); attempt = post_activate( @@ -214,6 +219,7 @@ pub async fn activate_device( replace_device_id.as_deref(), ) .await; + rotated_access = Some(refreshed.access_token); } } } @@ -221,7 +227,10 @@ pub async fn activate_device( match attempt { // The device-bound lease rides back to the frontend, which persists // it alongside the rest of the account session. - ActivateAttempt::Ok(result) => Ok(result), + ActivateAttempt::Ok(mut result) => { + result.access_token = rotated_access; + Ok(result) + } ActivateAttempt::LimitReached(info) => Err(device_limit_error(&info)), ActivateAttempt::Unauthorized => Err("session expired — please sign in again".to_string()), ActivateAttempt::Failed(message) => Err(message), @@ -296,6 +305,9 @@ mod tests { }); let result: ActivatedResult = serde_json::from_value(raw).expect("result"); assert_eq!(result.refresh_token.as_deref(), Some("opaque-lease")); + let ipc = serde_json::to_value(&result).expect("IPC result"); + assert_eq!(ipc["refreshToken"], "opaque-lease"); + assert!(ipc.get("refresh_token").is_none()); // camelCase lease responses must land too — a silent None here is // exactly what broke session renewal in the field. diff --git a/src-tauri/src/entitlement.rs b/src-tauri/src/entitlement.rs index 667217da..58b1814e 100644 --- a/src-tauri/src/entitlement.rs +++ b/src-tauri/src/entitlement.rs @@ -181,6 +181,8 @@ pub struct EntitlementView { /// Session health verdict (`SESSION_PHASE_*`) — see the constant docs. pub session_phase: &'static str, pub last_error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub refreshed_session: Option, } /// Pure entitlement decision. Fail-closed: an unparseable release date or @@ -274,6 +276,7 @@ impl EntitlementState { fetched_at_ms: binding.as_ref().map(|c| c.fetched_at_ms), session_phase, last_error, + refreshed_session: None, } } @@ -478,7 +481,7 @@ pub async fn refresh_entitlement( identity: State<'_, crate::device_activation::DeviceIdentityState>, app: AppHandle, ) -> Result { - let (view, rotated) = refresh_entitlement_core( + let (mut view, rotated) = refresh_entitlement_core( &token, refresh_token.as_deref(), force, @@ -488,11 +491,13 @@ pub async fn refresh_entitlement( ) .await?; if let Some(refreshed) = rotated { + view.refreshed_session = Some(refreshed.clone()); let _ = app.emit( "session-refreshed", json!({ "accessToken": refreshed.access_token, "refreshToken": refreshed.refresh_token, + "previousRefreshToken": refreshed.previous_refresh_token, }), ); } @@ -713,6 +718,66 @@ mod tests { .respond_with(wiremock::ResponseTemplate::new(status).set_body_json(body)) } + #[tokio::test] + async fn a_transient_rotation_failure_can_retry_the_same_lease() { + let server = console_server().await; + let lease = format!("transient-{}", uuid::Uuid::new_v4().simple()); + let state = crate::session::SessionState::default(); + let identity = identity_state("transient-rotation").payload(); + let failure = mock_refresh_lease(&lease, 503, json!({ "code": 5000 })) + .expect(1) + .mount_as_scoped(server) + .await; + assert!(crate::session::rotate_session(&state, &lease, &identity) + .await + .is_err()); + drop(failure); + let success = mock_refresh_lease( + &lease, + 200, + json!({ + "code": 2000, + "data": { "access_token": "next-access", "refresh_token": "next-lease" } + }), + ) + .expect(1) + .mount_as_scoped(server) + .await; + let rotated = crate::session::rotate_session(&state, &lease, &identity) + .await + .expect("retry after transient failure"); + assert_eq!(rotated.previous_refresh_token, lease); + drop(success); + } + + #[tokio::test] + async fn concurrent_rotations_share_the_successor_without_replaying_the_lease() { + let server = console_server().await; + let lease = format!("concurrent-{}", uuid::Uuid::new_v4().simple()); + let response = mock_refresh_lease( + &lease, + 200, + json!({ + "code": 2000, + "data": { "access_token": "next-access", "refresh_token": "next-lease" } + }), + ) + .expect(1) + .mount_as_scoped(server) + .await; + let state = crate::session::SessionState::default(); + let identity = identity_state("concurrent-rotation").payload(); + let (first, second) = tokio::join!( + crate::session::rotate_session(&state, &lease, &identity), + crate::session::rotate_session(&state, &lease, &identity), + ); + let first = first.expect("first rotation"); + let second = second.expect("shared rotation"); + assert_eq!(first.refresh_token, second.refresh_token); + assert_eq!(second.previous_refresh_token, lease); + drop(response); + } + #[test] fn views_carry_the_derived_session_phase() { let state = EntitlementState::load(None); diff --git a/src-tauri/src/session.rs b/src-tauri/src/session.rs index 5d980ae8..292b9025 100644 --- a/src-tauri/src/session.rs +++ b/src-tauri/src/session.rs @@ -6,13 +6,13 @@ //! lease + device identity, receive the successor pair, and classify //! rejections so the frontend can drop dead leases. //! -//! Rotations serialize through `SessionState::rotate_lock`: the lease is -//! single-use and the server treats presenting an already-rotated token as a -//! leak that revokes the device scope, so two concurrent rotations must -//! never race. +//! Successful rotations are shared by callers presenting the same lease so +//! concurrent entitlement and activation requests cannot replay a consumed token, +//! including when the app sleeps between queued callers. use serde::{Deserialize, Serialize}; use serde_json::json; +use std::collections::VecDeque; use tauri::{AppHandle, Emitter, State}; use crate::common::console; @@ -22,10 +22,16 @@ pub const SESSION_REJECTED_ERROR_TYPE: &str = "SESSION_REJECTED"; #[derive(Default)] pub struct SessionState { - /// See module docs — guards the present-then-replace window. - pub rotate_lock: tokio::sync::Mutex<()>, + rotations: tokio::sync::Mutex>, } +struct CachedRotation { + previous: String, + session: RefreshedSession, +} + +const ROTATION_CACHE_LIMIT: usize = 64; + pub fn session_rejected_error(message: &str) -> String { json!({ "error_type": SESSION_REJECTED_ERROR_TYPE, @@ -40,6 +46,8 @@ pub struct RefreshedSession { pub access_token: String, #[serde(rename = "refresh_token")] pub refresh_token: String, + #[serde(default, skip_deserializing)] + pub previous_refresh_token: String, } /// Exchange a lease for the successor pair. The caller must surface the @@ -60,7 +68,13 @@ pub async fn rotate_session( return Err("no stored refresh token".to_string()); } - let _guard = state.rotate_lock.lock().await; + let mut rotations = state.rotations.lock().await; + if let Some(cached) = rotations + .iter() + .find(|entry| entry.previous == refresh_token) + { + return Ok(cached.session.clone()); + } let response = console::client() .post(format!("{}/api/v1/auth/refresh", console::api_base_url())) .json(&json!({ "refresh_token": refresh_token, "device": device_payload })) @@ -89,8 +103,19 @@ pub async fn rotate_session( } let data = raw.get("data").cloned().unwrap_or(json!({})); - let session: RefreshedSession = + let mut session: RefreshedSession = serde_json::from_value(data).map_err(|e| format!("invalid refresh result: {e}"))?; + if !status.is_success() { + return Err(format!("refresh endpoint returned HTTP {status}")); + } + session.previous_refresh_token = refresh_token.to_string(); + if rotations.len() >= ROTATION_CACHE_LIMIT { + rotations.pop_front(); + } + rotations.push_back(CachedRotation { + previous: refresh_token.to_string(), + session: session.clone(), + }); Ok(session) } @@ -114,6 +139,7 @@ pub async fn rotate_session_now( json!({ "accessToken": refreshed.access_token, "refreshToken": refreshed.refresh_token, + "previousRefreshToken": refreshed.previous_refresh_token, }), ); Ok(refreshed) @@ -134,7 +160,10 @@ pub async fn revoke_session(refresh_token: Option) -> Result<(), String> .await .map_err(|e| format!("network error: {e}"))?; if !response.status().is_success() { - return Err(format!("logout endpoint returned HTTP {}", response.status())); + return Err(format!( + "logout endpoint returned HTTP {}", + response.status() + )); } Ok(()) } diff --git a/src/App.vue b/src/App.vue index f0cedaa6..a543d0eb 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,11 +1,10 @@