File size: 6,587 Bytes
cd8bd0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
"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>
  );
}