mirror of
https://github.com/matrix-org/matrix-rust-sdk.git
synced 2026-07-30 17:36:27 -04:00
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:
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user