atagen

macltok

public
Star0
Watch0
713205a95af25a47e69b765f3f76902360fe75dd
3
ssh://brewery@pls.lobotomise.me:33/atagen/macltok.git
src/account.rsRust448 lines14.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, 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, 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
}
259
260
impl SessionRecord {
261
    #[must_use]
262
    pub fn homeserver(&self) -> &str {
263
        &self.homeserver
264
    }
265
}
266
267
#[derive(Debug, Error)]
268
pub enum AccountError {
269
    #[error("{context} failed")]
270
    Operation {
271
        context: &'static str,
272
        #[source]
273
        source: Box<SourceError>,
274
    },
275
    #[error("the homeserver returned no authenticated session")]
276
    MissingSession,
277
    #[error("the SDK returned an unsupported authentication session")]
278
    UnsupportedAuthentication,
279
    #[error("the OAuth callback was already submitted")]
280
    OAuthCallbackAlreadySubmitted,
281
    #[error(transparent)]
282
    Engine(#[from] EngineError),
283
}
284
285
impl Session {
286
    pub async fn login_password(
287
        login: PasswordLogin,
288
        sessions: &dyn SessionStore,
289
    ) -> Result<Self, AccountError> {
290
        let client = matrix_client_builder(sessions.store())
291
            .server_name_or_homeserver_url(login.homeserver.trim())
292
            .build()
293
            .await
294
            .map_err(|error| account_error("building the Matrix client", error))?;
295
        let mut request = client
296
            .matrix_auth()
297
            .login_username(login.username.trim(), &login.password)
298
            .request_refresh_token();
299
        if let Some(name) = login.device_display_name.as_deref() {
300
            request = request.initial_device_display_name(name);
301
        }
302
        if let Some(device_id) = login.device_id.as_deref() {
303
            request = request.device_id(device_id.as_str());
304
        }
305
        request
306
            .send()
307
            .await
308
            .map_err(|error| account_error("password login", error))?;
309
        Self::saved(client, sessions).await
310
    }
311
312
    pub async fn begin_oauth(
313
        login: OAuthLogin,
314
        sessions: Arc<dyn SessionStore>,
315
    ) -> Result<PendingOAuthLogin, AccountError> {
316
        let redirect_uri = Url::parse(login.redirect_uri.trim())
317
            .map_err(|error| account_error("parsing the OAuth redirect URI", error))?;
318
        let client_uri = Url::parse(login.client_uri.trim())
319
            .map_err(|error| account_error("parsing the OAuth client URI", error))?;
320
        let client = matrix_client_builder(sessions.store())
321
            .server_name_or_homeserver_url(login.homeserver.trim())
322
            .build()
323
            .await
324
            .map_err(|error| account_error("building the Matrix client", error))?;
325
        let application_type = match login.application_type {
326
            OAuthApplicationType::Native => ApplicationType::Native,
327
            OAuthApplicationType::Web => ApplicationType::Web,
328
        };
329
        let mut metadata = ClientMetadata::new(
330
            application_type,
331
            vec![OAuthGrantType::AuthorizationCode {
332
                redirect_uris: vec![redirect_uri.clone()],
333
            }],
334
            Localized::new(client_uri, []),
335
        );
336
        metadata.client_name = login.client_name.map(|name| Localized::new(name, []));
337
        let metadata = Raw::new(&metadata)
338
            .map_err(|error| account_error("serializing OAuth client metadata", error))?;
339
        let mut registration = ClientRegistrationData::new(metadata);
340
        if let Some(client_id) = login.client_id {
341
            registration.static_registrations = Some(HashMap::from([(
342
                client.homeserver(),
343
                ClientId::new(client_id),
344
            )]));
345
        }
346
        let authorization = client
347
            .oauth()
348
            .login(redirect_uri, login.device_id, Some(registration), None)
349
            .build()
350
            .await
351
            .map_err(|error| account_error("starting OAuth login", error))?;
352
        Ok(PendingOAuthLogin {
353
            client,
354
            sessions,
355
            authorization_url: authorization.url.to_string(),
356
            state: Some(authorization.state),
357
        })
358
    }
359
360
    /// Restore the record `sessions` holds, or `None` when it holds none.
361
    /// The caller must also handle refresh, rejection, and explicit logout.
362
    pub async fn restore(sessions: &dyn SessionStore) -> Result<Option<Self>, AccountError> {
363
        let Some(record) = sessions.load().await? else {
364
            return Ok(None);
365
        };
366
        let client = matrix_client_builder(sessions.store())
367
            .homeserver_url(&record.homeserver)
368
            .build()
369
            .await
370
            .map_err(|error| account_error("reopening the Matrix store", error))?;
371
        let authentication = match record.authentication {
372
            AuthenticationRecord::Matrix(session) => AuthSession::Matrix(session),
373
            AuthenticationRecord::OAuth { client_id, user } => {
374
                OAuthSession { client_id, user }.into()
375
            }
376
        };
377
        client
378
            .restore_session(authentication)
379
            .await
380
            .map_err(|error| account_error("restoring the authenticated session", error))?;
381
        Ok(Some(Self { client }))
382
    }
383
384
    pub async fn build(
385
        self,
386
        config: EngineConfig,
387
    ) -> Result<(Engine, ClientHandle, EventStream), AccountError> {
388
        Engine::from_client(self.client, config)
389
            .await
390
            .map_err(AccountError::from)
391
    }
392
393
    async fn saved(client: Client, sessions: &dyn SessionStore) -> Result<Self, AccountError> {
394
        sessions.save(&session_record(&client)?).await?;
395
        Ok(Self { client })
396
    }
397
}
398
399
pub(crate) fn session_record(client: &Client) -> Result<SessionRecord, AccountError> {
400
    let authentication = match client.session().ok_or(AccountError::MissingSession)? {
401
        AuthSession::Matrix(session) => AuthenticationRecord::Matrix(session),
402
        AuthSession::OAuth(session) => {
403
            let OAuthSession { client_id, user } = *session;
404
            AuthenticationRecord::OAuth { client_id, user }
405
        }
406
        _ => return Err(AccountError::UnsupportedAuthentication),
407
    };
408
    Ok(SessionRecord {
409
        homeserver: client.homeserver().to_string(),
410
        authentication,
411
    })
412
}
413
414
fn encryption_settings() -> EncryptionSettings {
415
    EncryptionSettings {
416
        auto_enable_cross_signing: true,
417
        backup_download_strategy: BackupDownloadStrategy::OneShot,
418
        auto_enable_backups: true,
419
    }
420
}
421
422
fn matrix_client_builder(store: &StoreConfig) -> ClientBuilder {
423
    let mut builder = Client::builder()
424
        .with_encryption_settings(encryption_settings())
425
        .with_threading_support(ThreadingSupport::Enabled {
426
            with_subscriptions: false,
427
        })
428
        .handle_refresh_tokens();
429
    if let Some(holder) = &store.lock_holder {
430
        builder = builder.cross_process_store_config(CrossProcessLockConfig::multi_process(holder));
431
    }
432
    match &store.location {
433
        #[cfg(feature = "sqlite")]
434
        StoreLocation::Sqlite(path) => builder.sqlite_store(path, Some(&store.passphrase)),
435
        #[cfg(feature = "indexeddb")]
436
        StoreLocation::IndexedDb(name) => builder.indexeddb_store(name, Some(&store.passphrase)),
437
    }
438
}
439
440
pub(crate) fn account_error(
441
    context: &'static str,
442
    source: impl std::error::Error + SendOutsideWasm + SyncOutsideWasm + 'static,
443
) -> AccountError {
444
    AccountError::Operation {
445
        context,
446
        source: Box::new(source),
447
    }
448
}