atagen

macltok

public
Star0
Watch0
f12377bce9b9427ca9b557bbf34679a4bd6fae14
3
ssh://brewery@pls.lobotomise.me:33/atagen/macltok.git
src/account.rsRust612 lines20.1 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| account_error("deleting the Matrix store", error)),
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
    #[error(transparent)]
352
    Engine(#[from] EngineError),
353
}
354
355
impl Session {
356
    pub async fn login_password(
357
        login: PasswordLogin,
358
        sessions: Arc<dyn SessionStore>,
359
    ) -> Result<Self, AccountError> {
360
        let client = matrix_client_builder(sessions.store())
361
            .server_name_or_homeserver_url(login.homeserver.trim())
362
            .build()
363
            .await
364
            .map_err(|error| account_error("building the Matrix client", error))?;
365
        let tracking = Tracking::start(&client, sessions.clone())?;
366
        let mut request = client
367
            .matrix_auth()
368
            .login_username(login.username.trim(), &login.password)
369
            .request_refresh_token();
370
        if let Some(name) = login.device_display_name.as_deref() {
371
            request = request.initial_device_display_name(name);
372
        }
373
        if let Some(device_id) = login.device_id.as_deref() {
374
            request = request.device_id(device_id.as_str());
375
        }
376
        request
377
            .send()
378
            .await
379
            .map_err(|error| account_error("password login", error))?;
380
        Self::saved(client, &*sessions, tracking).await
381
    }
382
383
    pub async fn begin_oauth(
384
        login: OAuthLogin,
385
        sessions: Arc<dyn SessionStore>,
386
    ) -> Result<PendingOAuthLogin, AccountError> {
387
        let redirect_uri = Url::parse(login.redirect_uri.trim())
388
            .map_err(|error| account_error("parsing the OAuth redirect URI", error))?;
389
        let client_uri = Url::parse(login.client_uri.trim())
390
            .map_err(|error| account_error("parsing the OAuth client URI", error))?;
391
        let client = matrix_client_builder(sessions.store())
392
            .server_name_or_homeserver_url(login.homeserver.trim())
393
            .build()
394
            .await
395
            .map_err(|error| account_error("building the Matrix client", error))?;
396
        let tracking = Tracking::start(&client, sessions.clone())?;
397
        let application_type = match login.application_type {
398
            OAuthApplicationType::Native => ApplicationType::Native,
399
            OAuthApplicationType::Web => ApplicationType::Web,
400
        };
401
        let mut metadata = ClientMetadata::new(
402
            application_type,
403
            vec![OAuthGrantType::AuthorizationCode {
404
                redirect_uris: vec![redirect_uri.clone()],
405
            }],
406
            Localized::new(client_uri, []),
407
        );
408
        metadata.client_name = login.client_name.map(|name| Localized::new(name, []));
409
        let metadata = Raw::new(&metadata)
410
            .map_err(|error| account_error("serializing OAuth client metadata", error))?;
411
        let mut registration = ClientRegistrationData::new(metadata);
412
        if let Some(client_id) = login.client_id {
413
            registration.static_registrations = Some(HashMap::from([(
414
                client.homeserver(),
415
                ClientId::new(client_id),
416
            )]));
417
        }
418
        let authorization = client
419
            .oauth()
420
            .login(redirect_uri, login.device_id, Some(registration), None)
421
            .build()
422
            .await
423
            .map_err(|error| account_error("starting OAuth login", error))?;
424
        Ok(PendingOAuthLogin {
425
            client,
426
            sessions,
427
            tracking: Some(tracking),
428
            authorization_url: authorization.url.to_string(),
429
            state: Some(authorization.state),
430
        })
431
    }
432
433
    /// Restore the record `sessions` holds, or `None` when it holds none.
434
    /// The caller must also handle rejection and explicit logout.
435
    pub async fn restore(sessions: Arc<dyn SessionStore>) -> Result<Option<Restore>, AccountError> {
436
        let Some(record) = sessions.load().await? else {
437
            return Ok(None);
438
        };
439
        let authentication = match record.authentication {
440
            AuthenticationRecord::Matrix(session) => AuthSession::Matrix(session),
441
            AuthenticationRecord::OAuth { client_id, user } => {
442
                OAuthSession { client_id, user }.into()
443
            }
444
            AuthenticationRecord::SoftLoggedOut { meta, client_id } => {
445
                return Ok(Some(Restore::SoftLoggedOut(SoftLogout {
446
                    homeserver: record.homeserver,
447
                    user_id: meta.user_id,
448
                    device_id: meta.device_id,
449
                    client_id: client_id.map(|client_id| client_id.as_str().to_owned()),
450
                })));
451
            }
452
        };
453
        let client = matrix_client_builder(sessions.store())
454
            .homeserver_url(&record.homeserver)
455
            .build()
456
            .await
457
            .map_err(|error| account_error("reopening the Matrix store", error))?;
458
        let tracking = Tracking::start(&client, sessions)?;
459
        client
460
            .restore_session(authentication)
461
            .await
462
            .map_err(|error| account_error("restoring the authenticated session", error))?;
463
        Ok(Some(Restore::Resumed(Self { client, tracking })))
464
    }
465
466
    /// leave the record to the host, which then saves `ClientHandle::session_record()`
467
    /// on every `SessionTokensChanged`
468
    #[must_use]
469
    pub fn without_persistence(mut self) -> Self {
470
        self.tracking.persistence = None;
471
        #[cfg(not(target_family = "wasm"))]
472
        self.tracking.inline.store(false, Ordering::Release);
473
        self
474
    }
475
476
    pub async fn build(
477
        self,
478
        config: EngineConfig,
479
    ) -> Result<(Engine, ClientHandle, EventStream), AccountError> {
480
        Engine::with_persistence(
481
            self.client,
482
            config,
483
            self.tracking.session_changes,
484
            self.tracking.persistence,
485
        )
486
        .await
487
        .map_err(AccountError::from)
488
    }
489
490
    async fn saved(
491
        client: Client,
492
        sessions: &dyn SessionStore,
493
        tracking: Tracking,
494
    ) -> Result<Self, AccountError> {
495
        sessions.save(&session_record(&client)?).await?;
496
        Ok(Self { client, tracking })
497
    }
498
}
499
500
impl Tracking {
501
    fn start(client: &Client, sessions: Arc<dyn SessionStore>) -> Result<Self, AccountError> {
502
        let session_changes = client.subscribe_to_session_changes();
503
        #[cfg_attr(target_family = "wasm", allow(unused_variables))]
504
        let (results, inline_results) = mpsc::unbounded_channel();
505
        #[cfg(not(target_family = "wasm"))]
506
        let inline = save_inline(client, sessions.clone(), results)?;
507
        Ok(Self {
508
            session_changes,
509
            persistence: Some(Persistence {
510
                save: Box::new(move |client| {
511
                    let sessions = sessions.clone();
512
                    Box::pin(async move {
513
                        let record = session_record(&client).map_err(save_failed)?;
514
                        sessions.save(&record).await.map_err(save_failed)
515
                    })
516
                }),
517
                inline: inline_results,
518
            }),
519
            #[cfg(not(target_family = "wasm"))]
520
            inline,
521
        })
522
    }
523
}
524
525
/// the sdk calls this inside its refresh, after it has the new tokens and before it uses
526
/// them, so a store that writes there and then can't lose a refresh token to a crash
527
#[cfg(not(target_family = "wasm"))]
528
fn save_inline(
529
    client: &Client,
530
    sessions: Arc<dyn SessionStore>,
531
    results: mpsc::UnboundedSender<Result<(), crate::Error>>,
532
) -> Result<Arc<AtomicBool>, AccountError> {
533
    let enabled = Arc::new(AtomicBool::new(true));
534
    let saving = enabled.clone();
535
    client
536
        .set_session_callbacks(
537
            // only asked for with the cross process refresh lock, which we never enable
538
            Box::new(|client| {
539
                client
540
                    .session_tokens()
541
                    .ok_or_else(|| AccountError::MissingSession.into())
542
            }),
543
            Box::new(move |client| {
544
                if !saving.load(Ordering::Acquire) {
545
                    return Ok(());
546
                }
547
                let record = session_record(&client)?;
548
                if let Some(result) = sessions.save_blocking(&record) {
549
                    let _ = results.send(result.map_err(save_failed));
550
                }
551
                Ok(())
552
            }),
553
        )
554
        .map_err(|error| account_error("registering the session callbacks", error))?;
555
    Ok(enabled)
556
}
557
558
fn save_failed(error: AccountError) -> crate::Error {
559
    let message = format!("saving the session record failed, {error}");
560
    crate::Error::with_source(ErrorKind::Store, message, error)
561
}
562
563
pub(crate) fn session_record(client: &Client) -> Result<SessionRecord, AccountError> {
564
    let authentication = match client.session().ok_or(AccountError::MissingSession)? {
565
        AuthSession::Matrix(session) => AuthenticationRecord::Matrix(session),
566
        AuthSession::OAuth(session) => {
567
            let OAuthSession { client_id, user } = *session;
568
            AuthenticationRecord::OAuth { client_id, user }
569
        }
570
        _ => return Err(AccountError::UnsupportedAuthentication),
571
    };
572
    Ok(SessionRecord {
573
        homeserver: client.homeserver().to_string(),
574
        authentication,
575
    })
576
}
577
578
fn encryption_settings() -> EncryptionSettings {
579
    EncryptionSettings {
580
        auto_enable_cross_signing: true,
581
        backup_download_strategy: BackupDownloadStrategy::OneShot,
582
        auto_enable_backups: true,
583
    }
584
}
585
586
fn matrix_client_builder(store: &StoreConfig) -> ClientBuilder {
587
    let mut builder = Client::builder()
588
        .with_encryption_settings(encryption_settings())
589
        .with_threading_support(ThreadingSupport::Enabled {
590
            with_subscriptions: false,
591
        })
592
        .handle_refresh_tokens();
593
    if let Some(holder) = &store.lock_holder {
594
        builder = builder.cross_process_store_config(CrossProcessLockConfig::multi_process(holder));
595
    }
596
    match &store.location {
597
        #[cfg(feature = "sqlite")]
598
        StoreLocation::Sqlite(path) => builder.sqlite_store(path, Some(&store.passphrase)),
599
        #[cfg(feature = "indexeddb")]
600
        StoreLocation::IndexedDb(name) => builder.indexeddb_store(name, Some(&store.passphrase)),
601
    }
602
}
603
604
pub(crate) fn account_error(
605
    context: &'static str,
606
    source: impl std::error::Error + SendOutsideWasm + SyncOutsideWasm + 'static,
607
) -> AccountError {
608
    AccountError::Operation {
609
        context,
610
        source: Box::new(source),
611
    }
612
}