Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions src-tauri/src/device_activation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
#[serde(
alias = "access_token",
default,
skip_serializing_if = "Option::is_none"
)]
pub access_token: Option<String>,
}

fn device_limit_error(info: &DeviceLimitInfo) -> String {
Expand Down Expand Up @@ -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();
Expand All @@ -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(
Expand All @@ -214,14 +219,18 @@ pub async fn activate_device(
replace_device_id.as_deref(),
)
.await;
rotated_access = Some(refreshed.access_token);
}
}
}

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),
Expand Down Expand Up @@ -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.
Expand Down
67 changes: 66 additions & 1 deletion src-tauri/src/entitlement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub refreshed_session: Option<crate::session::RefreshedSession>,
}

/// Pure entitlement decision. Fail-closed: an unparseable release date or
Expand Down Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -478,7 +481,7 @@ pub async fn refresh_entitlement(
identity: State<'_, crate::device_activation::DeviceIdentityState>,
app: AppHandle,
) -> Result<EntitlementView, String> {
let (view, rotated) = refresh_entitlement_core(
let (mut view, rotated) = refresh_entitlement_core(
&token,
refresh_token.as_deref(),
force,
Expand All @@ -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,
}),
);
}
Expand Down Expand Up @@ -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);
Expand Down
47 changes: 38 additions & 9 deletions src-tauri/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<VecDeque<CachedRotation>>,
}

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,
Expand All @@ -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
Expand All @@ -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 }))
Expand Down Expand Up @@ -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)
}

Expand All @@ -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)
Expand All @@ -134,7 +160,10 @@ pub async fn revoke_session(refresh_token: Option<String>) -> 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(())
}
Expand Down
Loading
Loading