From e1bf467693c0550e82624d33159a4a0e2e032282 Mon Sep 17 00:00:00 2001 From: xrpbanks <126300068+xrpbanks@users.noreply.github.com> Date: Sat, 29 Aug 2026 06:32:30 +0200 Subject: [PATCH] Keep Xaman login in the current tab --- README.md | 21 +- frontend/app/auth/callback/page.tsx | 12 +- frontend/app/auth/complete/page.tsx | 12 +- frontend/app/auth/launching/page.tsx | 96 ------- frontend/components/FoodSearchPlaceholder.tsx | 4 +- frontend/components/XamanLoginPanel.tsx | 253 +++++++++++------- 6 files changed, 185 insertions(+), 213 deletions(-) delete mode 100644 frontend/app/auth/launching/page.tsx diff --git a/README.md b/README.md index cbf7445..21d6c76 100644 --- a/README.md +++ b/README.md @@ -115,15 +115,18 @@ probe the public backend `/health` endpoint directly so a Render cold start does not occupy the frontend proxy long enough to trigger frontend 429 responses. All authenticated requests continue through the same-origin proxy. -Xaman sign-in opens in a separate tab while the original CalorieApp tab waits -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. +Xaman sign-in starts with same-tab navigation. CalorieApp does not call +`window.open` and therefore does not create a launch tab. Mobile platforms can +still return from Xaman through the configured default browser because they do +not let a web flow select or reuse the original browser tab; this limitation is +documented by Xaman in its +[Payload Return URL guidance](https://docs.xaman.dev/concepts/payloads-sign-requests/payload-return-url). +The callback browser receives its normal session. A short-lived, one-time +browser handoff is kept only in the initiating tab's session storage so that +the session can be securely restored if the user returns to that browsing +context. Only hashes of the handoff proof are stored server-side, 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 23878be..848f983 100644 --- a/frontend/app/auth/callback/page.tsx +++ b/frontend/app/auth/callback/page.tsx @@ -157,9 +157,9 @@ function AuthCallbackContent() {

- 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. + Phone browser notice: mobile systems may return from Xaman through + your configured default browser instead of the tab where you started. + This page completes your CalorieApp session in the browser shown now.

- 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. + Phone browser notice: mobile systems may return from Xaman + through your configured default browser. CalorieApp itself does + not open an extra tab.

- Phone browser notice: Xaman normally opens this return page in your - configured default browser, possibly in a new tab. + Xaman may have returned through your phone's configured default + browser. Your CalorieApp session is active here.

- Your original CalorieApp tab is signing in automatically. You can - close this tab and return there. -

-

- You are also signed in in this browser, so continuing here is safe. + Continue below to use CalorieApp in this browser.

- Continue in this browser + Continue to CalorieApp diff --git a/frontend/app/auth/launching/page.tsx b/frontend/app/auth/launching/page.tsx deleted file mode 100644 index 90075e7..0000000 --- a/frontend/app/auth/launching/page.tsx +++ /dev/null @@ -1,96 +0,0 @@ -"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 ( -
-
-

- Preparing Xaman sign-in -

-

- 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} -

