diff --git a/README.md b/README.md index 205f4e0..93de6a2 100644 --- a/README.md +++ b/README.md @@ -111,11 +111,14 @@ forwards only the supported CalorieApp endpoints to the configured backend and keeps mobile authentication sessions first-party. Xaman sign-in opens in a separate tab while the original CalorieApp tab waits -for a short-lived, one-time browser handoff. If Android returns from Xaman in a -different default browser, the callback browser receives its normal session and -the original tab securely claims a separate session for the same user. Only -hashes of the handoff proof are stored, the proof is never sent through -WordPress/Xaman URLs, and it cannot be claimed by a third browser after use. +for a short-lived, one-time browser handoff. Every CalorieApp-owned Xaman login +surface must clearly warn phone users before and during sign-in that the return +page normally opens in their configured default browser, possibly in a new tab, +and that they should keep the original CalorieApp tab open. The callback browser +receives its normal session and the original tab securely claims a separate +session for the same user. Only hashes of the handoff proof are stored, the +proof is never sent through WordPress/Xaman URLs, and it cannot be claimed by a +third browser after use. Create frontend/.env.local from the template before running the frontend. diff --git a/frontend/app/auth/callback/page.tsx b/frontend/app/auth/callback/page.tsx index f233bfb..23878be 100644 --- a/frontend/app/auth/callback/page.tsx +++ b/frontend/app/auth/callback/page.tsx @@ -156,8 +156,14 @@ function AuthCallbackContent() { Returning to CalorieApp +

+ Phone browser notice: Xaman normally opens this return page in your + configured default browser, possibly in a new tab. Your original + CalorieApp tab can remain open and will sign in automatically too. +

+

@@ -192,8 +198,14 @@ export default function AuthCallbackPage() { CalorieApp Sign-In +

+ Phone browser notice: Xaman normally opens this return page in + your configured default browser, possibly in a new tab. Keep the + original CalorieApp tab open. +

+

diff --git a/frontend/app/auth/complete/page.tsx b/frontend/app/auth/complete/page.tsx index 3f8b14d..54662ea 100644 --- a/frontend/app/auth/complete/page.tsx +++ b/frontend/app/auth/complete/page.tsx @@ -45,10 +45,13 @@ function LoginCompleteContent() {

Sign-in completed

-

+

+ Phone browser notice: Xaman normally opens this return page in your + configured default browser, possibly in a new tab. +

+

Your original CalorieApp tab is signing in automatically. You can - close this tab and return there, even if Xaman opened this page in - your phone's default browser. + close this tab and return there.

You are also signed in in this browser, so continuing here is safe. diff --git a/frontend/app/auth/launching/page.tsx b/frontend/app/auth/launching/page.tsx index 6300739..90075e7 100644 --- a/frontend/app/auth/launching/page.tsx +++ b/frontend/app/auth/launching/page.tsx @@ -1,4 +1,73 @@ +"use client"; + +import { useEffect, useState } from "react"; + +const XAMAN_LAUNCH_MESSAGE_TYPE = "calorieapp-xaman-navigate"; +const XAMAN_LAUNCH_ERROR_TYPE = "calorieapp-xaman-error"; + +type XamanLaunchMessage = { + type?: unknown; + attemptId?: unknown; + url?: unknown; +}; + +function isAllowedXamanSigninUrl(value: string): boolean { + try { + const target = new URL(value); + return ( + target.protocol === "https:" && + target.hostname === "calorietoken.net" && + target.searchParams.has("xl-signin") + ); + } catch { + return false; + } +} + export default function XamanLaunchingPage() { + const [message, setMessage] = useState( + "Waiting for CalorieApp to prepare the secure Xaman request. During heavy traffic this can take up to two minutes; do not refresh either tab." + ); + + useEffect(() => { + const attemptId = new URLSearchParams(window.location.search).get( + "attempt" + ); + + const handleMessage = (event: MessageEvent) => { + if ( + event.origin !== window.location.origin || + event.source !== window.opener || + !event.data || + event.data.attemptId !== attemptId + ) { + return; + } + + if (event.data.type === XAMAN_LAUNCH_ERROR_TYPE) { + setMessage( + "CalorieApp could not start Xaman. Return to the original tab and try again after a short wait." + ); + return; + } + + if ( + event.data.type !== XAMAN_LAUNCH_MESSAGE_TYPE || + typeof event.data.url !== "string" || + !isAllowedXamanSigninUrl(event.data.url) + ) { + return; + } + + setMessage("Opening Xaman now..."); + window.opener = null; + window.location.replace(event.data.url); + }; + + window.addEventListener("message", handleMessage); + return () => window.removeEventListener("message", handleMessage); + }, []); + return (

@@ -6,9 +75,16 @@ export default function XamanLaunchingPage() { Preparing Xaman sign-in

- Keep your original CalorieApp tab open. After approval, Xaman may - return in your default browser; the original tab will finish signing - in automatically. + On phones, expect the Xaman return page to open in your configured + default browser, possibly in a new tab. Keep your original CalorieApp + tab open; it will finish signing in automatically too. +

+

+ {message}

Food search is available to everyone. Sign in with Xaman to save and manage items.

+

+ Phone browser notice: Xaman normally returns in your configured default + browser, possibly in a new tab. Keep the original CalorieApp tab open. +

) : logError ? (
diff --git a/frontend/components/XamanLoginPanel.tsx b/frontend/components/XamanLoginPanel.tsx index 40ca2d5..3b241dc 100644 --- a/frontend/components/XamanLoginPanel.tsx +++ b/frontend/components/XamanLoginPanel.tsx @@ -5,6 +5,7 @@ import { announceAuthState } from "@/components/authEvents"; import { backendRequest, backendUnavailableMessage, + BackendRequestTimeoutError, waitForBackendReady, } from "@/lib/backendRequest"; @@ -30,6 +31,10 @@ const LOGIN_STATUS_POLL_INTERVAL_MS = 5_000; const LOGIN_STATUS_FALLBACK_LIFETIME_MS = 5 * 60_000; const LOGIN_STATUS_RATE_LIMIT_DELAY_MS = 15_000; const LOGIN_STATUS_MAX_RETRY_AFTER_MS = 60_000; +const LOGIN_START_RETRY_WINDOW_MS = 2 * 60_000; +const LOGIN_START_RETRY_DELAY_MS = 15_000; +const XAMAN_LAUNCH_MESSAGE_TYPE = "calorieapp-xaman-navigate"; +const XAMAN_LAUNCH_ERROR_TYPE = "calorieapp-xaman-error"; function delay(milliseconds: number, signal: AbortSignal) { return new Promise((resolve, reject) => { @@ -117,6 +122,84 @@ async function waitForOriginLogin( throw new Error("Login handoff expired"); } +function retryAfterMilliseconds(response: Response): number { + const value = response.headers.get("retry-after")?.trim(); + if (!value) { + return LOGIN_START_RETRY_DELAY_MS; + } + + const seconds = Number(value); + if (Number.isFinite(seconds) && seconds >= 0) { + return Math.min(seconds * 1_000, LOGIN_STATUS_MAX_RETRY_AFTER_MS); + } + + const retryAt = Date.parse(value); + if (Number.isNaN(retryAt)) { + return LOGIN_START_RETRY_DELAY_MS; + } + + return Math.min( + Math.max(0, retryAt - Date.now()), + LOGIN_STATUS_MAX_RETRY_AFTER_MS + ); +} + +async function startLoginWithRetry( + signal: AbortSignal, + onRateLimited: () => void +): Promise { + const deadline = Date.now() + LOGIN_START_RETRY_WINDOW_MS; + + while (Date.now() < deadline) { + const response = await backendRequest( + `${BACKEND_BASE_URL}/api/identity/login/start`, + { method: "POST", signal } + ); + + if (response.ok) { + return (await response.json()) as LoginStartResponse; + } + + if (response.status === 429) { + onRateLimited(); + await delay(retryAfterMilliseconds(response), signal); + continue; + } + + if ([502, 503, 504].includes(response.status)) { + await delay(LOGIN_START_RETRY_DELAY_MS, signal); + continue; + } + + throw new Error(`Unable to start login (${response.status})`); + } + + throw new BackendRequestTimeoutError(); +} + +function sendXamanLocationToLaunchTab( + loginWindow: Window, + attemptId: string, + wordpressSigninUrl: string +) { + const message = { + type: XAMAN_LAUNCH_MESSAGE_TYPE, + attemptId, + url: wordpressSigninUrl, + }; + + // Repeat briefly so a slow mobile browser cannot miss the message while the + // holding page is still attaching its listener. Once navigation starts, the + // target-origin check prevents delivery to the external page. + [0, 300, 1_000, 2_500].forEach((delayMs) => { + window.setTimeout(() => { + if (!loginWindow.closed) { + loginWindow.postMessage(message, window.location.origin); + } + }, delayMs); + }); +} + export function XamanLoginPanel() { const [isLoading, setIsLoading] = useState(false); const [isLoggingOut, setIsLoggingOut] = useState(false); @@ -161,8 +244,9 @@ export function XamanLoginPanel() { }, [refreshCurrentUser]); async function handleLogin() { + const attemptId = window.crypto.randomUUID(); const loginWindow = window.open( - "/auth/launching", + `/auth/launching?attempt=${encodeURIComponent(attemptId)}`, "calorieapp-xaman-login" ); const controller = new AbortController(); @@ -173,23 +257,18 @@ export function XamanLoginPanel() { setSuccessNotice(null); setIsLoading(true); setLoginStatus( - "Preparing a separate Xaman sign-in tab. Keep this CalorieApp tab open; it will finish automatically." + "Preparing Xaman. On phones, expect the return page to open in your configured default browser, possibly in a new tab. Keep this original CalorieApp tab open; it will sign in automatically too." ); try { await waitForBackendReady(BACKEND_BASE_URL, controller.signal); setLoginStatus("Service ready. Opening Xaman..."); - const response = await backendRequest(`${BACKEND_BASE_URL}/api/identity/login/start`, { - method: "POST", - signal: controller.signal, + const data = await startLoginWithRetry(controller.signal, () => { + setLoginStatus( + "CalorieApp is temporarily busy. Waiting safely before opening Xaman; keep both tabs open." + ); }); - - if (!response.ok) { - throw new Error("Unable to start login"); - } - - const data = (await response.json()) as LoginStartResponse; if (!data.wordpress_signin_url || !data.browser_handoff_token) { throw new Error("Missing signin handoff data"); } @@ -199,10 +278,13 @@ export function XamanLoginPanel() { return; } - loginWindow.opener = null; - loginWindow.location.replace(data.wordpress_signin_url); + sendXamanLocationToLaunchTab( + loginWindow, + attemptId, + data.wordpress_signin_url + ); setLoginStatus( - "Approve the request in Xaman. It may return in your default browser; this original tab will sign in automatically." + "Approve the request in Xaman. On phones, the return page normally opens in your configured default browser, possibly in a new tab. Keep this original tab open; it will sign in automatically too." ); await waitForOriginLogin( @@ -232,6 +314,10 @@ export function XamanLoginPanel() { try { if (loginWindow && !loginWindow.closed) { + loginWindow.postMessage( + { type: XAMAN_LAUNCH_ERROR_TYPE, attemptId }, + window.location.origin + ); loginWindow.close(); } } catch { @@ -290,11 +376,15 @@ export function XamanLoginPanel() {

Sign in securely to save, review, and manage your personal food log.

-

- A separate sign-in tab opens. Xaman may return in your phone's default - browser, while this original CalorieApp tab remains available and signs - in automatically. -

+
+ Phone browser notice: Expect the + Xaman return page to open in your configured default browser, possibly + in a new tab. Keep this original CalorieApp tab open; it will sign in + automatically too. +
{currentUser ? (