Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
16 changes: 14 additions & 2 deletions frontend/app/auth/callback/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,14 @@ function AuthCallbackContent() {
Returning to CalorieApp
</h1>

<p 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: 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.
</p>

<p
className="mt-3 text-sm text-brand-secondary/90"
className="mt-4 text-sm text-brand-secondary/90"
role="status"
aria-live="polite"
>
Expand Down Expand Up @@ -192,8 +198,14 @@ export default function AuthCallbackPage() {
CalorieApp Sign-In
</h1>

<p 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: Xaman normally opens this return page in
your configured default browser, possibly in a new tab. Keep the
original CalorieApp tab open.
</p>

<p
className="mt-3 text-sm text-brand-secondary/90"
className="mt-4 text-sm text-brand-secondary/90"
role="status"
aria-live="polite"
>
Expand Down
9 changes: 6 additions & 3 deletions frontend/app/auth/complete/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,13 @@ function LoginCompleteContent() {
<h1 className="mt-4 text-xl font-semibold text-brand-primary">
Sign-in completed
</h1>
<p className="mt-3 text-sm leading-relaxed text-brand-secondary/90">
<p 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: Xaman normally opens this return page in your
configured default browser, possibly in a new tab.
</p>
<p className="mt-4 text-sm leading-relaxed text-brand-secondary/90">
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&apos;s default browser.
close this tab and return there.
</p>
<p className="mt-2 text-xs leading-relaxed text-brand-secondary/70">
You are also signed in in this browser, so continuing here is safe.
Expand Down
82 changes: 79 additions & 3 deletions frontend/app/auth/launching/page.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,90 @@
"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"
);

Comment on lines +32 to +36
const handleMessage = (event: MessageEvent<XamanLaunchMessage>) => {
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 (
<main className="mx-auto flex min-h-screen w-full max-w-xl items-center justify-center px-4 py-16">
<section className="w-full rounded-2xl border border-brand-secondary/20 bg-white p-8 text-center shadow-sm">
<h1 className="text-xl font-semibold text-brand-primary">
Preparing Xaman sign-in
</h1>
<p className="mt-3 text-sm leading-relaxed text-brand-secondary/90">
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.
</p>
<p
className="mt-4 text-xs leading-relaxed text-brand-secondary/75"
role="status"
aria-live="polite"
>
{message}
</p>
<div
className="mx-auto mt-5 h-6 w-6 animate-spin rounded-full border-2 border-brand-secondary/30 border-t-brand-primary"
Expand Down
4 changes: 4 additions & 0 deletions frontend/components/FoodSearchPlaceholder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,10 @@ export function FoodSearchPlaceholder() {
<p className="mt-1">
Food search is available to everyone. Sign in with Xaman to save and manage items.
</p>
<p className="mt-2 text-xs leading-relaxed">
Phone browser notice: Xaman normally returns in your configured default
browser, possibly in a new tab. Keep the original CalorieApp tab open.
</p>
</div>
) : logError ? (
<div className="space-y-3">
Expand Down
128 changes: 109 additions & 19 deletions frontend/components/XamanLoginPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { announceAuthState } from "@/components/authEvents";
import {
backendRequest,
backendUnavailableMessage,
BackendRequestTimeoutError,
waitForBackendReady,
} from "@/lib/backendRequest";

Expand All @@ -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";
Comment on lines +34 to +37

function delay(milliseconds: number, signal: AbortSignal) {
return new Promise<void>((resolve, reject) => {
Expand Down Expand Up @@ -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<LoginStartResponse> {
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);
Expand Down Expand Up @@ -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();
Expand All @@ -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");
}
Expand All @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -290,11 +376,15 @@ export function XamanLoginPanel() {
<p className="mt-1 text-sm text-brand-secondary/90">
Sign in securely to save, review, and manage your personal food log.
</p>
<p className="mt-2 text-xs leading-relaxed text-brand-secondary/75">
A separate sign-in tab opens. Xaman may return in your phone&apos;s default
browser, while this original CalorieApp tab remains available and signs
in automatically.
</p>
<div
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"
>
<span className="font-semibold">Phone browser notice:</span> 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.
</div>

{currentUser ? (
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
Expand Down