ai_api / src /app /callback /page.tsx
Yogesh
initial deploy
cd8bd0a
Raw
History Blame Contribute Delete
6.59 kB
"use client";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
/**
* OAuth Callback Page
*
* Reads URL params via window.location.search (not useSearchParams) to avoid
* the Next.js Suspense boundary requirement, which can delay hydration in popup
* windows that navigate back from a cross-origin OAuth page (e.g. Google).
* Sends the callback data back via three methods in order of reliability:
* 1. postMessage to window.opener (may be null after COOP cross-origin nav)
* 2. BroadcastChannel (same-origin, works across browsing context groups)
* 3. localStorage storage event (works across browsing context groups)
*/
export default function CallbackPage() {
const [status, setStatus] = useState<"processing" | "success" | "done" | "manual">("processing");
const [currentUrl] = useState(() =>
typeof window === "undefined" ? "" : window.location.href
);
const t = useTranslations("auth");
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
const state = params.get("state");
const error = params.get("error");
const errorDescription = params.get("error_description");
const callbackData = {
code,
state,
error,
errorDescription,
fullUrl: window.location.href,
};
let sent = false;
let openerSameOrigin = false;
const queueStatusUpdate = (nextStatus: "processing" | "success" | "done" | "manual") => {
queueMicrotask(() => setStatus(nextStatus));
};
if (window.opener) {
try {
openerSameOrigin = window.opener.location.origin === window.location.origin;
} catch {
openerSameOrigin = false;
}
}
// Method 1: postMessage to opener (popup mode).
// May be null when Google OAuth's COOP header severs the opener reference.
//
// Only relay {code, state} to a known-trusted target origin. A wildcard "*"
// here would leak the OAuth code/state to a hostile opener — e.g. a page
// that opened this callback URL in a popup to phish the code. The browser
// delivers postMessage only when the opener's origin matches `targetOrigin`,
// so iterating over an allowlist lets the same-origin parent and Codex's
// fixed loopback helper receive it while silently dropping it for any other
// origin. Methods 2 (BroadcastChannel) and 3 (localStorage) cover the
// same-origin fallback when the opener was severed by COOP.
const trustedTargetOrigins = [
window.location.origin, // Same origin (dashboard popup mode).
"http://localhost:1455", // Codex helper (fixed loopback port).
"http://127.0.0.1:1455", // Same Codex helper, IPv4 literal form.
];
if (window.opener) {
for (const origin of trustedTargetOrigins) {
try {
window.opener.postMessage(
{ type: "oauth_callback", data: callbackData },
origin
);
sent = true;
} catch (e) {
console.log("postMessage failed:", e);
}
}
}
// Method 2: BroadcastChannel — works across browsing context groups for same origin.
try {
const channel = new BroadcastChannel("oauth_callback");
channel.postMessage(callbackData);
channel.close();
sent = true;
} catch (e) {
console.log("BroadcastChannel failed:", e);
}
// Method 3: localStorage — triggers storage event in all same-origin windows,
// regardless of browsing context group isolation from COOP.
try {
localStorage.setItem(
"oauth_callback",
JSON.stringify({ ...callbackData, timestamp: Date.now() })
);
sent = true;
} catch (e) {
console.log("localStorage failed:", e);
}
if (sent && (code || error)) {
if (window.opener && openerSameOrigin) {
queueStatusUpdate("success");
setTimeout(() => {
window.close();
// If close is prevented (browser policy), fall through to manual close prompt.
setTimeout(() => setStatus("done"), 500);
}, 1500);
} else {
// Opened as a tab, opener severed by COOP, or remote dashboard using a
// loopback/tunnel callback. Keep the full URL visible as a manual fallback
// in case the opener cannot receive the cross-origin postMessage.
queueStatusUpdate("manual");
}
} else {
// No code/error in URL or all send methods failed — show URL for manual copy.
// Batch the URL and status update so they render together (React 18 auto-batching).
queueStatusUpdate("manual");
}
}, []);
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center p-8 max-w-md">
{status === "processing" && (
<>
<div className="size-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
<span className="material-symbols-outlined text-3xl text-primary animate-spin">
progress_activity
</span>
</div>
<h1 className="text-xl font-semibold mb-2">{t("processing")}</h1>
<p className="text-text-muted">{t("pleaseWait")}</p>
</>
)}
{(status === "success" || status === "done") && (
<>
<div className="size-16 mx-auto mb-4 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<span className="material-symbols-outlined text-3xl text-green-600">
check_circle
</span>
</div>
<h1 className="text-xl font-semibold mb-2">{t("authSuccess")}</h1>
<p className="text-text-muted">
{status === "success" ? t("windowWillClose") : t("closeTabNow")}
</p>
</>
)}
{status === "manual" && (
<>
<div className="size-16 mx-auto mb-4 rounded-full bg-yellow-100 dark:bg-yellow-900/30 flex items-center justify-center">
<span className="material-symbols-outlined text-3xl text-yellow-600">info</span>
</div>
<h1 className="text-xl font-semibold mb-2">{t("copyUrl")}</h1>
<p className="text-text-muted mb-4">{t("copyUrlManual")}</p>
<div className="bg-surface border border-border rounded-lg p-3 text-left">
<code className="text-xs break-all">{currentUrl}</code>
</div>
</>
)}
</div>
</div>
);
}