atagen

macltok

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