diff --git a/app/(main)/[locale]/auth/callback/page.tsx b/app/(main)/[locale]/auth/callback/page.tsx index 28a75200c..e9862713d 100644 --- a/app/(main)/[locale]/auth/callback/page.tsx +++ b/app/(main)/[locale]/auth/callback/page.tsx @@ -11,6 +11,7 @@ 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 { confirmMobileHandoffCodeUrl } from "@/lib/auth/mobile-handoff-client"; import { MobileHandoffConsent } from "@/components/auth/mobile-handoff-consent"; function OAuthCallbackInner() { @@ -22,7 +23,11 @@ function OAuthCallbackInner() { 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); + const [pendingHandoff, setPendingHandoff] = useState<{ + url: string | null; + resolveUrl?: () => Promise; + 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. @@ -166,6 +171,27 @@ function OAuthCallbackInner() { return; } const data = await res.json(); + if (typeof data.mobile_handoff_code === "string" && data.mobile_handoff_code) { + // The app sent a PKCE challenge: the server kept the tokens and + // the link carries only a one-time code (docs/mobile-handoff.md). + // The code starts working when the user confirms. + const handoffCode: string = data.mobile_handoff_code; + const handoffState = typeof data.mobile_state === "string" ? data.mobile_state : mobileState ?? ""; + try { + sessionStorage.removeItem("mobile_redirect_uri"); + sessionStorage.removeItem("mobile_state"); + } catch { /* ignore */ } + setPendingHandoff({ + url: null, + resolveUrl: () => confirmMobileHandoffCodeUrl({ + target: mobileRedirectUri, + code: handoffCode, + state: handoffState, + }), + username: typeof data.server_url === "string" ? data.server_url : "", + }); + return; + } const serverUrl = data.server_url as string | undefined; const accessToken = data.access_token as string | undefined; const tokenEndpoint = data.token_endpoint as string | undefined; @@ -235,7 +261,12 @@ function OAuthCallbackInner() {
{ + setPendingHandoff(null); + setError("token_exchange_failed"); + }} onCancel={() => { setPendingHandoff(null); router.push(toRouterPath(`${getPathPrefix(params.locale as string)}/${params.locale}/login`)); diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index e633a0ce4..dbe79acf4 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -33,7 +33,8 @@ import { } from "@/lib/auth/lite-oauth"; import { toAsciiDomain } from "@/lib/idn"; import { usePrefetchMailView } from "@/hooks/use-prefetch-mail-view"; -import { buildMobileHandoffUrl, mobileHandoffTarget, signedInLoginAction } from "@/lib/auth/mobile-handoff"; +import { buildMobileHandoffUrl, mobileCodeChallenge, mobileHandoffTarget, signedInLoginAction } from "@/lib/auth/mobile-handoff"; +import { requestMobileHandoffCodeUrl } from "@/lib/auth/mobile-handoff-client"; import { MobileHandoffConsent } from "@/components/auth/mobile-handoff-consent"; import { HelpLinks } from "@/components/layout/help-links"; import { consumeSessionEnd, type SessionEndReason } from "@/lib/session-policy/sign-out-reason"; @@ -193,10 +194,25 @@ function LoginPageContent() { // 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 = mobileHandoffTarget(searchParams.get("mobile_redirect_uri")); + // App builds that send a PKCE challenge get a one-time code in the link + // instead of the sign-in (docs/mobile-handoff.md). A malformed challenge + // makes this no hand-off at all: falling back to the fragment would put + // the password in the link the app asked to keep it out of. Lite has no + // server to park the sign-in on and keeps handing over in the fragment. + const rawMobileCodeChallenge = IS_LITE ? "" : searchParams.get("mobile_code_challenge") ?? ""; + const mobileChallenge = mobileCodeChallenge(rawMobileCodeChallenge); + const mobileRedirectUri = rawMobileCodeChallenge && !mobileChallenge + ? "" + : mobileHandoffTarget(searchParams.get("mobile_redirect_uri")); const mobileState = mobileRedirectUri ? rawMobileState : ""; const isMobileHandoff = Boolean(mobileRedirectUri); - const [pendingHandoff, setPendingHandoff] = useState<{ url: string; username: string } | null>(null); + const [pendingHandoff, setPendingHandoff] = useState<{ + url: string | null; + resolveUrl?: () => Promise; + username: string; + } | null>(null); + // Parking the sign-in for the app failed (code hand-off only). + const [handoffError, setHandoffError] = useState(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 }))); @@ -266,6 +282,8 @@ function LoginPageContent() { const [oauthLoading, setOauthLoading] = useState(false); const [demoLoading, setDemoLoading] = useState(false); + // The auth store's error, else a failed hand-off to the app. + const shownError = error || handoffError; // The error box and the recent-accounts list, tied to the fields they // belong to so a screen reader reads them with the field. const formId = useId(); @@ -451,12 +469,28 @@ function LoginPageContent() { const payload = mobileHandoffPayloadRef.current; if (!payload) return; mobileHandoffPayloadRef.current = null; + setHandoffError(null); + if (mobileChallenge) { + // The app gets a one-time code: on confirmation the sign-in is parked + // on the server, and its 60 seconds start then. + setPendingHandoff({ + url: null, + resolveUrl: () => requestMobileHandoffCodeUrl({ + target: mobileRedirectUri, + challenge: mobileChallenge, + state: mobileState, + payload, + }), + username: payload.username, + }); + return; + } // Shown to the user first; the navigation happens on their confirmation. setPendingHandoff({ url: buildMobileHandoffUrl(mobileRedirectUri, { flow: "password", ...payload, state: mobileState }), username: payload.username, }); - }, [mobileRedirectUri, mobileState]); + }, [mobileRedirectUri, mobileState, mobileChallenge]); useEffect(() => { if (!isAuthenticated) return; @@ -490,6 +524,7 @@ function LoginPageContent() { useEffect(() => { clearError(); + setHandoffError(null); }, [formData, clearError]); useEffect(() => { @@ -591,7 +626,11 @@ function LoginPageContent() { server_id: selectedServer?.id, remember: persistProviderSession, ...(isMobileHandoff - ? { mobile_redirect_uri: mobileRedirectUri, mobile_state: mobileState } + ? { + mobile_redirect_uri: mobileRedirectUri, + mobile_state: mobileState, + ...(mobileChallenge ? { mobile_code_challenge: mobileChallenge } : {}), + } : {}), }), }); @@ -619,7 +658,7 @@ function LoginPageContent() { } catch { setOauthLoading(false); } - }, [params.locale, selectedServer?.id, persistProviderSession, isMobileHandoff, mobileRedirectUri, mobileState]); + }, [params.locale, selectedServer?.id, persistProviderSession, isMobileHandoff, mobileRedirectUri, mobileState, mobileChallenge]); useEffect(() => { // After signing out, show the login page rather than signing straight @@ -1263,11 +1302,16 @@ function LoginPageContent() {
{ setPendingHandoff(null); router.push('/'); }} + onError={() => { + setPendingHandoff(null); + setHandoffError("server_error"); + }} /> {/* Theme toggle - top right, dropdown style; hidden while the theme policy locks the mode (#1280) */}