Commit d8d8a9fe770a
tarball5 files changed+358-32
@@ -283,16 +283,24 @@ first still thinks it's alone. with our lock keeping everyone else out, clients
283283
run with `CrossProcessLockConfig::SingleProcess` and the sdk's own sections skip284284
the lease writes and renewal tasks285285
286
-a pending OAuth login isn't persisted. `begin_oauth` and `finish` have to run in287
-the same process, so a web host runs the login in a popup or in a worker that288
-outlives the redirect. the sdk keeps what `finish_login` needs, the PKCE289
-verifier among it, in `AuthorizationValidationData` inside a `pub(crate)` map290
-keyed by `CsrfToken` (matrix-sdk 0.18, `authentication/oauth/mod.rs` around291
-262-266 and 1744-1756). nothing public reads it or puts it back, and292
-`finish_login` on a fresh client answers `InvalidState`. doing the code grant293
-ourselves would copy some 250 lines of the sdk's private oauth behaviour and294
-drift from it every upgrade. patching the sdk to export and import that data is295
-small, and since we carry a fork now that's the way to do it. not built yet286
+a pending OAuth login survives the page going away for its redirect.287
+`begin_oauth` saves it as the store's record, sealed like any other since it288
+holds the PKCE verifier, through the fork's `OAuth::pending_authorization`.289
+`Accounts::finish_oauth` takes the callback url and finds the account by its290
+`state`, so the host keeps nothing across the redirect, and291
+`Session::finish_oauth` does the same for one store. a host that keeps the292
+process alive can still use `PendingOAuthLogin::finish`. while it waits,293
+`restore` answers `Restore::OAuthPending`. the record lives in the store294
+rather than with the host because `list` sweeps a store with no record, which295
+would delete a fresh login's store mid redirect296
+297
+the pending record keeps whatever the store held before, so a login resuming a298
+soft-logged-out device that never comes back puts that device back, and a299
+password login can still resume it meanwhile. abort, a failed finish after the300
+redirect and an hour's wait all undo the login, a new account's store deleted301
+and an old record put back. a failed `PendingOAuthLogin::finish` still holds302
+the client, so it puts back an old record and leaves a new one to the hour.303
+codes live minutes, so an hour is generous296304
297305
## room lists, spaces and profiles298306
@@ -389,6 +397,8 @@ which keep their public sans-io apis
389397
with `Timeline::toggle_reaction_with_shortcode`390398
- `SendQueue::enable_global_upload_progress`, upload progress on the391399
client-wide send queue channel400
+- `OAuth::pending_authorization` and `restore_pending_authorization`, to401
+ finish an oauth login in another process392402
393403
## public model394404
@@ -23,7 +23,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
2323
)2424
.await?2525
}26
- None => {26
+ None | Some(Restore::OAuthPending) => {2727
Session::login_password(2828
PasswordLogin::new(2929
required("MATRIX_HOMESERVER")?,@@ -11,13 +11,14 @@ use matrix_sdk::{
1111
AuthSession,1212
matrix::MatrixSession,1313
oauth::{14
- ClientId, ClientRegistrationData, CsrfToken, OAuthSession, UserSession,14
+ ClientId, ClientRegistrationData, CsrfToken, OAuthSession, PendingAuthorization,15
+ UserSession,1516
registration::{ApplicationType, ClientMetadata, Localized, OAuthGrantType},1617
},1718
},1819
cross_process_lock::CrossProcessLockConfig,1920
encryption::{BackupDownloadStrategy, EncryptionSettings},20
- ruma::{OwnedDeviceId, OwnedUserId, serde::Raw},21
+ ruma::{MilliSecondsSinceUnixEpoch, OwnedDeviceId, OwnedUserId, serde::Raw},2122
};2223
use serde::{Deserialize, Serialize};2324
use thiserror::Error;@@ -167,6 +168,7 @@ pub struct PendingOAuthLogin {
167168
tracking: Option<(Tracking, StoreLock)>,168169
authorization_url: String,169170
state: Option<CsrfToken>,171
+ previous: Option<SessionRecord>,170172
}171173
172174
impl PendingOAuthLogin {@@ -200,6 +202,9 @@ impl PendingOAuthLogin {
200202
let oauth = self.client.oauth();201203
if let Err(error) = oauth.finish_login(callback_url.into()).await {202204
oauth.abort_login(&state).await;205
+ if let Some(previous) = self.previous.take() {206
+ put_back(self.sessions.as_ref(), previous).await;207
+ }203208
return Err(account_error("OAuth login", error));204209
}205210
let (tracking, store_lock) = self@@ -215,10 +220,36 @@ impl PendingOAuthLogin {
215220
.await216221
}217222
218
- pub async fn abort(mut self) {219
- if let Some(state) = self.state.take() {220
- self.client.oauth().abort_login(&state).await;223
+ /// puts back what the store held before, or deletes the store if it held nothing224
+ pub async fn abort(self) {225
+ let Self {226
+ client,227
+ sessions,228
+ tracking,229
+ state,230
+ previous,231
+ ..232
+ } = self;233
+ let Some(state) = state else {234
+ return;235
+ };236
+ client.oauth().abort_login(&state).await;237
+ drop(client);238
+ match previous {239
+ Some(previous) => put_back(sessions.as_ref(), previous).await,240
+ None => {241
+ if let Err(error) = sessions.discard().await {242
+ tracing::warn!(%error, "deleting an abandoned OAuth login's store failed");243
+ }244
+ }221245
}246
+ drop(tracking);247
+ }248
+}249
+250
+async fn put_back(sessions: &dyn SessionStore, previous: SessionRecord) {251
+ if let Err(error) = sessions.save(&previous).await {252
+ tracing::warn!(%error, "putting back the record an OAuth login replaced failed");222253
}223254
}224255
@@ -308,6 +339,32 @@ enum AuthenticationRecord {
308339
meta: SessionMeta,309340
client_id: Option<ClientId>,310341
},342
+ /// an oauth login waiting on its redirect. `authorization` holds the pkce verifier343
+ PendingOAuth {344
+ client_id: ClientId,345
+ state: CsrfToken,346
+ authorization: Box<PendingAuthorization>,347
+ started_ms: u64,348
+ /// what the store held before, put back if the login never finishes349
+ previous: Option<Box<SessionRecord>>,350
+ },351
+}352
+353
+/// authorization codes live for minutes, so a login this old was abandoned354
+const OAUTH_PENDING_FOR_MS: u64 = 60 * 60 * 1000;355
+356
+/// the authorization server answers in the query, or the fragment when asked to357
+pub(crate) fn callback_state(callback: &Url) -> Option<String> {358
+ let query = callback.query_pairs();359
+ let fragment = url::form_urlencoded::parse(callback.fragment().unwrap_or_default().as_bytes());360
+ query361
+ .chain(fragment)362
+ .find(|(key, _)| key == "state")363
+ .map(|(_, state)| state.into_owned())364
+}365
+366
+fn now_ms() -> u64 {367
+ MilliSecondsSinceUnixEpoch::now().get().into()311368
}312369
313370
impl SessionRecord {@@ -316,17 +373,47 @@ impl SessionRecord {
316373
&self.homeserver317374
}318375
319
- pub(crate) fn user_id(&self) -> &OwnedUserId {376
+ /// a pending oauth login is nobody yet, unless it resumes someone377
+ pub(crate) fn user_id(&self) -> Option<&OwnedUserId> {320378
match &self.authentication {321
- AuthenticationRecord::Matrix(session) => &session.meta.user_id,322
- AuthenticationRecord::OAuth { user, .. } => &user.meta.user_id,323
- AuthenticationRecord::SoftLoggedOut { meta, .. } => &meta.user_id,379
+ AuthenticationRecord::Matrix(session) => Some(&session.meta.user_id),380
+ AuthenticationRecord::OAuth { user, .. } => Some(&user.meta.user_id),381
+ AuthenticationRecord::SoftLoggedOut { meta, .. } => Some(&meta.user_id),382
+ AuthenticationRecord::PendingOAuth { previous, .. } => previous.as_ref()?.user_id(),383
+ }384
+ }385
+386
+ /// what a login should resume. a pending oauth login stands in for the record before it387
+ pub(crate) fn resumable(self) -> Option<Self> {388
+ match self.authentication {389
+ AuthenticationRecord::PendingOAuth { previous, .. } => {390
+ previous.and_then(|previous| previous.resumable())391
+ }392
+ _ => Some(self),324393
}325394
}326395
396
+ pub(crate) fn pending_oauth_state(&self) -> Option<&str> {397
+ match &self.authentication {398
+ AuthenticationRecord::PendingOAuth { state, .. } => Some(state.secret()),399
+ _ => None,400
+ }401
+ }402
+403
+ pub(crate) fn oauth_expired(&self) -> bool {404
+ let AuthenticationRecord::PendingOAuth { started_ms, .. } = &self.authentication else {405
+ return false;406
+ };407
+ now_ms().saturating_sub(*started_ms) > OAUTH_PENDING_FOR_MS408
+ }409
+327410
pub(crate) fn soft_logout(&self) -> Option<SoftLogout> {328
- let AuthenticationRecord::SoftLoggedOut { meta, client_id } = &self.authentication else {329
- return None;411
+ let (meta, client_id) = match &self.authentication {412
+ AuthenticationRecord::SoftLoggedOut { meta, client_id } => (meta, client_id),413
+ AuthenticationRecord::PendingOAuth { previous, .. } => {414
+ return previous.as_ref()?.soft_logout();415
+ }416
+ _ => return None,330417
};331418
Some(SoftLogout {332419
homeserver: self.homeserver.clone(),@@ -351,6 +438,7 @@ impl SessionRecord {
351438
}352439
}353440
soft @ AuthenticationRecord::SoftLoggedOut { .. } => soft,441
+ pending @ AuthenticationRecord::PendingOAuth { .. } => pending,354442
};355443
Self {356444
homeserver: self.homeserver,@@ -362,6 +450,8 @@ impl SessionRecord {
362450
pub enum Restore {363451
Resumed(Session),364452
SoftLoggedOut(SoftLogout),453
+ /// an oauth login is waiting on its redirect. `Session::finish_oauth` takes the callback454
+ OAuthPending,365455
}366456
367457
/// who to log in again as. the store keeps this device's e2ee keys, so a login@@ -422,6 +512,10 @@ pub enum AccountError {
422512
UnsupportedAuthentication,423513
#[error("the OAuth callback was already submitted")]424514
OAuthCallbackAlreadySubmitted,515
+ /// nothing is waiting for this callback. it was finished, abandoned, expired, or begun516
+ /// somewhere else517
+ #[error("no OAuth login is waiting for this callback")]518
+ NoPendingOAuth,425519
/// what was typed can't name a homeserver, so nothing was sent anywhere426520
#[error("that isn't a usable homeserver, {0}")]427521
InvalidHomeserver(&'static str),@@ -586,15 +680,99 @@ impl Session {
586680
.build()587681
.await588682
.map_err(|error| account_error("starting OAuth login", error))?;683
+ let previous = sessions.load().await?.and_then(SessionRecord::resumable);684
+ let pending = SessionRecord {685
+ homeserver: client.homeserver().to_string(),686
+ authentication: AuthenticationRecord::PendingOAuth {687
+ client_id: client688
+ .oauth()689
+ .client_id()690
+ .ok_or(AccountError::MissingSession)?691
+ .clone(),692
+ state: authorization.state.clone(),693
+ authorization: Box::new(694
+ client695
+ .oauth()696
+ .pending_authorization(&authorization.state)697
+ .await698
+ .ok_or(AccountError::MissingSession)?,699
+ ),700
+ started_ms: now_ms(),701
+ previous: previous.clone().map(Box::new),702
+ },703
+ };704
+ sessions.save(&pending).await?;589705
Ok(PendingOAuthLogin {590706
client,591707
sessions,592708
tracking: Some((tracking, store_lock)),593709
authorization_url: authorization.url.to_string(),594710
state: Some(authorization.state),711
+ previous,595712
})596713
}597714
715
+ /// finishes a login `begin_oauth` started in another process, from the callback the716
+ /// redirect brought back. the pending login is in `sessions`, so nothing else is needed717
+ pub async fn finish_oauth(718
+ sessions: Arc<dyn SessionStore>,719
+ callback_url: &str,720
+ ) -> Result<Self, AccountError> {721
+ let store_lock = sessions.store().lock().await?;722
+ Self::finish_oauth_locked(sessions, store_lock, callback_url).await723
+ }724
+725
+ pub(crate) async fn finish_oauth_locked(726
+ sessions: Arc<dyn SessionStore>,727
+ store_lock: StoreLock,728
+ callback_url: &str,729
+ ) -> Result<Self, AccountError> {730
+ let callback = Url::parse(callback_url)731
+ .map_err(|error| account_error("parsing the OAuth callback URL", error))?;732
+ let record = sessions.load().await?.ok_or(AccountError::NoPendingOAuth)?;733
+ if record.pending_oauth_state() != callback_state(&callback).as_deref() {734
+ return Err(AccountError::NoPendingOAuth);735
+ }736
+ let AuthenticationRecord::PendingOAuth {737
+ client_id,738
+ state,739
+ authorization,740
+ previous,741
+ ..742
+ } = record.authentication743
+ else {744
+ return Err(AccountError::NoPendingOAuth);745
+ };746
+ let client = matrix_client_builder(sessions.store())747
+ .homeserver_url(&record.homeserver)748
+ .build()749
+ .await750
+ .map_err(|error| account_error("reopening the Matrix store", error))?;751
+ let tracking = Tracking::start(&client, sessions.clone())?;752
+ let oauth = client.oauth();753
+ oauth.restore_registered_client(client_id);754
+ let finished = match oauth755
+ .restore_pending_authorization(state, *authorization)756
+ .await757
+ {758
+ Ok(()) => oauth.finish_login(callback.into()).await,759
+ Err(error) => Err(error.into()),760
+ };761
+ if let Err(error) = finished {762
+ match previous {763
+ Some(previous) => put_back(sessions.as_ref(), *previous).await,764
+ None => {765
+ drop((tracking, client));766
+ if let Err(error) = sessions.discard().await {767
+ tracing::warn!(%error, "deleting a failed OAuth login's store failed");768
+ }769
+ }770
+ }771
+ return Err(account_error("OAuth login", error));772
+ }773
+ Self::saved(client, sessions, tracking, store_lock).await774
+ }775
+598776
/// Restore the record `sessions` holds, or `None` when it holds none.599777
/// The caller must also handle rejection and explicit logout.600778
pub async fn restore(sessions: Arc<dyn SessionStore>) -> Result<Option<Restore>, AccountError> {@@ -606,6 +784,15 @@ impl Session {
606784
AuthenticationRecord::OAuth { client_id, user } => {607785
OAuthSession { client_id, user }.into()608786
}787
+ AuthenticationRecord::PendingOAuth { .. } if record.oauth_expired() => {788
+ let previous = record.resumable();789
+ match previous {790
+ Some(previous) => put_back(sessions.as_ref(), previous).await,791
+ None => sessions.discard().await?,792
+ }793
+ return Box::pin(Self::restore(sessions)).await;794
+ }795
+ AuthenticationRecord::PendingOAuth { .. } => return Ok(Some(Restore::OAuthPending)),609796
AuthenticationRecord::SoftLoggedOut { meta, client_id } => {610797
return Ok(Some(Restore::SoftLoggedOut(SoftLogout {611798
homeserver: record.homeserver,@@ -6,7 +6,8 @@ use ruma::TransactionId;
66
77
use crate::{88
AccountError, EncryptedSessionStore, OAuthLogin, PasswordLogin, PendingOAuthLogin, Restore,9
- Session, SessionRecord, SessionStore, SoftLogout, StoreConfig, account::homeserver_input,9
+ Session, SessionRecord, SessionStore, SoftLogout, StoreConfig,10
+ account::{callback_state, homeserver_input},1011
engine::StoreLock,1112
};1213
@@ -94,18 +95,50 @@ impl Accounts {
9495
}9596
9697
/// every account with a record. a store left without one, by a login that died halfway or a97
- /// discard that couldn't finish, is deleted on the way past98
+ /// discard that couldn't finish, is deleted on the way past, and so is an oauth login whose99
+ /// redirect never came back. one that resumed an account puts that account back98100
pub async fn list(&self) -> Result<Vec<Account>, AccountError> {99101
let mut accounts = Vec::new();100102
for account in self.slots().await? {101103
match account.sessions.load().await {102104
Ok(None) => self.sweep(&account).await,105
+ Ok(Some(record)) if record.oauth_expired() => {106
+ if self.expire(&account).await {107
+ accounts.push(account);108
+ }109
+ }103110
Ok(Some(_)) | Err(_) => accounts.push(account),104111
}105112
}106113
Ok(accounts)107114
}108115
116
+ /// finishes an oauth login begun before a redirect, finding its account by the callback's117
+ /// state, so the host needs to have kept nothing across the redirect118
+ pub async fn finish_oauth(119
+ &self,120
+ callback_url: &str,121
+ ) -> Result<(Account, Session), AccountError> {122
+ let state = url::Url::parse(callback_url)123
+ .ok()124
+ .as_ref()125
+ .and_then(callback_state)126
+ .ok_or(AccountError::NoPendingOAuth)?;127
+ for account in self.slots().await? {128
+ let Ok(Some(record)) = account.sessions.load().await else {129
+ continue;130
+ };131
+ if record.pending_oauth_state() != Some(state.as_str()) {132
+ continue;133
+ }134
+ let store_lock = account.sessions.store().lock().await?;135
+ let session =136
+ Session::finish_oauth_locked(account.sessions(), store_lock, callback_url).await?;137
+ return Ok((account, session));138
+ }139
+ Err(AccountError::NoPendingOAuth)140
+ }141
+109142
/// a soft-logged-out account the login names is resumed as its own device, keeping its keys.110143
/// anyone else gets a new account111144
pub async fn login_password(@@ -193,9 +226,10 @@ impl Accounts {
193226
if !resumes(&record) {194227
continue;195228
}196
- return match record.soft_logout() {197
- Some(soft) => Ok(Found::SoftLoggedOut(account, soft)),198
- None => Err(AccountError::AlreadyLoggedIn(record.user_id().clone())),229
+ return match (record.soft_logout(), record.user_id()) {230
+ (Some(soft), _) => Ok(Found::SoftLoggedOut(account, soft)),231
+ (None, Some(user_id)) => Err(AccountError::AlreadyLoggedIn(user_id.clone())),232
+ (None, None) => continue,199233
};200234
}201235
Ok(Found::Nobody)@@ -227,9 +261,36 @@ impl Accounts {
227261
let Ok(_store_lock) = account.sessions.store().lock().await else {228262
return;229263
};230
- if !matches!(account.sessions.load().await, Ok(None)) {231
- return;264
+ if matches!(account.sessions.load().await, Ok(None)) {265
+ self.remove(account).await;232266
}267
+ }268
+269
+ /// whether the account is still there. a login running in another tab holds the lock and270
+ /// is left alone271
+ async fn expire(&self, account: &Account) -> bool {272
+ let Ok(_store_lock) = account.sessions.store().lock().await else {273
+ return true;274
+ };275
+ let Ok(Some(record)) = account.sessions.load().await else {276
+ return true;277
+ };278
+ if !record.oauth_expired() {279
+ return true;280
+ }281
+ match record.resumable() {282
+ Some(previous) => {283
+ let _ = account.sessions.save(&previous).await;284
+ true285
+ }286
+ None => {287
+ self.remove(account).await;288
+ false289
+ }290
+ }291
+ }292
+293
+ async fn remove(&self, account: &Account) {233294
if account.sessions.discard().await.is_err() {234295
return;235296
}@@ -274,7 +335,9 @@ impl Accounts {
274335
/// is compared by name as well as by url, so a delegated `example.org` still finds the account275336
/// discovery took to `matrix.example.org`276337
fn record_names(record: &SessionRecord, homeserver: &str, username: &str) -> bool {277
- let user_id = record.user_id();338
+ let Some(user_id) = record.user_id() else {339
+ return false;340
+ };278341
let username = username.trim();279342
let user = if username.starts_with('@') {280343
username.eq_ignore_ascii_case(user_id.as_str())@@ -4,8 +4,8 @@ mod support;
44
use std::{path::PathBuf, sync::Arc, time::SystemTime};55
66
use macltok::{7
- AccountError, Accounts, EncryptedSessionStore, EngineConfig, Event, PasswordLogin, Restore,8
- Session, SessionRecord, SessionStore, StoreConfig,7
+ AccountError, Accounts, EncryptedSessionStore, EngineConfig, Event, OAuthApplicationType,8
+ OAuthLogin, PasswordLogin, Restore, Session, SessionRecord, SessionStore, StoreConfig,99
};1010
use matrix_sdk::{ruma::api::client::sync::sync_events::v5, test_utils::mocks::MatrixMockServer};1111
@@ -435,3 +435,69 @@ fn temporary_path() -> PathBuf {
435435
.as_nanos();436436
PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(format!("macltok-account-{nonce}"))437437
}438
+439
+#[tokio::test]440
+async fn an_oauth_login_survives_the_page_going_away_for_its_redirect() {441
+ let server = MatrixMockServer::new().await;442
+ server443
+ .mock_versions()444
+ .with_simplified_sliding_sync()445
+ .ok()446
+ .mount()447
+ .await;448
+ server.mock_who_am_i().ok().mount().await;449
+ let oauth = server.oauth();450
+ oauth.mock_server_metadata().ok().mount().await;451
+ oauth.mock_registration().ok().mount().await;452
+ oauth.mock_token().ok().mount().await;453
+ let root = temporary_path();454
+ let accounts = Accounts::sqlite(&root, "test store passphrase");455
+ let redirect_uri = "http://127.0.0.1/callback";456
+457
+ let (account, pending) = accounts458
+ .begin_oauth(459
+ OAuthLogin::new(460
+ server.uri(),461
+ redirect_uri,462
+ "http://127.0.0.1/",463
+ OAuthApplicationType::Native,464
+ )465
+ .allow_insecure_loopback(true),466
+ )467
+ .await468
+ .unwrap();469
+ let state = url::Url::parse(pending.authorization_url())470
+ .unwrap()471
+ .query_pairs()472
+ .find(|(key, _)| key == "state")473
+ .map(|(_, state)| state.into_owned())474
+ .unwrap();475
+ drop(pending);476
+477
+ assert!(matches!(478
+ account.restore().await.unwrap(),479
+ Some(Restore::OAuthPending)480
+ ));481
+ assert_eq!(482
+ accounts.list().await.unwrap().len(),483
+ 1,484
+ "the redirect's store was swept"485
+ );486
+ assert!(matches!(487
+ accounts488
+ .finish_oauth(&format!("{redirect_uri}?code=42&state=someone-else"))489
+ .await,490
+ Err(AccountError::NoPendingOAuth)491
+ ));492
+493
+ let (finished, session) = accounts494
+ .finish_oauth(&format!("{redirect_uri}?code=42&state={state}"))495
+ .await496
+ .unwrap();497
+ assert_eq!(finished.id(), account.id());498
+ drop(session);499
+ assert!(matches!(500
+ account.restore().await.unwrap(),501
+ Some(Restore::Resumed(_))502
+ ));503
+}