diff --git a/app/src-tauri/src/webview_accounts/mod.rs b/app/src-tauri/src/webview_accounts/mod.rs index af6c37e57..2890f2edf 100644 --- a/app/src-tauri/src/webview_accounts/mod.rs +++ b/app/src-tauri/src/webview_accounts/mod.rs @@ -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 { 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()); } diff --git a/app/src-tauri/src/webview_accounts/mod_tests.rs b/app/src-tauri/src/webview_accounts/mod_tests.rs index deaeca231..0aadeac48 100644 --- a/app/src-tauri/src/webview_accounts/mod_tests.rs +++ b/app/src-tauri/src/webview_accounts/mod_tests.rs @@ -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