atagen

macltok

public
Star0
Watch0

Commit cb0668d30c14

tarball

atagen <boss@atagen.co>2026-10-08 13:08 UTC

6 files changed+588-5
accounts: keep every account an owner has, and resume a soft logout by itself
Modified file: README.md+5 additions
@@ -78,6 +78,11 @@ nothing, and the login overwrites it with a live record. `ShutDown` and
7878 one store directory per login, the sdk won't share a crypto store between
7979 devices
8080
81+`Accounts` does that bookkeeping. it holds one person's accounts under one root,
82+`login_password` and `begin_oauth` pick the store, resuming a soft-logged-out
83+device when the login names it, and `list` hands back an `Account` each to
84+`restore` and to apply `Termination::retain` through `sessions`
85+
8186 ## The mirror
8287
8388 the loop above is easy to get subtly wrong, so the `mirror` feature ships one.
Modified file: docs/design-decisions.md+18 additions
@@ -254,6 +254,24 @@ it. one store directory per login. a login whose first save fails comes back as
254254 `AccountError::Unsaved` holding the live session, so the host retries the save
255255 rather than spending another device
256256
257+`Accounts` picks the store, so a host never decides which account a login form
258+means. it's one owner's accounts under one root and one passphrase, a store and
259+record each, named by a random id. a password login that names a
260+soft-logged-out account, by localpart or full user id on the same server, logs
261+back in as that device into that store. the server is compared by name as well
262+as by url, since a typed `example.org` is stored as wherever discovery led. a
263+live account it names is `AlreadyLoggedIn`, anyone else gets a new store. oauth
264+can't know who it's for until it finishes, so it resumes only the device
265+`SoftLogout::oauth_login` names. a new store is locked before it's listed, so
266+`list` can sweep stores with no record, left by a login that died or a discard
267+that didn't finish, without catching one mid login. a browser lists accounts in
268+an IndexedDB database of their own, a key each, so two tabs adding at once
269+don't lose one
270+
271+the set is per owner because resuming needs only the password, and the store
272+holds the device's e2ee keys. a set shared across people would hand someone who
273+knows a password the history a fresh login wouldn't get
274+
257275 one engine per store. before the client opens the store we take the platform's
258276 own exclusive lock on it, `File::try_lock` on `macltok.lock` in the store
259277 directory natively and a Web Lock named after the store in a browser. the
Modified file: src/account.rs+70 additions−3 deletions
@@ -316,6 +316,28 @@ impl SessionRecord {
316316 &self.homeserver
317317 }
318318
319+ pub(crate) fn user_id(&self) -> &OwnedUserId {
320+ match &self.authentication {
321+ AuthenticationRecord::Matrix(session) => &session.meta.user_id,
322+ AuthenticationRecord::OAuth { user, .. } => &user.meta.user_id,
323+ AuthenticationRecord::SoftLoggedOut { meta, .. } => &meta.user_id,
324+ }
325+ }
326+
327+ pub(crate) fn soft_logout(&self) -> Option<SoftLogout> {
328+ let AuthenticationRecord::SoftLoggedOut { meta, client_id } = &self.authentication else {
329+ return None;
330+ };
331+ Some(SoftLogout {
332+ homeserver: self.homeserver.clone(),
333+ user_id: meta.user_id.clone(),
334+ device_id: meta.device_id.clone(),
335+ client_id: client_id
336+ .as_ref()
337+ .map(|client_id| client_id.as_str().to_owned()),
338+ })
339+ }
340+
319341 pub(crate) fn soft_logged_out(self) -> Self {
320342 let authentication = match self.authentication {
321343 AuthenticationRecord::Matrix(session) => AuthenticationRecord::SoftLoggedOut {
@@ -361,6 +383,27 @@ impl SoftLogout {
361383 .with_device_id(self.device_id.clone())
362384 .allow_insecure_loopback(true)
363385 }
386+
387+ #[must_use]
388+ pub fn oauth_login(
389+ &self,
390+ redirect_uri: impl Into<String>,
391+ client_uri: impl Into<String>,
392+ application_type: OAuthApplicationType,
393+ ) -> OAuthLogin {
394+ let login = OAuthLogin::new(
395+ self.homeserver.clone(),
396+ redirect_uri,
397+ client_uri,
398+ application_type,
399+ )
400+ .with_device_id(self.device_id.clone())
401+ .allow_insecure_loopback(true);
402+ match &self.client_id {
403+ Some(client_id) => login.with_client_id(client_id.clone()),
404+ None => login,
405+ }
406+ }
364407 }
365408
366409 #[derive(Debug, Error)]
@@ -382,6 +425,9 @@ pub enum AccountError {
382425 /// what was typed can't name a homeserver, so nothing was sent anywhere
383426 #[error("that isn't a usable homeserver, {0}")]
384427 InvalidHomeserver(&'static str),
428+ /// `Accounts` already has a live login for them. a second would be another device
429+ #[error("{0} is already logged in")]
430+ AlreadyLoggedIn(OwnedUserId),
385431 /// an IndexedDB delete is waiting on the store's other connections. it goes through
386432 /// once they close, and opening the store again waits behind it
387433 #[error("the store is still open elsewhere, it will be deleted once that closes")]
@@ -453,8 +499,17 @@ impl Session {
453499 login: PasswordLogin,
454500 sessions: Arc<dyn SessionStore>,
455501 ) -> Result<Self, AccountError> {
456- let homeserver = homeserver_input(&login.homeserver, login.allow_insecure_loopback)?;
502+ homeserver_input(&login.homeserver, login.allow_insecure_loopback)?;
457503 let store_lock = sessions.store().lock().await?;
504+ Self::login_password_locked(login, sessions, store_lock).await
505+ }
506+
507+ pub(crate) async fn login_password_locked(
508+ login: PasswordLogin,
509+ sessions: Arc<dyn SessionStore>,
510+ store_lock: StoreLock,
511+ ) -> Result<Self, AccountError> {
512+ let homeserver = homeserver_input(&login.homeserver, login.allow_insecure_loopback)?;
458513 let client = matrix_client_builder(sessions.store())
459514 .server_name_or_homeserver_url(homeserver)
460515 .build()
@@ -483,12 +538,21 @@ impl Session {
483538 login: OAuthLogin,
484539 sessions: Arc<dyn SessionStore>,
485540 ) -> Result<PendingOAuthLogin, AccountError> {
541+ homeserver_input(&login.homeserver, login.allow_insecure_loopback)?;
542+ let store_lock = sessions.store().lock().await?;
543+ Self::begin_oauth_locked(login, sessions, store_lock).await
544+ }
545+
546+ pub(crate) async fn begin_oauth_locked(
547+ login: OAuthLogin,
548+ sessions: Arc<dyn SessionStore>,
549+ store_lock: StoreLock,
550+ ) -> Result<PendingOAuthLogin, AccountError> {
486551 let redirect_uri = Url::parse(login.redirect_uri.trim())
487552 .map_err(|error| account_error("parsing the OAuth redirect URI", error))?;
488553 let client_uri = Url::parse(login.client_uri.trim())
489554 .map_err(|error| account_error("parsing the OAuth client URI", error))?;
490555 let homeserver = homeserver_input(&login.homeserver, login.allow_insecure_loopback)?;
491- let store_lock = sessions.store().lock().await?;
492556 let client = matrix_client_builder(sessions.store())
493557 .server_name_or_homeserver_url(homeserver)
494558 .build()
@@ -718,7 +782,10 @@ fn matrix_client_builder(store: &StoreConfig) -> ClientBuilder {
718782 }
719783
720784 /// a bare server name goes through untouched, since that's what makes the sdk try `.well-known`
721-fn homeserver_input(input: &str, allow_insecure_loopback: bool) -> Result<&str, AccountError> {
785+pub(crate) fn homeserver_input(
786+ input: &str,
787+ allow_insecure_loopback: bool,
788+) -> Result<&str, AccountError> {
722789 let input = input.trim();
723790 let invalid = |reason| Err(AccountError::InvalidHomeserver(reason));
724791 if input.is_empty() {
Added file: src/accounts.rs+407 additions
@@ -0,0 +1,407 @@
1+use std::sync::Arc;
2+#[cfg(feature = "sqlite")]
3+use std::{collections::BTreeSet, path::PathBuf};
4+
5+use ruma::TransactionId;
6+
7+use crate::{
8+ AccountError, EncryptedSessionStore, OAuthLogin, PasswordLogin, PendingOAuthLogin, Restore,
9+ Session, SessionRecord, SessionStore, SoftLogout, StoreConfig, account::homeserver_input,
10+ engine::StoreLock,
11+};
12+
13+/// every account one owner has, a store each under one root and one passphrase. whoever holds
14+/// the set can resume any soft-logged-out device in it with just its password, and the device's
15+/// store has its e2ee keys, so a set belongs to one person and never to a whole machine
16+pub struct Accounts {
17+ root: Root,
18+ passphrase: String,
19+}
20+
21+enum Root {
22+ #[cfg(feature = "sqlite")]
23+ Directory(PathBuf),
24+ #[cfg(feature = "indexeddb")]
25+ Prefix(String),
26+}
27+
28+/// one account's record and store
29+#[derive(Clone)]
30+pub struct Account {
31+ id: String,
32+ sessions: Arc<EncryptedSessionStore>,
33+}
34+
35+impl Account {
36+ /// stable for the account's life, and safe in a cookie or a url
37+ #[must_use]
38+ pub fn id(&self) -> &str {
39+ &self.id
40+ }
41+
42+ /// for `Termination::retain`. a discarded account drops out of `Accounts::list`
43+ #[must_use]
44+ pub fn sessions(&self) -> Arc<dyn SessionStore> {
45+ self.sessions.clone()
46+ }
47+
48+ pub async fn restore(&self) -> Result<Option<Restore>, AccountError> {
49+ Session::restore(self.sessions()).await
50+ }
51+}
52+
53+impl std::fmt::Debug for Account {
54+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55+ formatter
56+ .debug_struct("Account")
57+ .field("id", &self.id)
58+ .finish_non_exhaustive()
59+ }
60+}
61+
62+enum Found {
63+ Nobody,
64+ SoftLoggedOut(Account, SoftLogout),
65+}
66+
67+impl Accounts {
68+ /// each account is a store directory under `root` with its record beside it
69+ #[cfg(feature = "sqlite")]
70+ #[must_use]
71+ pub fn sqlite(root: impl Into<PathBuf>, passphrase: impl Into<String>) -> Self {
72+ Self {
73+ root: Root::Directory(root.into()),
74+ passphrase: passphrase.into(),
75+ }
76+ }
77+
78+ /// each account is a store named `<prefix>::<id>`, listed in `<prefix>::macltok-accounts`
79+ #[cfg(feature = "indexeddb")]
80+ #[must_use]
81+ pub fn indexeddb(prefix: impl Into<String>, passphrase: impl Into<String>) -> Self {
82+ Self {
83+ root: Root::Prefix(prefix.into()),
84+ passphrase: passphrase.into(),
85+ }
86+ }
87+
88+ /// `None` for anything `Account::id` couldn't have been
89+ #[must_use]
90+ pub fn account(&self, id: &str) -> Option<Account> {
91+ let valid =
92+ !id.is_empty() && id.len() <= 64 && id.bytes().all(|b| b.is_ascii_alphanumeric());
93+ valid.then(|| self.open(id.to_owned()))
94+ }
95+
96+ /// every account with a record. a store left without one, by a login that died halfway or a
97+ /// discard that couldn't finish, is deleted on the way past
98+ pub async fn list(&self) -> Result<Vec<Account>, AccountError> {
99+ let mut accounts = Vec::new();
100+ for account in self.slots().await? {
101+ match account.sessions.load().await {
102+ Ok(None) => self.sweep(&account).await,
103+ Ok(Some(_)) | Err(_) => accounts.push(account),
104+ }
105+ }
106+ Ok(accounts)
107+ }
108+
109+ /// a soft-logged-out account the login names is resumed as its own device, keeping its keys.
110+ /// anyone else gets a new account
111+ pub async fn login_password(
112+ &self,
113+ login: PasswordLogin,
114+ ) -> Result<(Account, Session), AccountError> {
115+ homeserver_input(&login.homeserver, login.allow_insecure_loopback)?;
116+ let found = self
117+ .find(|record| record_names(record, &login.homeserver, &login.username))
118+ .await?;
119+ match found {
120+ Found::SoftLoggedOut(account, soft) => {
121+ let store_lock = account.sessions.store().lock().await?;
122+ let login = PasswordLogin {
123+ device_display_name: login.device_display_name,
124+ ..soft.password_login(login.password)
125+ };
126+ let session =
127+ Session::login_password_locked(login, account.sessions(), store_lock).await?;
128+ Ok((account, session))
129+ }
130+ Found::Nobody => {
131+ let (account, store_lock) = self.fresh().await?;
132+ let login = PasswordLogin {
133+ device_id: None,
134+ ..login
135+ };
136+ match Session::login_password_locked(login, account.sessions(), store_lock).await {
137+ Ok(session) => Ok((account, session)),
138+ Err(error) => Err(self.abandon(&account, error).await),
139+ }
140+ }
141+ }
142+ }
143+
144+ /// resumes the soft-logged-out account whose device `login` names, as
145+ /// `SoftLogout::oauth_login` sets it. without one it's a new account, since which account an
146+ /// OAuth login is for isn't known until it finishes
147+ pub async fn begin_oauth(
148+ &self,
149+ login: OAuthLogin,
150+ ) -> Result<(Account, PendingOAuthLogin), AccountError> {
151+ homeserver_input(&login.homeserver, login.allow_insecure_loopback)?;
152+ let device_id = login.device_id.clone();
153+ let found = self
154+ .find(|record| {
155+ record
156+ .soft_logout()
157+ .is_some_and(|soft| device_id.as_ref() == Some(&soft.device_id))
158+ })
159+ .await?;
160+ match found {
161+ Found::SoftLoggedOut(account, soft) => {
162+ let store_lock = account.sessions.store().lock().await?;
163+ let login = OAuthLogin {
164+ homeserver: soft.homeserver,
165+ client_id: login.client_id.or(soft.client_id),
166+ allow_insecure_loopback: true,
167+ ..login
168+ };
169+ let pending =
170+ Session::begin_oauth_locked(login, account.sessions(), store_lock).await?;
171+ Ok((account, pending))
172+ }
173+ Found::Nobody => {
174+ let (account, store_lock) = self.fresh().await?;
175+ let login = OAuthLogin {
176+ device_id: None,
177+ ..login
178+ };
179+ match Session::begin_oauth_locked(login, account.sessions(), store_lock).await {
180+ Ok(pending) => Ok((account, pending)),
181+ Err(error) => Err(self.abandon(&account, error).await),
182+ }
183+ }
184+ }
185+ }
186+
187+ /// a live login for the same person is refused rather than given a second device
188+ async fn find(&self, resumes: impl Fn(&SessionRecord) -> bool) -> Result<Found, AccountError> {
189+ for account in self.slots().await? {
190+ let Ok(Some(record)) = account.sessions.load().await else {
191+ continue;
192+ };
193+ if !resumes(&record) {
194+ continue;
195+ }
196+ return match record.soft_logout() {
197+ Some(soft) => Ok(Found::SoftLoggedOut(account, soft)),
198+ None => Err(AccountError::AlreadyLoggedIn(record.user_id().clone())),
199+ };
200+ }
201+ Ok(Found::Nobody)
202+ }
203+
204+ /// locked before anything else can see it, so `list` never sweeps a login still under way
205+ async fn fresh(&self) -> Result<(Account, StoreLock), AccountError> {
206+ let account = self.open(TransactionId::new().to_string());
207+ let store_lock = account.sessions.store().lock().await?;
208+ #[cfg(feature = "indexeddb")]
209+ {
210+ let Root::Prefix(prefix) = &self.root;
211+ index::add(prefix, &account.id).await?;
212+ }
213+ Ok((account, store_lock))
214+ }
215+
216+ /// a failed first login leaves nothing behind. one whose record didn't save has a live device
217+ /// on the server, so that stays for `UnsavedSession::save`
218+ async fn abandon(&self, account: &Account, error: AccountError) -> AccountError {
219+ if !matches!(error, AccountError::Unsaved { .. }) {
220+ self.sweep(account).await;
221+ }
222+ error
223+ }
224+
225+ /// best effort, since whatever it leaves is found and swept again by the next `list`
226+ async fn sweep(&self, account: &Account) {
227+ let Ok(_store_lock) = account.sessions.store().lock().await else {
228+ return;
229+ };
230+ if !matches!(account.sessions.load().await, Ok(None)) {
231+ return;
232+ }
233+ if account.sessions.discard().await.is_err() {
234+ return;
235+ }
236+ #[cfg(feature = "indexeddb")]
237+ {
238+ let Root::Prefix(prefix) = &self.root;
239+ let _ = index::remove(prefix, &account.id).await;
240+ }
241+ }
242+
243+ fn open(&self, id: String) -> Account {
244+ let sessions = match &self.root {
245+ #[cfg(feature = "sqlite")]
246+ Root::Directory(root) => EncryptedSessionStore::file(
247+ root.join(format!("{id}.session")),
248+ StoreConfig::sqlite(root.join(&id), self.passphrase.clone()),
249+ ),
250+ #[cfg(feature = "indexeddb")]
251+ Root::Prefix(prefix) => EncryptedSessionStore::indexeddb(StoreConfig::indexeddb(
252+ format!("{prefix}::{id}"),
253+ self.passphrase.clone(),
254+ )),
255+ };
256+ Account {
257+ id,
258+ sessions: Arc::new(sessions),
259+ }
260+ }
261+
262+ async fn slots(&self) -> Result<Vec<Account>, AccountError> {
263+ let ids = match &self.root {
264+ #[cfg(feature = "sqlite")]
265+ Root::Directory(root) => directory_slots(root)?,
266+ #[cfg(feature = "indexeddb")]
267+ Root::Prefix(prefix) => index::list(prefix).await?,
268+ };
269+ Ok(ids.iter().filter_map(|id| self.account(id)).collect())
270+ }
271+}
272+
273+/// a localpart or a full user id names the same account, but only on the same server. the server
274+/// is compared by name as well as by url, so a delegated `example.org` still finds the account
275+/// discovery took to `matrix.example.org`
276+fn record_names(record: &SessionRecord, homeserver: &str, username: &str) -> bool {
277+ let user_id = record.user_id();
278+ let username = username.trim();
279+ let user = if username.starts_with('@') {
280+ username.eq_ignore_ascii_case(user_id.as_str())
281+ } else {
282+ username.eq_ignore_ascii_case(user_id.localpart())
283+ };
284+ let typed = authority(homeserver);
285+ user && (typed.eq_ignore_ascii_case(user_id.server_name().as_str())
286+ || typed.eq_ignore_ascii_case(authority(record.homeserver())))
287+}
288+
289+fn authority(homeserver: &str) -> &str {
290+ let homeserver = homeserver.trim();
291+ let rest = homeserver
292+ .split_once("://")
293+ .map_or(homeserver, |(_, rest)| rest);
294+ rest.split('/').next().unwrap_or(rest)
295+}
296+
297+#[cfg(feature = "sqlite")]
298+fn directory_slots(root: &std::path::Path) -> Result<BTreeSet<String>, AccountError> {
299+ use std::io::ErrorKind;
300+
301+ let entries = match std::fs::read_dir(root) {
302+ Ok(entries) => entries,
303+ Err(error) if error.kind() == ErrorKind::NotFound => return Ok(BTreeSet::new()),
304+ Err(error) => return Err(crate::account::account_error("listing the accounts", error)),
305+ };
306+ let mut ids = BTreeSet::new();
307+ for entry in entries {
308+ let entry =
309+ entry.map_err(|error| crate::account::account_error("listing the accounts", error))?;
310+ let name = entry.file_name();
311+ let Some(name) = name.to_str() else {
312+ continue;
313+ };
314+ if entry.path().is_dir() {
315+ ids.insert(name.to_owned());
316+ } else if let Some(id) = name.strip_suffix(".session") {
317+ ids.insert(id.to_owned());
318+ }
319+ }
320+ Ok(ids)
321+}
322+
323+/// a key per account, so two tabs adding at once can't lose one to the other
324+#[cfg(feature = "indexeddb")]
325+mod index {
326+ use std::collections::BTreeSet;
327+
328+ use indexed_db_futures::{
329+ Build,
330+ database::Database,
331+ error::{Error, OpenDbError},
332+ prelude::*,
333+ transaction::{Transaction, TransactionMode},
334+ };
335+
336+ use crate::{AccountError, account::account_error};
337+
338+ const ACCOUNTS: &str = "accounts";
339+
340+ fn name(prefix: &str) -> String {
341+ format!("{prefix}::macltok-accounts")
342+ }
343+
344+ async fn open(prefix: &str) -> Result<Database, OpenDbError> {
345+ Database::open(&name(prefix))
346+ .with_version(1u32)
347+ .with_on_upgrade_needed(|_, transaction: &Transaction<'_>| -> Result<(), Error> {
348+ transaction.db().create_object_store(ACCOUNTS).build()?;
349+ Ok(())
350+ })
351+ .build()?
352+ .await
353+ }
354+
355+ pub(super) async fn list(prefix: &str) -> Result<BTreeSet<String>, AccountError> {
356+ let failed = |error| account_error("listing the accounts", error);
357+ let database = open(prefix).await.map_err(failed)?;
358+ let ids = keys(&database).await;
359+ database.close();
360+ ids.map_err(|error| account_error("listing the accounts", error))
361+ }
362+
363+ pub(super) async fn add(prefix: &str, id: &str) -> Result<(), AccountError> {
364+ let failed = |error| account_error("adding the account", error);
365+ let database = open(prefix).await.map_err(failed)?;
366+ let added = write(&database, |store| {
367+ store.put(true).with_key(id).build()?;
368+ Ok(())
369+ })
370+ .await;
371+ database.close();
372+ added.map_err(|error| account_error("adding the account", error))
373+ }
374+
375+ pub(super) async fn remove(prefix: &str, id: &str) -> Result<(), AccountError> {
376+ let failed = |error| account_error("removing the account", error);
377+ let database = open(prefix).await.map_err(failed)?;
378+ let removed = write(&database, |store| {
379+ store.delete(id).build()?;
380+ Ok(())
381+ })
382+ .await;
383+ database.close();
384+ removed.map_err(|error| account_error("removing the account", error))
385+ }
386+
387+ async fn keys(database: &Database) -> Result<BTreeSet<String>, Error> {
388+ let transaction = database
389+ .transaction(ACCOUNTS)
390+ .with_mode(TransactionMode::Readonly)
391+ .build()?;
392+ let store = transaction.object_store(ACCOUNTS)?;
393+ store.get_all_keys::<String>().await?.collect()
394+ }
395+
396+ async fn write(
397+ database: &Database,
398+ change: impl FnOnce(&indexed_db_futures::object_store::ObjectStore<'_>) -> Result<(), Error>,
399+ ) -> Result<(), Error> {
400+ let transaction = database
401+ .transaction(ACCOUNTS)
402+ .with_mode(TransactionMode::Readwrite)
403+ .build()?;
404+ change(&transaction.object_store(ACCOUNTS)?)?;
405+ transaction.commit().await
406+ }
407+}
Modified file: src/lib.rs+4 additions
@@ -21,6 +21,8 @@ compile_error!("indexeddb only exists in a browser, use sqlite");
2121 #[cfg(any(feature = "sqlite", feature = "indexeddb"))]
2222 mod account;
2323 #[cfg(any(feature = "sqlite", feature = "indexeddb"))]
24+mod accounts;
25+#[cfg(any(feature = "sqlite", feature = "indexeddb"))]
2426 mod session_store;
2527 #[cfg(any(feature = "sqlite", feature = "indexeddb"))]
2628 mod store_lock;
@@ -31,6 +33,8 @@ pub use account::{
3133 Session, SessionRecord, SoftLogout, StoreConfig, UnsavedSession,
3234 };
3335 #[cfg(any(feature = "sqlite", feature = "indexeddb"))]
36+pub use accounts::{Account, Accounts};
37+#[cfg(any(feature = "sqlite", feature = "indexeddb"))]
3438 pub use session_store::{EncryptedSessionStore, SessionStore};
3539
3640 pub use engine::{Engine, EngineConfig, EngineError};
Modified file: tests/account.rs+84 additions−2 deletions
@@ -4,8 +4,8 @@ mod support;
44 use std::{path::PathBuf, sync::Arc, time::SystemTime};
55
66 use macltok::{
7- AccountError, EncryptedSessionStore, EngineConfig, Event, PasswordLogin, Restore, Session,
8- SessionRecord, SessionStore, StoreConfig,
7+ AccountError, Accounts, EncryptedSessionStore, EngineConfig, Event, PasswordLogin, Restore,
8+ Session, SessionRecord, SessionStore, StoreConfig,
99 };
1010 use matrix_sdk::{ruma::api::client::sync::sync_events::v5, test_utils::mocks::MatrixMockServer};
1111
@@ -302,6 +302,88 @@ async fn a_refresh_is_saved_before_anyone_hears_of_it() {
302302 }
303303
304304 #[tokio::test]
305+async fn logging_in_again_after_a_soft_logout_keeps_the_device_and_its_store() {
306+ let server = MatrixMockServer::new().await;
307+ server.mock_versions().ok().mount().await;
308+ server.mock_login().ok().mount().await;
309+ let root = temporary_path();
310+ let accounts = Accounts::sqlite(&root, "test store passphrase");
311+ let login = |username: &str| {
312+ PasswordLogin::new(server.uri(), username, "wordpass").allow_insecure_loopback(true)
313+ };
314+ let device_ids = || async {
315+ let requests = server.server().received_requests().await.unwrap();
316+ requests
317+ .iter()
318+ .filter(|request| request.url.path().ends_with("/login"))
319+ .map(|request| request.body_json::<serde_json::Value>().unwrap()["device_id"].clone())
320+ .collect::<Vec<_>>()
321+ };
322+
323+ let (first, session) = accounts
324+ .login_password(login("cheeky_monkey"))
325+ .await
326+ .unwrap();
327+ drop(session);
328+ assert!(matches!(
329+ accounts.login_password(login("cheeky_monkey")).await,
330+ Err(AccountError::AlreadyLoggedIn(_))
331+ ));
332+
333+ first.sessions().soft_log_out().await.unwrap();
334+ let (resumed, session) = accounts
335+ .login_password(login(" Cheeky_Monkey "))
336+ .await
337+ .unwrap();
338+ drop(session);
339+ assert_eq!(resumed.id(), first.id());
340+ assert_eq!(device_ids().await.last().unwrap(), "GHTYAJCE");
341+
342+ resumed.sessions().soft_log_out().await.unwrap();
343+ let (other, session) = accounts
344+ .login_password(login("someone_else"))
345+ .await
346+ .unwrap();
347+ drop(session);
348+ assert_ne!(other.id(), first.id());
349+ assert!(device_ids().await.last().unwrap().is_null());
350+ assert_eq!(accounts.list().await.unwrap().len(), 2);
351+ std::fs::remove_dir_all(root).unwrap();
352+}
353+
354+#[tokio::test]
355+async fn a_refused_login_leaves_no_account_and_says_why() {
356+ use wiremock::ResponseTemplate;
357+
358+ let server = MatrixMockServer::new().await;
359+ server.mock_versions().ok().mount().await;
360+ server
361+ .mock_login()
362+ .respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({
363+ "errcode": "M_FORBIDDEN",
364+ "error": "Invalid username or password",
365+ })))
366+ .mount()
367+ .await;
368+ let root = temporary_path();
369+ let accounts = Accounts::sqlite(&root, "test store passphrase");
370+ let Err(refused) = accounts
371+ .login_password(
372+ PasswordLogin::new(server.uri(), "example", "wrong").allow_insecure_loopback(true),
373+ )
374+ .await
375+ else {
376+ panic!("a refused login went through");
377+ };
378+ let server_error = refused.server().unwrap();
379+ assert_eq!(server_error.errcode, "M_FORBIDDEN");
380+ assert_eq!(server_error.message, "Invalid username or password");
381+ assert!(accounts.list().await.unwrap().is_empty());
382+ assert_eq!(std::fs::read_dir(&root).unwrap().count(), 0);
383+ std::fs::remove_dir_all(root).unwrap();
384+}
385+
386+#[tokio::test]
305387 async fn a_homeserver_that_cant_be_one_is_refused_before_anything_is_sent() {
306388 let root = temporary_path();
307389 let sessions: Arc<dyn SessionStore> = Arc::new(EncryptedSessionStore::file(