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:
artickc
2026-08-01 02:54:13 +03:00
committed by diegosouzapw
parent f234fa584f
commit 66a77dd7b5
3 changed files with 97 additions and 86 deletions

View File

@@ -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));

View File

@@ -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) {

View File

@@ -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;
}
});