fix(security): sanitize error messages, fix ReDoS patterns, harden OAuth callback

Error message sanitization (Hard Rule #12):
- claude-auth/export, codex-auth/export, gemini-cli-auth/export routes: replace
  raw err.message with sanitizeErrorMessage() from open-sse/utils/error.ts
- imageGeneration, musicGeneration, videoGeneration handlers: import
  sanitizeErrorMessage and replace all err.message in return values
- veoaifree-web executor: replace raw upstream response data in errResp() calls
  with static strings

OAuth callback page (callback/page.tsx):
- Remove useSearchParams/Suspense dependency that caused hydration failures in
  popup windows navigating back from Google OAuth (COOP header severs opener)
- Use window.location.search directly in useEffect with three send methods:
  postMessage, BroadcastChannel, localStorage
- Fix postMessage target from "*" to window.location.origin (semgrep finding)
- Move setCurrentUrl call to manual-only branch to avoid unnecessary renders

copilot-web executor:
- Move accessToken from WebSocket URL query string to Authorization header
  (avoids credential exposure in server logs)
- Add MAX_POOL_SIZE=100 cap to sessionPool with LRU eviction of oldest entry

CodeQL ReDoS fixes (js/polynomial-redos #233-240):
- Replace while(s.endsWith("/")) s=s.slice(0,-1) pattern (O(n²) allocations)
  with index-based loop (O(n) time, single final slice) in:
  bin/cli/api.mjs, all 6 cli-helper config generators, opencode-provider

Gemini OAuth:
- mapTokens: add idToken field to fix "missing id_token" export error
This commit is contained in:
diegosouzapw
2026-05-18 17:42:09 -03:00
parent 72900bead3
commit 04d44f6262
19 changed files with 185 additions and 115 deletions

View File

@@ -193,7 +193,9 @@ export function normalizeBaseURL(rawBaseURL: string): string {
); );
} }
let base = trimmed; let base = trimmed;
while (base.endsWith("/")) base = base.slice(0, -1); let end = base.length;
while (end > 0 && base[end - 1] === "/") end--;
base = end < base.length ? base.slice(0, end) : base;
if (base.endsWith("/v1")) base = base.slice(0, -3); if (base.endsWith("/v1")) base = base.slice(0, -3);
return base + "/v1"; return base + "/v1";
} }

View File

