atagen

macltok

public
Star0
Watch0

Commit d8d8a9fe770a

tarball

atagen <boss@atagen.co>2026-10-22 09:31 UTC

5 files changed+358-32
account: let an oauth login finish after its page went away for the redirect

A web host that navigates to the authorization server loses the process, and with it the PKCE verifier the sdk kept in a private map, so OAuth only worked in a popup. The fork's PendingAuthorization export lets begin_oauth seal the pending login into the store's record, and Accounts::finish_oauth finds it again by the callback's state. Keeping it in the store rather than with the host stops Accounts::list from sweeping the new store mid-redirect.
Modified file: docs/design-decisions.md+20 additions−10 deletions
@@ -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 skip
284284 the lease writes and renewal tasks
285285
286-a pending OAuth login isn't persisted. `begin_oauth` and `finish` have to run in
287-the same process, so a web host runs the login in a popup or in a worker that
288-outlives the redirect. the sdk keeps what `finish_login` needs, the PKCE
289-verifier among it, in `AuthorizationValidationData` inside a `pub(crate)` map
290-keyed by `CsrfToken` (matrix-sdk 0.18, `authentication/oauth/mod.rs` around
291-262-266 and 1744-1756). nothing public reads it or puts it back, and
292-`finish_login` on a fresh client answers `InvalidState`. doing the code grant
293-ourselves would copy some 250 lines of the sdk's private oauth behaviour and
294-drift from it every upgrade. patching the sdk to export and import that data is
295-small, and since we carry a fork now that's the way to do it. not built yet
286+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 it
288+holds the PKCE verifier, through the fork's `OAuth::pending_authorization`.
289+`Accounts::finish_oauth` takes the callback url and finds the account by its
290+`state`, so the host keeps nothing across the redirect, and
291+`Session::finish_oauth` does the same for one store. a host that keeps the
292+process alive can still use `PendingOAuthLogin::finish`. while it waits,
293+`restore` answers `Restore::OAuthPending`. the record lives in the store
294+rather than with the host because `list` sweeps a store with no record, which
295+would delete a fresh login's store mid redirect
296+
297+the pending record keeps whatever the store held before, so a login resuming a
298+soft-logged-out device that never comes back puts that device back, and a
299+password login can still resume it meanwhile. abort, a failed finish after the
300+redirect and an hour's wait all undo the login, a new account's store deleted
301+and an old record put back. a failed `PendingOAuthLogin::finish` still holds
302+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 generous
296304
297305 ## room lists, spaces and profiles
298306
@@ -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 the
391399 client-wide send queue channel
400+- `OAuth::pending_authorization` and `restore_pending_authorization`, to
401+ finish an oauth login in another process
392402
393403 ## public model
394404
Modified file: examples/headless.rs+1 additions−1 deletions
@@ -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")?,
Modified file: src/account.rs+198 additions−11 deletions
@@ -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 .await
216221 }
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 nothing
224+ 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 verifier
343+ 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 finishes
349+ previous: Option<Box<SessionRecord>>,
350+ },
351+}
352+
353+/// authorization codes live for minutes, so a login this old was abandoned
354+const OAUTH_PENDING_FOR_MS: u64 = 60 * 60 * 1000;
355+
356+/// the authorization server answers in the query, or the fragment when asked to
357+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+ query
361+ .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.homeserver
317374 }
318375
319- pub(crate) fn user_id(&self) -> &OwnedUserId {
376+ /// a pending oauth login is nobody yet, unless it resumes someone
377+ 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 it
387+ 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_MS
408+ }
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 callback
454+ 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 begun
516+ /// somewhere else
517+ #[error("no OAuth login is waiting for this callback")]
518+ NoPendingOAuth,
425519 /// what was typed can't name a homeserver, so nothing was sent anywhere
426520 #[error("that isn't a usable homeserver, {0}")]
427521 InvalidHomeserver(&'static str),
@@ -586,15 +680,99 @@ impl Session {
586680 .build()
587681 .await
588682 .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: client
688+ .oauth()
689+ .client_id()
690+ .ok_or(AccountError::MissingSession)?
691+ .clone(),
692+ state: authorization.state.clone(),
693+ authorization: Box::new(
694+ client
695+ .oauth()
696+ .pending_authorization(&authorization.state)
697+ .await
698+ .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 the
716+ /// redirect brought back. the pending login is in `sessions`, so nothing else is needed
717+ 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).await
723+ }
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.authentication
743+ else {
744+ return Err(AccountError::NoPendingOAuth);
745+ };
746+ let client = matrix_client_builder(sessions.store())
747+ .homeserver_url(&record.homeserver)
748+ .build()
749+ .await
750+ .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 oauth
755+ .restore_pending_authorization(state, *authorization)
756+ .await
757+ {
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).await
774+ }
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,
Modified file: src/accounts.rs+71 additions−8 deletions
@@ -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 a
97- /// discard that couldn't finish, is deleted on the way past
98+ /// discard that couldn't finish, is deleted on the way past, and so is an oauth login whose
99+ /// redirect never came back. one that resumed an account puts that account back
98100 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's
117+ /// state, so the host needs to have kept nothing across the redirect
118+ 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 account
111144 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 and
270+ /// is left alone
271+ 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+ true
285+ }
286+ None => {
287+ self.remove(account).await;
288+ false
289+ }
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 account
275336 /// 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())
Modified file: tests/account.rs+68 additions−2 deletions
@@ -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+ server
443+ .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) = accounts
458+ .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+ .await
468+ .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+ accounts
488+ .finish_oauth(&format!("{redirect_uri}?code=42&state=someone-else"))
489+ .await,
490+ Err(AccountError::NoPendingOAuth)
491+ ));
492+
493+ let (finished, session) = accounts
494+ .finish_oauth(&format!("{redirect_uri}?code=42&state={state}"))
495+ .await
496+ .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+}