Commit cb0668d30c14
tarball6 files changed+588-5
@@ -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 between7979
devices8080
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-out83
+device when the login names it, and `list` hands back an `Account` each to84
+`restore` and to apply `Termination::retain` through `sessions`85
+8186
## The mirror8287
8388
the loop above is easy to get subtly wrong, so the `mirror` feature ships one.@@ -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 save255255
rather than spending another device256256
257
+`Accounts` picks the store, so a host never decides which account a login form258
+means. it's one owner's accounts under one root and one passphrase, a store and259
+record each, named by a random id. a password login that names a260
+soft-logged-out account, by localpart or full user id on the same server, logs261
+back in as that device into that store. the server is compared by name as well262
+as by url, since a typed `example.org` is stored as wherever discovery led. a263
+live account it names is `AlreadyLoggedIn`, anyone else gets a new store. oauth264
+can't know who it's for until it finishes, so it resumes only the device265
+`SoftLogout::oauth_login` names. a new store is locked before it's listed, so266
+`list` can sweep stores with no record, left by a login that died or a discard267
+that didn't finish, without catching one mid login. a browser lists accounts in268
+an IndexedDB database of their own, a key each, so two tabs adding at once269
+don't lose one270
+271
+the set is per owner because resuming needs only the password, and the store272
+holds the device's e2ee keys. a set shared across people would hand someone who273
+knows a password the history a fresh login wouldn't get274
+257275
one engine per store. before the client opens the store we take the platform's258276
own exclusive lock on it, `File::try_lock` on `macltok.lock` in the store259277
directory natively and a Web Lock named after the store in a browser. the@@ -316,6 +316,28 @@ impl SessionRecord {
316316
&self.homeserver317317
}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_id336
+ .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 anywhere383426
#[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 device429
+ #[error("{0} is already logged in")]430
+ AlreadyLoggedIn(OwnedUserId),385431
/// an IndexedDB delete is waiting on the store's other connections. it goes through386432
/// once they close, and opening the store again waits behind it387433
#[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).await505
+ }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).await544
+ }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() {@@ -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 holds14
+/// the set can resume any soft-logged-out device in it with just its password, and the device's15
+/// store has its e2ee keys, so a set belongs to one person and never to a whole machine16
+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 store29
+#[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 url37
+ #[must_use]38
+ pub fn id(&self) -> &str {39
+ &self.id40
+ }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()).await50
+ }51
+}52
+53
+impl std::fmt::Debug for Account {54
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {55
+ formatter56
+ .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 it69
+ #[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 been89
+ #[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 a97
+ /// discard that couldn't finish, is deleted on the way past98
+ 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 account111
+ 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 = self117
+ .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
+ ..login135
+ };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, as145
+ /// `SoftLogout::oauth_login` sets it. without one it's a new account, since which account an146
+ /// OAuth login is for isn't known until it finishes147
+ 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 = self154
+ .find(|record| {155
+ record156
+ .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
+ ..login168
+ };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
+ ..login178
+ };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 device188
+ 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 way205
+ 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 device217
+ /// 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
+ error223
+ }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 server274
+/// is compared by name as well as by url, so a delegated `example.org` still finds the account275
+/// 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 = homeserver292
+ .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 other324
+#[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
+ .await353
+ }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 = database389
+ .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 = database401
+ .transaction(ACCOUNTS)402
+ .with_mode(TransactionMode::Readwrite)403
+ .build()?;404
+ change(&transaction.object_store(ACCOUNTS)?)?;405
+ transaction.commit().await406
+ }407
+}@@ -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};@@ -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
+ requests317
+ .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) = accounts324
+ .login_password(login("cheeky_monkey"))325
+ .await326
+ .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) = accounts335
+ .login_password(login(" Cheeky_Monkey "))336
+ .await337
+ .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) = accounts344
+ .login_password(login("someone_else"))345
+ .await346
+ .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
+ server361
+ .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) = accounts371
+ .login_password(372
+ PasswordLogin::new(server.uri(), "example", "wrong").allow_insecure_loopback(true),373
+ )374
+ .await375
+ 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(