atagen

macltok

public
Star0
Watch0
6f1d6591c10d24ed02cad8602671b55d6cc21374
3
ssh://brewery@pls.lobotomise.me:33/atagen/macltok.git
src/account.rsRust505 lines16.1 KB
1
#[cfg(feature = "sqlite")]
2
use std::path::PathBuf;
3
use std::{collections::HashMap, sync::Arc};
4
5
use matrix_sdk::{
6
    Client, ClientBuilder, SendOutsideWasm, SessionMeta, SyncOutsideWasm, ThreadingSupport,
7
    authentication::{
8
        AuthSession,
9
        matrix::MatrixSession,
10
        oauth::{
11
            ClientId, ClientRegistrationData, CsrfToken, OAuthSession, UserSession,
12
            registration::{ApplicationType, ClientMetadata, Localized, OAuthGrantType},
13
        },
14
    },
15
    cross_process_lock::CrossProcessLockConfig,
16
    encryption::{BackupDownloadStrategy, EncryptionSettings},
17
    ruma::{OwnedDeviceId, OwnedUserId, serde::Raw},
18
};
19
use serde::{Deserialize, Serialize};
20
use thiserror::Error;
21
use url::Url;
22
23
use crate::{
24
    ClientHandle, Engine, EngineConfig, EngineError, EventStream, SessionStore, error::SourceError,
25
};
26
27
pub struct Session {
28
    client: Client,
29
}
30
31
#[non_exhaustive]
32
pub struct PasswordLogin {
33
    pub homeserver: String,
34
    pub username: String,
35
    pub password: String,
36
    pub device_display_name: Option<String>,
37
    /// set after a soft logout so the store's keys still belong to us
38
    pub device_id: Option<OwnedDeviceId>,
39
}
40
41
impl PasswordLogin {
42
    #[must_use]
43
    pub fn new(
44
        homeserver: impl Into<String>,
45
        username: impl Into<String>,
46
        password: impl Into<String>,
47
    ) -> Self {
48
        Self {
49
            homeserver: homeserver.into(),
50
            username: username.into(),
51
            password: password.into(),
52
            device_display_name: None,
53
            device_id: None,
54
        }
55
    }
56
57
    #[must_use]
58
    pub fn with_device_display_name(mut self, name: impl Into<String>) -> Self {
59
        self.device_display_name = Some(name.into());
60
        self
61
    }
62
63
    #[must_use]
64
    pub fn with_device_id(mut self, device_id: OwnedDeviceId) -> Self {
65
        self.device_id = Some(device_id);
66
        self
67
    }
68
}
69
70
#[non_exhaustive]
71
pub struct OAuthLogin {
72
    pub homeserver: String,
73
    pub redirect_uri: String,
74
    pub client_uri: String,
75
    pub client_name: Option<String>,
76
    pub client_id: Option<String>,
77
    pub device_id: Option<OwnedDeviceId>,
78
    pub application_type: OAuthApplicationType,
79
}
80
81
impl OAuthLogin {
82
    #[must_use]
83
    pub fn new(
84
        homeserver: impl Into<String>,
85
        redirect_uri: impl Into<String>,
86
        client_uri: impl Into<String>,
87
        application_type: OAuthApplicationType,
88
    ) -> Self {
89
        Self {
90
            homeserver: homeserver.into(),
91
            redirect_uri: redirect_uri.into(),
92
            client_uri: client_uri.into(),
93
            client_name: None,
94
            client_id: None,
95
            device_id: None,
96
            application_type,
97
        }
98
    }
99
100
    #[must_use]
101
    pub fn with_client_name(mut self, name: impl Into<String>) -> Self {
102
        self.client_name = Some(name.into());
103
        self
104
    }
105
106
    /// a statically registered client id, skipping dynamic registration
107
    #[must_use]
108
    pub fn with_client_id(mut self, client_id: impl Into<String>) -> Self {
109
        self.client_id = Some(client_id.into());
110
        self
111
    }
112
113
    #[must_use]
114
    pub fn with_device_id(mut self, device_id: OwnedDeviceId) -> Self {
115
        self.device_id = Some(device_id);
116
        self
117
    }
118
}
119
120
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
121
#[non_exhaustive]
122
pub enum OAuthApplicationType {
123
    Native,
124
    Web,
125
}
126
127
pub struct PendingOAuthLogin {
128
    client: Client,
129
    sessions: Arc<dyn SessionStore>,
130
    authorization_url: String,
131
    state: Option<CsrfToken>,
132
}
133
134
impl PendingOAuthLogin {
135
    #[must_use]
136
    pub fn authorization_url(&self) -> &str {
137
        &self.authorization_url
138
    }
139
140
    #[must_use]
141
    pub fn registered_client_id(&self) -> String {
142
        self.client
143
            .oauth()
144
            .client_id()
145
            .expect("OAuth client was registered before authorization")
146
            .as_str()
147
            .to_owned()
148
    }
149
150
    #[must_use]
151
    pub const fn is_pending(&self) -> bool {
152
        self.state.is_some()
153
    }
154
155
    pub async fn finish(&mut self, callback_url: &str) -> Result<Session, AccountError> {
156
        let callback_url = Url::parse(callback_url)
157
            .map_err(|error| account_error("parsing the OAuth callback URL", error))?;
158
        let state = self
159
            .state
160
            .take()
161
            .ok_or(AccountError::OAuthCallbackAlreadySubmitted)?;
162
        let oauth = self.client.oauth();
163
        if let Err(error) = oauth.finish_login(callback_url.into()).await {
164
            oauth.abort_login(&state).await;
165
            return Err(account_error("OAuth login", error));
166
        }
167
        Session::saved(self.client.clone(), &*self.sessions).await
168
    }
169
170
    pub async fn abort(mut self) {
171
        if let Some(state) = self.state.take() {
172
            self.client.oauth().abort_login(&state).await;
173
        }
174
    }
175
}
176
177
pub struct StoreConfig {
178
    location: StoreLocation,
179
    passphrase: String,
180
    lock_holder: Option<String>,
181
}
182
183
enum StoreLocation {
184
    #[cfg(feature = "sqlite")]
185
    Sqlite(PathBuf),
186
    #[cfg(feature = "indexeddb")]
187
    IndexedDb(String),
188
}
189
190
impl StoreConfig {
191
    #[cfg(feature = "sqlite")]
192
    #[must_use]
193
    pub fn sqlite(path: impl Into<PathBuf>, passphrase: impl Into<String>) -> Self {
194
        Self {
195
            location: StoreLocation::Sqlite(path.into()),
196
            passphrase: passphrase.into(),
197
            lock_holder: None,
198
        }
199
    }
200
201
    #[cfg(feature = "indexeddb")]
202
    #[must_use]
203
    pub fn indexeddb(name: impl Into<String>, passphrase: impl Into<String>) -> Self {
204
        Self {
205
            location: StoreLocation::IndexedDb(name.into()),
206
            passphrase: passphrase.into(),
207
            lock_holder: None,
208
        }
209
    }
210
211
    /// the sdk names every client `main`, so two tabs on one store would both think they hold its lock
212
    #[must_use]
213
    pub fn with_lock_holder(mut self, holder: impl Into<String>) -> Self {
214
        self.lock_holder = Some(holder.into());
215
        self
216
    }
217
218
    /// delete the store's databases, once nothing has them open
219
    pub async fn delete(&self) -> Result<(), AccountError> {
220
        match &self.location {
221
            #[cfg(feature = "sqlite")]
222
            StoreLocation::Sqlite(path) => crate::session_store::delete_sqlite_store(path)
223
                .map_err(|error| account_error("deleting the Matrix store", error)),
224
            #[cfg(feature = "indexeddb")]
225
            StoreLocation::IndexedDb(name) => crate::session_store::delete_indexeddb_store(name)
226
                .await
227
                .map_err(|error| account_error("deleting the Matrix store", error)),
228
        }
229
    }
230
231
    pub(crate) fn passphrase(&self) -> &str {
232
        &self.passphrase
233
    }
234
235
    #[cfg(feature = "indexeddb")]
236
    pub(crate) fn indexeddb_name(&self) -> &str {
237
        let StoreLocation::IndexedDb(name) = &self.location;
238
        name
239
    }
240
}
241
242
/// Serializable authentication secrets. A `SessionStore` keeps it, and it must
243
/// be replaced when `Event::SessionTokensChanged` arrives.
244
#[derive(Clone, Deserialize, Serialize)]
245
pub struct SessionRecord {
246
    homeserver: String,
247
    authentication: AuthenticationRecord,
248
}
249
250
#[derive(Clone, Deserialize, Serialize)]
251
#[serde(tag = "type", content = "session", rename_all = "snake_case")]
252
enum AuthenticationRecord {
253
    Matrix(MatrixSession),
254
    OAuth {
255
        client_id: ClientId,
256
        user: UserSession,
257
    },
258
    SoftLoggedOut {
259
        meta: SessionMeta,
260
        client_id: Option<ClientId>,
261
    },
262
}
263
264
impl SessionRecord {
265
    #[must_use]
266
    pub fn homeserver(&self) -> &str {
267
        &self.homeserver
268
    }
269
270
    pub(crate) fn soft_logged_out(self) -> Self {
271
        let authentication = match self.authentication {
272
            AuthenticationRecord::Matrix(session) => AuthenticationRecord::SoftLoggedOut {
273
                meta: session.meta,
274
                client_id: None,
275
            },
276
            AuthenticationRecord::OAuth { client_id, user } => {
277
                AuthenticationRecord::SoftLoggedOut {
278
                    meta: user.meta,
279
                    client_id: Some(client_id),
280
                }
281
            }
282
            soft @ AuthenticationRecord::SoftLoggedOut { .. } => soft,
283
        };
284
        Self {
285
            homeserver: self.homeserver,
286
            authentication,
287
        }
288
    }
289
}
290
291
pub enum Restore {
292
    Resumed(Session),
293
    SoftLoggedOut(SoftLogout),
294
}
295
296
/// who to log in again as. the store keeps this device's e2ee keys, so a login
297
/// as any other device would orphan them
298
#[derive(Clone, Debug)]
299
#[non_exhaustive]
300
pub struct SoftLogout {
301
    pub homeserver: String,
302
    pub user_id: OwnedUserId,
303
    pub device_id: OwnedDeviceId,
304
    /// set when the login was OAuth, for `OAuthLogin::with_client_id`
305
    pub client_id: Option<String>,
306
}
307
308
impl SoftLogout {
309
    #[must_use]
310
    pub fn password_login(&self, password: impl Into<String>) -> PasswordLogin {
311
        PasswordLogin::new(self.homeserver.clone(), self.user_id.as_str(), password)
312
            .with_device_id(self.device_id.clone())
313
    }
314
}
315
316
#[derive(Debug, Error)]
317
pub enum AccountError {
318
    #[error("{context} failed")]
319
    Operation {
320
        context: &'static str,
321
        #[source]
322
        source: Box<SourceError>,
323
    },
324
    #[error("the homeserver returned no authenticated session")]
325
    MissingSession,
326
    #[error("the SDK returned an unsupported authentication session")]
327
    UnsupportedAuthentication,
328
    #[error("the OAuth callback was already submitted")]
329
    OAuthCallbackAlreadySubmitted,
330
    #[error(transparent)]
331
    Engine(#[from] EngineError),
332
}
333
334
impl Session {
335
    pub async fn login_password(
336
        login: PasswordLogin,
337
        sessions: &dyn SessionStore,
338
    ) -> Result<Self, AccountError> {
339
        let client = matrix_client_builder(sessions.store())
340
            .server_name_or_homeserver_url(login.homeserver.trim())
341
            .build()
342
            .await
343
            .map_err(|error| account_error("building the Matrix client", error))?;
344
        let mut request = client
345
            .matrix_auth()
346
            .login_username(login.username.trim(), &login.password)
347
            .request_refresh_token();
348
        if let Some(name) = login.device_display_name.as_deref() {
349
            request = request.initial_device_display_name(name);
350
        }
351
        if let Some(device_id) = login.device_id.as_deref() {
352
            request = request.device_id(device_id.as_str());
353
        }
354
        request
355
            .send()
356
            .await
357
            .map_err(|error| account_error("password login", error))?;
358
        Self::saved(client, sessions).await
359
    }
360
361
    pub async fn begin_oauth(
362
        login: OAuthLogin,
363
        sessions: Arc<dyn SessionStore>,
364
    ) -> Result<PendingOAuthLogin, AccountError> {
365
        let redirect_uri = Url::parse(login.redirect_uri.trim())
366
            .map_err(|error| account_error("parsing the OAuth redirect URI", error))?;
367
        let client_uri = Url::parse(login.client_uri.trim())
368
            .map_err(|error| account_error("parsing the OAuth client URI", error))?;
369
        let client = matrix_client_builder(sessions.store())
370
            .server_name_or_homeserver_url(login.homeserver.trim())
371
            .build()
372
            .await
373
            .map_err(|error| account_error("building the Matrix client", error))?;
374
        let application_type = match login.application_type {
375
            OAuthApplicationType::Native => ApplicationType::Native,
376
            OAuthApplicationType::Web => ApplicationType::Web,
377
        };
378
        let mut metadata = ClientMetadata::new(
379
            application_type,
380
            vec![OAuthGrantType::AuthorizationCode {
381
                redirect_uris: vec![redirect_uri.clone()],
382
            }],
383
            Localized::new(client_uri, []),
384
        );
385
        metadata.client_name = login.client_name.map(|name| Localized::new(name, []));
386
        let metadata = Raw::new(&metadata)
387
            .map_err(|error| account_error("serializing OAuth client metadata", error))?;
388
        let mut registration = ClientRegistrationData::new(metadata);
389
        if let Some(client_id) = login.client_id {
390
            registration.static_registrations = Some(HashMap::from([(
391
                client.homeserver(),
392
                ClientId::new(client_id),
393
            )]));
394
        }
395
        let authorization = client
396
            .oauth()
397
            .login(redirect_uri, login.device_id, Some(registration), None)
398
            .build()
399
            .await
400
            .map_err(|error| account_error("starting OAuth login", error))?;
401
        Ok(PendingOAuthLogin {
402
            client,
403
            sessions,
404
            authorization_url: authorization.url.to_string(),
405
            state: Some(authorization.state),
406
        })
407
    }
408
409
    /// Restore the record `sessions` holds, or `None` when it holds none.
410
    /// The caller must also handle refresh, rejection, and explicit logout.
411
    pub async fn restore(sessions: &dyn SessionStore) -> Result<Option<Restore>, AccountError> {
412
        let Some(record) = sessions.load().await? else {
413
            return Ok(None);
414
        };
415
        let authentication = match record.authentication {
416
            AuthenticationRecord::Matrix(session) => AuthSession::Matrix(session),
417
            AuthenticationRecord::OAuth { client_id, user } => {
418
                OAuthSession { client_id, user }.into()
419
            }
420
            AuthenticationRecord::SoftLoggedOut { meta, client_id } => {
421
                return Ok(Some(Restore::SoftLoggedOut(SoftLogout {
422
                    homeserver: record.homeserver,
423
                    user_id: meta.user_id,
424
                    device_id: meta.device_id,
425
                    client_id: client_id.map(|client_id| client_id.as_str().to_owned()),
426
                })));
427
            }
428
        };
429
        let client = matrix_client_builder(sessions.store())
430
            .homeserver_url(&record.homeserver)
431
            .build()
432
            .await
433
            .map_err(|error| account_error("reopening the Matrix store", error))?;
434
        client
435
            .restore_session(authentication)
436
            .await
437
            .map_err(|error| account_error("restoring the authenticated session", error))?;
438
        Ok(Some(Restore::Resumed(Self { client })))
439
    }
440
441
    pub async fn build(
442
        self,
443
        config: EngineConfig,
444
    ) -> Result<(Engine, ClientHandle, EventStream), AccountError> {
445
        Engine::from_client(self.client, config)
446
            .await
447
            .map_err(AccountError::from)
448
    }
449
450
    async fn saved(client: Client, sessions: &dyn SessionStore) -> Result<Self, AccountError> {
451
        sessions.save(&session_record(&client)?).await?;
452
        Ok(Self { client })
453
    }
454
}
455
456
pub(crate) fn session_record(client: &Client) -> Result<SessionRecord, AccountError> {
457
    let authentication = match client.session().ok_or(AccountError::MissingSession)? {
458
        AuthSession::Matrix(session) => AuthenticationRecord::Matrix(session),
459
        AuthSession::OAuth(session) => {
460
            let OAuthSession { client_id, user } = *session;
461
            AuthenticationRecord::OAuth { client_id, user }
462
        }
463
        _ => return Err(AccountError::UnsupportedAuthentication),
464
    };
465
    Ok(SessionRecord {
466
        homeserver: client.homeserver().to_string(),
467
        authentication,
468
    })
469
}
470
471
fn encryption_settings() -> EncryptionSettings {
472
    EncryptionSettings {
473
        auto_enable_cross_signing: true,
474
        backup_download_strategy: BackupDownloadStrategy::OneShot,
475
        auto_enable_backups: true,
476
    }
477
}
478
479
fn matrix_client_builder(store: &StoreConfig) -> ClientBuilder {
480
    let mut builder = Client::builder()
481
        .with_encryption_settings(encryption_settings())
482
        .with_threading_support(ThreadingSupport::Enabled {
483
            with_subscriptions: false,
484
        })
485
        .handle_refresh_tokens();
486
    if let Some(holder) = &store.lock_holder {
487
        builder = builder.cross_process_store_config(CrossProcessLockConfig::multi_process(holder));
488
    }
489
    match &store.location {
490
        #[cfg(feature = "sqlite")]
491
        StoreLocation::Sqlite(path) => builder.sqlite_store(path, Some(&store.passphrase)),
492
        #[cfg(feature = "indexeddb")]
493
        StoreLocation::IndexedDb(name) => builder.indexeddb_store(name, Some(&store.passphrase)),
494
    }
495
}
496
497
pub(crate) fn account_error(
498
    context: &'static str,
499
    source: impl std::error::Error + SendOutsideWasm + SyncOutsideWasm + 'static,
500
) -> AccountError {
501
    AccountError::Operation {
502
        context,
503
        source: Box::new(source),
504
    }
505
}