@@ -45,9 +45,10 @@ export function getBaseUrl(opts = {}) {
} }
function stripTrailingSlash(value) { function stripTrailingSlash(value) {
let s = String(value); const s = String(value);
while (s.endsWith("/")) s = s.slice(0, -1); let end = s.length;
return s; while (end > 0 && s.charCodeAt(end - 1) === 47) end--;
return end === s.length ? s : s.slice(0, end);
} }
function resolveUrl(path, opts) { function resolveUrl(path, opts) {

View File

@@ -117,6 +117,7 @@ const sessionPool = new Map<string, CopilotSession>();
let sessionRotationCount = 0; let sessionRotationCount = 0;
const MIN_REMAINING_TURNS = 5; const MIN_REMAINING_TURNS = 5;
const MAX_ROTATIONS = 1000; const MAX_ROTATIONS = 1000;
const MAX_POOL_SIZE = 100;
// ─── Executor ─────────────────────────────────────────────────────────────── // ─── Executor ───────────────────────────────────────────────────────────────
@@ -150,6 +151,10 @@ export class CopilotWebExecutor extends BaseExecutor {
} }
const session = await this.createSession(accessToken, signal); const session = await this.createSession(accessToken, signal);
// Evict oldest entry if pool is at capacity (Map preserves insertion order)
if (sessionPool.size >= MAX_POOL_SIZE) {
sessionPool.delete(sessionPool.keys().next().value!);
}
sessionPool.set(poolKey, session); sessionPool.set(poolKey, session);
sessionRotationCount++; sessionRotationCount++;
return session; return session;
@@ -214,11 +219,8 @@ export class CopilotWebExecutor extends BaseExecutor {
accessToken?: string, accessToken?: string,
signal?: AbortSignal signal?: AbortSignal
): Promise<ReadableStream<Uint8Array>> { ): Promise<ReadableStream<Uint8Array>> {
// Build WebSocket URL with optional auth // Build WebSocket URL without credentials in query string
let wsUrl = `${COPILOT_WS_URL}&clientSessionId=${crypto.randomUUID()}`; const wsUrl = `${COPILOT_WS_URL}&clientSessionId=${crypto.randomUUID()}`;
if (accessToken) {
wsUrl += `&accessToken=${encodeURIComponent(accessToken)}`;
}
return new ReadableStream({ return new ReadableStream({
start: async (controller) => { start: async (controller) => {
@@ -261,13 +263,23 @@ export class CopilotWebExecutor extends BaseExecutor {
signal?.addEventListener("abort", () => abort("Request aborted"), { once: true }); signal?.addEventListener("abort", () => abort("Request aborted"), { once: true });
try { try {
// Use Node.js built-in WebSocket if available, else dynamic import // Use Node.js built-in WebSocket if available, else dynamic import.
// Pass the access token via Authorization header (not URL) to avoid
// credential exposure in server logs.
let WS = globalThis.WebSocket; let WS = globalThis.WebSocket;
if (!WS) { if (!WS) {
// @ts-ignore — ws module has no type declarations in this project // @ts-ignore — ws module has no type declarations in this project
WS = (await import("ws")).default as unknown as typeof WebSocket; WS = (await import("ws")).default as unknown as typeof WebSocket;
if (accessToken) {
// @ts-ignore — ws module supports headers option in second arg
ws = new WS(wsUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
}) as WebSocket;
}
}
if (!ws) {
ws = new WS(wsUrl) as WebSocket;
} }
ws = new WS(wsUrl) as WebSocket;
const timeout = setTimeout(() => abort("Copilot WebSocket timeout"), FETCH_TIMEOUT_MS); const timeout = setTimeout(() => abort("Copilot WebSocket timeout"), FETCH_TIMEOUT_MS);

View File

@@ -82,7 +82,7 @@ async function handleVideo(nonce: string, prompt: string, aspectRatio: string):
}); });
const sceneData = genResult.trim(); const sceneData = genResult.trim();
if (!sceneData || sceneData === "0" || sceneData.toLowerCase().includes("error")) { if (!sceneData || sceneData === "0" || sceneData.toLowerCase().includes("error")) {
return errResp(`Video generation failed: ${sceneData}`); return errResp("Video generation failed");
} }
// Poll // Poll
@@ -123,7 +123,7 @@ async function handleImage(nonce: string, prompt: string, aspectRatio: string):
}); });
const trimmed = result.trim(); const trimmed = result.trim();
if (!trimmed || trimmed === "0" || trimmed.toLowerCase().includes("error")) { if (!trimmed || trimmed === "0" || trimmed.toLowerCase().includes("error")) {
return errResp(`Image generation failed: ${trimmed}`); return errResp("Image generation failed");
} }
// Response is comma-separated base64 PNGs or URLs // Response is comma-separated base64 PNGs or URLs
const parts = trimmed const parts = trimmed
@@ -191,7 +191,7 @@ async function handleTTS(prompt: string, voice?: string, lang?: string): Promise
} catch { } catch {
/* not JSON */ /* not JSON */
} }
return errResp(`TTS unexpected response: ${data.slice(0, 200)}`); return errResp("TTS unexpected response format");
} }
async function handleEnhance(nonce: string, prompt: string): Promise<Response> { async function handleEnhance(nonce: string, prompt: string): Promise<Response> {

View File

@@ -38,6 +38,7 @@ import {
extractComfyOutputFiles, extractComfyOutputFiles,
} from "../utils/comfyuiClient.ts"; } from "../utils/comfyuiClient.ts";
import { fetchRemoteImage } from "@/shared/network/remoteImageFetch"; import { fetchRemoteImage } from "@/shared/network/remoteImageFetch";
import { sanitizeErrorMessage } from "../utils/error.ts";
interface KieImageOptions { interface KieImageOptions {
model: string; model: string;
@@ -722,7 +723,11 @@ async function handleGeminiImageGeneration({ model, providerConfig, body, creden
requestBody: logRequestBody, requestBody: logRequestBody,
}).catch(() => {}); }).catch(() => {});
return { success: false, status: 502, error: `Image provider error: ${err.message}` }; return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Image provider error",
};
} }
} }
@@ -1301,7 +1306,7 @@ async function handleFalAIImageGeneration({
model, model,
status: 502, status: 502,
startTime, startTime,
error: `Image provider error: ${err.message}`, error: sanitizeErrorMessage(err) || "Image provider error",
}); });
} }
} }
@@ -1491,7 +1496,7 @@ async function handleStabilityAIImageGeneration({
model, model,
status: 502, status: 502,
startTime, startTime,
error: `Image provider error: ${err.message}`, error: sanitizeErrorMessage(err) || "Image provider error",
}); });
} }
} }
@@ -1610,7 +1615,7 @@ async function handleBlackForestLabsImageGeneration({
model, model,
status: 502, status: 502,
startTime, startTime,
error: `Image provider error: ${err.message}`, error: sanitizeErrorMessage(err) || "Image provider error",
}); });
} }
} }
@@ -1685,7 +1690,7 @@ async function handleRecraftImageGeneration({
model, model,
status: 502, status: 502,
startTime, startTime,
error: `Image provider error: ${err.message}`, error: sanitizeErrorMessage(err) || "Image provider error",
}); });
} }
} }
@@ -1773,7 +1778,7 @@ async function handleTopazImageGeneration({
model, model,
status: 502, status: 502,
startTime, startTime,
error: `Image provider error: ${err.message}`, error: sanitizeErrorMessage(err) || "Image provider error",
}); });
} }
} }
@@ -2353,7 +2358,7 @@ async function fetchImageEndpoint(url, headers, body, provider, log) {
return { return {
success: false, success: false,
status: 502, status: 502,
error: `Image provider error: ${err.message}`, error: sanitizeErrorMessage(err) || "Image provider error",
}; };
} }
} }
@@ -2448,7 +2453,11 @@ async function handleHyperbolicImageGeneration({
duration: Date.now() - startTime, duration: Date.now() - startTime,
error: err.message, error: err.message,
}).catch(() => {}); }).catch(() => {});
return { success: false, status: 502, error: `Image provider error: ${err.message}` }; return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Image provider error",
};
} }
} }
@@ -2702,7 +2711,11 @@ async function handleNanoBananaImageGeneration({
duration: Date.now() - startTime, duration: Date.now() - startTime,
error: err.message, error: err.message,
}).catch(() => {}); }).catch(() => {});
return { success: false, status: 502, error: `Image provider error: ${err.message}` }; return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Image provider error",
};
} }
} }
@@ -2894,7 +2907,11 @@ async function handleSDWebUIImageGeneration({ model, provider, providerConfig, b
duration: Date.now() - startTime, duration: Date.now() - startTime,
error: err.message, error: err.message,
}).catch(() => {}); }).catch(() => {});
return { success: false, status: 502, error: `Image provider error: ${err.message}` }; return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Image provider error",
};
} }
} }
@@ -2996,7 +3013,11 @@ async function handleComfyUIImageGeneration({ model, provider, providerConfig, b
duration: Date.now() - startTime, duration: Date.now() - startTime,
error: err.message, error: err.message,
}).catch(() => {}); }).catch(() => {});
return { success: false, status: 502, error: `Image provider error: ${err.message}` }; return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Image provider error",
};
} }
} }
@@ -3104,7 +3125,11 @@ async function handleHaiperImageGeneration({
duration: Date.now() - startTime, duration: Date.now() - startTime,
error: err.message, error: err.message,
}).catch(() => {}); }).catch(() => {});
return { success: false, status: 502, error: `Image provider error: ${err.message}` }; return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Image provider error",
};
} }
} }
@@ -3232,7 +3257,11 @@ async function handleLeonardoImageGeneration({
duration: Date.now() - startTime, duration: Date.now() - startTime,
error: err.message, error: err.message,
}).catch(() => {}); }).catch(() => {});
return { success: false, status: 502, error: `Image provider error: ${err.message}` }; return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Image provider error",
};
} }
} }
@@ -3318,7 +3347,11 @@ async function handleIdeogramImageGeneration({
duration: Date.now() - startTime, duration: Date.now() - startTime,
error: err.message, error: err.message,
}).catch(() => {}); }).catch(() => {});
return { success: false, status: 502, error: `Image provider error: ${err.message}` }; return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Image provider error",
};
} }
} }

