mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 09:42:15 +03:00
fix(adobe-firefly): open browser sign-in and resolve provider slug in /login
POST /api/providers/[id]/login passed the connection DB id to inAppLoginService.startLogin, but TOKEN_EXTRACTION_CONFIGS is keyed by provider slug — so browser login never launched for web-cookie providers. Adobe Firefly also cannot use cookie extraction: the IMS JWT only appears on Authorization headers to firefly-3p.ff.adobe.io. Add a dedicated Playwright interceptor and persist credentials with camelCase keys that updateProviderConnection actually reads.
This commit is contained in:
@@ -3,21 +3,20 @@
|
||||
*
|
||||
* 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
|
||||
* cookies/localStorage тАФ the SPA only holds it in memory and attaches it
|
||||
* as `Authorization: Bearer <jwt>` on XHRs to firefly-3p.ff.adobe.io.
|
||||
*
|
||||
* So unlike conol-web (cookie-only), we open a Playwright browser at
|
||||
* firefly.adobe.com, then intercept outgoing requests to firefly-3p and
|
||||
* grab the Bearer JWT + sherlockToken cookie once the user is signed in.
|
||||
*
|
||||
* Mirrors the shape of conolBrowserLogin.ts so the /login route can call
|
||||
* it the same way.
|
||||
* Open a Playwright browser at firefly.adobe.com, intercept outgoing
|
||||
* firefly-3p requests, and capture the Bearer JWT + useful session cookies
|
||||
* once the user is signed in.
|
||||
*/
|
||||
import { sanitizeErrorMessage } from "../utils/error.ts";
|
||||
|
||||
const FIREFLY_HOME_URL = "https://firefly.adobe.com/";
|
||||
const FIREFLY_3P_HOST_SUFFIX = "firefly-3p.ff.adobe.io";
|
||||
const ADOBE_BEARER_REGEX = /^Bearer\s+(eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/i;
|
||||
// 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;
|
||||
@@ -27,20 +26,73 @@ const POLL_INTERVAL_MS = 1_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;
|
||||
}
|
||||
|
||||
type BrowserLauncher = Pick<typeof import("playwright"), "chromium">;
|
||||
|
||||
function clampTimeout(value: unknown): number {
|
||||
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 the relevant Firefly cookies. We only need
|
||||
* sherlockToken (used as x-arp-session-id); a few companions help session rebuild.
|
||||
* 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 (no signature verify).
|
||||
* 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 decode failures
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Try several browser launch strategies (configured path, Chrome, Edge, default)
|
||||
* so the visible sign-in window appears even on minimal installs. Mirrors
|
||||
* conolBrowserLogin.launchConolLoginBrowser.
|
||||
* so the visible sign-in window appears even on minimal installs.
|
||||
*/
|
||||
export async function launchAdobeFireflyLoginBrowser(
|
||||
playwright: BrowserLauncher
|
||||
@@ -56,6 +108,8 @@ export async function launchAdobeFireflyLoginBrowser(
|
||||
let lastError: unknown;
|
||||
for (const options of attempts) {
|
||||
try {
|
||||
// Playwright always uses an ephemeral profile unless userDataDir is set,
|
||||
// so each sign-in is a fresh SSO session (matches freshSession:true callers).
|
||||
return await playwright.chromium.launch(options);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
@@ -66,30 +120,10 @@ export async function launchAdobeFireflyLoginBrowser(
|
||||
: new Error("No compatible browser is available for Adobe Firefly sign-in");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single cookie header from the relevant Firefly cookies. We only need
|
||||
* sherlockToken (used as x-arp-session-id); the rest of the page cookies are
|
||||
* not useful for the 3P API (wrong origin) and are dropped by the executor.
|
||||
*/
|
||||
function buildCookieHeader(
|
||||
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 && candidate.value && !/[\r\n;]/.test(candidate.value)
|
||||
);
|
||||
if (c) parts.push(`${wantedName}=${c.value}`);
|
||||
}
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
||||
export async function startAdobeFireflyBrowserLogin(
|
||||
requestedTimeout?: unknown
|
||||
): Promise<AdobeFireflyBrowserLoginResult> {
|
||||
const timeout = clampTimeout(requestedTimeout);
|
||||
const timeout = clampAdobeFireflyLoginTimeout(requestedTimeout);
|
||||
|
||||
let playwright: typeof import("playwright");
|
||||
try {
|
||||
@@ -121,14 +155,14 @@ export async function startAdobeFireflyBrowserLogin(
|
||||
const url = request.url();
|
||||
if (!url.includes(FIREFLY_3P_HOST_SUFFIX)) return;
|
||||
const authHeader = request.headers()["authorization"] || "";
|
||||
const m = authHeader.match(ADOBE_BEARER_REGEX);
|
||||
if (m?.[1]) capturedAccessToken = m[1];
|
||||
const token = extractAdobeBearerTokenFromAuthorization(authHeader);
|
||||
if (token) capturedAccessToken = token;
|
||||
} catch {
|
||||
// Headers can throw on navigations; ignore.
|
||||
}
|
||||
};
|
||||
|
||||
// Attach interception to every page (including future popups).
|
||||
// Attach interception to every page (including OAuth popups).
|
||||
context.on("page", (page) => {
|
||||
page.on("request", onPageRequest);
|
||||
});
|
||||
@@ -143,15 +177,23 @@ export async function startAdobeFireflyBrowserLogin(
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
if (capturedAccessToken) {
|
||||
const cookie = buildCookieHeader(
|
||||
await context.cookies(["https://firefly.adobe.com", "https://.adobe.com"])
|
||||
);
|
||||
// Do NOT pass invalid URLs like "https://.adobe.com" тАФ Playwright rejects them
|
||||
// and would turn a successful capture into a failure.
|
||||
let cookies: Array<{ name: string; value: string; domain?: string }> = [];
|
||||
try {
|
||||
cookies = await context.cookies();
|
||||
} catch {
|
||||
cookies = [];
|
||||
}
|
||||
const cookie = buildAdobeFireflyCookieHeader(cookies);
|
||||
const account = accountLabelFromAdobeJwt(capturedAccessToken);
|
||||
return {
|
||||
success: true,
|
||||
credentials: {
|
||||
accessToken: capturedAccessToken,
|
||||
...(cookie ? { cookie } : {}),
|
||||
},
|
||||
...(account ? { account } : {}),
|
||||
};
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
|
||||
@@ -13,14 +13,14 @@ import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
|
||||
const ADOBE_FIREFLY_SLUGS = new Set(["adobe-firefly", "firefly"]);
|
||||
|
||||
/** Resolve the provider slug (e.g. "conol-web", "adobe-firefly") from the connection row. */
|
||||
/** 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 ────────────────────────────────────────────────
|
||||
// тФАтФАтФА POST: Start login flow тФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФА
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
@@ -37,7 +37,7 @@ export async function POST(
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const timeout = typeof body.timeout === "number" ? body.timeout : undefined;
|
||||
const providerSlug = resolveProviderSlug(provider);
|
||||
const providerSlug = resolveProviderSlug(provider as Record<string, unknown>);
|
||||
|
||||
try {
|
||||
// Adobe Firefly is special: the IMS JWT is only ever in the Authorization
|
||||
@@ -52,11 +52,16 @@ export async function POST(
|
||||
if (fireflyResult.success && fireflyResult.credentials) {
|
||||
const credentials = fireflyResult.credentials;
|
||||
try {
|
||||
// Store the JWT in api_key (where resolveAdobeAccessToken looks first)
|
||||
// and the cookie + access_token in provider_specific_data.
|
||||
// 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;
|
||||
if (credentials.accessToken) {
|
||||
providerSpecificData.access_token = credentials.accessToken;
|
||||
}
|
||||
if (credentials.cookie) {
|
||||
providerSpecificData.cookie = credentials.cookie;
|
||||
}
|
||||
|
||||
await updateProviderConnection(id, {
|
||||
apiKey: credentials.accessToken || "",
|
||||
@@ -65,9 +70,9 @@ export async function POST(
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
// Return fields the C# LoginWithOmniRouteAdobeFireflyAsync reads.
|
||||
accessToken: credentials.accessToken || "",
|
||||
cookie: credentials.cookie || "",
|
||||
account: fireflyResult.account || "",
|
||||
credentials: providerSpecificData,
|
||||
persisted: true,
|
||||
});
|
||||
@@ -88,9 +93,11 @@ export async function POST(
|
||||
|
||||
// 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, { timeout });
|
||||
const result = await inAppLoginService.startLogin(providerSlug || id, { timeout });
|
||||
|
||||
// Persist credentials if extraction succeeded
|
||||
if (result.success && result.credentials) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Pure-function tests for Adobe Firefly browser login helpers.
|
||||
* (No Playwright launch тАФ that path is integration-only.)
|
||||
* (No Playwright launch — that path is integration-only.)
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
buildAdobeFireflyCookieHeader,
|
||||
clampAdobeFireflyLoginTimeout,
|
||||
extractAdobeBearerTokenFromAuthorization,
|
||||
resolveSystemBrowserExecutable,
|
||||
} from "../../open-sse/services/adobeFireflyBrowserLogin.ts";
|
||||
|
||||
test("clampAdobeFireflyLoginTimeout defaults and clamps", () => {
|
||||
@@ -50,40 +49,3 @@ test("accountLabelFromAdobeJwt prefers email", () => {
|
||||
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