fix(auth): reconcile expired desktop sessions

This commit is contained in:
2026-07-10 09:46:55 -04:00
parent 2f346abaea
commit 46eac43326
15 changed files with 669 additions and 121 deletions
+1 -67
View File
@@ -16,6 +16,7 @@ use crate::sync_records::{SNAPSHOT_SCHEMA_REV, SyncSnapshotBody};
mod concurrency;
mod device_management;
mod session;
mod vault_management;
use concurrency::{conflict_head, ensure_remote_generation_is_available};
@@ -84,73 +85,6 @@ impl SyncEngine {
self.bearer_store.load().map(|token| token.is_some())
}
/// Reconcile a pre-serialised local payload with the authenticated global snapshot head.
pub fn sync_bytes(&mut self, bytes: Vec<u8>) -> Result<SyncOutcome, SyncClientError> {
let Some(bearer) = self.bearer_store.load()? else {
let outcome = SyncOutcome::SignedOut;
self.last_outcome = Some(outcome.clone());
return Ok(outcome);
};
let client = SyncApiClient::new(self.api_config.clone(), bearer)?;
let Some((client, user_id)) = self.approved_client(client)? else {
let outcome =
SyncOutcome::AwaitingDeviceApproval { device_id: self.identity.device_id.clone() };
self.last_outcome = Some(outcome.clone());
return Ok(outcome);
};
let vault = self.resolve_vault(&client, &user_id)?;
let status = client.sync_status()?;
validate_sync_status(&status, &user_id, &self.identity.device_id)?;
let outcome = match status.snapshots.head {
Some(head) => {
let remote = self.download_remote_snapshot(&client, &user_id, &vault, head)?;
if remote.bytes == bytes && remote.merge_base.vault_generation() == vault.generation
{
SyncOutcome::AlreadyCurrent {
snapshot_id: remote.merge_base.snapshot_id().to_string(),
logical_clock: remote.merge_base.logical_clock(),
payload_bytes: remote.merge_base.size_bytes(),
device_id: remote.merge_base.device_id().to_string(),
}
} else if remote.bytes == bytes {
self.upload_payload(&client, &user_id, &vault, bytes, Some(&remote.merge_base))?
} else {
remote.into_outcome(false)
}
}
None => self.upload_payload(&client, &user_id, &vault, bytes, None)?,
};
self.last_outcome = Some(outcome.clone());
Ok(outcome)
}
/// Upload a local payload after the UI thread has applied a remote
/// snapshot. The caller passes the remote logical clock so the new
/// merged snapshot is ordered after the downloaded one.
pub fn upload_merged_bytes(
&mut self,
bytes: Vec<u8>,
merge_base: AuthenticatedSnapshotHead,
) -> Result<SyncOutcome, SyncClientError> {
let Some(bearer) = self.bearer_store.load()? else {
let outcome = SyncOutcome::SignedOut;
self.last_outcome = Some(outcome.clone());
return Ok(outcome);
};
let client = SyncApiClient::new(self.api_config.clone(), bearer)?;
let Some((client, user_id)) = self.approved_client(client)? else {
let outcome =
SyncOutcome::AwaitingDeviceApproval { device_id: self.identity.device_id.clone() };
self.last_outcome = Some(outcome.clone());
return Ok(outcome);
};
let vault = self.resolve_vault(&client, &user_id)?;
let outcome = self.upload_payload(&client, &user_id, &vault, bytes, Some(&merge_base))?;
self.last_outcome = Some(outcome.clone());
Ok(outcome)
}
fn approved_client(
&self,
client: SyncApiClient,
@@ -9,7 +9,13 @@ use super::SyncEngine;
impl SyncEngine {
pub fn cloud_devices(&self) -> Result<DeviceListResponse, SyncClientError> {
let client = self.authenticated_client()?;
self.with_required_authenticated_client(|client| self.cloud_devices_with_client(client))
}
fn cloud_devices_with_client(
&self,
client: SyncApiClient,
) -> Result<DeviceListResponse, SyncClientError> {
let (client, registration) = self.registered_client(client)?;
let devices = client.list_devices()?;
validate_device_list(
@@ -26,7 +32,18 @@ impl SyncEngine {
target_device_id: &str,
verification_code: &str,
) -> Result<DeviceApprovalDocument, SyncClientError> {
let (client, user_id) = self.approved_device_client()?;
self.with_required_authenticated_client(|client| {
self.approve_cloud_device_with_client(client, target_device_id, verification_code)
})
}
fn approve_cloud_device_with_client(
&self,
client: SyncApiClient,
target_device_id: &str,
verification_code: &str,
) -> Result<DeviceApprovalDocument, SyncClientError> {
let (client, user_id) = self.approved_device_client(client)?;
let devices = client.list_devices()?;
validate_device_list(&devices, &user_id, &self.identity.device_id, true)?;
let target = pending_device(&devices.devices, target_device_id)?;
@@ -72,7 +89,17 @@ impl SyncEngine {
&self,
target_device_id: &str,
) -> Result<DeviceRevocationDocument, SyncClientError> {
let (client, user_id) = self.approved_device_client()?;
self.with_required_authenticated_client(|client| {
self.revoke_cloud_device_with_client(client, target_device_id)
})
}
fn revoke_cloud_device_with_client(
&self,
client: SyncApiClient,
target_device_id: &str,
) -> Result<DeviceRevocationDocument, SyncClientError> {
let (client, user_id) = self.approved_device_client(client)?;
let devices = client.list_devices()?;
validate_device_list(&devices, &user_id, &self.identity.device_id, true)?;
let target = revocable_device(&devices.devices, target_device_id)?;
@@ -134,20 +161,15 @@ impl SyncEngine {
Ok(document)
}
fn approved_device_client(&self) -> Result<(SyncApiClient, String), SyncClientError> {
let client = self.authenticated_client()?;
fn approved_device_client(
&self,
client: SyncApiClient,
) -> Result<(SyncApiClient, String), SyncClientError> {
self.approved_client(client)?.ok_or_else(|| SyncClientError::DeviceApprovalStatus {
device_id: self.identity.device_id.clone(),
status: "pending".to_string(),
})
}
fn authenticated_client(&self) -> Result<SyncApiClient, SyncClientError> {
let bearer = self.bearer_store.load()?.ok_or(SyncClientError::DeviceTrust {
reason: "device management requires an authenticated session",
})?;
SyncApiClient::new(self.api_config.clone(), bearer)
}
}
fn validate_device_list(
@@ -0,0 +1,159 @@
use ely_sync_client::{SyncApiClient, SyncClientError};
use super::{SyncEngine, SyncOutcome, validate_sync_status};
impl SyncEngine {
pub fn sync_bytes(&mut self, bytes: Vec<u8>) -> Result<SyncOutcome, SyncClientError> {
let outcome = self
.with_authenticated_client(|client| {
let Some((client, user_id)) = self.approved_client(client)? else {
return Ok(SyncOutcome::AwaitingDeviceApproval {
device_id: self.identity.device_id.clone(),
});
};
let vault = self.resolve_vault(&client, &user_id)?;
let status = client.sync_status()?;
validate_sync_status(&status, &user_id, &self.identity.device_id)?;
match status.snapshots.head {
Some(head) => {
let remote =
self.download_remote_snapshot(&client, &user_id, &vault, head)?;
if remote.bytes == bytes
&& remote.merge_base.vault_generation() == vault.generation
{
Ok(SyncOutcome::AlreadyCurrent {
snapshot_id: remote.merge_base.snapshot_id().to_string(),
logical_clock: remote.merge_base.logical_clock(),
payload_bytes: remote.merge_base.size_bytes(),
device_id: remote.merge_base.device_id().to_string(),
})
} else if remote.bytes == bytes {
self.upload_payload(
&client,
&user_id,
&vault,
bytes,
Some(&remote.merge_base),
)
} else {
Ok(remote.into_outcome(false))
}
}
None => self.upload_payload(&client, &user_id, &vault, bytes, None),
}
})?
.unwrap_or(SyncOutcome::SignedOut);
self.last_outcome = Some(outcome.clone());
Ok(outcome)
}
pub fn upload_merged_bytes(
&mut self,
bytes: Vec<u8>,
merge_base: ely_sync_client::AuthenticatedSnapshotHead,
) -> Result<SyncOutcome, SyncClientError> {
let outcome = self
.with_authenticated_client(|client| {
let Some((client, user_id)) = self.approved_client(client)? else {
return Ok(SyncOutcome::AwaitingDeviceApproval {
device_id: self.identity.device_id.clone(),
});
};
let vault = self.resolve_vault(&client, &user_id)?;
self.upload_payload(&client, &user_id, &vault, bytes, Some(&merge_base))
})?
.unwrap_or(SyncOutcome::SignedOut);
self.last_outcome = Some(outcome.clone());
Ok(outcome)
}
pub(super) fn with_required_authenticated_client<T>(
&self,
operation: impl FnOnce(SyncApiClient) -> Result<T, SyncClientError>,
) -> Result<T, SyncClientError> {
self.with_authenticated_client(operation)?.ok_or(SyncClientError::SessionEnded)
}
fn with_authenticated_client<T>(
&self,
operation: impl FnOnce(SyncApiClient) -> Result<T, SyncClientError>,
) -> Result<Option<T>, SyncClientError> {
let Some(bearer) = self.bearer_store.load()? else {
return Ok(None);
};
let client = SyncApiClient::new(self.api_config.clone(), bearer.clone())?;
reconcile_authenticated_result(operation(client), || {
self.bearer_store.clear_if_matches(&bearer)
})
}
}
fn reconcile_authenticated_result<T>(
result: Result<T, SyncClientError>,
clear_if_matches: impl FnOnce() -> Result<bool, SyncClientError>,
) -> Result<Option<T>, SyncClientError> {
match result {
Ok(value) => Ok(Some(value)),
Err(SyncClientError::SessionEnded) => {
if clear_if_matches()? {
Ok(None)
} else {
Err(SyncClientError::SessionChanged)
}
}
Err(error) => Err(error),
}
}
#[cfg(test)]
mod tests {
use std::cell::Cell;
use super::reconcile_authenticated_result;
use ely_sync_client::SyncClientError;
#[test]
fn terminal_session_clears_the_captured_credential() -> Result<(), SyncClientError> {
let cleared = Cell::new(false);
let result =
reconcile_authenticated_result::<()>(Err(SyncClientError::SessionEnded), || {
cleared.set(true);
Ok(true)
})?;
assert!(result.is_none());
assert!(cleared.get());
Ok(())
}
#[test]
fn replacement_credential_stays_signed_in() {
let result =
reconcile_authenticated_result::<()>(Err(SyncClientError::SessionEnded), || Ok(false));
assert!(matches!(result, Err(SyncClientError::SessionChanged)));
}
#[test]
fn credential_clear_failures_are_preserved() {
let result =
reconcile_authenticated_result::<()>(Err(SyncClientError::SessionEnded), || {
Err(SyncClientError::BearerCredentialStorage("locked".to_string()))
});
assert!(matches!(result, Err(SyncClientError::BearerCredentialStorage(_))));
}
#[test]
fn ordinary_failures_preserve_the_credential() {
let clear_called = Cell::new(false);
let result =
reconcile_authenticated_result::<()>(Err(SyncClientError::SnapshotBusy), || {
clear_called.set(true);
Ok(true)
});
assert!(matches!(result, Err(SyncClientError::SnapshotBusy)));
assert!(!clear_called.get());
}
}