fix(webview-accounts): keep X/Twitter Google (GSI) sign-in popup in-app (#5009) (#5015)

This commit is contained in:
Mega Mind
2026-07-18 06:44:39 +03:00
committed by GitHub
parent f1de40cafa
commit c2e85d4bcb
2 changed files with 153 additions and 22 deletions
+56 -22
View File
@@ -310,32 +310,55 @@ fn popup_should_stay_in_app(provider: &str, url: &Url) -> bool {
None => false,
}
}
"linkedin" => {
// LinkedIn's "Sign in with Google" button is rendered as a Google
// Identity Services (GSI) iframe loaded from
// `accounts.google.com/gsi/button`. When the user clicks it, GSI
// calls `window.open("https://accounts.google.com/gsi/select?...",
// "gsig", "width=500,height=600,...")` to show the account chooser.
//
// This popup MUST stay as a real in-app child window — NOT routed
// to the system browser (blank screen) and NOT a parent navigation
// (the parent page would be replaced, so the postMessage credential
// callback from the popup can never reach LinkedIn's JS handler).
//
// After the user selects an account the popup postMessages the
// signed credential back to the opener; LinkedIn's GSI callback
// receives it and completes sign-in (#1021).
match url.host_str() {
Some(host) => {
is_google_sso_host(host) && url.path().to_ascii_lowercase().contains("gsi")
}
None => false,
}
}
// LinkedIn (#1021) and X/Twitter (#5009) render "Sign in with Google"
// as a Google Identity Services (GSI) popup that the GIS SDK drives via
// window.open() and completes by postMessage-ing the credential back to
// window.opener (the provider page). Every leg of that popup — the
// account chooser and the /o/oauth2 auth hop — MUST stay as a real
// in-app child window: routing it to the system browser leaves the
// embedded webview on a dead page, and navigating the parent to it
// destroys the opener so the credential can never post back. See
// `is_google_gsi_popup`.
"linkedin" | "twitter" => is_google_gsi_popup(url),
_ => false,
}
}
/// `true` for a Google Identity Services (GSI) **popup-mode** "Sign in with
/// Google" window — the opener-dependent flow the GIS SDK drives via
/// `window.open(...)` and completes by `postMessage`-ing the credential back to
/// `window.opener` (the provider page). Covers both the account-chooser
/// (`accounts.google.com/gsi/select`, #1021) and the `/o/oauth2/v2/auth` leg the
/// same popup navigates through (#5009).
///
/// Matched by Google SSO host (`is_google_sso_host`) AND either a `gsi` path
/// segment or one of the GIS-SDK popup-mode query markers. Deliberately narrow
/// — a redirect-mode Google sign-in (a real https `redirect_uri`, no GSI
/// markers) does NOT match, so it still replaces the parent. NOT a blanket
/// popup allow.
///
/// Keeping these popups in-app is what lets the postMessage handshake complete
/// inside the account's isolated CEF profile instead of leaking to the system
/// browser (#5009) or being dropped when the parent (the opener) is navigated
/// away (#5009 / #1021).
fn is_google_gsi_popup(url: &Url) -> bool {
if !url.host_str().is_some_and(is_google_sso_host) {
return false;
}
if url.path().to_ascii_lowercase().contains("gsi") {
return true;
}
// GIS-SDK popup-mode markers. These appear on the popup's auth URLs (incl.
// `/o/oauth2/v2/auth`) and never on a plain redirect-mode sign-in.
url.query_pairs().any(|(key, value)| {
let k = key.to_ascii_lowercase();
let v = value.to_ascii_lowercase();
((k == "ux_mode" || k == "display") && v == "popup")
|| k == "gsiwebsdk"
|| (k == "redirect_uri" && v == "gis_transform")
})
}
/// `true` if `scheme` is a known provider native-desktop-app deep-link
/// scheme. We suppress these instead of routing them to the system
/// browser because macOS hands them to the native provider app
@@ -397,6 +420,17 @@ fn popup_should_navigate_parent(provider: &str, url: &Url) -> Option<Url> {
if url.scheme() == "about" {
return None;
}
// #5009: X/Twitter (and LinkedIn) sign in with Google via the GSI popup —
// an opener-dependent flow where the popup postMessages the credential back
// to `window.opener` (the provider page). Navigating the parent to any leg
// of that popup (chooser or the `/o/oauth2` hop) destroys the opener, so the
// sign-in never completes and the pane paints blank. Keep it in-app instead
// (`popup_should_stay_in_app` catches it for these providers). Redirect-mode
// Google sign-in (a real https `redirect_uri`, no GSI markers) is NOT a GSI
// popup, so it still falls through to the parent-navigation below.
if matches!(provider, "linkedin" | "twitter") && is_google_gsi_popup(url) {
return None;
}
if is_google_auth_popup(url) {
return Some(url.clone());
}
@@ -341,6 +341,103 @@ fn linkedin_google_oauth2_popup_navigates_parent() {
.is_some());
}
#[test]
fn linkedin_google_account_chooser_popup_stays_in_app() {
// Regression guard for the refactor that shares the GSI account-chooser
// arm between linkedin + twitter: LinkedIn's gsi/select popup must still
// stay in-app (#1021).
assert!(popup_should_stay_in_app(
"linkedin",
&url("https://accounts.google.com/gsi/select?client_id=x&ux_mode=popup"),
));
}
#[test]
fn twitter_supports_google_sso() {
// X/Twitter offers "Continue with Google"; without twitter in the SSO set
// the /o/oauth2 leg would not navigate the parent in-app (#5009).
assert!(provider_supports_google_sso("twitter"));
}
#[test]
fn twitter_google_account_chooser_popup_stays_in_app() {
// #5009: X's "Continue with Google" opens the GSI account chooser via
// window.open(accounts.google.com/gsi/select). Before the fix twitter had
// no popup_should_stay_in_app arm, so this popup fell through to the system
// browser — the user picked their account OUTSIDE the app and the embedded
// webview was revealed on a dead page (blank screen). It must stay in-app.
assert!(popup_should_stay_in_app(
"twitter",
&url("https://accounts.google.com/gsi/select?client_id=x&ux_mode=popup&origin=https%3A%2F%2Fx.com"),
));
// The GSI button/status endpoints share the same in-app requirement.
assert!(popup_should_stay_in_app(
"twitter",
&url("https://accounts.google.com/gsi/button?client_id=x"),
));
}
#[test]
fn twitter_google_account_chooser_popup_is_not_navigate_parent() {
// The account-chooser popup must be handled by popup_should_stay_in_app,
// NOT popup_should_navigate_parent — navigating the parent to gsi/select
// would destroy the opener the popup postMessages the credential back to.
// (gsi/select is not an is_google_auth_popup match, so this holds.)
assert!(popup_should_navigate_parent(
"twitter",
&url("https://accounts.google.com/gsi/select?client_id=x&ux_mode=popup"),
)
.is_none());
}
#[test]
fn twitter_google_oauth2_popup_mode_stays_in_app_not_navigate_parent() {
// #5009 core fix: X's "Continue with Google" is a GSI *popup* (ux_mode=popup)
// — the popup postMessages the id_token back to the x.com opener. The
// /o/oauth2 leg must stay in-app and must NOT navigate the parent; doing so
// destroyed the opener and painted the pane white.
let u = url(
"https://accounts.google.com/o/oauth2/v2/auth?client_id=x&ux_mode=popup\
&gsiwebsdk=gis_attributes&redirect_uri=gis_transform&response_type=id_token\
&origin=https%3A%2F%2Fx.com",
);
assert!(
popup_should_navigate_parent("twitter", &u).is_none(),
"popup-mode /o/oauth2 must not replace the x.com opener"
);
assert!(
popup_should_stay_in_app("twitter", &u),
"popup-mode /o/oauth2 must stay as an in-app child window"
);
}
#[test]
fn linkedin_google_oauth2_redirect_mode_still_navigates_parent() {
// Regression: LinkedIn's Google sign-in is a *redirect* flow (a real https
// redirect_uri, no GSI popup markers). That is not a GSI popup, so it must
// still replace the parent in-app (#1021) — the #5009 guard must not touch
// it.
let u = url("https://accounts.google.com/o/oauth2/v2/auth?client_id=x\
&redirect_uri=https://www.linkedin.com/oauth/callback");
assert!(popup_should_navigate_parent("linkedin", &u).is_some());
assert!(!popup_should_stay_in_app("linkedin", &u));
}
#[test]
fn twitter_non_google_popup_does_not_stay_in_app() {
// Scope guard: only the Google account-chooser popup is kept in-app. An
// ordinary target="_blank"/window.open link still routes to the system
// browser, so this is not a blanket popup allow.
assert!(!popup_should_stay_in_app(
"twitter",
&url("https://example.com/some/article"),
));
assert!(!popup_should_stay_in_app(
"twitter",
&url("https://x.com/i/status/123")
));
}
#[test]
fn linkedin_google_sso_navigation_is_internal() {
// Direct (non-popup) navigation to accounts.google.com during the