mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user