View File

@@ -24,6 +24,7 @@ import {
} from "../utils/comfyuiClient.ts"; } from "../utils/comfyuiClient.ts";
import { saveCallLog } from "@/lib/usageDb"; import { saveCallLog } from "@/lib/usageDb";
import { getKieCallbackUrl, isJsonObject, parseKieResultJson } from "../utils/kieTask.ts"; import { getKieCallbackUrl, isJsonObject, parseKieResultJson } from "../utils/kieTask.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";
function normalizeKieSunoModel(model: string): string { function normalizeKieSunoModel(model: string): string {
const map: Record<string, string> = { const map: Record<string, string> = {
@@ -219,7 +220,11 @@ async function handleComfyUIMusicGeneration({ model, provider, providerConfig, b
duration: Date.now() - startTime, duration: Date.now() - startTime,
error: err.message, error: err.message,
}).catch(() => {}); }).catch(() => {});
return { success: false, status: 502, error: `Music provider error: ${err.message}` }; return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Music provider error",
};
} }
} }
@@ -369,7 +374,7 @@ async function handleKieMusicGeneration({
return { return {
success: false, success: false,
status: isJsonObject(err) && Number.isFinite(Number(err.status)) ? Number(err.status) : 502, status: isJsonObject(err) && Number.isFinite(Number(err.status)) ? Number(err.status) : 502,
error: `Music provider error: ${err instanceof Error ? err.message : String(err)}`, error: sanitizeErrorMessage(err) || "Music provider error",
}; };
} }
} }
@@ -487,7 +492,11 @@ async function handleSunoMusicGeneration({
duration: Date.now() - startTime, duration: Date.now() - startTime,
error: err.message, error: err.message,
}).catch(() => {}); }).catch(() => {});
return { success: false, status: 502, error: `Music provider error: ${err.message}` }; return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Music provider error",
};
} }
} }
@@ -598,6 +607,10 @@ async function handleUdioMusicGeneration({
duration: Date.now() - startTime, duration: Date.now() - startTime,
error: err.message, error: err.message,
}).catch(() => {}); }).catch(() => {});
return { success: false, status: 502, error: `Music provider error: ${err.message}` }; return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Music provider error",
};
} }
} }