-
-
- ); -} diff --git a/frontend/components/FoodSearchPlaceholder.tsx b/frontend/components/FoodSearchPlaceholder.tsx index 7793196..bf5e636 100644 --- a/frontend/components/FoodSearchPlaceholder.tsx +++ b/frontend/components/FoodSearchPlaceholder.tsx @@ -735,8 +735,8 @@ export function FoodSearchPlaceholder() { 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. + CalorieApp starts Xaman from the current tab. Your phone may return + through its configured default browser after signing.

) : logError ? ( diff --git a/frontend/components/XamanLoginPanel.tsx b/frontend/components/XamanLoginPanel.tsx index 12e4cab..da9256a 100644 --- a/frontend/components/XamanLoginPanel.tsx +++ b/frontend/components/XamanLoginPanel.tsx @@ -26,6 +26,12 @@ type LoginStatusResponse = { redirect_to?: string | null; }; +type PendingLogin = { + state: string; + expiresAt: string; + browserHandoffToken: string; +}; + const BACKEND_BASE_URL = "/api/backend"; const RENDER_BACKEND_HEALTH_URL = "https://calorieapp-backend-rvul.onrender.com"; @@ -35,8 +41,8 @@ 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"; +const PENDING_LOGIN_STORAGE_KEY = "calorieapp-pending-xaman-login"; +const LOGIN_RETURN_STORAGE_KEY = "calorieapp-login-return"; function backendHealthBaseUrl(): string { const configuredUrl = process.env.NEXT_PUBLIC_BACKEND_HEALTH_URL?.trim(); @@ -51,6 +57,68 @@ function backendHealthBaseUrl(): string { return BACKEND_BASE_URL; } +function isAllowedWordPressSigninUrl(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; + } +} + +function clearPendingLogin() { + window.sessionStorage.removeItem(PENDING_LOGIN_STORAGE_KEY); +} + +function storePendingLogin(data: LoginStartResponse) { + const pendingLogin: PendingLogin = { + state: data.state, + expiresAt: data.expires_at, + browserHandoffToken: data.browser_handoff_token, + }; + + window.sessionStorage.setItem( + PENDING_LOGIN_STORAGE_KEY, + JSON.stringify(pendingLogin) + ); +} + +function readPendingLogin(): PendingLogin | null { + const stored = window.sessionStorage.getItem(PENDING_LOGIN_STORAGE_KEY); + if (!stored) { + return null; + } + + try { + const value = JSON.parse(stored) as Partial; + const expiresAt = Date.parse(value.expiresAt ?? ""); + if ( + typeof value.state !== "string" || + value.state.length < 32 || + typeof value.browserHandoffToken !== "string" || + value.browserHandoffToken.length < 32 || + !Number.isFinite(expiresAt) || + expiresAt <= Date.now() + ) { + clearPendingLogin(); + return null; + } + + return { + state: value.state, + expiresAt: value.expiresAt as string, + browserHandoffToken: value.browserHandoffToken, + }; + } catch { + clearPendingLogin(); + return null; + } +} + function delay(milliseconds: number, signal: AbortSignal) { return new Promise((resolve, reject) => { if (signal.aborted) { @@ -192,29 +260,6 @@ async function startLoginWithRetry( 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); @@ -224,7 +269,7 @@ export function XamanLoginPanel() { const [currentUser, setCurrentUser] = useState(null); const loginAbortController = useRef(null); - const refreshCurrentUser = useCallback(async () => { + const refreshCurrentUser = useCallback(async (): Promise => { try { const response = await backendRequest( `${BACKEND_BASE_URL}/api/identity/me` @@ -234,36 +279,94 @@ export function XamanLoginPanel() { if (response.status === 401) { announceAuthState(false); } - return; + return null; } const data = (await response.json()) as MeResponse; setCurrentUser(data); + return data; } catch { setCurrentUser(null); + return null; } }, []); useEffect(() => { - refreshCurrentUser(); + let cancelled = false; + const controller = new AbortController(); + loginAbortController.current = controller; - if (window.sessionStorage.getItem("calorieapp-login-return")) { - window.sessionStorage.removeItem("calorieapp-login-return"); - setSuccessNotice( - "Sign-in completed in your default browser. You can continue safely in this tab." - ); + async function restoreLogin() { + const user = await refreshCurrentUser(); + if (cancelled || controller.signal.aborted) { + return; + } + + if (user) { + clearPendingLogin(); + if (window.sessionStorage.getItem(LOGIN_RETURN_STORAGE_KEY)) { + window.sessionStorage.removeItem(LOGIN_RETURN_STORAGE_KEY); + setSuccessNotice( + "Sign-in completed. You can continue safely in this browser." + ); + } + return; + } + + const pendingLogin = readPendingLogin(); + if (!pendingLogin) { + return; + } + + setError(null); + setIsLoading(true); + setLoginStatus("Restoring the Xaman sign-in started from this tab..."); + + try { + await waitForOriginLogin( + pendingLogin.state, + pendingLogin.browserHandoffToken, + pendingLogin.expiresAt, + controller.signal + ); + clearPendingLogin(); + + const restoredUser = await refreshCurrentUser(); + if (!restoredUser) { + throw new Error("Restored session was unavailable"); + } + if (cancelled) { + return; + } + + announceAuthState(true); + setSuccessNotice( + "Sign-in completed. Your session was restored in this browser." + ); + setLoginStatus(null); + setIsLoading(false); + } catch { + if (controller.signal.aborted || cancelled) { + return; + } + + clearPendingLogin(); + setError( + "The earlier Xaman sign-in could not be restored. Start again from this tab." + ); + setLoginStatus(null); + setIsLoading(false); + } } + void restoreLogin(); + return () => { - loginAbortController.current?.abort(); + cancelled = true; + controller.abort(); }; }, [refreshCurrentUser]); async function handleLogin() { - const attemptId = window.crypto.randomUUID(); - const loginWindow = window.open( - `/auth/launching?attempt=${encodeURIComponent(attemptId)}`, - "calorieapp-xaman-login" - ); const controller = new AbortController(); loginAbortController.current?.abort(); loginAbortController.current = controller; @@ -272,7 +375,7 @@ export function XamanLoginPanel() { setSuccessNotice(null); setIsLoading(true); setLoginStatus( - "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." + "Preparing Xaman in this tab. Please wait without refreshing." ); try { @@ -281,67 +384,32 @@ export function XamanLoginPanel() { const data = await startLoginWithRetry(controller.signal, () => { setLoginStatus( - "CalorieApp is temporarily busy. Waiting safely before opening Xaman; keep both tabs open." + "CalorieApp is temporarily busy. Waiting safely before opening Xaman..." ); }); - if (!data.wordpress_signin_url || !data.browser_handoff_token) { + if ( + data.state.length < 32 || + data.browser_handoff_token.length < 32 || + !Number.isFinite(Date.parse(data.expires_at)) || + Date.parse(data.expires_at) <= Date.now() || + !isAllowedWordPressSigninUrl(data.wordpress_signin_url) + ) { throw new Error("Missing signin handoff data"); } - if (!loginWindow || loginWindow.closed) { - window.location.assign(data.wordpress_signin_url); - return; - } - - sendXamanLocationToLaunchTab( - loginWindow, - attemptId, - data.wordpress_signin_url - ); - setLoginStatus( - "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( - data.state, - data.browser_handoff_token, - data.expires_at, - controller.signal - ); - - try { - loginWindow.close(); - } catch { - // A browser may keep the external Xaman tab open. The login still succeeded. - } - - await refreshCurrentUser(); - announceAuthState(true); - setSuccessNotice( - "Sign-in completed. You can continue in this original CalorieApp tab." - ); - setLoginStatus(null); - setIsLoading(false); + storePendingLogin(data); + setLoginStatus("Opening Xaman from this tab..."); + window.location.assign(data.wordpress_signin_url); } catch (requestError) { if (controller.signal.aborted) { return; } - try { - if (loginWindow && !loginWindow.closed) { - loginWindow.postMessage( - { type: XAMAN_LAUNCH_ERROR_TYPE, attemptId }, - window.location.origin - ); - loginWindow.close(); - } - } catch { - // The external sign-in window may no longer be script-controllable. - } + clearPendingLogin(); setError( backendUnavailableMessage( requestError, - "Sign-in could not be confirmed in this tab. You can continue in the browser Xaman opened, or try again." + "Xaman could not be opened from this tab. Please try again." ) ); setLoginStatus(null); @@ -395,10 +463,11 @@ export function XamanLoginPanel() { role="note" className="mt-3 rounded-xl border border-amber-300 bg-amber-50 px-3 py-2.5 text-xs leading-relaxed text-amber-950" > - 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. + Phone browser notice: CalorieApp + opens Xaman from this tab and does not create an extra tab. After + signing, your phone may still return through its configured default + browser because mobile systems do not let websites select the previous + browser tab. {currentUser ? (