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 -1
View File
@@ -380,7 +380,7 @@ fn run_sync_upload(
match outcome {
Ok(ely_browser_core::SyncOutcome::SignedOut) => {
tracing::info!(target: "ely::sync", "no bearer token on disk; sync skipped");
let _ = inbox.send(SyncStateUpdate::SignedOut { profile_id });
let _ = inbox.send(SyncStateUpdate::AuthenticationExpired { profile_id });
}
Ok(ely_browser_core::SyncOutcome::AwaitingDeviceApproval { device_id }) => {
tracing::info!(
+17 -2
View File
@@ -300,7 +300,7 @@ mod tests {
}
#[test]
fn credential_failures_do_not_finish_an_upload() {
fn credential_failures_use_the_unavailable_update() {
let profile_id = ProfileId::new();
let update = device_failure_update(
profile_id.clone(),
@@ -311,12 +311,27 @@ mod tests {
update,
SyncStateUpdate::CredentialUnavailable {
profile_id: owner,
finishes_upload: false,
..
} if owner == profile_id
));
}
#[test]
fn terminal_sessions_expire_profile_authentication() {
let profile_id = ProfileId::new();
for error in [
ely_sync_client::SyncClientError::SessionEnded,
ely_sync_client::SyncClientError::SessionChanged,
] {
let update = device_failure_update(profile_id.clone(), error);
assert!(matches!(
update,
SyncStateUpdate::AuthenticationExpired { profile_id: owner } if owner == profile_id
));
}
}
#[test]
fn approval_failures_do_not_finish_an_upload() {
let profile_id = ProfileId::new();
+82 -2
View File
@@ -23,6 +23,24 @@ impl SyncStateMessage {
}
}
pub(super) fn early_reconciliation_priority(update: &SyncStateUpdate) -> Option<u8> {
match update {
SyncStateUpdate::AuthenticationExpired { .. } => Some(0),
SyncStateUpdate::CredentialUnavailable { .. } => Some(1),
_ => None,
}
}
pub(super) fn early_reconciliation_messages(
messages: &[SyncStateMessage],
) -> impl Iterator<Item = &SyncStateMessage> {
[0_u8, 1].into_iter().flat_map(move |priority| {
messages
.iter()
.filter(move |message| early_reconciliation_priority(&message.update) == Some(priority))
})
}
#[derive(Clone, Debug)]
pub(super) struct SyncStateSender {
generation: SyncWorkGeneration,
@@ -46,12 +64,20 @@ impl ElyShell {
pub(super) fn invalidate_sync_work(&mut self) {
self.release_auth_flow_barrier();
self.sync_generation.advance();
self.reset_sync_work_state();
}
pub(super) fn reset_sync_work_state(&mut self) {
self.sync_upload_scheduled = false;
self.sync_upload_in_flight = false;
self.sync_retry_at = None;
self.clear_pending_cloud_sync_upload();
self.sync_devices.reset();
}
pub(super) fn expire_authenticated_work(&mut self) {
self.invalidate_sync_work();
}
}
#[cfg(test)]
@@ -65,7 +91,7 @@ mod tests {
let generation = SyncWorkGeneration(7);
let sender = SyncStateSender { generation, sender };
sender.send(SyncStateUpdate::SignedOut { profile_id: ProfileId::new() })?;
sender.send(SyncStateUpdate::AuthenticationExpired { profile_id: ProfileId::new() })?;
assert_eq!(receiver.recv()?.generation, generation);
Ok(())
@@ -80,9 +106,63 @@ mod tests {
current.advance();
current.advance();
old_sender.send(SyncStateUpdate::SignedOut { profile_id: ProfileId::new() })?;
old_sender.send(SyncStateUpdate::AuthenticationExpired { profile_id: ProfileId::new() })?;
assert!(!receiver.recv()?.belongs_to(current));
Ok(())
}
#[test]
fn early_reconciliation_order_is_queue_independent() {
let profile_id = ProfileId::new();
let generation = SyncWorkGeneration::default();
let expired = SyncStateMessage {
generation,
update: SyncStateUpdate::AuthenticationExpired { profile_id: profile_id.clone() },
};
let unavailable = SyncStateMessage {
generation,
update: SyncStateUpdate::CredentialUnavailable {
profile_id,
message: "locked".to_string(),
},
};
for messages in
[vec![expired.clone(), unavailable.clone()], vec![unavailable.clone(), expired.clone()]]
{
let ordered = early_reconciliation_messages(&messages)
.map(|message| early_reconciliation_priority(&message.update))
.collect::<Vec<_>>();
assert_eq!(ordered, [Some(0), Some(1)]);
}
}
#[gpui::test]
async fn authentication_expiry_resets_scoped_work(cx: &mut gpui::TestAppContext) {
cx.update(gpui_component::init);
let (shell, cx) = cx.add_window_view(ElyShell::new);
cx.update(|_, app_cx| {
shell.update(app_cx, |shell, _| {
let generation = shell.sync_generation;
shell.sync_upload_scheduled = true;
shell.sync_upload_in_flight = true;
shell.sync_upload_pending = true;
shell.sync_retry_at = Some(std::time::Instant::now());
shell
.sync_devices
.set_error(ProfileId::new(), "expired device session".to_string());
shell.expire_authenticated_work();
assert_ne!(shell.sync_generation, generation);
assert!(!shell.sync_upload_scheduled);
assert!(!shell.sync_upload_in_flight);
assert!(!shell.sync_upload_pending);
assert!(shell.sync_retry_at.is_none());
assert!(shell.sync_devices.error().is_none());
});
});
}
}
+52 -26
View File
@@ -4,10 +4,14 @@ use ely_domain::{ProfileId, ProfileKind, SyncConnectionState};
use ely_sync_client::{AuthenticatedSnapshotHead, BearerToken, BearerTokenStore, DeviceRecord};
use gpui::{Context, Timer};
use super::{ElyShell, ShellState, auth};
use super::{
ElyShell, ShellState, auth,
sync_inbox::{early_reconciliation_messages, early_reconciliation_priority},
};
mod failure;
mod legacy_sync_migration;
mod session_expiry;
mod sign_out;
pub(super) use failure::{device_failure_update, sync_failure_update};
@@ -26,17 +30,16 @@ pub(crate) struct PendingMergeUpload {
/// Messages the off-thread sync workers push back to the shell so
/// `SyncConnectionState` on `BrowserCore` and the in-flight auth
/// form reflect live state without the UI thread ever touching the
/// network. `SignedIn` is the initial-probe state set synchronously
/// on shell startup and does not flow through this channel.
/// network. Startup seeds `SignedIn` outside this channel.
#[derive(Clone, Debug)]
pub(crate) enum SyncStateUpdate {
SignedOut { profile_id: ProfileId },
AuthenticationExpired { profile_id: ProfileId },
AwaitingDeviceApproval { profile_id: ProfileId, finishes_upload: bool },
RemoteSnapshot { profile_id: ProfileId, bytes: Vec<u8>, merge: PendingMergeUpload },
SyncReady { profile_id: ProfileId, last_synced_at_secs: u64 },
SyncBusy { profile_id: ProfileId },
SyncError { profile_id: ProfileId, message: String },
CredentialUnavailable { profile_id: ProfileId, message: String, finishes_upload: bool },
CredentialUnavailable { profile_id: ProfileId, message: String },
DevicesLoaded { profile_id: ProfileId, devices: Vec<DeviceRecord>, current_code: String },
DevicesError { profile_id: ProfileId, message: String },
SignOutSucceeded { profile_id: ProfileId },
@@ -172,7 +175,48 @@ impl ElyShell {
let mut trigger_merged_upload = None;
let mut upload_finished = false;
let mut devices_changed = false;
while let Ok(message) = self.sync_inbox_rx.try_recv() {
let messages = self.sync_inbox_rx.try_iter().collect::<Vec<_>>();
for state_message in early_reconciliation_messages(&messages) {
match &state_message.update {
SyncStateUpdate::AuthenticationExpired { profile_id } => {
let current = state_message.belongs_to(self.sync_generation);
match self.reconcile_authentication_expiry(profile_id, current) {
session_expiry::AuthenticationExpiryResolution::Ignored => {}
session_expiry::AuthenticationExpiryResolution::ReplacementCredential => {
latest_connection = Some(SyncConnectionState::SignedIn);
trigger_initial_sync = true;
devices_changed = true;
auth_changed = true;
}
session_expiry::AuthenticationExpiryResolution::Connection(connection) => {
latest_connection = Some(connection);
trigger_initial_sync = false;
trigger_merged_upload = None;
devices_changed = true;
auth_changed = true;
}
}
}
SyncStateUpdate::CredentialUnavailable { profile_id, message }
if state_message.belongs_to(self.sync_generation)
&& active_profile_id(&self.state).as_ref() == Some(profile_id) =>
{
self.reconcile_credential_unavailable(profile_id);
latest_connection = Some(SyncConnectionState::CredentialUnavailable {
message: message.clone(),
});
trigger_initial_sync = false;
trigger_merged_upload = None;
devices_changed = true;
auth_changed = true;
}
_ => {}
}
}
for message in messages {
if early_reconciliation_priority(&message.update).is_some() {
continue;
}
if let Some((profile_id, connection)) =
self.reconcile_sign_out_update(message.generation, &message.update)
{
@@ -191,12 +235,8 @@ impl ElyShell {
}
let update = message.update;
match update {
SyncStateUpdate::SignedOut { profile_id } => {
upload_finished = true;
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
latest_connection = Some(SyncConnectionState::SignedOut);
}
}
SyncStateUpdate::AuthenticationExpired { .. }
| SyncStateUpdate::CredentialUnavailable { .. } => {}
SyncStateUpdate::AwaitingDeviceApproval { profile_id, finishes_upload } => {
upload_finished |= finishes_upload;
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
@@ -258,20 +298,6 @@ impl ElyShell {
latest_connection = Some(SyncConnectionState::SyncError { message });
}
}
SyncStateUpdate::CredentialUnavailable { profile_id, message, finishes_upload } => {
upload_finished |= finishes_upload;
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
latest_connection =
Some(SyncConnectionState::CredentialUnavailable { message });
self.sync_devices.reset();
self.sync_upload_scheduled = false;
self.sync_retry_at = None;
self.clear_pending_cloud_sync_upload();
trigger_initial_sync = false;
trigger_merged_upload = None;
devices_changed = true;
}
}
SyncStateUpdate::DevicesLoaded { profile_id, devices, current_code } => {
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
self.sync_devices.set_ready(profile_id, devices, current_code);
@@ -8,10 +8,14 @@ pub(in crate::shell) fn sync_failure_update(
) -> SyncStateUpdate {
let message = error.to_string();
match error {
ely_sync_client::SyncClientError::SessionEnded
| ely_sync_client::SyncClientError::SessionChanged => {
SyncStateUpdate::AuthenticationExpired { profile_id }
}
ely_sync_client::SyncClientError::BearerCredentialStorage(_)
| ely_sync_client::SyncClientError::AccountKeyStorage(_)
| ely_sync_client::SyncClientError::DeviceKeyStorage(_) => {
SyncStateUpdate::CredentialUnavailable { profile_id, message, finishes_upload: true }
SyncStateUpdate::CredentialUnavailable { profile_id, message }
}
ely_sync_client::SyncClientError::DeviceApprovalStatus { .. } => {
SyncStateUpdate::AwaitingDeviceApproval { profile_id, finishes_upload: true }
@@ -28,9 +32,6 @@ pub(in crate::shell) fn device_failure_update(
error: ely_sync_client::SyncClientError,
) -> SyncStateUpdate {
match sync_failure_update(profile_id, error) {
SyncStateUpdate::CredentialUnavailable { profile_id, message, .. } => {
SyncStateUpdate::CredentialUnavailable { profile_id, message, finishes_upload: false }
}
SyncStateUpdate::AwaitingDeviceApproval { profile_id, .. } => {
SyncStateUpdate::AwaitingDeviceApproval { profile_id, finishes_upload: false }
}
@@ -0,0 +1,214 @@
use ely_domain::{ProfileId, SyncConnectionState};
use super::super::{ElyShell, ShellState, auth};
use crate::services::servo_profile_data::{default_profile_data_root, sync_profile_data_dir};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum CredentialState {
Present,
Missing,
Unavailable,
}
pub(super) enum AuthenticationExpiryResolution {
Ignored,
ReplacementCredential,
Connection(SyncConnectionState),
}
#[derive(Debug, Eq, PartialEq)]
enum ExpiryDecision {
Ignore,
RefreshReplacement { preserve_generation: bool },
Reconcile { connection: SyncConnectionState, preserve_generation: bool, finish_sign_out: bool },
}
impl ElyShell {
pub(super) fn reconcile_authentication_expiry(
&mut self,
profile_id: &ProfileId,
is_current_generation: bool,
) -> AuthenticationExpiryResolution {
let ShellState::Ready(core) = &self.state else {
return AuthenticationExpiryResolution::Ignored;
};
let active_profile_id = match core.snapshot() {
Ok(snapshot) => snapshot.active_profile_id,
Err(_) => return AuthenticationExpiryResolution::Ignored,
};
let active_matches = &active_profile_id == profile_id;
if !active_matches {
return AuthenticationExpiryResolution::Ignored;
}
let decision = expiry_decision(
active_matches,
is_current_generation,
self.sign_out_phases.contains_key(profile_id),
self.credential_state(profile_id),
preserves_auth_attempt(&self.auth_flow_phase, profile_id),
);
match decision {
ExpiryDecision::Ignore => AuthenticationExpiryResolution::Ignored,
ExpiryDecision::RefreshReplacement { preserve_generation } => {
if preserve_generation {
self.reset_sync_work_state();
} else {
self.invalidate_sync_work();
}
AuthenticationExpiryResolution::ReplacementCredential
}
ExpiryDecision::Reconcile { connection, preserve_generation, finish_sign_out } => {
if finish_sign_out {
self.sign_out_phases.remove(profile_id);
}
self.reset_after_credential_loss(preserve_generation);
AuthenticationExpiryResolution::Connection(connection)
}
}
}
pub(super) fn reconcile_credential_unavailable(&mut self, profile_id: &ProfileId) {
let preserve_generation = preserves_auth_attempt(&self.auth_flow_phase, profile_id);
self.reset_after_credential_loss(preserve_generation);
}
fn reset_after_credential_loss(&mut self, preserve_generation: bool) {
if preserve_generation {
self.reset_sync_work_state();
} else {
self.expire_authenticated_work();
self.auth_flow_phase = auth::AuthFlowPhase::Idle;
}
}
fn credential_state(&self, profile_id: &ProfileId) -> CredentialState {
let profile_root = match default_profile_data_root() {
Some(root) => root,
None => return CredentialState::Unavailable,
};
let profile_dir = sync_profile_data_dir(&profile_root, profile_id);
let store = auth::bearer_store_for_profile(
profile_id,
&profile_dir,
&profile_root,
self.default_profile_id.as_ref(),
);
match store.load() {
Ok(Some(_)) => CredentialState::Present,
Ok(None) => CredentialState::Missing,
Err(error) => {
tracing::warn!(target: "ely::sync", error = %error, "expired session credential probe failed");
CredentialState::Unavailable
}
}
}
}
fn expiry_decision(
active_matches: bool,
is_current_generation: bool,
sign_out_active: bool,
credential: CredentialState,
preserve_auth_attempt: bool,
) -> ExpiryDecision {
if !active_matches {
return ExpiryDecision::Ignore;
}
if credential == CredentialState::Present {
return if is_current_generation && !sign_out_active {
ExpiryDecision::RefreshReplacement { preserve_generation: preserve_auth_attempt }
} else {
ExpiryDecision::Ignore
};
}
if sign_out_active && credential == CredentialState::Unavailable {
return ExpiryDecision::Ignore;
}
let connection = match credential {
CredentialState::Present => return ExpiryDecision::Ignore,
CredentialState::Missing => SyncConnectionState::SignedOut,
CredentialState::Unavailable => SyncConnectionState::CredentialUnavailable {
message: "System credential access failed.".to_string(),
},
};
ExpiryDecision::Reconcile {
connection,
preserve_generation: preserve_auth_attempt,
finish_sign_out: sign_out_active,
}
}
fn preserves_auth_attempt(phase: &auth::AuthFlowPhase, profile_id: &ProfileId) -> bool {
!matches!(phase, auth::AuthFlowPhase::Idle) && phase.belongs_to(profile_id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expiry_decision_covers_profile_aba_replacement_and_sign_out() {
assert_eq!(
expiry_decision(false, false, false, CredentialState::Missing, false),
ExpiryDecision::Ignore
);
assert_eq!(
expiry_decision(true, true, false, CredentialState::Present, false),
ExpiryDecision::RefreshReplacement { preserve_generation: false }
);
assert_eq!(
expiry_decision(true, false, false, CredentialState::Present, false),
ExpiryDecision::Ignore
);
assert_eq!(
expiry_decision(true, true, true, CredentialState::Unavailable, false),
ExpiryDecision::Ignore
);
assert_eq!(
expiry_decision(true, false, true, CredentialState::Missing, false),
ExpiryDecision::Reconcile {
connection: SyncConnectionState::SignedOut,
preserve_generation: false,
finish_sign_out: true,
}
);
}
#[test]
fn missing_credential_preserves_a_current_otp_generation() {
assert_eq!(
expiry_decision(true, true, false, CredentialState::Present, true),
ExpiryDecision::RefreshReplacement { preserve_generation: true }
);
assert_eq!(
expiry_decision(true, false, false, CredentialState::Missing, true),
ExpiryDecision::Reconcile {
connection: SyncConnectionState::SignedOut,
preserve_generation: true,
finish_sign_out: false,
}
);
assert!(matches!(
expiry_decision(true, false, false, CredentialState::Unavailable, true),
ExpiryDecision::Reconcile {
connection: SyncConnectionState::CredentialUnavailable { .. },
preserve_generation: true,
finish_sign_out: false,
}
));
}
#[test]
fn current_otp_attempt_is_profile_scoped() {
let profile_id = ProfileId::new();
let phase = auth::AuthFlowPhase::Verifying {
profile_id: profile_id.clone(),
email: "user@example.com".to_string(),
};
assert!(preserves_auth_attempt(&phase, &profile_id));
assert!(!preserves_auth_attempt(&phase, &ProfileId::new()));
assert!(!preserves_auth_attempt(&auth::AuthFlowPhase::Idle, &profile_id));
}
}
@@ -80,8 +80,23 @@ fn credential_storage_failures_use_the_unavailable_update() {
update,
SyncStateUpdate::CredentialUnavailable {
profile_id: owner,
finishes_upload: true,
..
} if owner == profile_id
));
}
#[test]
fn terminal_sessions_expire_upload_authentication() {
let profile_id = ely_domain::ProfileId::new();
for error in [
ely_sync_client::SyncClientError::SessionEnded,
ely_sync_client::SyncClientError::SessionChanged,
] {
let update = sync_failure_update(profile_id.clone(), error);
assert!(matches!(
update,
SyncStateUpdate::AuthenticationExpired { profile_id: owner } if owner == profile_id
));
}
}