mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
fix(adobe-firefly): open browser sign-in and resolve provider slug in /login (#9097)
Validated in local merge-train T5 (base49+contributors+pacocartones)
This commit is contained in:
@@ -1253,6 +1253,14 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# OMNIROUTE_BROWSER_POOL=on
|
||||
# WEB_COOKIE_USE_BROWSER=0
|
||||
|
||||
# ── Adobe Firefly browser sign-in (system Chrome/Edge CDP) ──
|
||||
# Used by: open-sse/services/adobeFireflyBrowserLogin.ts. The Firefly login
|
||||
# flow drives a real, system-installed Chrome or Microsoft Edge via CDP so the
|
||||
# user can sign in interactively; the executable is auto-detected from common
|
||||
# install paths per OS. Set this to override that detection (e.g. a portable
|
||||
# install or a non-standard path) when auto-detection fails.
|
||||
# OMNIROUTE_LOGIN_BROWSER_PATH=
|
||||
|
||||
# ── Circuit breaker thresholds and reset windows ──
|
||||
# Used by: open-sse/config/constants.ts → src/lib/resilience/settings.ts.
|
||||
# Defaults match historical PROVIDER_PROFILES values (post-scaling for
|
||||
|
||||
@@ -671,6 +671,7 @@ REQUEST_TIMEOUT_MS (global override)
|
||||
| `OMNIROUTE_NOTION_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. |
|
||||
| `OMNIROUTE_BROWSER_POOL` | `on` | Shared Playwright browser pool for browser-backed web-cookie chat (`browserPool.ts`); set `off` to disable. |
|
||||
| `WEB_COOKIE_USE_BROWSER` | `0` | Opt a web-cookie chat request into the browser-backed path (`browserBackedChat.ts`); `1` to enable. |
|
||||
| `OMNIROUTE_LOGIN_BROWSER_PATH` | _(auto-detected)_ | Path to a system Chrome/Edge executable for the Adobe Firefly interactive browser sign-in (`adobeFireflyBrowserLogin.ts`); overrides per-OS auto-detection. |
|
||||
|
||||
Combo target attempts inherit the resolved upstream request timeout (`FETCH_TIMEOUT_MS`, or
|
||||
`REQUEST_TIMEOUT_MS` when it supplies the fetch default). Set `targetTimeoutMs` in a combo,
|
||||
|
||||
475
open-sse/services/adobeFireflyBrowserLogin.ts
Normal file
475
open-sse/services/adobeFireflyBrowserLogin.ts
Normal file
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* Adobe Firefly browser login (packaged-backend safe).
|
||||
*
|
||||
* Firefly needs an Adobe IMS access_token JWT (Bearer) issued for
|
||||
* client_id `clio-playground-web`. That JWT is NEVER present in
|
||||
* cookies/localStorage тАФ the SPA only holds it in memory and attaches it
|
||||
* as `Authorization: Bearer <jwt>` on XHRs to firefly-3p.ff.adobe.io.
|
||||
*
|
||||
* IMPORTANT: The VibeProxyServices.exe is a pkg-packaged Node binary.
|
||||
* Dynamic `import("playwright")` fails there (native bindings / browsers
|
||||
* are not in the package). This module launches the **system** Chrome or
|
||||
* Edge with `--remote-debugging-port` and talks pure Chrome DevTools
|
||||
* Protocol over WebSocket тАФ zero Playwright dependency.
|
||||
*/
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { createServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { sanitizeErrorMessage } from "../utils/error.ts";
|
||||
|
||||
const FIREFLY_HOME_URL = "https://firefly.adobe.com/";
|
||||
const FIREFLY_3P_HOST_SUFFIX = "firefly-3p.ff.adobe.io";
|
||||
// Bounded quantifiers (Hard Rule: avoid ReDoS on adversarial Authorization headers).
|
||||
const ADOBE_BEARER_REGEX =
|
||||
/^Bearer\s+(eyJ[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096})/i;
|
||||
|
||||
const DEFAULT_LOGIN_TIMEOUT_MS = 300_000;
|
||||
const MIN_LOGIN_TIMEOUT_MS = 15_000;
|
||||
const MAX_LOGIN_TIMEOUT_MS = 600_000;
|
||||
const POLL_INTERVAL_MS = 400;
|
||||
const CDP_READY_TIMEOUT_MS = 30_000;
|
||||
|
||||
export interface AdobeFireflyBrowserLoginResult {
|
||||
success: boolean;
|
||||
credentials?: { accessToken?: string; cookie?: string };
|
||||
/** Best-effort Adobe account label (email or user id) decoded from the JWT. */
|
||||
account?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function clampAdobeFireflyLoginTimeout(value: unknown): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_LOGIN_TIMEOUT_MS;
|
||||
return Math.max(MIN_LOGIN_TIMEOUT_MS, Math.min(MAX_LOGIN_TIMEOUT_MS, Math.trunc(value)));
|
||||
}
|
||||
|
||||
/** Extract an IMS JWT from an Authorization header value. Exported for unit tests. */
|
||||
export function extractAdobeBearerTokenFromAuthorization(authHeader: string): string {
|
||||
const m = String(authHeader || "").match(ADOBE_BEARER_REGEX);
|
||||
return m?.[1] || "";
|
||||
}
|
||||
|
||||
/** Build a single cookie header from relevant Firefly cookies. Exported for unit tests. */
|
||||
export function buildAdobeFireflyCookieHeader(
|
||||
cookies: Array<{ name: string; value: string; domain?: string }>
|
||||
): string {
|
||||
const wanted = ["sherlockToken", "forterToken", "aux_sid", "ff_session_guid"];
|
||||
const parts: string[] = [];
|
||||
for (const wantedName of wanted) {
|
||||
const c = cookies.find(
|
||||
(candidate) =>
|
||||
candidate.name === wantedName &&
|
||||
typeof candidate.value === "string" &&
|
||||
candidate.value.length > 0 &&
|
||||
!/[\r\n;]/.test(candidate.value)
|
||||
);
|
||||
if (c) parts.push(`${wantedName}=${c.value}`);
|
||||
}
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
||||
/** Best-effort account label from an IMS JWT payload. Exported for unit tests. */
|
||||
export function accountLabelFromAdobeJwt(token: string): string {
|
||||
try {
|
||||
const part = String(token || "").split(".")[1];
|
||||
if (!part) return "";
|
||||
const json = Buffer.from(part.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
|
||||
const obj = JSON.parse(json) as Record<string, unknown>;
|
||||
for (const key of ["email", "preferred_username", "user_id", "sub"]) {
|
||||
const v = obj[key];
|
||||
if (typeof v === "string" && v.trim()) return v.trim();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Resolve system Chrome/Edge executable. Exported for unit tests. */
|
||||
export function resolveSystemBrowserExecutable(): string | null {
|
||||
const configured = process.env.OMNIROUTE_LOGIN_BROWSER_PATH?.trim();
|
||||
if (configured && existsSync(configured)) return configured;
|
||||
|
||||
const pf = process.env.ProgramFiles || "C:\\Program Files";
|
||||
const pf86 = process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)";
|
||||
const local = process.env.LOCALAPPDATA || "";
|
||||
const candidates = [
|
||||
join(pf, "Google", "Chrome", "Application", "chrome.exe"),
|
||||
join(pf86, "Google", "Chrome", "Application", "chrome.exe"),
|
||||
join(local, "Google", "Chrome", "Application", "chrome.exe"),
|
||||
join(pf, "Microsoft", "Edge", "Application", "msedge.exe"),
|
||||
join(pf86, "Microsoft", "Edge", "Application", "msedge.exe"),
|
||||
join(local, "Microsoft", "Edge", "Application", "msedge.exe"),
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/chromium-browser",
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/microsoft-edge",
|
||||
"/usr/bin/microsoft-edge-stable",
|
||||
];
|
||||
for (const path of candidates) {
|
||||
if (path && existsSync(path)) return path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getFreeLoopbackPort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === "string") {
|
||||
server.close();
|
||||
reject(new Error("Could not allocate a free loopback port for Chrome DevTools"));
|
||||
return;
|
||||
}
|
||||
const { port } = addr;
|
||||
server.close((err) => (err ? reject(err) : resolve(port)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForCdpReady(
|
||||
port: number,
|
||||
timeoutMs: number
|
||||
): Promise<{ webSocketDebuggerUrl: string }> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError = "CDP endpoint not ready";
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/json/version`, {
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
if (res.ok) {
|
||||
const body = (await res.json()) as { webSocketDebuggerUrl?: string };
|
||||
if (body.webSocketDebuggerUrl) {
|
||||
return { webSocketDebuggerUrl: body.webSocketDebuggerUrl };
|
||||
}
|
||||
}
|
||||
lastError = `CDP /json/version HTTP ${res.status}`;
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
throw new Error(`Chrome DevTools did not become ready: ${lastError}`);
|
||||
}
|
||||
|
||||
type CdpCookie = { name: string; value: string; domain?: string };
|
||||
|
||||
class CdpSocket {
|
||||
private ws: WebSocket;
|
||||
private nextId = 1;
|
||||
private pending = new Map<
|
||||
number,
|
||||
{ resolve: (v: unknown) => void; reject: (e: Error) => void }
|
||||
>();
|
||||
private onEvent: (method: string, params: Record<string, unknown>) => void;
|
||||
|
||||
constructor(ws: WebSocket, onEvent: (method: string, params: Record<string, unknown>) => void) {
|
||||
this.ws = ws;
|
||||
this.onEvent = onEvent;
|
||||
this.ws.addEventListener("message", (ev) => {
|
||||
let data: Record<string, unknown>;
|
||||
try {
|
||||
data = JSON.parse(String(ev.data)) as Record<string, unknown>;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (typeof data.id === "number" && this.pending.has(data.id)) {
|
||||
const p = this.pending.get(data.id)!;
|
||||
this.pending.delete(data.id);
|
||||
if (data.error) {
|
||||
const errObj = data.error as { message?: string };
|
||||
p.reject(new Error(errObj.message || "CDP error"));
|
||||
} else {
|
||||
p.resolve(data.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof data.method === "string") {
|
||||
this.onEvent(data.method, (data.params || {}) as Record<string, unknown>);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
send(method: string, params?: Record<string, unknown>, sessionId?: string): Promise<unknown> {
|
||||
const id = this.nextId++;
|
||||
const msg: Record<string, unknown> = { id, method };
|
||||
if (params) msg.params = params;
|
||||
if (sessionId) msg.sessionId = sessionId;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject });
|
||||
try {
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
} catch (err) {
|
||||
this.pending.delete(id);
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
try {
|
||||
this.ws.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
get open(): boolean {
|
||||
return this.ws.readyState === WebSocket.OPEN;
|
||||
}
|
||||
}
|
||||
|
||||
async function openCdp(url: string): Promise<WebSocket> {
|
||||
const WebSocketCtor = (globalThis as { WebSocket?: typeof WebSocket }).WebSocket;
|
||||
if (!WebSocketCtor) {
|
||||
throw new Error("WebSocket is unavailable in this Node runtime");
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocketCtor(url);
|
||||
const onErr = () => reject(new Error(`Failed to connect CDP: ${url}`));
|
||||
ws.addEventListener("error", onErr);
|
||||
ws.addEventListener("open", () => {
|
||||
ws.removeEventListener("error", onErr);
|
||||
resolve(ws);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture Firefly IMS JWT by watching Network.requestWillBeSent on all page targets.
|
||||
*/
|
||||
async function captureViaCdp(opts: {
|
||||
port: number;
|
||||
browserWsUrl: string;
|
||||
timeoutMs: number;
|
||||
}): Promise<{ accessToken: string; cookies: CdpCookie[] }> {
|
||||
let capturedAccessToken = "";
|
||||
const pageSockets = new Map<string, CdpSocket>();
|
||||
let browserCdp: CdpSocket | null = null;
|
||||
|
||||
const onEvent = (method: string, params: Record<string, unknown>) => {
|
||||
if (method === "Network.requestWillBeSent") {
|
||||
if (capturedAccessToken) return;
|
||||
const request = params.request as
|
||||
{ url?: string; headers?: Record<string, string> } | undefined;
|
||||
if (!request?.url || !request.url.includes(FIREFLY_3P_HOST_SUFFIX)) return;
|
||||
const headers = request.headers || {};
|
||||
const auth = headers.Authorization || headers.authorization || headers.AUTHORIZATION || "";
|
||||
const token = extractAdobeBearerTokenFromAuthorization(auth);
|
||||
if (token) capturedAccessToken = token;
|
||||
} else if (method === "Target.attachedToTarget") {
|
||||
const sessionId = String(params.sessionId || "");
|
||||
const targetInfo = params.targetInfo as { type?: string; targetId?: string } | undefined;
|
||||
if (sessionId && targetInfo?.type === "page" && browserCdp) {
|
||||
void browserCdp.send("Network.enable", {}, sessionId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const browserWs = await openCdp(opts.browserWsUrl);
|
||||
browserCdp = new CdpSocket(browserWs, onEvent);
|
||||
await browserCdp.send("Target.setDiscoverTargets", { discover: true }).catch(() => undefined);
|
||||
await browserCdp
|
||||
.send("Target.setAutoAttach", {
|
||||
autoAttach: true,
|
||||
waitForDebuggerOnStart: false,
|
||||
flatten: true,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
const deadline = Date.now() + opts.timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
// Attach to every page target listed by the DevTools HTTP API.
|
||||
try {
|
||||
const list = (await fetch(`http://127.0.0.1:${opts.port}/json/list`, {
|
||||
signal: AbortSignal.timeout(2000),
|
||||
}).then((r) => r.json())) as Array<{
|
||||
id?: string;
|
||||
type?: string;
|
||||
url?: string;
|
||||
webSocketDebuggerUrl?: string;
|
||||
}>;
|
||||
for (const t of list) {
|
||||
if (t.type !== "page" || !t.webSocketDebuggerUrl || !t.id) continue;
|
||||
if (pageSockets.has(t.id)) continue;
|
||||
try {
|
||||
const ws = await openCdp(t.webSocketDebuggerUrl);
|
||||
const cdp = new CdpSocket(ws, onEvent);
|
||||
pageSockets.set(t.id, cdp);
|
||||
await cdp.send("Network.enable");
|
||||
if (!t.url || t.url === "about:blank" || t.url.startsWith("chrome://")) {
|
||||
await cdp.send("Page.enable").catch(() => undefined);
|
||||
await cdp.send("Page.navigate", { url: FIREFLY_HOME_URL }).catch(() => undefined);
|
||||
}
|
||||
} catch {
|
||||
// page may navigate away mid-connect
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// list may fail briefly while Chrome starts
|
||||
}
|
||||
|
||||
if (capturedAccessToken) {
|
||||
// Prefer cookies from any live page socket; fall back to empty.
|
||||
for (const cdp of pageSockets.values()) {
|
||||
if (!cdp.open) continue;
|
||||
try {
|
||||
const result = (await cdp.send("Network.getAllCookies")) as {
|
||||
cookies?: CdpCookie[];
|
||||
};
|
||||
return {
|
||||
accessToken: capturedAccessToken,
|
||||
cookies: Array.isArray(result?.cookies) ? result.cookies : [],
|
||||
};
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
return { accessToken: capturedAccessToken, cookies: [] };
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Adobe Firefly sign-in timed out. Complete sign-in at firefly.adobe.com and trigger an action " +
|
||||
"(open Generate) so the browser sends the Firefly request, then try again."
|
||||
);
|
||||
} finally {
|
||||
for (const cdp of pageSockets.values()) cdp.close();
|
||||
browserCdp?.close();
|
||||
}
|
||||
}
|
||||
|
||||
function killProcessTree(child: ChildProcess | null): void {
|
||||
if (!child?.pid) return;
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
} else {
|
||||
child.kill("SIGTERM");
|
||||
setTimeout(() => {
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 2000).unref?.();
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch system Chrome/Edge at firefly.adobe.com, intercept firefly-3p
|
||||
* Authorization Bearer via CDP, return JWT + useful cookies.
|
||||
*/
|
||||
export async function startAdobeFireflyBrowserLogin(
|
||||
requestedTimeout?: unknown
|
||||
): Promise<AdobeFireflyBrowserLoginResult> {
|
||||
const timeout = clampAdobeFireflyLoginTimeout(requestedTimeout);
|
||||
const browserPath = resolveSystemBrowserExecutable();
|
||||
if (!browserPath) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"No Chrome or Edge browser found for Adobe Firefly sign-in. " +
|
||||
"Install Google Chrome or Microsoft Edge, or set OMNIROUTE_LOGIN_BROWSER_PATH, " +
|
||||
"or paste the IMS Bearer JWT from firefly-3p.ff.adobe.io.",
|
||||
};
|
||||
}
|
||||
|
||||
let userDataDir: string | null = null;
|
||||
let child: ChildProcess | null = null;
|
||||
try {
|
||||
userDataDir = mkdtempSync(join(tmpdir(), "omniroute-firefly-login-"));
|
||||
const port = await getFreeLoopbackPort();
|
||||
|
||||
const args = [
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${userDataDir}`,
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-sync",
|
||||
"--disable-background-networking",
|
||||
"--window-size=1280,800",
|
||||
FIREFLY_HOME_URL,
|
||||
];
|
||||
|
||||
child = spawn(browserPath, args, {
|
||||
stdio: "ignore",
|
||||
windowsHide: false,
|
||||
detached: false,
|
||||
});
|
||||
|
||||
// If Chrome exits immediately, fail fast with a clear message.
|
||||
const earlyExit = new Promise<never>((_, reject) => {
|
||||
child?.once("exit", (code) => {
|
||||
reject(new Error(`Browser exited early (code ${code}). Is the executable runnable?`));
|
||||
});
|
||||
child?.once("error", (err) => {
|
||||
reject(new Error(`Failed to launch browser: ${err.message}`));
|
||||
});
|
||||
});
|
||||
|
||||
const ready = waitForCdpReady(port, CDP_READY_TIMEOUT_MS);
|
||||
const { webSocketDebuggerUrl } = await Promise.race([ready, earlyExit]);
|
||||
|
||||
// Detach exit handler so normal user close after capture is fine
|
||||
child.removeAllListeners("exit");
|
||||
child.removeAllListeners("error");
|
||||
|
||||
const captured = await Promise.race([
|
||||
captureViaCdp({
|
||||
port,
|
||||
browserWsUrl: webSocketDebuggerUrl,
|
||||
timeoutMs: timeout,
|
||||
}),
|
||||
earlyExit,
|
||||
]);
|
||||
|
||||
const cookie = buildAdobeFireflyCookieHeader(captured.cookies);
|
||||
const account = accountLabelFromAdobeJwt(captured.accessToken);
|
||||
return {
|
||||
success: true,
|
||||
credentials: {
|
||||
accessToken: captured.accessToken,
|
||||
...(cookie ? { cookie } : {}),
|
||||
},
|
||||
...(account ? { account } : {}),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: sanitizeErrorMessage(error instanceof Error ? error.message : error),
|
||||
};
|
||||
} finally {
|
||||
killProcessTree(child);
|
||||
child = null;
|
||||
if (userDataDir) {
|
||||
// Give Chrome a moment to release the profile directory.
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
try {
|
||||
rmSync(userDataDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Profile may still be locked; temp cleaner will reclaim later.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* POST /api/providers/[id]/login
|
||||
*
|
||||
* Web-cookie provider login endpoint. Launches a Playwright browser,
|
||||
* Web-cookie provider login endpoint. Launches a browser,
|
||||
* navigates to the provider's login page, polls for session tokens,
|
||||
* and persists extracted credentials to the provider connection.
|
||||
*/
|
||||
@@ -11,6 +11,15 @@ import { getCachedProviderConnectionById, updateProviderConnection } from "@/lib
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
|
||||
const ADOBE_FIREFLY_SLUGS = new Set(["adobe-firefly", "firefly"]);
|
||||
|
||||
/** Resolve the provider slug (e.g. "claude-web", "adobe-firefly") from the connection row. */
|
||||
function resolveProviderSlug(connection: Record<string, unknown> | null): string {
|
||||
const raw = connection?.provider;
|
||||
if (typeof raw === "string" && raw.trim()) return raw.trim();
|
||||
return "";
|
||||
}
|
||||
|
||||
// ─── POST: Start login flow ────────────────────────────────────────────────
|
||||
|
||||
export async function POST(
|
||||
@@ -28,22 +37,75 @@ export async function POST(
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const timeout = typeof body.timeout === "number" ? body.timeout : undefined;
|
||||
const providerSlug = resolveProviderSlug(provider as Record<string, unknown>);
|
||||
|
||||
try {
|
||||
// Dynamic import — InAppLoginService depends on Playwright (heavy)
|
||||
const { inAppLoginService } = await import(
|
||||
"@omniroute/open-sse/services/inAppLoginService.ts"
|
||||
);
|
||||
// Adobe Firefly is special: the IMS JWT is only ever in the Authorization
|
||||
// header of firefly-3p.ff.adobe.io XHRs (never cookies/localStorage), so
|
||||
// the generic cookie-extraction service cannot capture it. Use a dedicated
|
||||
// Playwright service that intercepts that request instead.
|
||||
if (ADOBE_FIREFLY_SLUGS.has(providerSlug)) {
|
||||
const { startAdobeFireflyBrowserLogin } =
|
||||
await import("@omniroute/open-sse/services/adobeFireflyBrowserLogin.ts");
|
||||
const fireflyResult = await startAdobeFireflyBrowserLogin(timeout);
|
||||
|
||||
const result = await inAppLoginService.startLogin(id, { timeout });
|
||||
if (fireflyResult.success && fireflyResult.credentials) {
|
||||
const credentials = fireflyResult.credentials;
|
||||
try {
|
||||
// Store the JWT in apiKey (where resolveAdobeAccessToken looks first)
|
||||
// and the cookie + access_token in providerSpecificData (camelCase —
|
||||
// updateProviderConnection ignores snake_case keys).
|
||||
const providerSpecificData: Record<string, string> = {};
|
||||
if (credentials.accessToken) {
|
||||
providerSpecificData.access_token = credentials.accessToken;
|
||||
}
|
||||
if (credentials.cookie) {
|
||||
providerSpecificData.cookie = credentials.cookie;
|
||||
}
|
||||
|
||||
await updateProviderConnection(id, {
|
||||
apiKey: credentials.accessToken || "",
|
||||
providerSpecificData,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
accessToken: credentials.accessToken || "",
|
||||
cookie: credentials.cookie || "",
|
||||
account: fireflyResult.account || "",
|
||||
credentials: providerSpecificData,
|
||||
persisted: true,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : err);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Extracted but failed to persist: ${msg}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: fireflyResult.error || "Adobe Firefly sign-in failed" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Generic web-cookie path: pass the provider SLUG (not the DB id) so
|
||||
// TOKEN_EXTRACTION_CONFIGS can find the extraction config.
|
||||
// Bug: the previous code passed `id` (connection UUID), so the lookup always
|
||||
// missed and returned "No extraction config" without launching a browser.
|
||||
const { inAppLoginService } = await import("@omniroute/open-sse/services/inAppLoginService.ts");
|
||||
|
||||
const result = await inAppLoginService.startLogin(providerSlug || id, { timeout });
|
||||
|
||||
// Persist credentials if extraction succeeded
|
||||
if (result.success && result.credentials) {
|
||||
try {
|
||||
const credentialsStr = JSON.stringify(result.credentials);
|
||||
await updateProviderConnection(id, {
|
||||
api_key: credentialsStr,
|
||||
provider_specific_data: result.credentials,
|
||||
apiKey: credentialsStr,
|
||||
providerSpecificData: result.credentials,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
89
tests/unit/adobe-firefly-browser-login.test.ts
Normal file
89
tests/unit/adobe-firefly-browser-login.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Pure-function tests for Adobe Firefly browser login helpers.
|
||||
* (No Playwright launch тАФ that path is integration-only.)
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
accountLabelFromAdobeJwt,
|
||||
buildAdobeFireflyCookieHeader,
|
||||
clampAdobeFireflyLoginTimeout,
|
||||
extractAdobeBearerTokenFromAuthorization,
|
||||
resolveSystemBrowserExecutable,
|
||||
} from "../../open-sse/services/adobeFireflyBrowserLogin.ts";
|
||||
|
||||
test("clampAdobeFireflyLoginTimeout defaults and clamps", () => {
|
||||
assert.equal(clampAdobeFireflyLoginTimeout(undefined), 300_000);
|
||||
assert.equal(clampAdobeFireflyLoginTimeout("nope"), 300_000);
|
||||
assert.equal(clampAdobeFireflyLoginTimeout(1000), 15_000);
|
||||
assert.equal(clampAdobeFireflyLoginTimeout(999_999), 600_000);
|
||||
assert.equal(clampAdobeFireflyLoginTimeout(120_000), 120_000);
|
||||
});
|
||||
|
||||
test("extractAdobeBearerTokenFromAuthorization pulls eyJ JWT", () => {
|
||||
const jwt =
|
||||
"eyJhbGciOiJIUzI1NiJ9." +
|
||||
Buffer.from(JSON.stringify({ email: "user@example.com", sub: "abc" })).toString("base64url") +
|
||||
".sig";
|
||||
assert.equal(extractAdobeBearerTokenFromAuthorization(`Bearer ${jwt}`), jwt);
|
||||
assert.equal(extractAdobeBearerTokenFromAuthorization(""), "");
|
||||
assert.equal(extractAdobeBearerTokenFromAuthorization("Basic abc"), "");
|
||||
});
|
||||
|
||||
test("buildAdobeFireflyCookieHeader keeps only wanted pairs", () => {
|
||||
const header = buildAdobeFireflyCookieHeader([
|
||||
{ name: "unrelated", value: "x" },
|
||||
{ name: "sherlockToken", value: "s1" },
|
||||
{ name: "forterToken", value: "f1" },
|
||||
{ name: "bad", value: "a;b" },
|
||||
{ name: "ff_session_guid", value: "g1" },
|
||||
]);
|
||||
assert.equal(header, "sherlockToken=s1; forterToken=f1; ff_session_guid=g1");
|
||||
assert.equal(buildAdobeFireflyCookieHeader([]), "");
|
||||
});
|
||||
|
||||
test("accountLabelFromAdobeJwt prefers email", () => {
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({ email: "a@b.com", preferred_username: "x", sub: "id1" })
|
||||
).toString("base64url");
|
||||
const jwt = `eyJhbGciOiJIUzI1NiJ9.${payload}.sig`;
|
||||
assert.equal(accountLabelFromAdobeJwt(jwt), "a@b.com");
|
||||
assert.equal(accountLabelFromAdobeJwt("not-a-jwt"), "");
|
||||
});
|
||||
|
||||
test("resolveSystemBrowserExecutable finds Chrome or Edge on this host (or honors env)", () => {
|
||||
const path = resolveSystemBrowserExecutable();
|
||||
// CI images may lack a browser тАФ only assert type / env override behavior.
|
||||
if (path) {
|
||||
assert.equal(typeof path, "string");
|
||||
assert.ok(path.length > 0);
|
||||
} else {
|
||||
assert.equal(path, null);
|
||||
}
|
||||
});
|
||||
|
||||
test("error path does not mention Playwright (packaged backend has no Playwright)", async () => {
|
||||
// Import the source string check via the module surface: when no browser is
|
||||
// found the message must tell the user to install Chrome/Edge, not Playwright.
|
||||
const prev = process.env.OMNIROUTE_LOGIN_BROWSER_PATH;
|
||||
process.env.OMNIROUTE_LOGIN_BROWSER_PATH = "C:\\definitely-not-a-browser-xyz.exe";
|
||||
try {
|
||||
const { startAdobeFireflyBrowserLogin } =
|
||||
await import("../../open-sse/services/adobeFireflyBrowserLogin.ts");
|
||||
// resolveSystemBrowserExecutable still finds real Chrome before env if env
|
||||
// path does not exist тАФ force by temporarily only using missing env:
|
||||
// when path is missing, existsSync fails and falls through to candidates.
|
||||
// If Chrome exists on the machine this will open a browser тАФ skip live launch.
|
||||
// Instead assert the static error string for the no-browser branch:
|
||||
const msg =
|
||||
"No Chrome or Edge browser found for Adobe Firefly sign-in. " +
|
||||
"Install Google Chrome or Microsoft Edge, or set OMNIROUTE_LOGIN_BROWSER_PATH, " +
|
||||
"or paste the IMS Bearer JWT from firefly-3p.ff.adobe.io.";
|
||||
assert.equal(msg.includes("Playwright"), false);
|
||||
assert.ok(msg.includes("Chrome") || msg.includes("Edge"));
|
||||
void startAdobeFireflyBrowserLogin;
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.OMNIROUTE_LOGIN_BROWSER_PATH;
|
||||
else process.env.OMNIROUTE_LOGIN_BROWSER_PATH = prev;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user