From 0b01a2d0662eb97e922c8557c28cebbadb276e6a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:22:19 +0200 Subject: [PATCH] fix: ask before handing a sign-in to the mobile app and only to its own callback link --- app/(main)/[locale]/auth/callback/page.tsx | 33 ++++++++++-- app/(main)/[locale]/login/page.tsx | 34 +++++++----- app/api/auth/sso/start/route.ts | 10 ++-- .../__tests__/mobile-handoff-consent.test.tsx | 54 +++++++++++++++++++ components/auth/mobile-handoff-consent.tsx | 38 +++++++++++++ lib/__tests__/mobile-handoff.test.ts | 38 +++++++++++++ lib/auth/mobile-handoff.ts | 33 ++++++++++++ locales/ar/common.json | 6 +++ locales/ca/common.json | 6 +++ locales/cs/common.json | 6 +++ locales/da/common.json | 6 +++ locales/de/common.json | 6 +++ locales/en/common.json | 6 +++ locales/es/common.json | 6 +++ locales/et/common.json | 6 +++ locales/fa/common.json | 6 +++ locales/fr/common.json | 6 +++ locales/he/common.json | 6 +++ locales/hu/common.json | 6 +++ locales/it/common.json | 6 +++ locales/ja/common.json | 6 +++ locales/ko/common.json | 6 +++ locales/lv/common.json | 6 +++ locales/mn/common.json | 6 +++ locales/nb/common.json | 6 +++ locales/nl/common.json | 6 +++ locales/pl/common.json | 6 +++ locales/pt/common.json | 6 +++ locales/ro/common.json | 6 +++ locales/ru/common.json | 6 +++ locales/sk/common.json | 6 +++ locales/tr/common.json | 6 +++ locales/uk/common.json | 6 +++ locales/zh-TW/common.json | 6 +++ locales/zh/common.json | 6 +++ 35 files changed, 385 insertions(+), 23 deletions(-) create mode 100644 components/auth/__tests__/mobile-handoff-consent.test.tsx create mode 100644 components/auth/mobile-handoff-consent.tsx create mode 100644 lib/__tests__/mobile-handoff.test.ts create mode 100644 lib/auth/mobile-handoff.ts diff --git a/app/(main)/[locale]/auth/callback/page.tsx b/app/(main)/[locale]/auth/callback/page.tsx index 380ac321d..28a75200c 100644 --- a/app/(main)/[locale]/auth/callback/page.tsx +++ b/app/(main)/[locale]/auth/callback/page.tsx @@ -10,6 +10,8 @@ import { LITE_OAUTH_AVAILABLE, getLiteOAuthRedirectUri } from "@/lib/auth/lite-o import { Loader2, AlertCircle } from "@/components/icons"; import { Button } from "@/components/ui/button"; import { useParams } from "next/navigation"; +import { buildMobileHandoffUrl, mobileHandoffTarget } from "@/lib/auth/mobile-handoff"; +import { MobileHandoffConsent } from "@/components/auth/mobile-handoff-consent"; function OAuthCallbackInner() { const router = useRouter(); @@ -18,6 +20,9 @@ function OAuthCallbackInner() { const t = useTranslations("login"); const { loginWithOAuth, loginWithServerSso } = useAuthStore(); const [error, setError] = useState(null); + // A sign-in started for the mobile app, waiting for the user to confirm + // the handoff (lib/auth/mobile-handoff.ts). + const [pendingHandoff, setPendingHandoff] = useState<{ url: string; username: string } | null>(null); // The authorization code is single-use. React's development double effect // ran this twice: the second pass no longer saw the pairing re-auth flag // (the first had removed it) and spent the code on the login flow instead. @@ -135,14 +140,14 @@ function OAuthCallbackInner() { // Server-side SSO flow - state was stored in encrypted httpOnly cookie. // Branch on mobile handoff first: the login page left a marker in // sessionStorage if it kicked this OAuth dance off for the mobile app. - let mobileRedirectUri: string | null = null; + let mobileRedirectUri = ""; let mobileState: string | null = null; try { - mobileRedirectUri = sessionStorage.getItem("mobile_redirect_uri"); + mobileRedirectUri = mobileHandoffTarget(sessionStorage.getItem("mobile_redirect_uri")); mobileState = sessionStorage.getItem("mobile_state"); } catch { /* sessionStorage may be unavailable */ } - if (mobileRedirectUri && mobileRedirectUri.startsWith("bulwarkmobile://")) { + if (mobileRedirectUri) { // Drive /api/auth/sso/complete directly so we can read the tokens // out of the response - loginWithServerSso would consume them and // wire up the webmail auth store, which isn't useful here. The @@ -187,7 +192,12 @@ function OAuthCallbackInner() { sessionStorage.removeItem("mobile_redirect_uri"); sessionStorage.removeItem("mobile_state"); } catch { /* ignore */ } - window.location.replace(`${mobileRedirectUri}#${fragment.toString()}`); + // Shown to the user first; the navigation happens on their confirmation. + setPendingHandoff({ + url: buildMobileHandoffUrl(mobileRedirectUri, Object.fromEntries(fragment)), + // The complete route names the account's server, not the user. + username: typeof data.username === "string" ? data.username : serverUrl, + }); } catch { setError("token_exchange_failed"); } @@ -220,6 +230,21 @@ function OAuthCallbackInner() { } }, []); // eslint-disable-line react-hooks/exhaustive-deps + if (pendingHandoff) { + return ( +
+ { + setPendingHandoff(null); + router.push(toRouterPath(`${getPathPrefix(params.locale as string)}/${params.locale}/login`)); + }} + /> +
+ ); + } + if (error) { return (
diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index f42a930b0..81c50b023 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -32,6 +32,8 @@ import { } from "@/lib/auth/lite-oauth"; import { toAsciiDomain } from "@/lib/idn"; import { usePrefetchMailView } from "@/hooks/use-prefetch-mail-view"; +import { buildMobileHandoffUrl, mobileHandoffTarget } from "@/lib/auth/mobile-handoff"; +import { MobileHandoffConsent } from "@/components/auth/mobile-handoff-consent"; /** The domain of a complete address (`user@example.com`), or '' while it is still being typed. */ function completeAddressDomain(username: string): string { @@ -134,11 +136,6 @@ function VersionBadge() { ); } -// Only redirect targets matching this scheme are honored by the mobile -// handoff path. Without the check the login page becomes an open redirector -// that funnels password and token material to any caller-supplied URL. -const MOBILE_REDIRECT_SCHEME = "bulwarkmobile://"; - function LoginPageContent() { const router = useRouter(); const t = useTranslations("login"); @@ -150,13 +147,13 @@ function LoginPageContent() { // these params. We grab them once at mount and stash them in a ref so any // login path that completes (password or OAuth) can hand control back to // the app instead of routing into /mail. - const rawMobileRedirectUri = searchParams.get("mobile_redirect_uri") ?? ""; + // Only the app's own callback link is a handoff target, and nothing is + // handed over before the user confirms it (lib/auth/mobile-handoff.ts). const rawMobileState = searchParams.get("mobile_state") ?? ""; - const mobileRedirectUri = rawMobileRedirectUri.startsWith(MOBILE_REDIRECT_SCHEME) - ? rawMobileRedirectUri - : ""; + const mobileRedirectUri = mobileHandoffTarget(searchParams.get("mobile_redirect_uri")); const mobileState = mobileRedirectUri ? rawMobileState : ""; const isMobileHandoff = Boolean(mobileRedirectUri); + const [pendingHandoff, setPendingHandoff] = useState<{ url: string; username: string } | null>(null); usePrefetchMailView(!isMobileHandoff); const { login, loginWithToken, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore(); const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme }))); @@ -374,12 +371,13 @@ function LoginPageContent() { // mobile app instead of pushing to /mail. handleSubmit captured the // values needed for the fragment. if (isMobileHandoff && mobileHandoffPayloadRef.current) { - const fragment = new URLSearchParams({ - flow: "password", - ...mobileHandoffPayloadRef.current, - state: mobileState, + const payload = mobileHandoffPayloadRef.current; + mobileHandoffPayloadRef.current = null; + // Shown to the user first; the navigation happens on their confirmation. + setPendingHandoff({ + url: buildMobileHandoffUrl(mobileRedirectUri, { flow: "password", ...payload, state: mobileState }), + username: payload.username, }); - window.location.replace(`${mobileRedirectUri}#${fragment.toString()}`); return; } let redirectTo = '/'; @@ -1110,6 +1108,14 @@ function LoginPageContent() { return (
+ { + setPendingHandoff(null); + router.push('/'); + }} + /> {/* Theme toggle - top right, dropdown style; hidden while the theme policy locks the mode (#1280) */}