Commit f12377bce9b9
tarball11 files changed+450-112
@@ -44,9 +44,15 @@ lands. stop applying, call `resync`, and pick up from the next `TimelineReset`
4444
with `sqlite` or `indexeddb`, a `SessionStore` holds one login's record and its4545
store. `EncryptedSessionStore` seals the record with the store's passphrase, in a4646
file you pick natively or an IndexedDB database beside the store in a browser.47
-logging in saves the first record and `Session::restore` loads it. on every48
-`SessionTokensChanged`, `save` a fresh `ClientHandle::session_record()` before49
-the next refresh, refresh tokens only work once47
+logging in saves the first record and `Session::restore` loads it. from then on48
+the engine the `Session` builds keeps it current. every token rotation is saved49
+before `SessionTokensChanged` goes out, and a save that fails says so with50
+`SessionSaveFailed`. natively `EncryptedSessionStore` writes from inside the51
+sdk's refresh, before the new refresh token is ever used, and a store of your52
+own gets the same by implementing `SessionStore::save_blocking`. in a browser53
+the save can only follow the refresh, so a tab killed in between wakes with a54
+spent token. `Session::without_persistence` hands the job back, and then it's55
+yours to save `ClientHandle::session_record()` on every `SessionTokensChanged`5056
5157
`Engine::run` resolves to a `Termination` saying why it stopped, and5258
`Termination::retain` says what to do about it. after `LoggedOut` or@@ -168,8 +168,17 @@ with an encrypted store, refresh tokens and threads. the store is sqlite
168168
natively and indexeddb in a browser. secret-bearing inputs don't derive `Clone`169169
170170
a `SessionStore` owns one login's record and its store. login saves the first171
-record, `restore` loads it, and the caller saves `ClientHandle::session_record()`172
-on every `SessionTokensChanged`. `EncryptedSessionStore` seals the record with171
+record, `restore` loads it, and the engine a `Session` builds saves every token172
+rotation after that, since a refresh token works once and a host that missed one173
+save has lost the login. the session changes are subscribed the moment the174
+client exists, because the sdk can refresh during restore before any engine175
+does. a save runs off the event stream, so a full stream never holds it back,176
+and `SessionTokensChanged` only goes out once it has landed, or177
+`SessionSaveFailed` if it didn't. natively the sdk's save callback runs inside178
+the refresh, before the new token is used, and `save_blocking` writes there and179
+then. in a browser there's only the async save, so a tab that dies between a180
+refresh and its save wakes up holding a spent token. `without_persistence`181
+hands the job back to the host. `EncryptedSessionStore` seals the record with173182
the sdk's `StoreCipher` under the store's passphrase and replaces it by rename,174183
so a crash mid save leaves the old one. it keeps the record out of the sdk's175184
state store because sqlite writes custom values there in the clear, and a@@ -1,3 +1,5 @@
1
+use std::sync::Arc;2
+13
use macltok::{24
EncryptedSessionStore, EngineConfig, Event, PasswordLogin, Restore, Retain, RoomMembership,35
Session, SessionStore, StoreConfig, TimelineContent, TimelineDiff, TimelineId, TimelineItem,@@ -8,15 +10,18 @@ use macltok::{
810
async fn main() -> Result<(), Box<dyn std::error::Error>> {911
let store_path = std::path::PathBuf::from(required("MATRIX_STORE_PATH")?);1012
let passphrase = required("MATRIX_STORE_PASSPHRASE")?;11
- let sessions = EncryptedSessionStore::file(13
+ let sessions = Arc::new(EncryptedSessionStore::file(1214
store_path.with_extension("session"),1315
StoreConfig::sqlite(&store_path, &passphrase),14
- );15
- let session = match Session::restore(&sessions).await? {16
+ ));17
+ let session = match Session::restore(sessions.clone()).await? {1618
Some(Restore::Resumed(session)) => session,1719
Some(Restore::SoftLoggedOut(soft)) => {18
- Session::login_password(soft.password_login(required("MATRIX_PASSWORD")?), &sessions)19
- .await?20
+ Session::login_password(21
+ soft.password_login(required("MATRIX_PASSWORD")?),22
+ sessions.clone(),23
+ )24
+ .await?2025
}2126
None => {2227
Session::login_password(@@ -26,7 +31,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
2631
required("MATRIX_PASSWORD")?,2732
)2833
.with_device_display_name("macltok headless example"),29
- &sessions,34
+ sessions.clone(),3035
)3136
.await?3237
}@@ -78,9 +83,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
7883
}7984
print_messages(&items);8085
}81
- Event::SessionTokensChanged => {82
- sessions.save(&handle.session_record()?).await?;83
- }8486
Event::Stopped => break,8587
_ => {}8688
}@@ -1,9 +1,12 @@
11
#[cfg(feature = "sqlite")]22
use std::path::PathBuf;3
+#[cfg(not(target_family = "wasm"))]4
+use std::sync::atomic::{AtomicBool, Ordering};35
use std::{collections::HashMap, sync::Arc};46
57
use matrix_sdk::{6
- Client, ClientBuilder, SendOutsideWasm, SessionMeta, SyncOutsideWasm, ThreadingSupport,8
+ Client, ClientBuilder, SendOutsideWasm, SessionChange, SessionMeta, SyncOutsideWasm,9
+ ThreadingSupport,710
authentication::{811
AuthSession,912
matrix::MatrixSession,@@ -18,14 +21,27 @@ use matrix_sdk::{
1821
};1922
use serde::{Deserialize, Serialize};2023
use thiserror::Error;24
+use tokio::sync::{broadcast, mpsc};2125
use url::Url;2226
2327
use crate::{24
- ClientHandle, Engine, EngineConfig, EngineError, EventStream, SessionStore, error::SourceError,28
+ ClientHandle, Engine, EngineConfig, EngineError, ErrorKind, EventStream, SessionStore,29
+ engine::Persistence, error::SourceError,2530
};2631
32
+/// a logged in client and the `SessionStore` its record lives in. the engine it builds33
+/// saves every token rotation there, unless told `without_persistence`2734
pub struct Session {2835
client: Client,36
+ tracking: Tracking,37
+}38
+39
+/// taken as soon as the client exists, since the sdk can refresh before there's an engine40
+struct Tracking {41
+ session_changes: broadcast::Receiver<SessionChange>,42
+ persistence: Option<Persistence>,43
+ #[cfg(not(target_family = "wasm"))]44
+ inline: Arc<AtomicBool>,2945
}3046
3147
#[non_exhaustive]@@ -127,6 +143,7 @@ pub enum OAuthApplicationType {
127143
pub struct PendingOAuthLogin {128144
client: Client,129145
sessions: Arc<dyn SessionStore>,146
+ tracking: Option<Tracking>,130147
authorization_url: String,131148
state: Option<CsrfToken>,132149
}@@ -164,7 +181,11 @@ impl PendingOAuthLogin {
164181
oauth.abort_login(&state).await;165182
return Err(account_error("OAuth login", error));166183
}167
- Session::saved(self.client.clone(), &*self.sessions).await184
+ let tracking = self185
+ .tracking186
+ .take()187
+ .ok_or(AccountError::OAuthCallbackAlreadySubmitted)?;188
+ Session::saved(self.client.clone(), &*self.sessions, tracking).await168189
}169190
170191
pub async fn abort(mut self) {@@ -239,8 +260,8 @@ impl StoreConfig {
239260
}240261
}241262
242
-/// Serializable authentication secrets. A `SessionStore` keeps it, and it must243
-/// be replaced when `Event::SessionTokensChanged` arrives.263
+/// Serializable authentication secrets. A `SessionStore` keeps it, and the engine264
+/// replaces it there on every token rotation.244265
#[derive(Clone, Deserialize, Serialize)]245266
pub struct SessionRecord {246267
homeserver: String,@@ -334,13 +355,14 @@ pub enum AccountError {
334355
impl Session {335356
pub async fn login_password(336357
login: PasswordLogin,337
- sessions: &dyn SessionStore,358
+ sessions: Arc<dyn SessionStore>,338359
) -> Result<Self, AccountError> {339360
let client = matrix_client_builder(sessions.store())340361
.server_name_or_homeserver_url(login.homeserver.trim())341362
.build()342363
.await343364
.map_err(|error| account_error("building the Matrix client", error))?;365
+ let tracking = Tracking::start(&client, sessions.clone())?;344366
let mut request = client345367
.matrix_auth()346368
.login_username(login.username.trim(), &login.password)@@ -355,7 +377,7 @@ impl Session {
355377
.send()356378
.await357379
.map_err(|error| account_error("password login", error))?;358
- Self::saved(client, sessions).await380
+ Self::saved(client, &*sessions, tracking).await359381
}360382
361383
pub async fn begin_oauth(@@ -371,6 +393,7 @@ impl Session {
371393
.build()372394
.await373395
.map_err(|error| account_error("building the Matrix client", error))?;396
+ let tracking = Tracking::start(&client, sessions.clone())?;374397
let application_type = match login.application_type {375398
OAuthApplicationType::Native => ApplicationType::Native,376399
OAuthApplicationType::Web => ApplicationType::Web,@@ -401,14 +424,15 @@ impl Session {
401424
Ok(PendingOAuthLogin {402425
client,403426
sessions,427
+ tracking: Some(tracking),404428
authorization_url: authorization.url.to_string(),405429
state: Some(authorization.state),406430
})407431
}408432
409433
/// Restore the record `sessions` holds, or `None` when it holds none.410
- /// The caller must also handle refresh, rejection, and explicit logout.411
- pub async fn restore(sessions: &dyn SessionStore) -> Result<Option<Restore>, AccountError> {434
+ /// The caller must also handle rejection and explicit logout.435
+ pub async fn restore(sessions: Arc<dyn SessionStore>) -> Result<Option<Restore>, AccountError> {412436
let Some(record) = sessions.load().await? else {413437
return Ok(None);414438
};@@ -431,28 +455,111 @@ impl Session {
431455
.build()432456
.await433457
.map_err(|error| account_error("reopening the Matrix store", error))?;458
+ let tracking = Tracking::start(&client, sessions)?;434459
client435460
.restore_session(authentication)436461
.await437462
.map_err(|error| account_error("restoring the authenticated session", error))?;438
- Ok(Some(Restore::Resumed(Self { client })))463
+ Ok(Some(Restore::Resumed(Self { client, tracking })))464
+ }465
+466
+ /// leave the record to the host, which then saves `ClientHandle::session_record()`467
+ /// on every `SessionTokensChanged`468
+ #[must_use]469
+ pub fn without_persistence(mut self) -> Self {470
+ self.tracking.persistence = None;471
+ #[cfg(not(target_family = "wasm"))]472
+ self.tracking.inline.store(false, Ordering::Release);473
+ self439474
}440475
441476
pub async fn build(442477
self,443478
config: EngineConfig,444479
) -> Result<(Engine, ClientHandle, EventStream), AccountError> {445
- Engine::from_client(self.client, config)446
- .await447
- .map_err(AccountError::from)480
+ Engine::with_persistence(481
+ self.client,482
+ config,483
+ self.tracking.session_changes,484
+ self.tracking.persistence,485
+ )486
+ .await487
+ .map_err(AccountError::from)448488
}449489
450
- async fn saved(client: Client, sessions: &dyn SessionStore) -> Result<Self, AccountError> {490
+ async fn saved(491
+ client: Client,492
+ sessions: &dyn SessionStore,493
+ tracking: Tracking,494
+ ) -> Result<Self, AccountError> {451495
sessions.save(&session_record(&client)?).await?;452
- Ok(Self { client })496
+ Ok(Self { client, tracking })497
+ }498
+}499
+500
+impl Tracking {501
+ fn start(client: &Client, sessions: Arc<dyn SessionStore>) -> Result<Self, AccountError> {502
+ let session_changes = client.subscribe_to_session_changes();503
+ #[cfg_attr(target_family = "wasm", allow(unused_variables))]504
+ let (results, inline_results) = mpsc::unbounded_channel();505
+ #[cfg(not(target_family = "wasm"))]506
+ let inline = save_inline(client, sessions.clone(), results)?;507
+ Ok(Self {508
+ session_changes,509
+ persistence: Some(Persistence {510
+ save: Box::new(move |client| {511
+ let sessions = sessions.clone();512
+ Box::pin(async move {513
+ let record = session_record(&client).map_err(save_failed)?;514
+ sessions.save(&record).await.map_err(save_failed)515
+ })516
+ }),517
+ inline: inline_results,518
+ }),519
+ #[cfg(not(target_family = "wasm"))]520
+ inline,521
+ })453522
}454523
}455524
525
+/// the sdk calls this inside its refresh, after it has the new tokens and before it uses526
+/// them, so a store that writes there and then can't lose a refresh token to a crash527
+#[cfg(not(target_family = "wasm"))]528
+fn save_inline(529
+ client: &Client,530
+ sessions: Arc<dyn SessionStore>,531
+ results: mpsc::UnboundedSender<Result<(), crate::Error>>,532
+) -> Result<Arc<AtomicBool>, AccountError> {533
+ let enabled = Arc::new(AtomicBool::new(true));534
+ let saving = enabled.clone();535
+ client536
+ .set_session_callbacks(537
+ // only asked for with the cross process refresh lock, which we never enable538
+ Box::new(|client| {539
+ client540
+ .session_tokens()541
+ .ok_or_else(|| AccountError::MissingSession.into())542
+ }),543
+ Box::new(move |client| {544
+ if !saving.load(Ordering::Acquire) {545
+ return Ok(());546
+ }547
+ let record = session_record(&client)?;548
+ if let Some(result) = sessions.save_blocking(&record) {549
+ let _ = results.send(result.map_err(save_failed));550
+ }551
+ Ok(())552
+ }),553
+ )554
+ .map_err(|error| account_error("registering the session callbacks", error))?;555
+ Ok(enabled)556
+}557
+558
+fn save_failed(error: AccountError) -> crate::Error {559
+ let message = format!("saving the session record failed, {error}");560
+ crate::Error::with_source(ErrorKind::Store, message, error)561
+}562
+456563
pub(crate) fn session_record(client: &Client) -> Result<SessionRecord, AccountError> {457564
let authentication = match client.session().ok_or(AccountError::MissingSession)? {458565
AuthSession::Matrix(session) => AuthenticationRecord::Matrix(session),@@ -3,6 +3,7 @@ pub(crate) mod executor;
33
mod media;44
mod mutations;55
mod room;6
+mod session;67
mod timeline;78
mod watchers;89
@@ -21,6 +22,7 @@ use thiserror::Error as ThisError;
2122
use tokio::sync::{broadcast, mpsc, watch};2223
2324
pub(crate) use media::MediaService;25
+pub(crate) use session::Persistence;2426
2527
use self::{2628
commands::Job,@@ -226,6 +228,7 @@ struct Queues {
226228
notifications: mpsc::Receiver<Job<NotificationCommand>>,227229
encryption: mpsc::Receiver<Job<EncryptionCommand>>,228230
session_changes: broadcast::Receiver<SessionChange>,231
+ persistence: Option<Persistence>,229232
upload_reports: mpsc::Receiver<watchers::RoomUpload>,230233
}231234
@@ -266,6 +269,18 @@ impl Engine {
266269
client: Client,267270
config: EngineConfig,268271
) -> Result<(Self, ClientHandle, EventStream), EngineError> {272
+ let session_changes = client.subscribe_to_session_changes();273
+ Self::with_persistence(client, config, session_changes, None).await274
+ }275
+276
+ /// `session_changes` is taken when the client is built, since the sdk can refresh277
+ /// before an engine exists278
+ pub(crate) async fn with_persistence(279
+ client: Client,280
+ config: EngineConfig,281
+ session_changes: broadcast::Receiver<SessionChange>,282
+ persistence: Option<Persistence>,283
+ ) -> Result<(Self, ClientHandle, EventStream), EngineError> {269284
if client.user_id().is_none() {270285
return Err(EngineError::Unauthenticated);271286
}@@ -275,7 +290,6 @@ impl Engine {
275290
{276291
return Err(EngineError::ZeroCapacity);277292
}278
- let session_changes = client.subscribe_to_session_changes();279293
let sync = Arc::new(280294
SyncService::builder(client.clone())281295
.with_offline_mode()@@ -324,6 +338,7 @@ impl Engine {
324338
notifications: notification_queue,325339
encryption: encryption_queue,326340
session_changes,341
+ persistence,327342
upload_reports: upload_report_queue,328343
}),329344
upload_reports,@@ -422,7 +437,8 @@ impl Engine {
422437
self.verification_handler = Some(self.verification_handler(incoming));423438
let client = &self.client;424439
let events = || self.port.event_sender();425
- let watchers: [(&'static str, EngineBoxFuture<'static, ()>); 11] = [440
+ let (notices, notice_queue) = mpsc::unbounded_channel();441
+ let watchers: [(&'static str, EngineBoxFuture<'static, ()>); 12] = [426442
(427443
"upload tracker",428444
Box::pin(watchers::uploads(@@ -441,18 +457,27 @@ impl Engine {
441457
),442458
(443459
"session change watcher",444
- Box::pin(watchers::session(460
+ Box::pin(session::session(461
+ client.clone(),445462
queues.session_changes,446
- events(),463
+ queues.persistence,464
+ notices,447465
client448466
.device_id()449467
.expect("authentication was checked during construction")450468
.to_owned(),451
- self.stop.clone(),452469
self.logouts.clone(),453470
)),454471
),455472
(473
+ "session notice watcher",474
+ Box::pin(session::session_notices(475
+ notice_queue,476
+ events(),477
+ self.stop.clone(),478
+ )),479
+ ),480
+ (456481
"connection state watcher",457482
Box::pin(watchers::connection(458483
self.sync.state(),@@ -560,7 +585,7 @@ impl Engine {
560585
false561586
});562587
if let Some(soft_logout) = rejected {563
- watchers::reject_session(&events, &stop, soft_logout).await;588
+ session::reject_session(&events, &stop, soft_logout).await;564589
}565590
logouts.send_modify(|logouts| logouts.in_flight -= 1);566591
result.map_err(|source| Error::from_sdk("logout failed", source))@@ -0,0 +1,132 @@
1
+use std::sync::Arc;2
+3
+use matrix_sdk::{BoxFuture, Client, SessionChange};4
+use ruma::OwnedDeviceId;5
+use tokio::sync::{6
+ broadcast::{self, error::RecvError},7
+ mpsc, watch,8
+};9
+10
+use super::Logouts;11
+use crate::{Error, Event, Termination, transport::EventSender};12
+13
+#[cfg(not(target_family = "wasm"))]14
+pub(crate) type SaveRecord = dyn Fn(Client) -> BoxFuture<'static, Result<(), Error>> + Send + Sync;15
+#[cfg(target_family = "wasm")]16
+pub(crate) type SaveRecord = dyn Fn(Client) -> BoxFuture<'static, Result<(), Error>>;17
+18
+/// how the engine keeps the saved record current, handed over by `Session::build`19
+pub(crate) struct Persistence {20
+ pub(crate) save: Box<SaveRecord>,21
+ /// what the sdk's own save callback made of each refresh, when the store could write22
+ /// there and then23
+ pub(crate) inline: mpsc::UnboundedReceiver<Result<(), Error>>,24
+}25
+26
+impl Persistence {27
+ async fn persist(&mut self, client: &Client) -> Result<(), Error> {28
+ let mut inline = None;29
+ while let Ok(result) = self.inline.try_recv() {30
+ inline = Some(result);31
+ }32
+ if let Some(Ok(())) = inline {33
+ return Ok(());34
+ }35
+ // an inline save for a newer refresh can land while this one writes, so go again36
+ // until what's on disk is what the client holds37
+ loop {38
+ let tokens = client.session_tokens();39
+ (self.save)(client.clone()).await?;40
+ if client.session_tokens() == tokens {41
+ return Ok(());42
+ }43
+ }44
+ }45
+}46
+47
+pub(super) enum Notice {48
+ Event(Event),49
+ Rejected(Option<OwnedDeviceId>),50
+}51
+52
+/// saves and notices are split, so a full event stream never holds a save back53
+pub(super) async fn session(54
+ client: Client,55
+ mut changes: broadcast::Receiver<SessionChange>,56
+ mut persistence: Option<Persistence>,57
+ notices: mpsc::UnboundedSender<Notice>,58
+ device_id: OwnedDeviceId,59
+ logouts: Arc<watch::Sender<Logouts>>,60
+) {61
+ loop {62
+ let event = match changes.recv().await {63
+ // a missed refresh costs one needless save, a missed rejection comes round again64
+ Ok(SessionChange::TokensRefreshed) | Err(RecvError::Lagged(_)) => {65
+ match persistence.as_mut() {66
+ None => Event::SessionTokensChanged,67
+ Some(persistence) => match persistence.persist(&client).await {68
+ Ok(()) => Event::SessionTokensChanged,69
+ Err(error) => Event::SessionSaveFailed {70
+ message: error.message,71
+ },72
+ },73
+ }74
+ }75
+ Ok(SessionChange::UnknownToken(data)) => {76
+ let soft_logout = data.soft_logout.then_some(device_id);77
+ let mut held = false;78
+ // our own logout revokes the token, so a rejection meanwhile is its doing79
+ logouts.send_if_modified(|logouts| {80
+ held = logouts.in_flight > 0;81
+ if held {82
+ logouts.rejected = Some(soft_logout.clone());83
+ }84
+ held85
+ });86
+ if !held {87
+ let _ = notices.send(Notice::Rejected(soft_logout));88
+ }89
+ return;90
+ }91
+ Err(RecvError::Closed) => return,92
+ };93
+ if notices.send(Notice::Event(event)).is_err() {94
+ return;95
+ }96
+ }97
+}98
+99
+pub(super) async fn session_notices(100
+ mut notices: mpsc::UnboundedReceiver<Notice>,101
+ events: EventSender,102
+ stop: mpsc::UnboundedSender<Termination>,103
+) {104
+ while let Some(notice) = notices.recv().await {105
+ match notice {106
+ Notice::Event(event) => {107
+ if events.send(event).await.is_err() {108
+ return;109
+ }110
+ }111
+ Notice::Rejected(soft_logout) => {112
+ return reject_session(&events, &stop, soft_logout).await;113
+ }114
+ }115
+ }116
+}117
+118
+pub(super) async fn reject_session(119
+ events: &EventSender,120
+ stop: &mpsc::UnboundedSender<Termination>,121
+ soft_logout: Option<OwnedDeviceId>,122
+) {123
+ let _ = events124
+ .send(Event::SessionAccessRejected {125
+ soft_logout: soft_logout.clone(),126
+ })127
+ .await;128
+ let _ = stop.send(match soft_logout {129
+ Some(device_id) => Termination::SoftLoggedOut { device_id },130
+ None => Termination::AccessRevoked,131
+ });132
+}@@ -2,18 +2,18 @@ use std::{collections::HashSet, sync::Arc, time::Duration};
22
33
use futures_util::StreamExt;44
use matrix_sdk::{5
- Client, Room, SessionChange,5
+ Client, Room,66
ruma::events::{AnyMessageLikeEventContent, room::message::MessageType},77
send_queue::{LocalEchoContent, RoomSendQueueUpdate, SendQueueUpdate},88
};99
use matrix_sdk_ui::sync_service::{State as SyncState, SyncService};10
-use ruma::{OwnedDeviceId, OwnedRoomId, OwnedTransactionId, time::Instant};10
+use ruma::{OwnedRoomId, OwnedTransactionId, time::Instant};1111
use tokio::sync::{broadcast::error::RecvError, mpsc, watch};1212
13
-use super::{Logouts, executor::TaskSet};13
+use super::executor::TaskSet;1414
use crate::{15
- ConnectionState, Event, Termination, UploadOutcome, encryption::encryption_state,16
- error::classify_sdk_error, transport::EventSender,15
+ ConnectionState, Event, UploadOutcome, encryption::encryption_state, error::classify_sdk_error,16
+ transport::EventSender,1717
};1818
1919
const SYNC_RESTART_MIN: Duration = Duration::from_secs(1);@@ -32,58 +32,6 @@ pub(super) async fn room_subscriptions(
3232
}3333
}3434
35
-pub(super) async fn session(36
- mut changes: tokio::sync::broadcast::Receiver<SessionChange>,37
- events: EventSender,38
- device_id: OwnedDeviceId,39
- stop: mpsc::UnboundedSender<Termination>,40
- logouts: Arc<watch::Sender<Logouts>>,41
-) {42
- loop {43
- let event = match changes.recv().await {44
- Ok(SessionChange::TokensRefreshed) => Event::SessionTokensChanged,45
- Ok(SessionChange::UnknownToken(data)) => {46
- let soft_logout = data.soft_logout.then_some(device_id);47
- let mut held = false;48
- // our own logout revokes the token, so a rejection meanwhile is its doing49
- logouts.send_if_modified(|logouts| {50
- held = logouts.in_flight > 0;51
- if held {52
- logouts.rejected = Some(soft_logout.clone());53
- }54
- held55
- });56
- if !held {57
- reject_session(&events, &stop, soft_logout).await;58
- }59
- return;60
- }61
- // a missed refresh costs one needless save, a missed rejection comes round again62
- Err(RecvError::Lagged(_)) => Event::SessionTokensChanged,63
- Err(RecvError::Closed) => return,64
- };65
- if events.send(event).await.is_err() {66
- return;67
- }68
- }69
-}70
-71
-pub(super) async fn reject_session(72
- events: &EventSender,73
- stop: &mpsc::UnboundedSender<Termination>,74
- soft_logout: Option<OwnedDeviceId>,75
-) {76
- let _ = events77
- .send(Event::SessionAccessRejected {78
- soft_logout: soft_logout.clone(),79
- })80
- .await;81
- let _ = stop.send(match soft_logout {82
- Some(device_id) => Termination::SoftLoggedOut { device_id },83
- None => Termination::AccessRevoked,84
- });85
-}86
-8735
/// `states` must be taken before sync starts, a late subscriber never hears the transition it missed8836
pub(super) async fn connection(8937
mut states: impl futures_util::Stream<Item = SyncState> + Unpin,@@ -202,10 +150,9 @@ pub(super) async fn room_uploads(
202150
(room_id.clone(), transaction_id),203151
UploadOutcome::Sent { event_id },204152
),205
- Ok(RoomSendQueueUpdate::CancelledLocalEvent { transaction_id }) => RoomUpload::Ended(206
- (room_id.clone(), transaction_id),207
- UploadOutcome::Cancelled,208
- ),153
+ Ok(RoomSendQueueUpdate::CancelledLocalEvent { transaction_id }) => {154
+ RoomUpload::Ended((room_id.clone(), transaction_id), UploadOutcome::Cancelled)155
+ }209156
Ok(_) => continue,210157
Err(RecvError::Lagged(_)) => RoomUpload::Lagged,211158
Err(RecvError::Closed) => return,@@ -634,9 +634,14 @@ pub enum Event {
634634
Authenticated {635635
user_id: OwnedUserId,636636
},637
- /// Refresh tokens changed. Persist a fresh `ClientHandle::session_record()`638
- /// atomically; a record captured before this event may no longer restore.637
+ /// the tokens rotated. with a `SessionStore` the record is already saved by now,638
+ /// without one persist a fresh `ClientHandle::session_record()` atomically639639
SessionTokensChanged,640
+ /// the rotated tokens didn't reach the `SessionStore`, so the saved record may not641
+ /// restore. the engine tries again on the next rotation642
+ SessionSaveFailed {643
+ message: String,644
+ },640645
/// the server stopped honouring our token and the engine stops. a soft logout641646
/// names the device to log in again as, keeping the store. otherwise the record642647
/// and the store are both dead@@ -22,6 +22,14 @@ pub trait SessionStore: SendOutsideWasm + SyncOutsideWasm {
2222
/// replace the saved record, all or nothing2323
fn save<'a>(&'a self, record: &'a SessionRecord) -> BoxFuture<'a, Result<(), AccountError>>;2424
25
+ /// `save`, but without awaiting. the sdk calls it from inside a refresh, before the new26
+ /// refresh token is ever used, so a store that can write synchronously closes the gap27
+ /// a crash would otherwise lose the login through. `None` leaves the refresh to `save`28
+ #[cfg(not(target_family = "wasm"))]29
+ fn save_blocking(&self, _record: &SessionRecord) -> Option<Result<(), AccountError>> {30
+ None31
+ }32
+2533
/// swap the record for one that only says who to log in again as, keeping the store.2634
/// `Session::restore` then answers `Restore::SoftLoggedOut`, even after a restart2735
fn soft_log_out(&self) -> BoxFuture<'_, Result<(), AccountError>>;@@ -113,14 +121,33 @@ impl EncryptedSessionStore {
113121
sealer: &mut Option<Sealer>,114122
record: &SessionRecord,115123
) -> Result<(), AccountError> {124
+ let sealed = self.seal(sealer, record)?;125
+ backend::write(&self.record, &sealed)126
+ .await127
+ .map_err(|error| account_error("writing the session record", error))128
+ }129
+130
+ fn seal(131
+ &self,132
+ sealer: &mut Option<Sealer>,133
+ record: &SessionRecord,134
+ ) -> Result<Vec<u8>, AccountError> {116135
let sealer = match sealer {117136
Some(sealer) => sealer,118137
empty => empty.insert(Sealer::new(self.store.passphrase())?),119138
};120
- let sealed = sealer.seal(record)?;121
- backend::write(&self.record, &sealed)122
- .await123
- .map_err(|error| account_error("writing the session record", error))139
+ sealer.seal(record)140
+ }141
+142
+ /// the lock is only ever held around one small write, so losing the race to it just143
+ /// hands this refresh to the async save144
+ #[cfg(feature = "sqlite")]145
+ fn save_now(&self, record: &SessionRecord) -> Option<Result<(), AccountError>> {146
+ let mut sealer = self.sealer.try_lock().ok()?;147
+ Some(self.seal(&mut sealer, record).and_then(|sealed| {148
+ backend::write_blocking(&self.record, &sealed)149
+ .map_err(|error| account_error("writing the session record", error))150
+ }))124151
}125152
}126153
@@ -137,6 +164,11 @@ impl SessionStore for EncryptedSessionStore {
137164
Box::pin(self.save_record(record))138165
}139166
167
+ #[cfg(feature = "sqlite")]168
+ fn save_blocking(&self, record: &SessionRecord) -> Option<Result<(), AccountError>> {169
+ self.save_now(record)170
+ }171
+140172
fn soft_log_out(&self) -> BoxFuture<'_, Result<(), AccountError>> {141173
Box::pin(self.soft_log_out_record())142174
}@@ -211,6 +243,10 @@ mod backend {
211243
}212244
213245
pub(super) async fn write(path: &Path, sealed: &[u8]) -> io::Result<()> {246
+ write_blocking(path, sealed)247
+ }248
+249
+ pub(super) fn write_blocking(path: &Path, sealed: &[u8]) -> io::Result<()> {214250
let mut temporary = path.as_os_str().to_owned();215251
temporary.push(".tmp");216252
let temporary = PathBuf::from(temporary);@@ -75,7 +75,8 @@ impl Future for Pending {
7575
}7676
7777
impl ClientHandle {78
- /// current credentials. persist this atomically on `Event::SessionTokensChanged`, it holds secrets78
+ /// current credentials, it holds secrets. the engine saves it for you unless the79
+ /// session was built `without_persistence`7980
#[cfg(any(feature = "sqlite", feature = "indexeddb"))]8081
pub fn session_record(&self) -> Result<crate::SessionRecord, crate::AccountError> {8182
crate::account::session_record(&self.client)@@ -1,7 +1,7 @@
11
#![cfg(feature = "sqlite")]22
mod support;33
4
-use std::{path::PathBuf, time::SystemTime};4
+use std::{path::PathBuf, sync::Arc, time::SystemTime};55
66
use macltok::{77
EncryptedSessionStore, EngineConfig, Event, PasswordLogin, Restore, Session, SessionRecord,@@ -37,7 +37,7 @@ async fn password_session_round_trips_and_discards_with_its_store() {
3737
let session = Session::login_password(3838
PasswordLogin::new(server.uri(), "example", "wordpass")3939
.with_device_display_name("macltok test"),40
- &sessions(),40
+ Arc::new(sessions()),4141
)4242
.await4343
.unwrap();@@ -56,8 +56,8 @@ async fn password_session_round_trips_and_discards_with_its_store() {
5656
);5757
assert!(wrong.load().await.is_err());5858
59
- let sessions = sessions();60
- let Some(Restore::Resumed(session)) = Session::restore(&sessions).await.unwrap() else {59
+ let sessions = Arc::new(sessions());60
+ let Some(Restore::Resumed(session)) = Session::restore(sessions.clone()).await.unwrap() else {6161
panic!("the saved login didn't resume");6262
};6363
let (engine, handle, mut events) = session.build(config).await.unwrap();@@ -72,7 +72,7 @@ async fn password_session_round_trips_and_discards_with_its_store() {
7272
sessions.discard().await.unwrap();7373
assert!(!record_path.exists());7474
assert!(!store_path.exists());75
- assert!(Session::restore(&sessions).await.unwrap().is_none());75
+ assert!(Session::restore(sessions).await.unwrap().is_none());7676
std::fs::remove_dir_all(root).unwrap();7777
}7878
@@ -90,18 +90,19 @@ async fn a_restart_after_a_soft_logout_still_knows_the_device() {
9090
};9191
Session::login_password(9292
PasswordLogin::new(server.uri(), "example", "wordpass"),93
- &sessions(),93
+ Arc::new(sessions()),9494
)9595
.await9696
.unwrap();9797
sessions().soft_log_out().await.unwrap();9898
99
- let Some(Restore::SoftLoggedOut(soft)) = Session::restore(&sessions()).await.unwrap() else {99
+ let Some(Restore::SoftLoggedOut(soft)) = Session::restore(Arc::new(sessions())).await.unwrap()100
+ else {100101
panic!("a soft logged out record came back as something else");101102
};102103
// the device matrix-sdk-test's canned login response hands out103104
assert_eq!(soft.device_id, "GHTYAJCE");104
- Session::login_password(soft.password_login("wordpass"), &sessions())105
+ Session::login_password(soft.password_login("wordpass"), Arc::new(sessions()))105106
.await106107
.unwrap();107108
let requests = server.server().received_requests().await.unwrap();@@ -113,7 +114,7 @@ async fn a_restart_after_a_soft_logout_still_knows_the_device() {
113114
.unwrap();114115
assert_eq!(relogin["device_id"], "GHTYAJCE");115116
assert!(matches!(116
- Session::restore(&sessions()).await.unwrap(),117
+ Session::restore(Arc::new(sessions())).await.unwrap(),117118
Some(Restore::Resumed(_))118119
));119120
std::fs::remove_dir_all(root).unwrap();@@ -153,6 +154,73 @@ async fn a_torn_or_failed_save_leaves_the_old_record() {
153154
std::fs::remove_dir_all(root).unwrap();154155
}155156
157
+#[tokio::test]158
+async fn a_refresh_is_saved_before_anyone_hears_of_it() {159
+ use matrix_sdk_test::test_json;160
+ use wiremock::{161
+ Mock, ResponseTemplate,162
+ matchers::{header, method, path, path_regex},163
+ };164
+165
+ let server = MatrixMockServer::new().await;166
+ server.mock_versions().ok().mount().await;167
+ server168
+ .mock_login()169
+ .respond_with(170
+ ResponseTemplate::new(200).set_body_json(&*test_json::LOGIN_WITH_REFRESH_TOKEN),171
+ )172
+ .mount()173
+ .await;174
+ Mock::given(method("POST"))175
+ .and(path_regex("sync"))176
+ .and(header("authorization", "Bearer abc123"))177
+ .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({178
+ "errcode": "M_UNKNOWN_TOKEN",179
+ "error": "expired",180
+ "soft_logout": false,181
+ })))182
+ .with_priority(1)183
+ .mount(server.server())184
+ .await;185
+ Mock::given(method("POST"))186
+ .and(path("/_matrix/client/v3/refresh"))187
+ .respond_with(188
+ ResponseTemplate::new(200).set_body_json(&*test_json::REFRESH_TOKEN_WITH_REFRESH_TOKEN),189
+ )190
+ .mount(server.server())191
+ .await;192
+ server193
+ .mock_sliding_sync()194
+ .ok(v5::Response::new("1".to_owned()))195
+ .mount()196
+ .await;197
+ let root = temporary_path();198
+ let sessions = Arc::new(EncryptedSessionStore::file(199
+ root.join("session"),200
+ StoreConfig::sqlite(root.join("store"), "test store passphrase"),201
+ ));202
+ let session = Session::login_password(203
+ PasswordLogin::new(server.uri(), "example", "wordpass"),204
+ sessions.clone(),205
+ )206
+ .await207
+ .unwrap();208
+ let (engine, handle, mut events) = session209
+ .build(EngineConfig::default().with_initial_history_limit(0))210
+ .await211
+ .unwrap();212
+ let running = tokio::spawn(engine.run());213
+ support::receive_until(&mut events, |event| {214
+ matches!(event, Event::SessionTokensChanged)215
+ })216
+ .await;217
+ let saved = serde_json::to_value(sessions.load().await.unwrap().unwrap()).unwrap();218
+ assert_eq!(saved["authentication"]["session"]["refresh_token"], "wxyz");219
+ support::shut_down(&handle, &mut events, running).await;220
+ drop(handle);221
+ std::fs::remove_dir_all(root).unwrap();222
+}223
+156224
fn record(homeserver: &str) -> SessionRecord {157225
serde_json::from_value(serde_json::json!({158226
"homeserver": homeserver,