fix(oauth): fetch server metadata before capturing the refresh token

refresh_access_token validated the session hash and captured the refresh token before awaiting server_metadata(). On iOS the process can be suspended during that request; while suspended the cross-process lock lease lapses and a sibling process (the NSE) can refresh and rotate the token. On resume the app exchanged the now-stale token and was hard logged out with invalid_grant.

Fetch the server metadata and client id before taking the cross-process lock, and read the refresh token after the hash check, so a rotation that happens during the metadata request is detected as a hash mismatch and handled via the existing optimistic exit instead of exchanging a stale token.
This commit is contained in:
manuroe
2026-07-24 17:33:13 +02:00
parent b6c521a857
commit 111567e2e2

View File

@@ -1228,6 +1228,31 @@ impl OAuth {
debug!("no other refresh happening in background, starting.");
// Fetch the authorization server metadata *before* taking the cross-process
// lock, checking the session hash, or reading the refresh token. This
// request can stall for a long time when the OS suspends the process (e.g.
// iOS background suspension). Doing it first means that when we resume we
// still (re)acquire the cross-process lock and re-check the session hash
// afterwards, so a refresh token that a sibling process (such as the NSE)
// rotated while we were suspended is detected here as a hash mismatch and
// handled via the optimistic exit below — instead of being exchanged while
// stale and getting the account hard-logged-out with `invalid_grant`.
let server_metadata = match self.server_metadata().await {
Ok(metadata) => metadata,
Err(err) => {
warn!("couldn't get authorization server metadata: {err:?}");
fail!(refresh_status_guard, RefreshTokenError::OAuth(Arc::new(err.into())));
}
};
let Some(client_id) = self.client_id().cloned() else {
warn!("invalid state: missing client ID");
fail!(
refresh_status_guard,
RefreshTokenError::OAuth(Arc::new(OAuthError::NotAuthenticated))
);
};
#[cfg(feature = "e2e-encryption")]
let cross_process_guard =
if let Some(manager) = self.ctx().cross_process_token_refresh_manager.get() {
@@ -1259,6 +1284,9 @@ impl OAuth {
None
};
// Read the refresh token only now — after the post-metadata hash check — so
// we always exchange the token that is current in the store, never one that
// was rotated out from under us while we were suspended.
let Some(session_tokens) = self.client.session_tokens() else {
warn!("invalid state: missing session tokens");
fail!(refresh_status_guard, RefreshTokenError::RefreshTokenRequired);
@@ -1269,22 +1297,6 @@ impl OAuth {
fail!(refresh_status_guard, RefreshTokenError::RefreshTokenRequired);
};
let server_metadata = match self.server_metadata().await {
Ok(metadata) => metadata,
Err(err) => {
warn!("couldn't get authorization server metadata: {err:?}");
fail!(refresh_status_guard, RefreshTokenError::OAuth(Arc::new(err.into())));
}
};
let Some(client_id) = self.client_id().cloned() else {
warn!("invalid state: missing client ID");
fail!(
refresh_status_guard,
RefreshTokenError::OAuth(Arc::new(OAuthError::NotAuthenticated))
);
};
// Do not interrupt refresh access token requests and processing, by detaching
// the request sending and response processing.
// Make sure to keep the `refresh_status_guard` during the entire processing.