atagen

macltok

public
Star0
Watch0
6db49c0ecf71cdcf132041e6ca6e135c711a0b33
3
ssh://brewery@pls.lobotomise.me:33/atagen/macltok.git
src/account.rsRust616 lines20.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, UserSession,
15
            registration::{ApplicationType, ClientMetadata, Localized, OAuthGrantType},
16
        },
17
    },
18
    cross_process_lock::CrossProcessLockConfig,
19
    encryption::{BackupDownloadStrategy, EncryptionSettings},
20
    ruma::{OwnedDeviceId, OwnedUserId, serde::Raw},
21
};
22
use serde::{Deserialize, Serialize};
23
use thiserror::Error;
24
use tokio::sync::{broadcast, mpsc};
25
use url::Url;
26
27
use crate::{
28
    ClientHandle, Engine, EngineConfig, EngineError, ErrorKind, EventStream, SessionStore,
29
    engine::Persistence, error::SourceError,
30
};
31
32
/// a logged in client and the `SessionStore` its record lives in. the engine it builds
33
/// saves every token rotation there, unless told `without_persistence`
34
pub struct Session {
35
    client: Client,
36
    tracking: Tracking,
37
}
38
39
/// taken as soon as the client exists, since the sdk can refresh before there's an engine
40
struct Tracking {
41
    session_changes: broadcast::Receiver<SessionChange>,
42
    persistence: Option<Persistence>,
43
    #[cfg(not(target_family = "wasm"))]
44
    inline: Arc<AtomicBool>,
45
}
46
47
#[non_exhaustive]
48
pub struct PasswordLogin {
49
    pub homeserver: String,
50
    pub username: String,
51
    pub password: String,
52
    pub device_display_name: Option<String>,
53
    /// set after a soft logout so the store's keys still belong to us
54
    pub device_id: Option<OwnedDeviceId>,
55
}
56
57
impl PasswordLogin {
58
    #[must_use]
59
    pub fn new(
60
        homeserver: impl Into<String>,
61
        username: impl Into<String>,
62
        password: impl Into<String>,
63
    ) -> Self {
64
        Self {
65
            homeserver: homeserver.into(),
66
            username: username.into(),
67
            password: password.into(),
68
            device_display_name: None,
69
            device_id: None,
70
        }
71
    }
72
73
    #[must_use]
74
    pub fn with_device_display_name(mut self, name: impl Into<String>) -> Self {
75
        self.device_display_name = Some(name.into());
76
        self
77
    }
78
79
    #[must_use]
80
    pub fn with_device_id(mut self, device_id: OwnedDeviceId) -> Self {
81
        self.device_id = Some(device_id);
82
        self
83
    }
84
}
85
86
#[non_exhaustive]
87
pub struct OAuthLogin {
88
    pub homeserver: String,
89
    pub redirect_uri: String,
90
    pub client_uri: String,
91
    pub client_name: Option<String>,
92
    pub client_id: Option<String>,
93
    pub device_id: Option<OwnedDeviceId>,
94
    pub application_type: OAuthApplicationType,
95
}
96
97
impl OAuthLogin {
98
    #[must_use]
99
    pub fn new(
100
        homeserver: impl Into<String>,
101
        redirect_uri: impl Into<String>,
102
        client_uri: impl Into<String>,
103
        application_type: OAuthApplicationType,
104
    ) -> Self {
105
        Self {
106
            homeserver: homeserver.into(),
107
            redirect_uri: redirect_uri.into(),
108
            client_uri: client_uri.into(),
109
            client_name: None,
110
            client_id: None,
111
            device_id: None,
112
            application_type,
113
        }
114
    }
115
116
    #[must_use]
117
    pub fn with_client_name(mut self, name: impl Into<String>) -> Self {
118
        self.client_name = Some(name.into());
119
        self
120
    }
121
122
    /// a statically registered client id, skipping dynamic registration
123
    #[must_use]
124
    pub fn with_client_id(mut self, client_id: impl Into<String>) -> Self {
125
        self.client_id = Some(client_id.into());
126
        self
127
    }
128
129
    #[must_use]
130
    pub fn with_device_id(mut self, device_id: OwnedDeviceId) -> Self {
131
        self.device_id = Some(device_id);
132
        self
133
    }
134
}
135
136
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
137
#[non_exhaustive]
138
pub enum OAuthApplicationType {
139
    Native,
140
    Web,
141
}
142
143
pub struct PendingOAuthLogin {
144
    client: Client,
145
    sessions: Arc<dyn SessionStore>,
146
    tracking: Option<Tracking>,
147
    authorization_url: String,
148
    state: Option<CsrfToken>,
149
}
150
151
impl PendingOAuthLogin {
152
    #[must_use]
153
    pub fn authorization_url(&self) -> &str {
154
        &self.authorization_url
155
    }
156
157
    #[must_use]
158
    pub fn registered_client_id(&self) -> String {
159
        self.client
160
            .oauth()
161
            .client_id()
162
            .expect("OAuth client was registered before authorization")
163
            .as_str()
164
            .to_owned()
165
    }
166
167
    #[must_use]
168
    pub const fn is_pending(&self) -> bool {
169
        self.state.is_some()
170
    }
171
172
    pub async fn finish(&mut self, callback_url: &str) -> Result<Session, AccountError> {
173
        let callback_url = Url::parse(callback_url)
174
            .map_err(|error| account_error("parsing the OAuth callback URL", error))?;
175
        let state = self
176
            .state
177
            .take()
178
            .ok_or(AccountError::OAuthCallbackAlreadySubmitted)?;
179
        let oauth = self.client.oauth();
180
        if let Err(error) = oauth.finish_login(callback_url.into()).await {
181
            oauth.abort_login(&state).await;
182
            return Err(account_error("OAuth login", error));
183
        }
184
        let tracking = self
185
            .tracking
186
            .take()
187
            .ok_or(AccountError::OAuthCallbackAlreadySubmitted)?;
188
        Session::saved(self.client.clone(), &*self.sessions, tracking).await
189
    }
190
191
    pub async fn abort(mut self) {
192
        if let Some(state) = self.state.take() {
193
            self.client.oauth().abort_login(&state).await;
194
        }
195
    }
196
}
197
198
pub struct StoreConfig {
199
    location: StoreLocation,
200
    passphrase: String,
201
    lock_holder: Option<String>,
202
}
203
204
enum StoreLocation {
205
    #[cfg(feature = "sqlite")]
206
    Sqlite(PathBuf),
207
    #[cfg(feature = "indexeddb")]
208
    IndexedDb(String),
209
}
210
211
impl StoreConfig {
212
    #[cfg(feature = "sqlite")]
213
    #[must_use]
214
    pub fn sqlite(path: impl Into<PathBuf>, passphrase: impl Into<String>) -> Self {
215
        Self {
216
            location: StoreLocation::Sqlite(path.into()),
217
            passphrase: passphrase.into(),
218
            lock_holder: None,
219
        }
220
    }
221
222
    #[cfg(feature = "indexeddb")]
223
    #[must_use]
224
    pub fn indexeddb(name: impl Into<String>, passphrase: impl Into<String>) -> Self {
225
        Self {
226
            location: StoreLocation::IndexedDb(name.into()),
227
            passphrase: passphrase.into(),
228
            lock_holder: None,
229
        }
230
    }
231
232
    /// the sdk names every client `main`, so two tabs on one store would both think they hold its lock
233
    #[must_use]
234
    pub fn with_lock_holder(mut self, holder: impl Into<String>) -> Self {
235
        self.lock_holder = Some(holder.into());
236
        self
237
    }
238
239
    /// delete the store's databases, once nothing has them open
240
    pub async fn delete(&self) -> Result<(), AccountError> {
241
        match &self.location {
242
            #[cfg(feature = "sqlite")]
243
            StoreLocation::Sqlite(path) => crate::session_store::delete_sqlite_store(path)
244
                .map_err(|error| account_error("deleting the Matrix store", error)),
245
            #[cfg(feature = "indexeddb")]
246
            StoreLocation::IndexedDb(name) => crate::session_store::delete_indexeddb_store(name)
247
                .await
248
                .map_err(|error| error.into_account_error("deleting the Matrix store")),
249
        }
250
    }
251
252
    pub(crate) fn passphrase(&self) -> &str {
253
        &self.passphrase
254
    }
255
256
    #[cfg(feature = "indexeddb")]
257
    pub(crate) fn indexeddb_name(&self) -> &str {
258
        let StoreLocation::IndexedDb(name) = &self.location;
259
        name
260
    }
261
}
262
263
/// Serializable authentication secrets. A `SessionStore` keeps it, and the engine
264
/// replaces it there on every token rotation.
265
#[derive(Clone, Deserialize, Serialize)]
266
pub struct SessionRecord {
267
    homeserver: String,
268
    authentication: AuthenticationRecord,
269
}
270
271
#[derive(Clone, Deserialize, Serialize)]
272
#[serde(tag = "type", content = "session", rename_all = "snake_case")]
273
enum AuthenticationRecord {
274
    Matrix(MatrixSession),
275
    OAuth {
276
        client_id: ClientId,
277
        user: UserSession,
278
    },
279
    SoftLoggedOut {
280
        meta: SessionMeta,
281
        client_id: Option<ClientId>,
282
    },
283
}
284
285
impl SessionRecord {
286
    #[must_use]
287
    pub fn homeserver(&self) -> &str {
288
        &self.homeserver
289
    }
290
291
    pub(crate) fn soft_logged_out(self) -> Self {
292
        let authentication = match self.authentication {
293
            AuthenticationRecord::Matrix(session) => AuthenticationRecord::SoftLoggedOut {
294
                meta: session.meta,
295
                client_id: None,
296
            },
297
            AuthenticationRecord::OAuth { client_id, user } => {
298
                AuthenticationRecord::SoftLoggedOut {
299
                    meta: user.meta,
300
                    client_id: Some(client_id),
301
                }
302
            }
303
            soft @ AuthenticationRecord::SoftLoggedOut { .. } => soft,
304
        };
305
        Self {
306
            homeserver: self.homeserver,
307
            authentication,
308
        }
309
    }
310
}
311
312
pub enum Restore {
313
    Resumed(Session),
314
    SoftLoggedOut(SoftLogout),
315
}
316
317
/// who to log in again as. the store keeps this device's e2ee keys, so a login
318
/// as any other device would orphan them
319
#[derive(Clone, Debug)]
320
#[non_exhaustive]
321
pub struct SoftLogout {
322
    pub homeserver: String,
323
    pub user_id: OwnedUserId,
324
    pub device_id: OwnedDeviceId,
325
    /// set when the login was OAuth, for `OAuthLogin::with_client_id`
326
    pub client_id: Option<String>,
327
}
328
329
impl SoftLogout {
330
    #[must_use]
331
    pub fn password_login(&self, password: impl Into<String>) -> PasswordLogin {
332
        PasswordLogin::new(self.homeserver.clone(), self.user_id.as_str(), password)
333
            .with_device_id(self.device_id.clone())
334
    }
335
}
336
337
#[derive(Debug, Error)]
338
pub enum AccountError {
339
    #[error("{context} failed")]
340
    Operation {
341
        context: &'static str,
342
        #[source]
343
        source: Box<SourceError>,
344
    },
345
    #[error("the homeserver returned no authenticated session")]
346
    MissingSession,
347
    #[error("the SDK returned an unsupported authentication session")]
348
    UnsupportedAuthentication,
349
    #[error("the OAuth callback was already submitted")]
350
    OAuthCallbackAlreadySubmitted,
351
    /// an IndexedDB delete is waiting on the store's other connections. it goes through
352
    /// once they close, and opening the store again waits behind it
353
    #[error("the store is still open elsewhere, it will be deleted once that closes")]
354
    StoreDeletePending,
355
    #[error(transparent)]
356
    Engine(#[from] EngineError),
357
}
358
359
impl Session {
360
    pub async fn login_password(
361
        login: PasswordLogin,
362
        sessions: Arc<dyn SessionStore>,
363
    ) -> Result<Self, AccountError> {
364
        let client = matrix_client_builder(sessions.store())
365
            .server_name_or_homeserver_url(login.homeserver.trim())
366
            .build()
367
            .await
368
            .map_err(|error| account_error("building the Matrix client", error))?;
369
        let tracking = Tracking::start(&client, sessions.clone())?;
370
        let mut request = client
371
            .matrix_auth()
372
            .login_username(login.username.trim(), &login.password)
373
            .request_refresh_token();
374
        if let Some(name) = login.device_display_name.as_deref() {
375
            request = request.initial_device_display_name(name);
376
        }
377
        if let Some(device_id) = login.device_id.as_deref() {
378
            request = request.device_id(device_id.as_str());
379
        }
380
        request
381
            .send()
382
            .await
383
            .map_err(|error| account_error("password login", error))?;
384
        Self::saved(client, &*sessions, tracking).await
385
    }
386
387
    pub async fn begin_oauth(
388
        login: OAuthLogin,
389
        sessions: Arc<dyn SessionStore>,
390
    ) -> Result<PendingOAuthLogin, AccountError> {
391
        let redirect_uri = Url::parse(login.redirect_uri.trim())
392
            .map_err(|error| account_error("parsing the OAuth redirect URI", error))?;
393
        let client_uri = Url::parse(login.client_uri.trim())
394
            .map_err(|error| account_error("parsing the OAuth client URI", error))?;
395
        let client = matrix_client_builder(sessions.store())
396
            .server_name_or_homeserver_url(login.homeserver.trim())
397
            .build()
398
            .await
399
            .map_err(|error| account_error("building the Matrix client", error))?;
400
        let tracking = Tracking::start(&client, sessions.clone())?;
401
        let application_type = match login.application_type {
402
            OAuthApplicationType::Native => ApplicationType::Native,
403
            OAuthApplicationType::Web => ApplicationType::Web,
404
        };
405
        let mut metadata = ClientMetadata::new(
406
            application_type,
407
            vec![OAuthGrantType::AuthorizationCode {
408
                redirect_uris: vec![redirect_uri.clone()],
409
            }],
410
            Localized::new(client_uri, []),
411
        );
412
        metadata.client_name = login.client_name.map(|name| Localized::new(name, []));
413
        let metadata = Raw::new(&metadata)
414
            .map_err(|error| account_error("serializing OAuth client metadata", error))?;
415
        let mut registration = ClientRegistrationData::new(metadata);
416
        if let Some(client_id) = login.client_id {
417
            registration.static_registrations = Some(HashMap::from([(
418
                client.homeserver(),
419
                ClientId::new(client_id),
420
            )]));
421
        }
422
        let authorization = client
423
            .oauth()
424
            .login(redirect_uri, login.device_id, Some(registration), None)
425
            .build()
426
            .await
427
            .map_err(|error| account_error("starting OAuth login", error))?;
428
        Ok(PendingOAuthLogin {
429
            client,
430
            sessions,
431
            tracking: Some(tracking),
432
            authorization_url: authorization.url.to_string(),
433
            state: Some(authorization.state),
434
        })
435
    }
436
437
    /// Restore the record `sessions` holds, or `None` when it holds none.
438
    /// The caller must also handle rejection and explicit logout.
439
    pub async fn restore(sessions: Arc<dyn SessionStore>) -> Result<Option<Restore>, AccountError> {
440
        let Some(record) = sessions.load().await? else {
441
            return Ok(None);
442
        };
443
        let authentication = match record.authentication {
444
            AuthenticationRecord::Matrix(session) => AuthSession::Matrix(session),
445
            AuthenticationRecord::OAuth { client_id, user } => {
446
                OAuthSession { client_id, user }.into()
447
            }
448
            AuthenticationRecord::SoftLoggedOut { meta, client_id } => {
449
                return Ok(Some(Restore::SoftLoggedOut(SoftLogout {
450
                    homeserver: record.homeserver,
451
                    user_id: meta.user_id,
452
                    device_id: meta.device_id,
453
                    client_id: client_id.map(|client_id| client_id.as_str().to_owned()),
454
                })));
455
            }
456
        };
457
        let client = matrix_client_builder(sessions.store())
458
            .homeserver_url(&record.homeserver)
459
            .build()
460
            .await
461
            .map_err(|error| account_error("reopening the Matrix store", error))?;
462
        let tracking = Tracking::start(&client, sessions)?;
463
        client
464
            .restore_session(authentication)
465
            .await
466
            .map_err(|error| account_error("restoring the authenticated session", error))?;
467
        Ok(Some(Restore::Resumed(Self { client, tracking })))
468
    }
469
470
    /// leave the record to the host, which then saves `ClientHandle::session_record()`
471
    /// on every `SessionTokensChanged`
472
    #[must_use]
473
    pub fn without_persistence(mut self) -> Self {
474
        self.tracking.persistence = None;
475
        #[cfg(not(target_family = "wasm"))]
476
        self.tracking.inline.store(false, Ordering::Release);
477
        self
478
    }
479
480
    pub async fn build(
481
        self,
482
        config: EngineConfig,
483
    ) -> Result<(Engine, ClientHandle, EventStream), AccountError> {
484
        Engine::with_persistence(
485
            self.client,
486
            config,
487
            self.tracking.session_changes,
488
            self.tracking.persistence,
489
        )
490
        .await
491
        .map_err(AccountError::from)
492
    }
493
494
    async fn saved(
495
        client: Client,
496
        sessions: &dyn SessionStore,
497
        tracking: Tracking,
498
    ) -> Result<Self, AccountError> {
499
        sessions.save(&session_record(&client)?).await?;
500
        Ok(Self { client, tracking })
501
    }
502
}
503
504
impl Tracking {
505
    fn start(client: &Client, sessions: Arc<dyn SessionStore>) -> Result<Self, AccountError> {
506
        let session_changes = client.subscribe_to_session_changes();
507
        #[cfg_attr(target_family = "wasm", allow(unused_variables))]
508
        let (results, inline_results) = mpsc::unbounded_channel();
509
        #[cfg(not(target_family = "wasm"))]
510
        let inline = save_inline(client, sessions.clone(), results)?;
511
        Ok(Self {
512
            session_changes,
513
            persistence: Some(Persistence {
514
                save: Box::new(move |client| {
515
                    let sessions = sessions.clone();
516
                    Box::pin(async move {
517
                        let record = session_record(&client).map_err(save_failed)?;
518
                        sessions.save(&record).await.map_err(save_failed)
519
                    })
520
                }),
521
                inline: inline_results,
522
            }),
523
            #[cfg(not(target_family = "wasm"))]
524
            inline,
525
        })
526
    }
527
}
528
529
/// the sdk calls this inside its refresh, after it has the new tokens and before it uses
530
/// them, so a store that writes there and then can't lose a refresh token to a crash
531
#[cfg(not(target_family = "wasm"))]
532
fn save_inline(
533
    client: &Client,
534
    sessions: Arc<dyn SessionStore>,
535
    results: mpsc::UnboundedSender<Result<(), crate::Error>>,
536
) -> Result<Arc<AtomicBool>, AccountError> {
537
    let enabled = Arc::new(AtomicBool::new(true));
538
    let saving = enabled.clone();
539
    client
540
        .set_session_callbacks(
541
            // only asked for with the cross process refresh lock, which we never enable
542
            Box::new(|client| {
543
                client
544
                    .session_tokens()
545
                    .ok_or_else(|| AccountError::MissingSession.into())
546
            }),
547
            Box::new(move |client| {
548
                if !saving.load(Ordering::Acquire) {
549
                    return Ok(());
550
                }
551
                let record = session_record(&client)?;
552
                if let Some(result) = sessions.save_blocking(&record) {
553
                    let _ = results.send(result.map_err(save_failed));
554
                }
555
                Ok(())
556
            }),
557
        )
558
        .map_err(|error| account_error("registering the session callbacks", error))?;
559
    Ok(enabled)
560
}
561
562
fn save_failed(error: AccountError) -> crate::Error {
563
    let message = format!("saving the session record failed, {error}");
564
    crate::Error::with_source(ErrorKind::Store, message, error)
565
}
566
567
pub(crate) fn session_record(client: &Client) -> Result<SessionRecord, AccountError> {
568
    let authentication = match client.session().ok_or(AccountError::MissingSession)? {
569
        AuthSession::Matrix(session) => AuthenticationRecord::Matrix(session),
570
        AuthSession::OAuth(session) => {
571
            let OAuthSession { client_id, user } = *session;
572
            AuthenticationRecord::OAuth { client_id, user }
573
        }
574
        _ => return Err(AccountError::UnsupportedAuthentication),
575
    };
576
    Ok(SessionRecord {
577
        homeserver: client.homeserver().to_string(),
578
        authentication,
579
    })
580
}
581
582
fn encryption_settings() -> EncryptionSettings {
583
    EncryptionSettings {
584
        auto_enable_cross_signing: true,
585
        backup_download_strategy: BackupDownloadStrategy::OneShot,
586
        auto_enable_backups: true,
587
    }
588
}
589
590
fn matrix_client_builder(store: &StoreConfig) -> ClientBuilder {
591
    let mut builder = Client::builder()
592
        .with_encryption_settings(encryption_settings())
593
        .with_threading_support(ThreadingSupport::Enabled {
594
            with_subscriptions: false,
595
        })
596
        .handle_refresh_tokens();
597
    if let Some(holder) = &store.lock_holder {
598
        builder = builder.cross_process_store_config(CrossProcessLockConfig::multi_process(holder));
599
    }
600
    match &store.location {
601
        #[cfg(feature = "sqlite")]
602
        StoreLocation::Sqlite(path) => builder.sqlite_store(path, Some(&store.passphrase)),
603
        #[cfg(feature = "indexeddb")]
604
        StoreLocation::IndexedDb(name) => builder.indexeddb_store(name, Some(&store.passphrase)),
605
    }
606
}
607
608
pub(crate) fn account_error(
609
    context: &'static str,
610
    source: impl std::error::Error + SendOutsideWasm + SyncOutsideWasm + 'static,
611
) -> AccountError {
612
    AccountError::Operation {
613
        context,
614
        source: Box::new(source),
615
    }
616
}