test(oauth): reproduce hard logout from stale refresh after suspension

When the app is suspended during the server_metadata() request of a token refresh, its cross-process lock lease lapses and a sibling process (the NSE) rotates the refresh token. On resume the app exchanges the now-stale token, gets invalid_grant, and is hard logged out.

This test reproduces that race. It fails on the current code, demonstrating the bug, and passes once refresh_access_token re-checks the session hash after fetching the server metadata.

The test failure here exists only to prove the bug, per the CONTRIBUTING commit guidelines.
This commit is contained in:
manuroe
2026-07-24 17:33:12 +02:00
parent c1db376b29
commit b6c521a857

View File

@@ -616,4 +616,160 @@ mod tests {
let hash = SessionHash(vec![0x13, 0x37, 0x42, 0xde, 0xad, 0xca, 0xfe]);
assert_eq!(hash.to_hex(), "0x133742deadcafe");
}
/// A refresh started while the token was current, but interrupted by an OS
/// suspension during `server_metadata()`, must not exchange the token a
/// sibling process (the NSE) rotated in the meantime and sign the user out.
/// It must instead notice the rotation (session-hash mismatch) and recover.
///
/// Regression test for a spontaneous hard-logout: the app was suspended mid
/// refresh, its 500ms cross-process lock lease lapsed, the NSE refreshed and
/// rotated the refresh token, and on resume the app spent the stale token and
/// got `invalid_grant`.
#[async_test]
async fn test_refresh_interrupted_by_suspension_does_not_sign_out() -> anyhow::Result<()> {
use std::{thread, time::Duration};
use wiremock::{
Mock, ResponseTemplate,
matchers::{body_string_contains, method, path_regex},
};
// This test models the OS suspension by blocking the runtime thread with
// `std::thread::sleep` below, which only freezes the lease-renew heartbeat on
// a single-threaded runtime. `#[async_test]` gives us one; assert it so a
// future switch to a multi-threaded flavor fails loudly here instead of
// silently no longer exercising the race.
assert_eq!(
tokio::runtime::Handle::current().runtime_flavor(),
tokio::runtime::RuntimeFlavor::CurrentThread,
"this regression test requires a current-thread runtime to model suspension",
);
let server = MatrixMockServer::new().await;
// The token endpoint behaves like a rotating MAS: the first exchange of the
// prev refresh token succeeds (that is the NSE's refresh, rotating it to the
// next token); any later exchange of the now-consumed token is rejected with
// `invalid_grant` (that would be the app spending its stale copy).
Mock::given(method("POST"))
.and(path_regex(r"^/oauth2/token"))
.and(body_string_contains("prev-refresh-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "1234", // == mock_session_tokens_with_refresh()
"expires_in": 300,
"refresh_token": "ZYXWV",
"token_type": "Bearer",
})))
.up_to_n_times(1)
.with_priority(1)
.mount(server.server())
.await;
Mock::given(method("POST"))
.and(path_regex(r"^/oauth2/token"))
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
"error": "invalid_grant",
})))
.with_priority(2)
.mount(server.server())
.await;
// Server metadata: the app's (first) request is delayed long enough to keep
// its refresh parked until after the NSE has rotated the token; the NSE's
// own request is answered immediately.
let metadata = serde_json::to_value(server.oauth().server_metadata())?;
Mock::given(method("GET"))
.and(path_regex(r"^/_matrix/client/unstable/org.matrix.msc2965/auth_metadata"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(metadata.clone())
.set_delay(Duration::from_secs(3)),
)
.up_to_n_times(1)
.with_priority(1)
.mount(server.server())
.await;
Mock::given(method("GET"))
.and(path_regex(r"^/_matrix/client/unstable/org.matrix.msc2965/auth_metadata"))
.respond_with(ResponseTemplate::new(200).set_body_json(metadata))
.with_priority(2)
.mount(server.server())
.await;
// The app and the NSE are two clients over one shared sqlite store, both
// restored with the same (prev) session.
let tmp_dir = tempfile::tempdir()?;
let app = server
.client_builder()
.on_builder(|b| b.sqlite_store(&tmp_dir, None))
.unlogged()
.build()
.await;
app.oauth().enable_cross_process_refresh_lock("app".to_owned()).await?;
app.oauth()
.restore_session(
mock_session(mock_prev_session_tokens_with_refresh()),
RoomLoadSettings::default(),
)
.await?;
app.set_session_callbacks(
Box::new(|_| Ok(mock_session_tokens_with_refresh())),
Box::new(|_| Ok(())),
)?;
let nse = server
.client_builder()
.on_builder(|b| b.sqlite_store(&tmp_dir, None))
.unlogged()
.build()
.await;
nse.oauth().enable_cross_process_refresh_lock("nse".to_owned()).await?;
nse.oauth()
.restore_session(
mock_session(mock_prev_session_tokens_with_refresh()),
RoomLoadSettings::default(),
)
.await?;
nse.set_session_callbacks(
Box::new(|_| Ok(mock_session_tokens_with_refresh())),
Box::new(|_| Ok(())),
)?;
// Start the app's refresh; it takes the lock (in the buggy ordering) and
// then parks in the delayed `server_metadata()` request.
let app_oauth = app.oauth();
let app_refresh = tokio::spawn(async move { app_oauth.refresh_access_token().await });
// Wait until the app has actually issued its auth_metadata request before
// suspending — by then it has taken the lock and captured the refresh token.
// Deterministic, unlike a fixed delay that could be too short under load.
let mut waited = Duration::ZERO;
while !server
.received_requests()
.await
.unwrap_or_default()
.iter()
.any(|request| request.url.path().contains("auth_metadata"))
{
assert!(waited < Duration::from_secs(5), "app never issued the metadata request");
tokio::time::sleep(Duration::from_millis(10)).await;
waited += Duration::from_millis(10);
}
// "Suspend" the app: block the current-thread runtime so the lease-renew
// heartbeat stops and the 500ms cross-process lock lease lapses.
thread::sleep(Duration::from_millis(700));
// The NSE steals the lapsed lock and refreshes, rotating prev -> next and
// consuming the prev token at the server.
nse.oauth().refresh_access_token().await?;
assert_eq!(nse.session_tokens(), Some(mock_session_tokens_with_refresh()));
// The app resumes (its metadata delay elapses). It must recover rather than
// exchange the stale token: `refresh_access_token` returns Ok.
app_refresh.await.expect("app refresh task should not panic")?;
Ok(())
}
}