fix(auth): reconcile expired desktop sessions
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user