atagen

macltok

public
Star0
Watch0
cff1b0a4eb30290a6fe32fea825c816b696fe4f4
3
ssh://brewery@pls.lobotomise.me:33/atagen/macltok.git
src/account.rsRust1075 lines37.2 KB
1
#[cfg(feature = "sqlite")]
2
use std::path::PathBuf;
3
#[cfg(not(target_family = "wasm"))]
4
use std::sync::atomic::{AtomicBool, Ordering};
5
use std::{collections::HashMap, sync::Arc};
6
7
use matrix_sdk::{
8
    Client, ClientBuilder, SendOutsideWasm, SessionChange, SessionMeta, SyncOutsideWasm,
9
    ThreadingSupport,
10
    authentication::{
11
        AuthSession,
12
        matrix::MatrixSession,
13
        oauth::{
14
            ClientId, ClientRegistrationData, CsrfToken, OAuthSession, PendingAuthorization,
15
            UserSession,
16
            registration::{ApplicationType, ClientMetadata, Localized, OAuthGrantType},
17
        },
18
    },
19
    cross_process_lock::CrossProcessLockConfig,
20
    encryption::{BackupDownloadStrategy, EncryptionSettings},
21
    ruma::{MilliSecondsSinceUnixEpoch, OwnedDeviceId, OwnedUserId, serde::Raw},
22
};
23
use serde::{Deserialize, Serialize};
24
use thiserror::Error;
25
use tokio::sync::{broadcast, mpsc};
26
use url::Url;
27
28
use crate::{
29
    ClientHandle, Engine, EngineConfig, EngineError, ErrorKind, EventStream, ServerError,
30
    SessionStore,
31
    engine::{Persistence, StoreLock},
32
    error::SourceError,
33
};
34
35
/// a logged in client and the `SessionStore` its record lives in. the engine it builds
36
/// saves every token rotation there, unless told `without_persistence`
37
pub struct Session {
38
    client: Client,
39
    tracking: Tracking,
40
    store_lock: StoreLock,
41
}
42
43
/// taken as soon as the client exists, since the sdk can refresh before there's an engine
44
struct Tracking {
45
    store: Arc<dyn SessionStore>,
46
    session_changes: broadcast::Receiver<SessionChange>,
47
    persistence: Option<Persistence>,
48
    #[cfg(not(target_family = "wasm"))]
49
    inline: Arc<AtomicBool>,
50
}
51
52
#[non_exhaustive]
53
pub struct PasswordLogin {
54
    pub homeserver: String,
55
    pub username: String,
56
    pub password: String,
57
    pub device_display_name: Option<String>,
58
    /// set after a soft logout so the store's keys still belong to us
59
    pub device_id: Option<OwnedDeviceId>,
60
    /// let plain http through, for loopback hosts only
61
    pub allow_insecure_loopback: bool,
62
}
63
64
impl PasswordLogin {
65
    #[must_use]
66
    pub fn new(
67
        homeserver: impl Into<String>,
68
        username: impl Into<String>,
69
        password: impl Into<String>,
70
    ) -> Self {
71
        Self {
72
            homeserver: homeserver.into(),
73
            username: username.into(),
74
            password: password.into(),
75
            device_display_name: None,
76
            device_id: None,
77
            allow_insecure_loopback: false,
78
        }
79
    }
80
81
    #[must_use]
82
    pub fn with_device_display_name(mut self, name: impl Into<String>) -> Self {
83
        self.device_display_name = Some(name.into());
84
        self
85
    }
86
87
    #[must_use]
88
    pub fn with_device_id(mut self, device_id: OwnedDeviceId) -> Self {
89
        self.device_id = Some(device_id);
90
        self
91
    }
92
93
    #[must_use]
94
    pub fn allow_insecure_loopback(mut self, allow: bool) -> Self {
95
        self.allow_insecure_loopback = allow;
96
        self
97
    }
98
}
99
100
#[non_exhaustive]
101
pub struct OAuthLogin {
102
    pub homeserver: String,
103
    pub redirect_uri: String,
104
    pub client_uri: String,
105
    pub client_name: Option<String>,
106
    pub client_id: Option<String>,
107
    pub device_id: Option<OwnedDeviceId>,
108
    pub application_type: OAuthApplicationType,
109
    /// let plain http through, for loopback hosts only
110
    pub allow_insecure_loopback: bool,
111
}
112
113
impl OAuthLogin {
114
    #[must_use]
115
    pub fn new(
116
        homeserver: impl Into<String>,
117
        redirect_uri: impl Into<String>,
118
        client_uri: impl Into<String>,
119
        application_type: OAuthApplicationType,
120
    ) -> Self {
121
        Self {
122
            homeserver: homeserver.into(),
123
            redirect_uri: redirect_uri.into(),
124
            client_uri: client_uri.into(),
125
            client_name: None,
126
            client_id: None,
127
            device_id: None,
128
            application_type,
129
            allow_insecure_loopback: false,
130
        }
131
    }
132
133
    #[must_use]
134
    pub fn with_client_name(mut self, name: impl Into<String>) -> Self {
135
        self.client_name = Some(name.into());
136
        self
137
    }
138
139
    /// a statically registered client id, skipping dynamic registration
140
    #[must_use]
141
    pub fn with_client_id(mut self, client_id: impl Into<String>) -> Self {
142
        self.client_id = Some(client_id.into());
143
        self
144
    }
145
146
    #[must_use]
147
    pub fn with_device_id(mut self, device_id: OwnedDeviceId) -> Self {
148
        self.device_id = Some(device_id);
149
        self
150
    }
151
152
    #[must_use]
153
    pub fn allow_insecure_loopback(mut self, allow: bool) -> Self {
154
        self.allow_insecure_loopback = allow;
155
        self
156
    }
157
}
158
159
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
160
#[non_exhaustive]
161
pub enum OAuthApplicationType {
162
    Native,
163
    Web,
164
}
165
166
pub struct PendingOAuthLogin {
167
    client: Client,
168
    sessions: Arc<dyn SessionStore>,
169
    tracking: Option<(Tracking, StoreLock)>,
170
    authorization_url: String,
171
    state: Option<CsrfToken>,
172
    previous: Option<SessionRecord>,
173
}
174
175
impl PendingOAuthLogin {
176
    #[must_use]
177
    pub fn authorization_url(&self) -> &str {
178
        &self.authorization_url
179
    }
180
181
    #[must_use]
182
    pub fn registered_client_id(&self) -> String {
183
        self.client
184
            .oauth()
185
            .client_id()
186
            .expect("OAuth client was registered before authorization")
187
            .as_str()
188
            .to_owned()
189
    }
190
191
    #[must_use]
192
    pub const fn is_pending(&self) -> bool {
193
        self.state.is_some()
194
    }
195
196
    pub async fn finish(&mut self, callback_url: &str) -> Result<Session, AccountError> {
197
        let callback_url = Url::parse(callback_url)
198
            .map_err(|error| account_error("parsing the OAuth callback URL", error))?;
199
        let state = self
200
            .state
201
            .take()
202
            .ok_or(AccountError::OAuthCallbackAlreadySubmitted)?;
203
        let oauth = self.client.oauth();
204
        if let Err(error) = oauth.finish_login(callback_url.into()).await {
205
            oauth.abort_login(&state).await;
206
            if let Some(previous) = self.previous.take() {
207
                put_back(self.sessions.as_ref(), previous).await;
208
            }
209
            return Err(account_error("OAuth login", error));
210
        }
211
        let (tracking, store_lock) = self
212
            .tracking
213
            .take()
214
            .ok_or(AccountError::OAuthCallbackAlreadySubmitted)?;
215
        Session::saved(
216
            self.client.clone(),
217
            self.sessions.clone(),
218
            tracking,
219
            store_lock,
220
        )
221
        .await
222
    }
223
224
    /// puts back what the store held before, or deletes the store if it held nothing
225
    pub async fn abort(self) {
226
        let Self {
227
            client,
228
            sessions,
229
            tracking,
230
            state,
231
            previous,
232
            ..
233
        } = self;
234
        let Some(state) = state else {
235
            return;
236
        };
237
        client.oauth().abort_login(&state).await;
238
        drop(client);
239
        match previous {
240
            Some(previous) => put_back(sessions.as_ref(), previous).await,
241
            None => {
242
                if let Err(error) = sessions.discard().await {
243
                    tracing::warn!(%error, "deleting an abandoned OAuth login's store failed");
244
                }
245
            }
246
        }
247
        drop(tracking);
248
    }
249
}
250
251
async fn put_back(sessions: &dyn SessionStore, previous: SessionRecord) {
252
    if let Err(error) = sessions.save(&previous).await {
253
        tracing::warn!(%error, "putting back the record an OAuth login replaced failed");
254
    }
255
}
256
257
pub struct StoreConfig {
258
    location: StoreLocation,
259
    passphrase: String,
260
}
261
262
enum StoreLocation {
263
    #[cfg(feature = "sqlite")]
264
    Sqlite(PathBuf),
265
    #[cfg(feature = "indexeddb")]
266
    IndexedDb(String),
267
}
268
269
impl StoreConfig {
270
    #[cfg(feature = "sqlite")]
271
    #[must_use]
272
    pub fn sqlite(path: impl Into<PathBuf>, passphrase: impl Into<String>) -> Self {
273
        Self {
274
            location: StoreLocation::Sqlite(path.into()),
275
            passphrase: passphrase.into(),
276
        }
277
    }
278
279
    #[cfg(feature = "indexeddb")]
280
    #[must_use]
281
    pub fn indexeddb(name: impl Into<String>, passphrase: impl Into<String>) -> Self {
282
        Self {
283
            location: StoreLocation::IndexedDb(name.into()),
284
            passphrase: passphrase.into(),
285
        }
286
    }
287
288
    /// delete the store's databases, once nothing has them open
289
    pub async fn delete(&self) -> Result<(), AccountError> {
290
        match &self.location {
291
            #[cfg(feature = "sqlite")]
292
            StoreLocation::Sqlite(path) => crate::session_store::delete_sqlite_store(path)
293
                .map_err(|error| account_error("deleting the Matrix store", error)),
294
            #[cfg(feature = "indexeddb")]
295
            StoreLocation::IndexedDb(name) => crate::session_store::delete_indexeddb_store(name)
296
                .await
297
                .map_err(|error| error.into_account_error("deleting the Matrix store")),
298
        }
299
    }
300
301
    /// taken before the client opens the store, and held until the engine and every
302
    /// `ClientHandle` are gone
303
    pub(crate) async fn lock(&self) -> Result<StoreLock, AccountError> {
304
        match &self.location {
305
            #[cfg(feature = "sqlite")]
306
            StoreLocation::Sqlite(path) => StoreLock::file(path),
307
            #[cfg(feature = "indexeddb")]
308
            StoreLocation::IndexedDb(name) => StoreLock::web(name).await,
309
        }
310
    }
311
312
    pub(crate) fn passphrase(&self) -> &str {
313
        &self.passphrase
314
    }
315
316
    #[cfg(feature = "sqlite")]
317
    pub(crate) fn mentions_location(&self) -> PathBuf {
318
        let StoreLocation::Sqlite(path) = &self.location;
319
        path.join("macltok-mentions.sqlite3")
320
    }
321
322
    #[cfg(feature = "indexeddb")]
323
    pub(crate) fn mentions_location(&self) -> String {
324
        format!("{}::macltok-mentions", self.indexeddb_name())
325
    }
326
327
    #[cfg(feature = "sqlite")]
328
    pub(crate) fn search_location(&self) -> PathBuf {
329
        let StoreLocation::Sqlite(path) = &self.location;
330
        path.join("macltok-search.sqlite3")
331
    }
332
333
    #[cfg(feature = "indexeddb")]
334
    pub(crate) fn search_location(&self) -> String {
335
        format!("{}::macltok-search", self.indexeddb_name())
336
    }
337
338
    #[cfg(feature = "indexeddb")]
339
    pub(crate) fn indexeddb_name(&self) -> &str {
340
        let StoreLocation::IndexedDb(name) = &self.location;
341
        name
342
    }
343
}
344
345
/// Serializable authentication secrets. A `SessionStore` keeps it, and the engine
346
/// replaces it there on every token rotation.
347
#[derive(Clone, Deserialize, Serialize)]
348
pub struct SessionRecord {
349
    homeserver: String,
350
    authentication: AuthenticationRecord,
351
}
352
353
#[derive(Clone, Deserialize, Serialize)]
354
#[serde(tag = "type", content = "session", rename_all = "snake_case")]
355
enum AuthenticationRecord {
356
    Matrix(MatrixSession),
357
    OAuth {
358
        client_id: ClientId,
359
        user: UserSession,
360
    },
361
    SoftLoggedOut {
362
        meta: SessionMeta,
363
        client_id: Option<ClientId>,
364
    },
365
    /// an oauth login waiting on its redirect. `authorization` holds the pkce verifier
366
    PendingOAuth {
367
        client_id: ClientId,
368
        state: CsrfToken,
369
        authorization: Box<PendingAuthorization>,
370
        started_ms: u64,
371
        /// what the store held before, put back if the login never finishes
372
        previous: Option<Box<SessionRecord>>,
373
    },
374
}
375
376
/// authorization codes live for minutes, so a login this old was abandoned
377
const OAUTH_PENDING_FOR_MS: u64 = 60 * 60 * 1000;
378
379
/// the authorization server answers in the query, or the fragment when asked to
380
pub(crate) fn callback_state(callback: &Url) -> Option<String> {
381
    let query = callback.query_pairs();
382
    let fragment = url::form_urlencoded::parse(callback.fragment().unwrap_or_default().as_bytes());
383
    query
384
        .chain(fragment)
385
        .find(|(key, _)| key == "state")
386
        .map(|(_, state)| state.into_owned())
387
}
388
389
fn now_ms() -> u64 {
390
    MilliSecondsSinceUnixEpoch::now().get().into()
391
}
392
393
impl SessionRecord {
394
    #[must_use]
395
    pub fn homeserver(&self) -> &str {
396
        &self.homeserver
397
    }
398
399
    /// a pending oauth login is nobody yet, unless it resumes someone
400
    pub(crate) fn user_id(&self) -> Option<&OwnedUserId> {
401
        match &self.authentication {
402
            AuthenticationRecord::Matrix(session) => Some(&session.meta.user_id),
403
            AuthenticationRecord::OAuth { user, .. } => Some(&user.meta.user_id),
404
            AuthenticationRecord::SoftLoggedOut { meta, .. } => Some(&meta.user_id),
405
            AuthenticationRecord::PendingOAuth { previous, .. } => previous.as_ref()?.user_id(),
406
        }
407
    }
408
409
    /// what a login should resume. a pending oauth login stands in for the record before it
410
    pub(crate) fn resumable(self) -> Option<Self> {
411
        match self.authentication {
412
            AuthenticationRecord::PendingOAuth { previous, .. } => {
413
                previous.and_then(|previous| previous.resumable())
414
            }
415
            _ => Some(self),
416
        }
417
    }
418
419
    pub(crate) fn pending_oauth_state(&self) -> Option<&str> {
420
        match &self.authentication {
421
            AuthenticationRecord::PendingOAuth { state, .. } => Some(state.secret()),
422
            _ => None,
423
        }
424
    }
425
426
    pub(crate) fn oauth_expired(&self) -> bool {
427
        let AuthenticationRecord::PendingOAuth { started_ms, .. } = &self.authentication else {
428
            return false;
429
        };
430
        now_ms().saturating_sub(*started_ms) > OAUTH_PENDING_FOR_MS
431
    }
432
433
    pub(crate) fn soft_logout(&self) -> Option<SoftLogout> {
434
        let (meta, client_id) = match &self.authentication {
435
            AuthenticationRecord::SoftLoggedOut { meta, client_id } => (meta, client_id),
436
            AuthenticationRecord::PendingOAuth { previous, .. } => {
437
                return previous.as_ref()?.soft_logout();
438
            }
439
            _ => return None,
440
        };
441
        Some(SoftLogout {
442
            homeserver: self.homeserver.clone(),
443
            user_id: meta.user_id.clone(),
444
            device_id: meta.device_id.clone(),
445
            client_id: client_id
446
                .as_ref()
447
                .map(|client_id| client_id.as_str().to_owned()),
448
        })
449
    }
450
451
    pub(crate) fn soft_logged_out(self) -> Self {
452
        let authentication = match self.authentication {
453
            AuthenticationRecord::Matrix(session) => AuthenticationRecord::SoftLoggedOut {
454
                meta: session.meta,
455
                client_id: None,
456
            },
457
            AuthenticationRecord::OAuth { client_id, user } => {
458
                AuthenticationRecord::SoftLoggedOut {
459
                    meta: user.meta,
460
                    client_id: Some(client_id),
461
                }
462
            }
463
            soft @ AuthenticationRecord::SoftLoggedOut { .. } => soft,
464
            pending @ AuthenticationRecord::PendingOAuth { .. } => pending,
465
        };
466
        Self {
467
            homeserver: self.homeserver,
468
            authentication,
469
        }
470
    }
471
}
472
473
pub enum Restore {
474
    Resumed(Session),
475
    SoftLoggedOut(SoftLogout),
476
    /// an oauth login is waiting on its redirect. `Session::finish_oauth` takes the callback
477
    OAuthPending,
478
}
479
480
/// who to log in again as. the store keeps this device's e2ee keys, so a login
481
/// as any other device would orphan them
482
#[derive(Clone, Debug)]
483
#[non_exhaustive]
484
pub struct SoftLogout {
485
    pub homeserver: String,
486
    pub user_id: OwnedUserId,
487
    pub device_id: OwnedDeviceId,
488
    /// set when the login was OAuth, for `OAuthLogin::with_client_id`
489
    pub client_id: Option<String>,
490
}
491
492
impl SoftLogout {
493
    #[must_use]
494
    pub fn password_login(&self, password: impl Into<String>) -> PasswordLogin {
495
        PasswordLogin::new(self.homeserver.clone(), self.user_id.as_str(), password)
496
            .with_device_id(self.device_id.clone())
497
            .allow_insecure_loopback(true)
498
    }
499
500
    #[must_use]
501
    pub fn oauth_login(
502
        &self,
503
        redirect_uri: impl Into<String>,
504
        client_uri: impl Into<String>,
505
        application_type: OAuthApplicationType,
506
    ) -> OAuthLogin {
507
        let login = OAuthLogin::new(
508
            self.homeserver.clone(),
509
            redirect_uri,
510
            client_uri,
511
            application_type,
512
        )
513
        .with_device_id(self.device_id.clone())
514
        .allow_insecure_loopback(true);
515
        match &self.client_id {
516
            Some(client_id) => login.with_client_id(client_id.clone()),
517
            None => login,
518
        }
519
    }
520
}
521
522
#[derive(Debug, Error)]
523
pub enum AccountError {
524
    #[error("{context} failed")]
525
    Operation {
526
        context: &'static str,
527
        kind: ErrorKind,
528
        server: Option<ServerError>,
529
        #[source]
530
        source: Box<SourceError>,
531
    },
532
    #[error("the homeserver returned no authenticated session")]
533
    MissingSession,
534
    #[error("the SDK returned an unsupported authentication session")]
535
    UnsupportedAuthentication,
536
    #[error("the OAuth callback was already submitted")]
537
    OAuthCallbackAlreadySubmitted,
538
    /// nothing is waiting for this callback. it was finished, abandoned, expired, or begun
539
    /// somewhere else
540
    #[error("no OAuth login is waiting for this callback")]
541
    NoPendingOAuth,
542
    /// what was typed can't name a homeserver, so nothing was sent anywhere
543
    #[error("that isn't a usable homeserver, {0}")]
544
    InvalidHomeserver(&'static str),
545
    /// `Accounts` already has a live login for them. a second would be another device
546
    #[error("{0} is already logged in")]
547
    AlreadyLoggedIn(OwnedUserId),
548
    /// an IndexedDB delete is waiting on the store's other connections. it goes through
549
    /// once they close, and opening the store again waits behind it
550
    #[error("the store is still open elsewhere, it will be deleted once that closes")]
551
    StoreDeletePending,
552
    /// another engine, in this process or another, has the store. only one runs per store
553
    #[error("another engine is using this store")]
554
    StoreInUse,
555
    /// the login worked but its first record didn't save. the server already has the
556
    /// device, so retry the save rather than log in again
557
    #[error("saving the new login's record failed")]
558
    Unsaved {
559
        session: Box<UnsavedSession>,
560
        #[source]
561
        source: Box<AccountError>,
562
    },
563
    #[error(transparent)]
564
    Engine(#[from] EngineError),
565
}
566
567
impl AccountError {
568
    /// what the homeserver said, when it refused
569
    #[must_use]
570
    pub fn server(&self) -> Option<&ServerError> {
571
        match self {
572
            Self::Operation { server, .. } => server.as_ref(),
573
            Self::Unsaved { source, .. } => source.server(),
574
            _ => None,
575
        }
576
    }
577
}
578
579
/// a live login whose record never reached its `SessionStore`
580
pub struct UnsavedSession {
581
    session: Session,
582
    sessions: Arc<dyn SessionStore>,
583
}
584
585
impl UnsavedSession {
586
    pub async fn save(self) -> Result<Session, AccountError> {
587
        let saved = match session_record(&self.session.client) {
588
            Ok(record) => self.sessions.save(&record).await,
589
            Err(error) => Err(error),
590
        };
591
        match saved {
592
            Ok(()) => Ok(self.session),
593
            Err(source) => Err(AccountError::Unsaved {
594
                session: Box::new(self),
595
                source: Box::new(source),
596
            }),
597
        }
598
    }
599
600
    /// the record to keep somewhere else, if the store won't take it
601
    pub fn record(&self) -> Result<SessionRecord, AccountError> {
602
        session_record(&self.session.client)
603
    }
604
}
605
606
impl std::fmt::Debug for UnsavedSession {
607
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
608
        formatter
609
            .debug_struct("UnsavedSession")
610
            .finish_non_exhaustive()
611
    }
612
}
613
614
impl Session {
615
    pub async fn login_password(
616
        login: PasswordLogin,
617
        sessions: Arc<dyn SessionStore>,
618
    ) -> Result<Self, AccountError> {
619
        homeserver_input(&login.homeserver, login.allow_insecure_loopback)?;
620
        let store_lock = sessions.store().lock().await?;
621
        Self::login_password_locked(login, sessions, store_lock).await
622
    }
623
624
    pub(crate) async fn login_password_locked(
625
        login: PasswordLogin,
626
        sessions: Arc<dyn SessionStore>,
627
        store_lock: StoreLock,
628
    ) -> Result<Self, AccountError> {
629
        let homeserver = homeserver_input(&login.homeserver, login.allow_insecure_loopback)?;
630
        let client = matrix_client_builder(sessions.store())
631
            .server_name_or_homeserver_url(homeserver)
632
            .build()
633
            .await
634
            .map_err(|error| account_error("building the Matrix client", error))?;
635
        let tracking = Tracking::start(&client, sessions.clone())?;
636
        let mut request = client
637
            .matrix_auth()
638
            .login_username(login.username.trim(), &login.password)
639
            .request_refresh_token();
640
        if let Some(name) = login.device_display_name.as_deref() {
641
            request = request.initial_device_display_name(name);
642
        }
643
        if let Some(device_id) = login.device_id.as_deref() {
644
            request = request.device_id(device_id.as_str());
645
        }
646
        request
647
            .send()
648
            .await
649
            .map_err(|error| account_error("password login", error))?;
650
        Self::saved(client, sessions, tracking, store_lock).await
651
    }
652
653
    /// the pending login lives only in this process, so the one that begins it has to finish it
654
    pub async fn begin_oauth(
655
        login: OAuthLogin,
656
        sessions: Arc<dyn SessionStore>,
657
    ) -> Result<PendingOAuthLogin, AccountError> {
658
        homeserver_input(&login.homeserver, login.allow_insecure_loopback)?;
659
        let store_lock = sessions.store().lock().await?;
660
        Self::begin_oauth_locked(login, sessions, store_lock).await
661
    }
662
663
    pub(crate) async fn begin_oauth_locked(
664
        login: OAuthLogin,
665
        sessions: Arc<dyn SessionStore>,
666
        store_lock: StoreLock,
667
    ) -> Result<PendingOAuthLogin, AccountError> {
668
        let redirect_uri = Url::parse(login.redirect_uri.trim())
669
            .map_err(|error| account_error("parsing the OAuth redirect URI", error))?;
670
        let client_uri = Url::parse(login.client_uri.trim())
671
            .map_err(|error| account_error("parsing the OAuth client URI", error))?;
672
        let homeserver = homeserver_input(&login.homeserver, login.allow_insecure_loopback)?;
673
        let client = matrix_client_builder(sessions.store())
674
            .server_name_or_homeserver_url(homeserver)
675
            .build()
676
            .await
677
            .map_err(|error| account_error("building the Matrix client", error))?;
678
        let tracking = Tracking::start(&client, sessions.clone())?;
679
        let application_type = match login.application_type {
680
            OAuthApplicationType::Native => ApplicationType::Native,
681
            OAuthApplicationType::Web => ApplicationType::Web,
682
        };
683
        let mut metadata = ClientMetadata::new(
684
            application_type,
685
            vec![OAuthGrantType::AuthorizationCode {
686
                redirect_uris: vec![redirect_uri.clone()],
687
            }],
688
            Localized::new(client_uri, []),
689
        );
690
        metadata.client_name = login.client_name.map(|name| Localized::new(name, []));
691
        let metadata = Raw::new(&metadata)
692
            .map_err(|error| account_error("serializing OAuth client metadata", error))?;
693
        let mut registration = ClientRegistrationData::new(metadata);
694
        if let Some(client_id) = login.client_id {
695
            registration.static_registrations = Some(HashMap::from([(
696
                client.homeserver(),
697
                ClientId::new(client_id),
698
            )]));
699
        }
700
        let authorization = client
701
            .oauth()
702
            .login(redirect_uri, login.device_id, Some(registration), None)
703
            .build()
704
            .await
705
            .map_err(|error| account_error("starting OAuth login", error))?;
706
        let previous = sessions.load().await?.and_then(SessionRecord::resumable);
707
        let pending = SessionRecord {
708
            homeserver: client.homeserver().to_string(),
709
            authentication: AuthenticationRecord::PendingOAuth {
710
                client_id: client
711
                    .oauth()
712
                    .client_id()
713
                    .ok_or(AccountError::MissingSession)?
714
                    .clone(),
715
                state: authorization.state.clone(),
716
                authorization: Box::new(
717
                    client
718
                        .oauth()
719
                        .pending_authorization(&authorization.state)
720
                        .await
721
                        .ok_or(AccountError::MissingSession)?,
722
                ),
723
                started_ms: now_ms(),
724
                previous: previous.clone().map(Box::new),
725
            },
726
        };
727
        sessions.save(&pending).await?;
728
        Ok(PendingOAuthLogin {
729
            client,
730
            sessions,
731
            tracking: Some((tracking, store_lock)),
732
            authorization_url: authorization.url.to_string(),
733
            state: Some(authorization.state),
734
            previous,
735
        })
736
    }
737
738
    /// finishes a login `begin_oauth` started in another process, from the callback the
739
    /// redirect brought back. the pending login is in `sessions`, so nothing else is needed
740
    pub async fn finish_oauth(
741
        sessions: Arc<dyn SessionStore>,
742
        callback_url: &str,
743
    ) -> Result<Self, AccountError> {
744
        let store_lock = sessions.store().lock().await?;
745
        Self::finish_oauth_locked(sessions, store_lock, callback_url).await
746
    }
747
748
    pub(crate) async fn finish_oauth_locked(
749
        sessions: Arc<dyn SessionStore>,
750
        store_lock: StoreLock,
751
        callback_url: &str,
752
    ) -> Result<Self, AccountError> {
753
        let callback = Url::parse(callback_url)
754
            .map_err(|error| account_error("parsing the OAuth callback URL", error))?;
755
        let record = sessions.load().await?.ok_or(AccountError::NoPendingOAuth)?;
756
        if record.pending_oauth_state() != callback_state(&callback).as_deref() {
757
            return Err(AccountError::NoPendingOAuth);
758
        }
759
        let AuthenticationRecord::PendingOAuth {
760
            client_id,
761
            state,
762
            authorization,
763
            previous,
764
            ..
765
        } = record.authentication
766
        else {
767
            return Err(AccountError::NoPendingOAuth);
768
        };
769
        let client = matrix_client_builder(sessions.store())
770
            .homeserver_url(&record.homeserver)
771
            .build()
772
            .await
773
            .map_err(|error| account_error("reopening the Matrix store", error))?;
774
        let tracking = Tracking::start(&client, sessions.clone())?;
775
        let oauth = client.oauth();
776
        oauth.restore_registered_client(client_id);
777
        let finished = match oauth
778
            .restore_pending_authorization(state, *authorization)
779
            .await
780
        {
781
            Ok(()) => oauth.finish_login(callback.into()).await,
782
            Err(error) => Err(error.into()),
783
        };
784
        if let Err(error) = finished {
785
            match previous {
786
                Some(previous) => put_back(sessions.as_ref(), *previous).await,
787
                None => {
788
                    drop((tracking, client));
789
                    if let Err(error) = sessions.discard().await {
790
                        tracing::warn!(%error, "deleting a failed OAuth login's store failed");
791
                    }
792
                }
793
            }
794
            return Err(account_error("OAuth login", error));
795
        }
796
        Self::saved(client, sessions, tracking, store_lock).await
797
    }
798
799
    /// Restore the record `sessions` holds, or `None` when it holds none.
800
    /// The caller must also handle rejection and explicit logout.
801
    pub async fn restore(sessions: Arc<dyn SessionStore>) -> Result<Option<Restore>, AccountError> {
802
        let Some(record) = sessions.load().await? else {
803
            return Ok(None);
804
        };
805
        let authentication = match record.authentication {
806
            AuthenticationRecord::Matrix(session) => AuthSession::Matrix(session),
807
            AuthenticationRecord::OAuth { client_id, user } => {
808
                OAuthSession { client_id, user }.into()
809
            }
810
            AuthenticationRecord::PendingOAuth { .. } if record.oauth_expired() => {
811
                let previous = record.resumable();
812
                match previous {
813
                    Some(previous) => put_back(sessions.as_ref(), previous).await,
814
                    None => sessions.discard().await?,
815
                }
816
                return Box::pin(Self::restore(sessions)).await;
817
            }
818
            AuthenticationRecord::PendingOAuth { .. } => return Ok(Some(Restore::OAuthPending)),
819
            AuthenticationRecord::SoftLoggedOut { meta, client_id } => {
820
                return Ok(Some(Restore::SoftLoggedOut(SoftLogout {
821
                    homeserver: record.homeserver,
822
                    user_id: meta.user_id,
823
                    device_id: meta.device_id,
824
                    client_id: client_id.map(|client_id| client_id.as_str().to_owned()),
825
                })));
826
            }
827
        };
828
        let store_lock = sessions.store().lock().await?;
829
        let client = matrix_client_builder(sessions.store())
830
            .homeserver_url(&record.homeserver)
831
            .build()
832
            .await
833
            .map_err(|error| account_error("reopening the Matrix store", error))?;
834
        let tracking = Tracking::start(&client, sessions)?;
835
        client
836
            .restore_session(authentication)
837
            .await
838
            .map_err(|error| account_error("restoring the authenticated session", error))?;
839
        Ok(Some(Restore::Resumed(Self {
840
            client,
841
            tracking,
842
            store_lock,
843
        })))
844
    }
845
846
    /// leave the record to the host, which then saves `ClientHandle::session_record()`
847
    /// on every `SessionTokensChanged`
848
    #[must_use]
849
    pub fn without_persistence(mut self) -> Self {
850
        self.tracking.persistence = None;
851
        #[cfg(not(target_family = "wasm"))]
852
        self.tracking.inline.store(false, Ordering::Release);
853
        self
854
    }
855
856
    pub async fn build(
857
        self,
858
        config: EngineConfig,
859
    ) -> Result<(Engine, ClientHandle, EventStream), AccountError> {
860
        let account = crate::mentions::store::Account {
861
            homeserver: self.client.homeserver().to_string(),
862
            user_id: self
863
                .client
864
                .user_id()
865
                .ok_or(AccountError::MissingSession)?
866
                .to_owned(),
867
        };
868
        let search =
869
            crate::search::store::SearchStore::open(self.tracking.store.store(), account.clone())
870
                .await;
871
        let opener = crate::mentions::store::Opener::new(
872
            self.tracking.store.store(),
873
            account,
874
            config.max_mentions_bytes,
875
        );
876
        let mentions = match opener.open().await {
877
            Ok(store) => Ok(store),
878
            Err(crate::mentions::store::OpenError::Unavailable(error)) => {
879
                Err(crate::mentions::service::Unopened {
880
                    error,
881
                    retry: Some(opener),
882
                })
883
            }
884
            Err(crate::mentions::store::OpenError::Newer(error)) => {
885
                Err(crate::mentions::service::Unopened { error, retry: None })
886
            }
887
        };
888
        Engine::with_persistence(
889
            self.client,
890
            config,
891
            self.tracking.session_changes,
892
            self.tracking.persistence,
893
            Some(self.store_lock),
894
            Some(mentions),
895
            Some(search),
896
        )
897
        .await
898
        .map_err(AccountError::from)
899
    }
900
901
    async fn saved(
902
        client: Client,
903
        sessions: Arc<dyn SessionStore>,
904
        tracking: Tracking,
905
        store_lock: StoreLock,
906
    ) -> Result<Self, AccountError> {
907
        UnsavedSession {
908
            session: Self {
909
                client,
910
                tracking,
911
                store_lock,
912
            },
913
            sessions,
914
        }
915
        .save()
916
        .await
917
    }
918
}
919
920
impl Tracking {
921
    fn start(client: &Client, sessions: Arc<dyn SessionStore>) -> Result<Self, AccountError> {
922
        let session_changes = client.subscribe_to_session_changes();
923
        #[cfg_attr(target_family = "wasm", allow(unused_variables))]
924
        let (results, inline_results) = mpsc::unbounded_channel();
925
        #[cfg(not(target_family = "wasm"))]
926
        let inline = save_inline(client, sessions.clone(), results)?;
927
        Ok(Self {
928
            store: sessions.clone(),
929
            session_changes,
930
            persistence: Some(Persistence {
931
                save: Box::new(move |client| {
932
                    let sessions = sessions.clone();
933
                    Box::pin(async move {
934
                        let record = session_record(&client).map_err(save_failed)?;
935
                        sessions.save(&record).await.map_err(save_failed)
936
                    })
937
                }),
938
                inline: inline_results,
939
            }),
940
            #[cfg(not(target_family = "wasm"))]
941
            inline,
942
        })
943
    }
944
}
945
946
/// the sdk calls this inside its refresh, after it has the new tokens and before it uses
947
/// them, so a store that writes there and then can't lose a refresh token to a crash
948
#[cfg(not(target_family = "wasm"))]
949
fn save_inline(
950
    client: &Client,
951
    sessions: Arc<dyn SessionStore>,
952
    results: mpsc::UnboundedSender<Result<(), crate::Error>>,
953
) -> Result<Arc<AtomicBool>, AccountError> {
954
    let enabled = Arc::new(AtomicBool::new(true));
955
    let saving = enabled.clone();
956
    client
957
        .set_session_callbacks(
958
            // only asked for with the cross process refresh lock, which we never enable
959
            Box::new(|client| {
960
                client
961
                    .session_tokens()
962
                    .ok_or_else(|| AccountError::MissingSession.into())
963
            }),
964
            Box::new(move |client| {
965
                if !saving.load(Ordering::Acquire) {
966
                    return Ok(());
967
                }
968
                let record = session_record(&client)?;
969
                if let Some(result) = sessions.save_blocking(&record) {
970
                    let _ = results.send(result.map_err(save_failed));
971
                }
972
                Ok(())
973
            }),
974
        )
975
        .map_err(|error| account_error("registering the session callbacks", error))?;
976
    Ok(enabled)
977
}
978
979
fn save_failed(error: AccountError) -> crate::Error {
980
    let message = format!("saving the session record failed, {error}");
981
    crate::Error::with_source(ErrorKind::Store, message, error)
982
}
983
984
pub(crate) fn session_record(client: &Client) -> Result<SessionRecord, AccountError> {
985
    let authentication = match client.session().ok_or(AccountError::MissingSession)? {
986
        AuthSession::Matrix(session) => AuthenticationRecord::Matrix(session),
987
        AuthSession::OAuth(session) => {
988
            let OAuthSession { client_id, user } = *session;
989
            AuthenticationRecord::OAuth { client_id, user }
990
        }
991
        _ => return Err(AccountError::UnsupportedAuthentication),
992
    };
993
    Ok(SessionRecord {
994
        homeserver: client.homeserver().to_string(),
995
        authentication,
996
    })
997
}
998
999
fn encryption_settings() -> EncryptionSettings {
1000
    EncryptionSettings {
1001
        auto_enable_cross_signing: true,
1002
        backup_download_strategy: BackupDownloadStrategy::OneShot,
1003
        auto_enable_backups: true,
1004
    }
1005
}
1006
1007
/// `StoreLock` already keeps every other client off the store, and the sdk's lease would cost
1008
/// a store write per locked section and a task renewing it every 50ms
1009
fn matrix_client_builder(store: &StoreConfig) -> ClientBuilder {
1010
    let builder = Client::builder()
1011
        .with_encryption_settings(encryption_settings())
1012
        .with_threading_support(ThreadingSupport::Enabled {
1013
            with_subscriptions: false,
1014
        })
1015
        .handle_refresh_tokens()
1016
        .cross_process_store_config(CrossProcessLockConfig::SingleProcess);
1017
    match &store.location {
1018
        #[cfg(feature = "sqlite")]
1019
        StoreLocation::Sqlite(path) => builder.sqlite_store(path, Some(&store.passphrase)),
1020
        #[cfg(feature = "indexeddb")]
1021
        StoreLocation::IndexedDb(name) => builder.indexeddb_store(name, Some(&store.passphrase)),
1022
    }
1023
}
1024
1025
/// a bare server name goes through untouched, since that's what makes the sdk try `.well-known`
1026
pub(crate) fn homeserver_input(
1027
    input: &str,
1028
    allow_insecure_loopback: bool,
1029
) -> Result<&str, AccountError> {
1030
    let input = input.trim();
1031
    let invalid = |reason| Err(AccountError::InvalidHomeserver(reason));
1032
    if input.is_empty() {
1033
        return invalid("it's empty");
1034
    }
1035
    if input.contains(char::is_whitespace) {
1036
        return invalid("it has spaces in it");
1037
    }
1038
    if !input.contains("://") {
1039
        return match <&ruma::ServerName>::try_from(input) {
1040
            Ok(_) => Ok(input),
1041
            Err(_) => invalid("it's neither a server name nor a url"),
1042
        };
1043
    }
1044
    let Ok(url) = Url::parse(input) else {
1045
        return invalid("it isn't a valid url");
1046
    };
1047
    if !url.username().is_empty() || url.password().is_some() {
1048
        return invalid("it has a username in it");
1049
    }
1050
    let loopback = match url.host() {
1051
        None => return invalid("it has no host"),
1052
        Some(url::Host::Domain(domain)) => domain.eq_ignore_ascii_case("localhost"),
1053
        Some(url::Host::Ipv4(address)) => address.is_loopback(),
1054
        Some(url::Host::Ipv6(address)) => address.is_loopback(),
1055
    };
1056
    match url.scheme() {
1057
        "https" => Ok(input),
1058
        "http" if allow_insecure_loopback && loopback => Ok(input),
1059
        "http" if allow_insecure_loopback => invalid("plain http only reaches loopback hosts"),
1060
        "http" => invalid("it isn't https"),
1061
        _ => invalid("only http and https are spoken"),
1062
    }
1063
}
1064
1065
pub(crate) fn account_error(
1066
    context: &'static str,
1067
    source: impl std::error::Error + SendOutsideWasm + SyncOutsideWasm + 'static,
1068
) -> AccountError {
1069
    AccountError::Operation {
1070
        context,
1071
        kind: crate::error::classify(&source),
1072
        server: ServerError::find(&source),
1073
        source: Box::new(source),
1074
    }
1075
}