View File

@@ -30,6 +30,7 @@ import {
extractComfyOutputFiles, extractComfyOutputFiles,
} from "../utils/comfyuiClient.ts"; } from "../utils/comfyuiClient.ts";
import { saveCallLog } from "@/lib/usageDb"; import { saveCallLog } from "@/lib/usageDb";
import { sanitizeErrorMessage } from "../utils/error.ts";
/** /**
* Handle video generation request * Handle video generation request
@@ -200,7 +201,11 @@ async function handleComfyUIVideoGeneration({ model, provider, providerConfig, b
duration: Date.now() - startTime, duration: Date.now() - startTime,
error: err.message, error: err.message,
}).catch(() => {}); }).catch(() => {});
return { success: false, status: 502, error: `Video provider error: ${err.message}` }; return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Video provider error",
};
} }
} }
@@ -288,7 +293,11 @@ async function handleSDWebUIVideoGeneration({ model, provider, providerConfig, b
duration: Date.now() - startTime, duration: Date.now() - startTime,
error: err.message, error: err.message,
}).catch(() => {}); }).catch(() => {});
return { success: false, status: 502, error: `Video provider error: ${err.message}` }; return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Video provider error",
};
} }
} }
@@ -419,7 +428,7 @@ async function handleKieVideoGeneration({
return { return {
success: false, success: false,
status: isJsonObject(err) && Number.isFinite(Number(err.status)) ? Number(err.status) : 502, status: isJsonObject(err) && Number.isFinite(Number(err.status)) ? Number(err.status) : 502,
error: `Video provider error: ${err instanceof Error ? err.message : String(err)}`, error: sanitizeErrorMessage(err) || "Video provider error",
}; };
} }
} }
@@ -619,7 +628,11 @@ async function handleRunwayVideoGeneration({
duration: Date.now() - startTime, duration: Date.now() - startTime,
error: err.message, error: err.message,
}).catch(() => {}); }).catch(() => {});
return { success: false, status: 502, error: `Video provider error: ${err.message}` }; return {
success: false,
status: 502,
error: sanitizeErrorMessage(err) || "Video provider error",
};
} }
} }

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { buildClaudeAuthFile, ClaudeAuthFileError } from "@/lib/oauth/utils/claudeAuthFile"; import { buildClaudeAuthFile, ClaudeAuthFileError } from "@/lib/oauth/utils/claudeAuthFile";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
function toErrorResponse(error: unknown) { function toErrorResponse(error: unknown) {
if (error instanceof ClaudeAuthFileError) { if (error instanceof ClaudeAuthFileError) {
@@ -13,7 +14,7 @@ function toErrorResponse(error: unknown) {
); );
} }
const message = error instanceof Error ? error.message : "Failed to export Claude auth file"; const message = sanitizeErrorMessage(error) || "Failed to export Claude auth file";
return NextResponse.json({ error: message }, { status: 500 }); return NextResponse.json({ error: message }, { status: 500 });
} }

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { buildCodexAuthFile, CodexAuthFileError } from "@/lib/oauth/utils/codexAuthFile"; import { buildCodexAuthFile, CodexAuthFileError } from "@/lib/oauth/utils/codexAuthFile";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
function toErrorResponse(error: unknown) { function toErrorResponse(error: unknown) {
if (error instanceof CodexAuthFileError) { if (error instanceof CodexAuthFileError) {
@@ -13,7 +14,7 @@ function toErrorResponse(error: unknown) {
); );
} }
const message = error instanceof Error ? error.message : "Failed to export Codex auth file"; const message = sanitizeErrorMessage(error) || "Failed to export Codex auth file";
return NextResponse.json({ error: message }, { status: 500 }); return NextResponse.json({ error: message }, { status: 500 });
} }

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { buildGeminiAuthFile, GeminiAuthFileError } from "@/lib/oauth/utils/geminiAuthFile"; import { buildGeminiAuthFile, GeminiAuthFileError } from "@/lib/oauth/utils/geminiAuthFile";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
function toErrorResponse(error: unknown) { function toErrorResponse(error: unknown) {
if (error instanceof GeminiAuthFileError) { if (error instanceof GeminiAuthFileError) {
@@ -13,7 +14,7 @@ function toErrorResponse(error: unknown) {
); );
} }
const message = error instanceof Error ? error.message : "Failed to export Gemini auth file"; const message = sanitizeErrorMessage(error) || "Failed to export Gemini auth file";
return NextResponse.json({ error: message }, { status: 500 }); return NextResponse.json({ error: message }, { status: 500 });
} }

View File

@@ -1,23 +1,30 @@
"use client"; "use client";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { Suspense, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
/** /**
* OAuth Callback Page Content * 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)
*/ */
function CallbackContent() { export default function CallbackPage() {
const searchParams = useSearchParams(); const [status, setStatus] = useState<"processing" | "success" | "done" | "manual">("processing");
const [status, setStatus] = useState("processing"); const [currentUrl, setCurrentUrl] = useState("");
const t = useTranslations("auth"); const t = useTranslations("auth");
useEffect(() => { useEffect(() => {
const code = searchParams.get("code"); const params = new URLSearchParams(window.location.search);
const state = searchParams.get("state"); const code = params.get("code");
const error = searchParams.get("error"); const state = params.get("state");
const errorDescription = searchParams.get("error_description"); const error = params.get("error");
const errorDescription = params.get("error_description");
const callbackData = { const callbackData = {
code, code,
@@ -29,23 +36,23 @@ function CallbackContent() {
let sent = false; let sent = false;
// Check if this callback is from expected origin/port // Method 1: postMessage to opener (popup mode).
const expectedOrigins = [ // May be null when Google OAuth's COOP header severs the opener reference.
window.location.origin, // Same origin (for most providers)
"http://localhost:1455", // Codex specific port
];
// Method 1: postMessage to opener (popup mode)
if (window.opener) { if (window.opener) {
try { try {
window.opener.postMessage({ type: "oauth_callback", data: callbackData }, "*"); // Allow any origin for local dev // Target this origin specifically — popup mode is only used when isTrueLocalhost,
// so the opener is always on the same origin as the callback page.
window.opener.postMessage(
{ type: "oauth_callback", data: callbackData },
window.location.origin
);
sent = true; sent = true;
} catch (e) { } catch (e) {
console.log("postMessage failed:", e); console.log("postMessage failed:", e);
} }
} }
// Method 2: BroadcastChannel (same origin tabs) // Method 2: BroadcastChannel — works across browsing context groups for same origin.
try { try {
const channel = new BroadcastChannel("oauth_callback"); const channel = new BroadcastChannel("oauth_callback");
channel.postMessage(callbackData); channel.postMessage(callbackData);
@@ -55,7 +62,8 @@ function CallbackContent() {
console.log("BroadcastChannel failed:", e); console.log("BroadcastChannel failed:", e);
} }
// Method 3: localStorage event (fallback) // Method 3: localStorage — triggers storage event in all same-origin windows,
// regardless of browsing context group isolation from COOP.
try { try {
localStorage.setItem( localStorage.setItem(
"oauth_callback", "oauth_callback",
@@ -67,25 +75,25 @@ function CallbackContent() {
} }
if (sent && (code || error)) { if (sent && (code || error)) {
// Use setTimeout to avoid synchronous setState in effect if (window.opener) {
setTimeout(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- initialization effect, window-only
// Only auto-close if opened as popup (has opener) — remote access keeps tab open setStatus("success");
if (window.opener) { setTimeout(() => {
setStatus("success"); window.close();
setTimeout(() => { // If close is prevented (browser policy), fall through to manual close prompt.
window.close(); setTimeout(() => setStatus("done"), 500);
// If can't close (not a popup), show success message }, 1500);
setTimeout(() => setStatus("done"), 500); } else {
}, 1500); // Opened as new tab or opener severed by COOP — show close prompt.
} else { setStatus("done");
// Opened as new tab (remote access) — show URL for manual copy }
setStatus("done");
}
}, 0);
} else { } else {
setTimeout(() => setStatus("manual"), 0); // 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).
setCurrentUrl(window.location.href);
setStatus("manual");
} }
}, [searchParams]); }, []);
return ( return (
<div className="min-h-screen flex items-center justify-center bg-bg"> <div className="min-h-screen flex items-center justify-center bg-bg">
@@ -124,9 +132,7 @@ function CallbackContent() {
<h1 className="text-xl font-semibold mb-2">{t("copyUrl")}</h1> <h1 className="text-xl font-semibold mb-2">{t("copyUrl")}</h1>
<p className="text-text-muted mb-4">{t("copyUrlManual")}</p> <p className="text-text-muted mb-4">{t("copyUrlManual")}</p>
<div className="bg-surface border border-border rounded-lg p-3 text-left"> <div className="bg-surface border border-border rounded-lg p-3 text-left">
<code className="text-xs break-all"> <code className="text-xs break-all">{currentUrl}</code>
{typeof window !== "undefined" ? window.location.href : ""}
</code>
</div> </div>
</> </>
)} )}
@@ -134,29 +140,3 @@ function CallbackContent() {
</div> </div>
); );
} }
/**
* OAuth Callback Page
* Receives callback from OAuth providers and sends data back via multiple methods
*/
export default function CallbackPage() {
const t = useTranslations("auth");
return (
<Suspense
fallback={
<div className="min-h-screen flex items-center justify-center bg-bg">
<div className="text-center p-8">
<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>
<p className="text-text-muted">{t("loading")}</p>
</div>
</div>
}
>
<CallbackContent />
</Suspense>
);
}

View File

@@ -704,13 +704,13 @@ export const autoSearchIndex: AutoGenSearchItem[] = [
fileName: "reference/PROVIDER_REFERENCE.md", fileName: "reference/PROVIDER_REFERENCE.md",
section: "Reference", section: "Reference",
content: content:
"Auto-generated from src/shared/constants/providers.ts — do not edit by hand. Regenerate with: npm run gen:provider-reference Last generated: 2026-05-13 Total providers: 177. See category breakdown below. - Free — free tier with API key (configured via dashboard) - OAuth — sign-in flow handled by Omn", "Auto-generated from src/shared/constants/providers.ts — do not edit by hand. Regenerate with: npm run gen:provider-reference Last generated: 2026-05-17 Total providers: 177. See category breakdown below. - Free — free tier with API key (configured via dashboard) - OAuth — sign-in flow handled by Omn",
headings: [ headings: [
"Categories", "Categories",
"Free Tier (OAuth-first or no-key) (5)", "Free Tier (OAuth-first or no-key) (5)",
"OAuth Providers (11)", "OAuth Providers (11)",
"Web Cookie Providers (5)", "Web Cookie Providers (6)",
"API Key Providers (paid / paid-with-free-credits) (123)", "API Key Providers (paid / paid-with-free-credits) (122)",
"Local Providers (10)", "Local Providers (10)",
"Search Providers (11)", "Search Providers (11)",
"Audio-only Providers (7)", "Audio-only Providers (7)",

View File

@@ -9,7 +9,9 @@ export function generateClaudeConfig(options: {
model?: string; model?: string;
}): string { }): string {
let base = options.baseUrl; let base = options.baseUrl;
while (base.endsWith("/")) base = base.slice(0, -1); let end = base.length;
while (end > 0 && base[end - 1] === "/") end--;
base = end < base.length ? base.slice(0, end) : base;
if (base.endsWith("/v1")) base = base.slice(0, -3); if (base.endsWith("/v1")) base = base.slice(0, -3);
const model = options.model || "claude-3-5-sonnet-20241022"; const model = options.model || "claude-3-5-sonnet-20241022";

View File

@@ -9,7 +9,9 @@ export function generateClineConfig(options: {
model?: string; model?: string;
}): string { }): string {
let base = options.baseUrl; let base = options.baseUrl;
while (base.endsWith("/")) base = base.slice(0, -1); let end = base.length;
while (end > 0 && base[end - 1] === "/") end--;
base = end < base.length ? base.slice(0, end) : base;
if (base.endsWith("/v1")) base = base.slice(0, -3); if (base.endsWith("/v1")) base = base.slice(0, -3);
const config = { const config = {

View File

@@ -18,7 +18,9 @@ export async function generateCodexConfig(options: {
}): Promise<string> { }): Promise<string> {
const y = await loadYaml(); const y = await loadYaml();
let base = options.baseUrl; let base = options.baseUrl;
while (base.endsWith("/")) base = base.slice(0, -1); let end = base.length;
while (end > 0 && base[end - 1] === "/") end--;
base = end < base.length ? base.slice(0, end) : base;
if (base.endsWith("/v1")) base = base.slice(0, -3); if (base.endsWith("/v1")) base = base.slice(0, -3);
const config = { const config = {

View File

@@ -18,7 +18,9 @@ export async function generateContinueConfig(options: {
}): Promise<string> { }): Promise<string> {
const y = await loadYaml(); const y = await loadYaml();
let base = options.baseUrl; let base = options.baseUrl;
while (base.endsWith("/")) base = base.slice(0, -1); let end = base.length;
while (end > 0 && base[end - 1] === "/") end--;
base = end < base.length ? base.slice(0, end) : base;
if (base.endsWith("/v1")) base = base.slice(0, -3); if (base.endsWith("/v1")) base = base.slice(0, -3);
const config = { const config = {

View File

@@ -9,7 +9,9 @@ export function generateKilocodeConfig(options: {
model?: string; model?: string;
}): string { }): string {
let base = options.baseUrl; let base = options.baseUrl;
while (base.endsWith("/")) base = base.slice(0, -1); let end = base.length;
while (end > 0 && base[end - 1] === "/") end--;
base = end < base.length ? base.slice(0, end) : base;
if (base.endsWith("/v1")) base = base.slice(0, -3); if (base.endsWith("/v1")) base = base.slice(0, -3);
const config = { const config = {

View File

@@ -9,7 +9,9 @@ export function generateOpencodeConfig(options: {
model?: string; model?: string;
}): string { }): string {
let base = options.baseUrl; let base = options.baseUrl;
while (base.endsWith("/")) base = base.slice(0, -1); let end = base.length;
while (end > 0 && base[end - 1] === "/") end--;
base = end < base.length ? base.slice(0, end) : base;
if (base.endsWith("/v1")) base = base.slice(0, -3); if (base.endsWith("/v1")) base = base.slice(0, -3);
const config = { const config = {

View File

@@ -90,6 +90,7 @@ export const gemini = {
mapTokens: (tokens, extra) => ({ mapTokens: (tokens, extra) => ({
accessToken: tokens.access_token, accessToken: tokens.access_token,
refreshToken: tokens.refresh_token, refreshToken: tokens.refresh_token,
idToken: tokens.id_token ?? null,
expiresIn: tokens.expires_in, expiresIn: tokens.expires_in,
scope: tokens.scope, scope: tokens.scope,
email: extra?.userInfo?.email, email: extra?.userInfo?.email,