atagen

macltok

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