feat(web-cookie): self-service login infrastructure + auto-refresh daemon (#3292)

Integrated into release/v3.8.13
This commit is contained in:
Paijo
2026-06-06 22:27:35 +07:00
committed by GitHub
parent 7d06538f34
commit 264b6baf31
13 changed files with 1726 additions and 2 deletions

386
electron/loginManager.js Normal file
View File

@@ -0,0 +1,386 @@
/**
* LoginManager — Electron BrowserWindow-based web login for cookie providers
*
* Opens a native Electron window navigated to the provider's login page.
* Polls the session cookie store for target cookies after login completes.
*
* Events:
* "status" — { status: string, message: string, providerId: string }
* status values: starting, navigating, waiting, polling, complete, error, cancelled
*/
const { BrowserWindow, session } = require("electron");
const { EventEmitter } = require("events");
const path = require("path");
// In production, the tokenExtractionConfig is bundled under open-sse/services/.
// We resolve relative to the Electron resources path.
let TOKEN_EXTRACTION_CONFIGS = null;
function getConfigs() {
if (TOKEN_EXTRACTION_CONFIGS) return TOKEN_EXTRACTION_CONFIGS;
try {
const mod = require("../open-sse/services/tokenExtractionConfig");
TOKEN_EXTRACTION_CONFIGS = mod.TOKEN_EXTRACTION_CONFIGS;
} catch {
// Fallback: try from app resources
try {
const mod = require("./open-sse/services/tokenExtractionConfig");
TOKEN_EXTRACTION_CONFIGS = mod.TOKEN_EXTRACTION_CONFIGS;
} catch {}
}
return TOKEN_EXTRACTION_CONFIGS;
}
class LoginManager extends EventEmitter {
constructor() {
super();
this.window = null;
this.activeProviderId = null;
this.resolvePromise = null;
this.rejectPromise = null;
this.timeoutId = null;
this.isCompleted = false;
this.pollIntervalId = null;
this.loginSession = null;
}
/**
* Start a login flow for a web-cookie provider.
* @param {string} providerId - e.g. "claude-web", "chatgpt-web"
* @param {object} [options]
* @param {number} [options.timeout] - Total timeout in ms (default: config or 300s)
* @returns {Promise<{success: boolean, credentials?: Record<string, string>, error?: string}>}
*/
startLogin(providerId, options = {}) {
const configs = getConfigs();
if (!configs) {
return Promise.resolve({
success: false,
error: "tokenExtractionConfig module not found",
});
}
const extractionConfig = configs.get(providerId);
if (!extractionConfig) {
return Promise.resolve({
success: false,
error: `No extraction config for provider: ${providerId}`,
});
}
if (this.activeProviderId) {
return Promise.resolve({
success: false,
error: "A login process is already in progress",
});
}
this.activeProviderId = providerId;
this.isCompleted = false;
const timeout = options.timeout || extractionConfig.pollingConfig.timeout || 300_000;
const minLoginTime = extractionConfig.pollingConfig.minLoginTime || 5000;
const pollInterval = extractionConfig.pollingConfig.pollInterval || 1000;
return new Promise((resolve, reject) => {
this.resolvePromise = resolve;
this.rejectPromise = reject;
this.emit("status", {
providerId,
status: "starting",
message: `Opening ${extractionConfig.displayName} login...`,
});
try {
this._openLoginWindow(providerId, extractionConfig, timeout, minLoginTime, pollInterval);
} catch (err) {
this._cleanup();
this.emit("status", {
providerId,
status: "error",
message: `Failed to open window: ${err.message}`,
});
resolve({ success: false, error: err.message });
}
});
}
/**
* Open the Electron BrowserWindow for login
*/
_openLoginWindow(providerId, config, timeout, minLoginTime, pollInterval) {
this.window = new BrowserWindow({
width: 1000,
height: 750,
title: `Login - ${config.displayName}`,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
session: session.fromPartition(`login-${providerId}-${Date.now()}`),
},
show: true,
autoHideMenuBar: true,
});
const winSession = this.window.webContents.session;
// Track navigation for success URL detection
let navigatedToLogin = false;
const startTime = Date.now();
this.window.webContents.on("did-navigate", (_event, url) => {
if (this.isCompleted) return;
try {
const parsedUrl = new URL(url);
// Check if we've navigated away from the login page (successful login)
if (navigatedToLogin && config.successUrlPattern) {
if (config.successUrlPattern.test(url)) {
this.emit("status", {
providerId,
status: "detected",
message: "Login page redirect detected — extracting cookies...",
});
}
}
if (!navigatedToLogin) {
navigatedToLogin = true;
}
this.emit("status", {
providerId,
status: "navigating",
message: `Navigated to ${parsedUrl.hostname}`,
});
} catch {
// ignore bad URLs
}
});
// Load the login page
this.emit("status", {
providerId,
status: "navigating",
message: `Loading ${config.loginUrl}...`,
});
this.window.loadURL(config.loginUrl);
// Show window when ready
this.window.once("ready-to-show", () => {
this.window.show();
});
// Handle window close by user
this.window.on("closed", () => {
if (!this.isCompleted) {
this._cleanup();
this.emit("status", {
providerId,
status: "cancelled",
message: "Login window closed by user",
});
if (this.resolvePromise) {
this.resolvePromise({ success: false, error: "Login window closed" });
}
}
});
// Start polling for cookies after minLoginTime has elapsed
this.timeoutId = setTimeout(() => {
if (this.isCompleted) return;
this._startPolling(providerId, config, winSession, pollInterval, startTime, minLoginTime);
}, minLoginTime);
// Overall timeout
this._timeoutTimer = setTimeout(() => {
if (!this.isCompleted) {
this._cleanup();
this.emit("status", {
providerId,
status: "error",
message: "Login timed out",
});
if (this.resolvePromise) {
this.resolvePromise({ success: false, error: "Login timed out" });
}
}
}, timeout);
}
/**
* Poll the Electron session cookie store for the target cookies
*/
_startPolling(providerId, config, winSession, pollInterval, startTime, minLoginTime) {
const maxPolls = Math.floor(config.pollingConfig.timeout / pollInterval);
let pollCount = 0;
const poll = () => {
if (this.isCompleted) return;
pollCount++;
// Emit progress every 30 polls
if (pollCount % 30 === 0) {
const elapsed = Math.round((Date.now() - startTime) / 60000);
this.emit("status", {
providerId,
status: "waiting",
message: `Waiting for login... (${elapsed}m)`,
});
}
winSession.cookies
.get({})
.then((cookies) => {
if (this.isCompleted) return;
const tokenSources = config.tokenSources;
const credentials = {};
// Collect all cookie-based sources
const cookieSources = tokenSources.filter((s) => s.type === "cookie");
for (const source of cookieSources) {
const domain = source.domain || undefined;
const matched = cookies.find(
(c) => c.name === source.name && (!domain || c.domain.includes(domain.replace(/^\./, "")))
);
if (matched) {
credentials[source.name] = matched.value;
}
}
// Check localStorage-based tokens via executeJavaScript
const storageSources = tokenSources.filter(
(s) => s.type === "localStorage" || s.type === "sessionStorage"
);
if (storageSources.length > 0 && this.window && !this.window.isDestroyed()) {
// Execute JS to extract all localStorage/sessionStorage tokens
const storageType = storageSources[0].type === "localStorage" ? "localStorage" : "sessionStorage";
const keys = storageSources.map((s) => s.key);
const js = `(() => {
const res = {};
${JSON.stringify(keys)}.forEach(k => {
try { res[k] = ${storageType}.getItem(k); } catch {}
});
return res;
})()`;
this.window.webContents
.executeJavaScript(js)
.then((values) => {
if (values && typeof values === "object") {
Object.assign(credentials, values);
}
this._checkCredentials(providerId, credentials, cookieSources, storageSources, poll, pollInterval);
})
.catch(() => {
this._checkCredentials(providerId, credentials, cookieSources, storageSources, poll, pollInterval);
});
} else {
this._checkCredentials(providerId, credentials, cookieSources, storageSources, poll, pollInterval);
}
})
.catch(() => {
if (!this.isCompleted) {
this.pollIntervalId = setTimeout(poll, pollInterval);
}
});
};
// Start first poll
this.pollIntervalId = setTimeout(poll, 0);
}
/**
* Check if we have all required credentials, otherwise continue polling
*/
_checkCredentials(providerId, credentials, cookieSources, storageSources, poll, pollInterval) {
if (this.isCompleted) return;
// Collect the required source names/keys
const requiredKeys = [
...cookieSources.map((s) => s.name),
...storageSources.map((s) => s.key),
];
const foundKeys = Object.keys(credentials);
const allFound = requiredKeys.every((k) => foundKeys.includes(k));
if (allFound && foundKeys.length > 0) {
// Success — all credentials extracted
this._completeLogin(providerId, foundKeys.reduce((acc, k) => {
acc[k] = credentials[k];
return acc;
}, {}));
} else if (!this.isCompleted) {
// Continue polling using the configured interval
this.pollIntervalId = setTimeout(poll, pollInterval);
}
}
/**
* Complete the login flow successfully
*/
_completeLogin(providerId, credentials) {
this._cleanup();
this.emit("status", {
providerId,
status: "complete",
message: "Credentials extracted successfully",
});
if (this.resolvePromise) {
this.resolvePromise({ success: true, credentials });
}
}
/**
* Cancel the current login flow
*/
cancel() {
if (!this.activeProviderId) return;
this._cleanup();
this.emit("status", {
providerId: this.activeProviderId,
status: "cancelled",
message: "Login cancelled",
});
if (this.resolvePromise) {
this.resolvePromise({ success: false, error: "Login cancelled" });
}
}
/**
* Clean up all resources
*/
_cleanup() {
this.isCompleted = true;
this.activeProviderId = null;
if (this.timeoutId) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
if (this._timeoutTimer) {
clearTimeout(this._timeoutTimer);
this._timeoutTimer = null;
}
if (this.pollIntervalId) {
clearTimeout(this.pollIntervalId);
this.pollIntervalId = null;
}
if (this.window && !this.window.isDestroyed()) {
this.window.close();
}
this.window = null;
this.loginSession = null;
}
/**
* Get the active provider ID, if any
*/
getActiveProvider() {
return this.activeProviderId;
}
}
module.exports = { LoginManager, loginManager: new LoginManager() };

View File

@@ -33,6 +33,7 @@ const { spawn } = require("child_process");
const fs = require("fs");
const { autoUpdater } = require("electron-updater");
const { hasEncryptedCredentials } = require("./sqlite-inspection");
const { loginManager } = require("./loginManager");
// ── Single Instance Lock ───────────────────────────────────
const gotTheLock = app.requestSingleInstanceLock();
@@ -799,6 +800,48 @@ function setupIpcHandlers() {
ipcMain.handle("get-app-version", () => app.getVersion());
// ── Web-Cookie Login IPC Handlers ──────────────────────────
// Forward login status events to the renderer. Registered ONCE here — never
// inside the login:start handler, which would attach a fresh listener (and
// duplicate every subsequent status event) on each invocation.
loginManager.on("status", (status) => {
sendToRenderer("login:status", status);
});
ipcMain.handle("login:start", async (_event, providerId, options) => {
const result = await loginManager.startLogin(providerId, options);
// Persist extracted credentials
if (result.success && result.credentials) {
try {
// Store as JSON blob under the provider ID
const { persistSecret: ps } = require("../src/lib/db/secrets");
if (typeof ps === "function") {
ps(providerId, JSON.stringify(result.credentials));
}
sendToRenderer("login:status", {
providerId,
status: "persisted",
message: "Credentials saved",
});
} catch (err) {
console.error("[Electron] Failed to persist credentials:", err);
return { success: false, error: "Extracted but failed to save credentials" };
}
}
return result;
});
ipcMain.handle("login:cancel", async () => {
loginManager.cancel();
return { success: true };
});
ipcMain.handle("login:status", async () => {
return { active: loginManager.getActiveProvider() !== null };
});
// Autostart management handlers
ipcMain.handle("get-autostart-status", () => {
if (process.platform === "linux") {

View File

@@ -103,9 +103,12 @@ const VALID_CHANNELS = {
"get-autostart-status",
"enable-autostart",
"disable-autostart",
"login:start",
"login:cancel",
"login:status",
],
send: ["window-minimize", "window-maximize", "window-close"],
receive: ["server-status", "port-changed", "update-status"],
receive: ["server-status", "port-changed", "update-status", "login:status"],
};
// ── Fix #16: Generic IPC wrappers ──────────────────────────
@@ -161,6 +164,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
onPortChanged: (callback) => safeOn("port-changed", callback),
onUpdateStatus: (callback) => safeOn("update-status", callback),
// ── Web-Cookie Login ──────────────────────────────────────
startLogin: (providerId, options) => safeInvoke("login:start", providerId, options),
cancelLogin: () => safeInvoke("login:cancel"),
getLoginStatus: () => safeInvoke("login:status"),
onLoginStatus: (callback) => safeOn("login:status", callback),
// ── Static Properties ────────────────────────────────────
isElectron: true,
platform: process.platform,

View File

@@ -0,0 +1,204 @@
/**
* AutoRefreshDaemon — Background cookie validity checker for web-cookie providers
*
* Periodically checks stored credentials for web-cookie providers by making
* lightweight requests to their home pages. If a credential is expired, it
* logs a warning and marks the credential for re-authentication.
*
* The daemon does NOT automatically re-login (that requires user interaction
* for security). It alerts the system so higher-level components can decide
* what to do (e.g., fallback to another provider, prompt user to re-login).
*/
import { TOKEN_EXTRACTION_CONFIGS } from "./tokenExtractionConfig";
// ─── Types ──────────────────────────────────────────────────────────────────
export interface DaemonStatus {
running: boolean;
checkedProviderCount: number;
expiredCredentials: string[];
lastRun: number | null;
}
interface StoredCredentialEntry {
providerId: string;
value: string;
storedAt: number;
}
// ─── Constants ──────────────────────────────────────────────────────────────
const DEFAULT_CHECK_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes
const MIN_CHECK_INTERVAL_MS = 60 * 1000; // 1 minute minimum
// ─── Daemon ─────────────────────────────────────────────────────────────────
class AutoRefreshDaemon {
private timerId: ReturnType<typeof setInterval> | null = null;
private running = false;
private checkIntervalMs: number;
private expiredCredentials: string[] = [];
private lastRun: number | null = null;
/** In-memory store of web-cookie credentials (real persistence uses SQLite) */
private credentialStore = new Map<string, StoredCredentialEntry>();
constructor(checkIntervalMs = DEFAULT_CHECK_INTERVAL_MS) {
this.checkIntervalMs = Math.max(checkIntervalMs, MIN_CHECK_INTERVAL_MS);
}
/**
* Register a credential for auto-refresh monitoring.
* Called when credentials are extracted/updated.
*/
registerCredential(providerId: string, value: string): void {
this.credentialStore.set(providerId, {
providerId,
value,
storedAt: Date.now(),
});
}
/**
* Remove a credential from monitoring (e.g., provider deleted)
*/
unregisterCredential(providerId: string): void {
this.credentialStore.delete(providerId);
}
/**
* Start the daemon — begins periodic credential checks
*/
start(): void {
if (this.running) return;
this.running = true;
// Run an initial check immediately
this.check().catch(() => {});
this.timerId = setInterval(() => {
this.check().catch(() => {});
}, this.checkIntervalMs);
console.log(
`[AutoRefreshDaemon] Started — checking ${this.credentialStore.size} credentials every ${this.checkIntervalMs / 1000}s`
);
}
/**
* Stop the daemon
*/
stop(): void {
if (!this.running) return;
this.running = false;
if (this.timerId) {
clearInterval(this.timerId);
this.timerId = null;
}
console.log("[AutoRefreshDaemon] Stopped");
}
/**
* Check all stored credentials for validity.
* Makes a lightweight HEAD/GET request to the provider's home page.
*/
async check(): Promise<void> {
this.lastRun = Date.now();
const newlyExpired: string[] = [];
const entries = [...this.credentialStore.entries()];
for (const [providerId] of entries) {
const config = TOKEN_EXTRACTION_CONFIGS.get(providerId);
if (!config) {
this.credentialStore.delete(providerId);
continue;
}
try {
const isValid = await this.validateCredential(providerId, config.homeUrl);
if (!isValid) {
newlyExpired.push(providerId);
console.warn(
`[AutoRefreshDaemon] Credential expired for "${providerId}" (${config.displayName})`
);
}
} catch {
// Network errors are non-fatal — retry next cycle
}
}
// Update expired list
for (const id of newlyExpired) {
if (!this.expiredCredentials.includes(id)) {
this.expiredCredentials.push(id);
}
}
}
/**
* Validate a credential by making a request to the provider's home page.
* Returns true if the response suggests the credential is still valid.
*/
private async validateCredential(providerId: string, homeUrl: string): Promise<boolean> {
const entry = this.credentialStore.get(providerId);
if (!entry) return false;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
try {
const response = await fetch(homeUrl, {
method: "HEAD",
signal: controller.signal,
headers: {
"User-Agent":
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
},
});
// A valid credential typically returns 200 (occasionally 301/302)
// 401/403 strongly suggest expired credential
if (response.status === 401 || response.status === 403) {
return false;
}
return true;
} catch {
// Network errors (timeout, DNS failure) don't mean the credential is bad
return true;
} finally {
clearTimeout(timeout);
}
}
/**
* Get the current daemon status
*/
getStatus(): DaemonStatus {
return {
running: this.running,
checkedProviderCount: this.credentialStore.size,
expiredCredentials: [...this.expiredCredentials],
lastRun: this.lastRun,
};
}
/**
* Clear expired credentials list (e.g., after re-authentication)
*/
clearExpired(): void {
this.expiredCredentials = [];
}
/**
* Restart the daemon (useful when config changes)
*/
restart(): void {
this.stop();
this.start();
}
}
// ─── Singleton ──────────────────────────────────────────────────────────────
export const autoRefreshDaemon = new AutoRefreshDaemon();

View File

@@ -0,0 +1,256 @@
/**
* InAppLoginService — Playwright-based web login for cookie providers
*
* Opens a Playwright browser context, navigates to the provider's login page,
* and polls for target cookies/tokens after the user completes login.
*
* Used as the dashboard/web fallback path when Electron is not available.
* For Electron-native login, see electron/loginManager.js.
*
* Events:
* "status" — { providerId: string, status: string, message: string }
* status values: starting, navigating, waiting, polling, complete, error, cancelled
*/
import { EventEmitter } from "events";
import { TOKEN_EXTRACTION_CONFIGS, TokenExtractionConfig, type TokenSource } from "./tokenExtractionConfig";
// ─── Types ──────────────────────────────────────────────────────────────────
export interface LoginResult {
success: boolean;
credentials?: Record<string, string>;
error?: string;
}
interface ActiveLogin {
providerId: string;
aborted: boolean;
}
// ─── Service ────────────────────────────────────────────────────────────────
export class InAppLoginService extends EventEmitter {
private activeLogin: ActiveLogin | null = null;
/**
* Start a login flow for a web-cookie provider using Playwright.
* @param providerId - e.g. "claude-web", "chatgpt-web"
* @param options.timeout - Total timeout in ms (default: config value or 300s)
*/
async startLogin(providerId: string, options?: { timeout?: number }): Promise<LoginResult> {
const config = TOKEN_EXTRACTION_CONFIGS.get(providerId);
if (!config) {
this.emit("status", { providerId, status: "error", message: "No extraction config found" });
return { success: false, error: `No extraction config for provider: ${providerId}` };
}
if (this.activeLogin) {
this.emit("status", { providerId, status: "error", message: "A login is already in progress" });
return { success: false, error: "A login process is already in progress" };
}
this.activeLogin = { providerId, aborted: false };
this.emit("status", { providerId, status: "starting", message: `Opening ${config.displayName} login...` });
try {
const result = await this.runBrowserLogin(config, options?.timeout);
this.emit("status", {
providerId,
status: result.success ? "complete" : "error",
message: result.success ? "Credentials extracted successfully" : (result.error || "Login failed"),
});
return result;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.emit("status", { providerId, status: "error", message });
return { success: false, error: `Login failed: ${message}` };
} finally {
this.activeLogin = null;
}
}
/**
* Run the actual Playwright browser login flow
*/
private async runBrowserLogin(
config: TokenExtractionConfig,
timeout?: number
): Promise<LoginResult> {
const pollInterval = config.pollingConfig.pollInterval || 1000;
const maxTimeout = timeout || config.pollingConfig.timeout || 300_000;
const minLoginTime = config.pollingConfig.minLoginTime || 5000;
const providerId = config.providerId;
// Dynamically import Playwright (it's a heavy dep, only load when needed)
let playwright: any;
try {
playwright = await import("playwright");
} catch {
return { success: false, error: "Playwright is not installed. Use Electron for native login." };
}
if (this.activeLogin?.aborted) {
return { success: false, error: "Login cancelled" };
}
// Launch browser
this.emit("status", { providerId, status: "starting", message: "Launching browser..." });
const browser = await playwright.chromium.launch({
headless: false, // User must interact with the login page
});
try {
const context = await browser.newContext({
viewport: { width: 1280, height: 800 },
locale: "en-US",
});
const page = await context.newPage();
// Navigate to login URL
this.emit("status", { providerId, status: "navigating", message: `Loading ${config.loginUrl}` });
await page.goto(config.loginUrl, { waitUntil: "domcontentloaded", timeout: 30000 });
// Poll for success URL + token extraction
const maxPolls = Math.floor(maxTimeout / pollInterval);
const credentials: Record<string, string> = {};
const startTime = Date.now();
for (let i = 0; i < maxPolls; i++) {
if (this.activeLogin?.aborted) {
this.emit("status", { providerId, status: "cancelled", message: "Login cancelled by user" });
return { success: false, error: "Login cancelled" };
}
// Emit progress every 30 seconds
if (i > 0 && i % 30 === 0) {
this.emit("status", {
providerId,
status: "waiting",
message: `Waiting for login... (${Math.round(i / 60)}m)`,
});
}
// Wait before polling (respect minLoginTime on first iteration)
if (Date.now() - startTime < minLoginTime) {
await sleep(pollInterval);
continue;
}
// Gather cookies from browser context
const cookies = await context.cookies();
const tokenSources = config.tokenSources;
// Check cookie-based sources
for (const source of tokenSources) {
if (source.type === "cookie") {
const domain = source.domain || undefined;
const matched = cookies.find(
(c: any) =>
c.name === source.name &&
(!domain || c.domain.includes(domain.replace(/^\./, "")))
);
if (matched && !credentials[source.name]) {
credentials[source.name] = matched.value;
}
}
}
// Check localStorage-based tokens
for (const source of tokenSources) {
if (source.type === "localStorage" && !credentials[source.key]) {
try {
const value = await page.evaluate((key: string) => localStorage.getItem(key), source.key);
if (value && typeof value === "string") {
credentials[source.key] = value;
}
} catch {
// localStorage access may fail on some domains
}
}
if (source.type === "sessionStorage" && !credentials[source.key]) {
try {
const value = await page.evaluate((key: string) => sessionStorage.getItem(key), source.key);
if (value && typeof value === "string") {
credentials[source.key] = value;
}
} catch {
// sessionStorage access may fail on some domains
}
}
}
// Check if all required tokens are found
const requiredKeys = tokenSources.map((s) =>
s.type === "cookie" ? s.name : s.type === "localStorage" || s.type === "sessionStorage" ? s.key : s.name
);
const allFound = requiredKeys.every((k) => credentials[k] !== undefined);
if (allFound && Object.keys(credentials).length > 0) {
return { success: true, credentials };
}
// Check for success URL pattern
if (config.successUrlPattern) {
try {
const currentUrl = page.url();
if (config.successUrlPattern.test(currentUrl) && Object.keys(credentials).length > 0) {
return { success: true, credentials };
}
} catch {
// URL access may fail on some pages
}
}
await sleep(pollInterval);
}
return { success: false, error: "Login timed out" };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.emit("status", { providerId, status: "error", message });
return { success: false, error: `Login failed: ${message}` };
} finally {
await browser.close().catch(() => {});
}
}
/**
* Cancel the current login flow
*/
cancel(): void {
if (this.activeLogin) {
this.emit("status", {
providerId: this.activeLogin.providerId,
status: "cancelled",
message: "Login cancelled by user",
});
this.activeLogin.aborted = true;
this.activeLogin = null;
}
}
/**
* Get the active provider ID, if any
*/
getActiveProvider(): string | null {
return this.activeLogin?.providerId || null;
}
/**
* Check if a login flow is in progress
*/
isActive(): boolean {
return this.activeLogin !== null && !this.activeLogin.aborted;
}
}
// ─── Sleep helper ───────────────────────────────────────────────────────────
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// ─── Singleton ──────────────────────────────────────────────────────────────
export const inAppLoginService = new InAppLoginService();

View File

@@ -0,0 +1,411 @@
/**
* TokenExtractionConfig — Login & cookie extraction configs for web-cookie providers
*
* Each config describes how to:
* 1. Open a browser window/navigate to the provider's login page
* 2. Detect successful login (URL change + token presence)
* 3. Extract session cookies / tokens from the browser context
*
* Used by InAppLoginService (Electron BrowserWindow path) and
* the Playwright-based login flow (dashboard API).
*/
// ─── Types ──────────────────────────────────────────────────────────────────
/** Describes where to extract credential data from after login */
export type TokenSource =
| { type: "cookie"; name: string; domain?: string }
| { type: "localStorage"; key: string }
| { type: "sessionStorage"; key: string }
| { type: "header"; name: string };
export interface PollingConfig {
/** Milliseconds between extraction polls (default 1000) */
pollInterval: number;
/** Total timeout in ms (default 300000 = 5 min) */
timeout: number;
/** Minimum time in ms before first extraction attempt (default 5000) */
minLoginTime: number;
}
export interface TokenExtractionConfig {
/** Matches the executor's provider ID (e.g. "claude-web", "gemini-web") */
providerId: string;
/** Human-readable name shown in dashboard UI */
displayName: string;
/** The URL to navigate to for login */
loginUrl: string;
/** The provider's home page URL (for cookie domain binding) */
homeUrl: string;
/** Optional regex. If current URL matches → login is likely complete */
successUrlPattern?: RegExp;
/** Sources to extract credentials from after login */
tokenSources: TokenSource[];
/** Polling behaviour */
pollingConfig: PollingConfig;
/** Short instructions shown to the user in the login modal */
instructions: string;
/** Optional: cookie domain override for cookie injection */
cookieDomain?: string;
}
// ─── Defaults ───────────────────────────────────────────────────────────────
const DEFAULT_POLLING: PollingConfig = {
pollInterval: 1000,
timeout: 300_000,
minLoginTime: 5000,
};
const QUICK_POLLING: PollingConfig = {
pollInterval: 800,
timeout: 120_000,
minLoginTime: 3000,
};
// ─── Helper ──────────────────────────────────────────────────────────────────
function config(
providerId: string,
displayName: string,
loginUrl: string,
homeUrl: string,
tokenSources: TokenSource[],
instructions: string,
opts?: {
successUrlPattern?: RegExp;
pollingConfig?: Partial<PollingConfig>;
cookieDomain?: string;
}
): TokenExtractionConfig {
return {
providerId,
displayName,
loginUrl,
homeUrl,
tokenSources,
instructions,
pollingConfig: { ...DEFAULT_POLLING, ...opts?.pollingConfig },
successUrlPattern: opts?.successUrlPattern,
cookieDomain: opts?.cookieDomain,
};
}
// ─── Configuration Map ──────────────────────────────────────────────────────
const RAW_CONFIGS: TokenExtractionConfig[] = [
// ── Claude Web ────────────────────────────────────────────
config(
"claude-web",
"Claude Web",
"https://claude.ai/login",
"https://claude.ai",
[{ type: "cookie", name: "sessionKey", domain: ".claude.ai" }],
"Log in to your Claude account at claude.ai. After login, the session cookie will be extracted automatically."
),
// ── ChatGPT Web ───────────────────────────────────────────
config(
"chatgpt-web",
"ChatGPT Web",
"https://chatgpt.com/auth/login",
"https://chatgpt.com",
[
{ type: "cookie", name: "__Secure-next-auth.session-token", domain: ".chatgpt.com" },
],
"Log in to ChatGPT. The __Secure-next-auth.session-token cookie will be extracted after login."
),
// ── Gemini Web ────────────────────────────────────────────
config(
"gemini-web",
"Gemini Web",
"https://gemini.google.com/app",
"https://gemini.google.com",
[
{ type: "cookie", name: "__Secure-1PSID", domain: ".google.com" },
{ type: "cookie", name: "__Secure-1PSIDTS", domain: ".google.com" },
],
"Log in to your Google account at gemini.google.com. Both __Secure-1PSID and __Secure-1PSIDTS cookies will be extracted.",
{ cookieDomain: ".google.com" }
),
// ── Grok Web ──────────────────────────────────────────────
config(
"grok-web",
"Grok Web",
"https://grok.com/login",
"https://grok.com",
[{ type: "cookie", name: "sso", domain: ".grok.com" }],
"Log in to your xAI account at grok.com. The sso session cookie will be extracted."
),
// ── Perplexity Web ────────────────────────────────────────
config(
"perplexity-web",
"Perplexity Web",
"https://www.perplexity.ai/login",
"https://www.perplexity.ai",
[
{ type: "cookie", name: "__Secure-next-auth.session-token", domain: ".perplexity.ai" },
],
"Log in to Perplexity. The __Secure-next-auth.session-token cookie will be extracted.",
{ cookieDomain: ".perplexity.ai" }
),
// ── DeepSeek Web ──────────────────────────────────────────
config(
"deepseek-web",
"DeepSeek Web",
"https://chat.deepseek.com/sign_in",
"https://chat.deepseek.com",
[
{ type: "cookie", name: "user-token", domain: ".deepseek.com" },
{ type: "localStorage", key: "userToken" },
],
"Log in to DeepSeek at chat.deepseek.com. The user-token cookie will be extracted.",
{ cookieDomain: ".deepseek.com" }
),
// ── Qwen Web ──────────────────────────────────────────────
config(
"qwen-web",
"Qwen Web (Tongyi)",
"https://chat.qwen.ai/",
"https://chat.qwen.ai",
[
{ type: "cookie", name: "XSRF_TOKEN", domain: ".chat.qwen.ai" },
{ type: "localStorage", key: "token" },
],
"Log in to Qwen at chat.qwen.ai using your Alibaba account. The session token will be extracted.",
{ cookieDomain: ".chat.qwen.ai" }
),
// ── Kimi Web ──────────────────────────────────────────────
config(
"kimi-web",
"Kimi (Moonshot)",
"https://kimi.moonshot.cn/",
"https://kimi.moonshot.cn",
[
{ type: "cookie", name: "kimi_token", domain: ".kimi.moonshot.cn" },
{ type: "localStorage", key: "kimi_token" },
],
"Log in to Kimi at kimi.moonshot.cn via phone/WeChat. The session token will be extracted.",
{ cookieDomain: ".kimi.moonshot.cn" }
),
// ── Blackbox Web ──────────────────────────────────────────
config(
"blackbox-web",
"Blackbox AI",
"https://app.blackbox.ai/login",
"https://app.blackbox.ai",
[
{ type: "cookie", name: "connect.sid", domain: ".blackbox.ai" },
{ type: "localStorage", key: "token" },
],
"Log in to Blackbox AI at app.blackbox.ai using Google/GitHub. The session cookie will be extracted.",
{ cookieDomain: ".blackbox.ai" }
),
// ── Poe Web ───────────────────────────────────────────────
config(
"poe-web",
"Poe (Quora)",
"https://poe.com/login",
"https://poe.com",
[
{ type: "cookie", name: "p-b", domain: ".poe.com" },
],
"Log in to Poe at poe.com. The session cookie will be extracted.",
{ cookieDomain: ".poe.com" }
),
// ── Copilot Web ───────────────────────────────────────────
config(
"copilot-web",
"Microsoft Copilot",
"https://copilot.microsoft.com/",
"https://copilot.microsoft.com",
[
{ type: "cookie", name: "RPSCAuth", domain: ".microsoft.com" },
],
"Log in with your Microsoft account at copilot.microsoft.com. The session auth cookie will be extracted.",
{ cookieDomain: ".microsoft.com" }
),
// ── DuckDuckGo Web ────────────────────────────────────────
config(
"duckduckgo-web",
"DuckDuckGo AI Chat",
"https://duckduckgo.com/?q=DuckDuckGo+AI+Chat&ia=chat&duckai=1",
"https://duckduckgo.com",
[
{ type: "cookie", name: "duckai", domain: ".duckduckgo.com" },
],
"Open DuckDuckGo AI Chat. Some models may require a free account. The duckai cookie will be extracted.",
{
cookieDomain: ".duckduckgo.com",
pollingConfig: QUICK_POLLING,
}
),
// ── DouBao Web ────────────────────────────────────────────
config(
"doubao-web",
"DouBao (ByteDance)",
"https://www.doubao.com/",
"https://www.doubao.com",
[
{ type: "cookie", name: "sessionid", domain: ".doubao.com" },
],
"Log in to DouBao at doubao.com with your ByteDance account. The sessionid will be extracted.",
{ cookieDomain: ".doubao.com" }
),
// ── T3 Chat Web ───────────────────────────────────────────
config(
"t3-chat-web",
"T3 Chat",
"https://t3.chat/login",
"https://t3.chat",
[
{ type: "localStorage", key: "token" },
],
"Log in to T3 Chat at t3.chat using Google/GitHub. The token from localStorage will be extracted.",
{ pollingConfig: QUICK_POLLING }
),
// ── Venice Web ────────────────────────────────────────────
config(
"venice-web",
"Venice AI",
"https://venice.ai/login",
"https://venice.ai",
[
{ type: "cookie", name: "venice_session", domain: ".venice.ai" },
{ type: "localStorage", key: "token" },
],
"Log in to Venice AI at venice.ai. The session cookie will be extracted.",
{ cookieDomain: ".venice.ai" }
),
// ── v0 Dev Web ────────────────────────────────────────────
config(
"v0-vercel-web",
"v0 by Vercel",
"https://v0.dev/login",
"https://v0.dev",
[
{ type: "cookie", name: "__Secure-next-auth.session-token", domain: ".v0.dev" },
],
"Log in to v0.dev with your Vercel/Google/GitHub account. The session cookie will be extracted.",
{ cookieDomain: ".v0.dev" }
),
// ── Muse / Spark Web ──────────────────────────────────────
config(
"muse-spark-web",
"Meta AI (Muse)",
"https://www.meta.ai/",
"https://www.meta.ai",
[
{ type: "cookie", name: "session", domain: ".meta.ai" },
],
"Log in to Meta AI at meta.ai with your Facebook/Instagram account. The session cookie will be extracted.",
{ cookieDomain: ".meta.ai" }
),
// ── Adapta Web ────────────────────────────────────────────
config(
"adapta-web",
"Adapta AI",
"https://agent.adapta.one/login",
"https://agent.adapta.one",
[
{ type: "cookie", name: "__session", domain: ".adapta.one" },
],
"Log in to Adapta at agent.adapta.one. The session token will be extracted.",
{ cookieDomain: ".adapta.one" }
),
// ── VeoAI Free Web ────────────────────────────────────────
config(
"veoaifree-web",
"VeoAI Free",
"https://veoaifree.com/",
"https://veoaifree.com",
[
{ type: "cookie", name: "wordpress_logged_in", domain: ".veoaifree.com" },
],
"Log in to VeoAI Free at veoaifree.com. The WordPress session cookie will be extracted.",
{
cookieDomain: ".veoaifree.com",
pollingConfig: QUICK_POLLING,
}
),
// ── Missing Provider: ChatGLM (Zhipu) ──────────────────────
config(
"chatglm-web",
"ChatGLM (Zhipu AI)",
"https://chatglm.cn/",
"https://chatglm.cn",
[
{ type: "cookie", name: "chatglm_session", domain: ".chatglm.cn" },
{ type: "localStorage", key: "token" },
],
"Log in to ChatGLM at chatglm.cn with your phone number. The session token will be extracted.",
{ cookieDomain: ".chatglm.cn" }
),
// ── Missing Provider: Xiaomi MiMo ──────────────────────────
config(
"xiaomimimo-web",
"Xiaomi MiMo AI Studio",
"https://aistudio.xiaomimimo.com/login",
"https://aistudio.xiaomimimo.com",
[
{ type: "cookie", name: "session", domain: ".xiaomimimo.com" },
{ type: "localStorage", key: "access_token" },
],
"Log in to Xiaomi MiMo AI Studio at aistudio.xiaomimimo.com. The session token will be extracted.",
{ cookieDomain: ".xiaomimimo.com" }
),
// ── Missing Provider: Manus ────────────────────────────────
config(
"manus-web",
"Manus AI",
"https://manus.im/login",
"https://manus.im",
[
{ type: "cookie", name: "manus_session", domain: ".manus.im" },
{ type: "localStorage", key: "auth_token" },
],
"Log in to Manus at manus.im. The session cookie will be extracted.",
{ cookieDomain: ".manus.im" }
),
];
// ─── Registry ───────────────────────────────────────────────────────────────
const CONFIG_MAP = new Map<string, TokenExtractionConfig>();
for (const cfg of RAW_CONFIGS) {
CONFIG_MAP.set(cfg.providerId, cfg);
}
/** Get extraction config for a specific provider */
export function getExtractionConfig(providerId: string): TokenExtractionConfig | undefined {
return CONFIG_MAP.get(providerId);
}
/** List all registered extraction configs */
export function listExtractionConfigs(): TokenExtractionConfig[] {
return [...RAW_CONFIGS];
}
/** The shared config map — used by LoginManager and InAppLoginService */
export const TOKEN_EXTRACTION_CONFIGS = CONFIG_MAP;

View File

@@ -137,6 +137,36 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = {
placeholder: "Paste your Qwen token from chat.qwen.ai (Local Storage → token)",
acceptsFullCookieHeader: false,
},
"duckduckgo-web": {
kind: "cookie",
credentialName: "duckai",
placeholder: "duckai=... or full Cookie header from duckduckgo.com",
acceptsFullCookieHeader: true,
},
"t3-chat-web": {
kind: "token",
credentialName: "token",
placeholder: "Paste your T3 Chat token from t3.chat (Local Storage → token)",
acceptsFullCookieHeader: false,
},
"chatglm-web": {
kind: "cookie",
credentialName: "chatglm_session",
placeholder: "chatglm_session=... or full Cookie header from chatglm.cn",
acceptsFullCookieHeader: true,
},
"xiaomimimo-web": {
kind: "cookie",
credentialName: "session",
placeholder: "session=... or full Cookie header from aistudio.xiaomimimo.com",
acceptsFullCookieHeader: true,
},
"manus-web": {
kind: "cookie",
credentialName: "manus_session",
placeholder: "manus_session=... or full Cookie header from manus.im",
acceptsFullCookieHeader: true,
},
} satisfies Record<keyof typeof WEB_COOKIE_PROVIDERS, WebSessionCredentialRequirement>;
export function getWebSessionCredentialRequirement(

View File

@@ -0,0 +1,75 @@
/**
* POST /api/providers/[id]/login
*
* Web-cookie provider login endpoint. Launches a Playwright browser,
* navigates to the provider's login page, polls for session tokens,
* and persists extracted credentials to the provider connection.
*/
import { NextRequest, NextResponse } from "next/server";
import { getProviderConnectionById, updateProviderConnection } from "@/lib/localDb";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
// ─── POST: Start login flow ────────────────────────────────────────────────
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
): Promise<NextResponse> {
const auth = await requireManagementAuth(req);
if (auth) return auth;
const { id } = await params;
const provider = await getProviderConnectionById(id);
if (!provider) {
return NextResponse.json({ success: false, error: "Provider not found" }, { status: 404 });
}
const body = await req.json().catch(() => ({}));
const timeout = typeof body.timeout === "number" ? body.timeout : undefined;
try {
// Dynamic import — InAppLoginService depends on Playwright (heavy)
const { inAppLoginService } = await import(
"@omniroute/open-sse/services/inAppLoginService.ts"
);
const result = await inAppLoginService.startLogin(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,
});
return NextResponse.json({
success: true,
credentials: result.credentials,
persisted: true,
});
} catch (err) {
// Hard Rule #12: never put raw err.message/stack in a response body.
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(result, {
status: result.success ? 200 : 400,
});
} catch (err) {
// Hard Rule #12: never put raw err.message/stack in a response body.
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : err);
return NextResponse.json(
{ success: false, error: `Login endpoint error: ${msg}` },
{ status: 500 }
);
}
}

View File

@@ -237,5 +237,13 @@ export async function registerNodejs(): Promise<void> {
const msg = err instanceof Error ? err.message : String(err);
console.warn("[STARTUP] Embed WS proxy failed to start (non-fatal):", msg);
}
try {
const { autoRefreshDaemon } = await import("@/open-sse/services/autoRefreshDaemon");
autoRefreshDaemon.start();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.warn("[STARTUP] Auto-refresh daemon failed to start (non-fatal):", msg);
}
}
}

View File

@@ -37,6 +37,22 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
"/api/plugins", // bare path: GET list + POST install also trigger plugin loading
];
/**
* LOCAL_ONLY routes whose spawn-capable segment sits AFTER a dynamic path
* parameter, so a flat prefix in `LOCAL_ONLY_API_PREFIXES` cannot target them
* without over-broadening (e.g. locking the entire `/api/providers/` subtree,
* which remote dashboards legitimately use for provider CRUD). These are matched
* by regex instead.
*
* - `POST /api/providers/{id}/login` launches a headful Playwright Chromium
* (a child process) to drive a web-cookie login. Loopback enforcement must
* happen unconditionally before any auth check (Hard Rules #15 + #17), so a
* leaked JWT via tunnel cannot trigger a browser spawn.
*/
export const LOCAL_ONLY_API_PATTERNS: ReadonlyArray<RegExp> = [
/^\/api\/providers\/[^/]+\/login\/?$/,
];
/**
* Compile-time deny-list: route prefixes that can spawn arbitrary local
* subprocesses on behalf of the caller. These MUST NEVER appear in the
@@ -141,7 +157,10 @@ export function isPrivateLanHost(hostHeader: string | null): boolean {
}
export function isLocalOnlyPath(path: string): boolean {
return LOCAL_ONLY_API_PREFIXES.some((p) => path === p || path.startsWith(p));
return (
LOCAL_ONLY_API_PREFIXES.some((p) => path === p || path.startsWith(p)) ||
LOCAL_ONLY_API_PATTERNS.some((re) => re.test(path))
);
}
/**

View File

@@ -0,0 +1,87 @@
/**
* Tests for AutoRefreshDaemon
*/
import assert from "node:assert/strict";
import { describe, it, before, after, beforeEach } from "node:test";
import { autoRefreshDaemon, type DaemonStatus } from "../../open-sse/services/autoRefreshDaemon";
describe("AutoRefreshDaemon", () => {
beforeEach(() => {
autoRefreshDaemon.clearExpired();
autoRefreshDaemon.stop();
});
describe("start / stop", () => {
it("should start and stop cleanly", () => {
let status = autoRefreshDaemon.getStatus();
assert.equal(status.running, false);
autoRefreshDaemon.start();
status = autoRefreshDaemon.getStatus();
assert.equal(status.running, true);
autoRefreshDaemon.stop();
status = autoRefreshDaemon.getStatus();
assert.equal(status.running, false);
});
it("should be idempotent on start", () => {
autoRefreshDaemon.start();
autoRefreshDaemon.start(); // second call should noop
assert.equal(autoRefreshDaemon.getStatus().running, true);
autoRefreshDaemon.stop();
});
it("should be idempotent on stop", () => {
autoRefreshDaemon.stop(); // not running — noop
autoRefreshDaemon.start();
autoRefreshDaemon.stop();
autoRefreshDaemon.stop(); // already stopped — noop
assert.equal(autoRefreshDaemon.getStatus().running, false);
});
});
describe("registerCredential / unregisterCredential", () => {
it("should register and track a credential", () => {
autoRefreshDaemon.registerCredential("test-provider", "cookie-value-123");
const status = autoRefreshDaemon.getStatus();
assert.equal(status.checkedProviderCount, 1);
});
it("should unregister and stop tracking", () => {
autoRefreshDaemon.registerCredential("test-provider", "cookie-value-123");
assert.equal(autoRefreshDaemon.getStatus().checkedProviderCount, 1);
autoRefreshDaemon.unregisterCredential("test-provider");
assert.equal(autoRefreshDaemon.getStatus().checkedProviderCount, 0);
});
});
describe("clearExpired", () => {
it("should clear expired list", () => {
// Force-expire by registering and running check against a provider that
// won't resolve — expired list stays empty since network errors are non-fatal
autoRefreshDaemon.registerCredential("fake-nonexistent", "test-value");
assert.equal(autoRefreshDaemon.getStatus().expiredCredentials.length, 0);
autoRefreshDaemon.clearExpired();
assert.equal(autoRefreshDaemon.getStatus().expiredCredentials.length, 0);
});
});
describe("getStatus", () => {
it("should return current daemon state", () => {
autoRefreshDaemon.registerCredential("provider-a", "val-a");
autoRefreshDaemon.start();
const status = autoRefreshDaemon.getStatus();
assert.ok(typeof status.running === "boolean");
assert.ok(typeof status.checkedProviderCount === "number");
assert.ok(Array.isArray(status.expiredCredentials));
assert.ok(typeof status.lastRun === "number" || status.lastRun === null);
autoRefreshDaemon.stop();
});
});
});

View File

@@ -0,0 +1,38 @@
/**
* Security regression (#3292): POST /api/providers/[id]/login launches a headful
* Playwright Chromium (a child process) to drive a web-cookie login. It MUST be
* classified as LOCAL_ONLY so loopback enforcement runs unconditionally before
* any auth check — a leaked JWT over a Cloudflared/Ngrok tunnel cannot trigger a
* browser spawn. Hard Rules #15 + #17. See docs/security/ROUTE_GUARD_TIERS.md.
*
* The login segment sits AFTER the dynamic `[id]` param, so it is matched by a
* regex in LOCAL_ONLY_API_PATTERNS rather than a flat prefix — classifying the
* whole `/api/providers/` subtree as LOCAL_ONLY would wrongly lock the remote
* dashboard out of ordinary provider CRUD. These tests pin BOTH the gate AND the
* narrowness (no over-match).
*/
import test from "node:test";
import assert from "node:assert/strict";
import { isLocalOnlyPath } from "../../src/server/authz/routeGuard.ts";
test("/api/providers/[id]/login is LOCAL_ONLY (spawns Playwright Chromium)", () => {
assert.equal(isLocalOnlyPath("/api/providers/claude-web/login"), true);
assert.equal(isLocalOnlyPath("/api/providers/abc-123/login"), true);
});
test("/api/providers/[id]/login with a trailing slash is LOCAL_ONLY", () => {
assert.equal(isLocalOnlyPath("/api/providers/claude-web/login/"), true);
});
test("the provider-login gate does NOT over-match the rest of /api/providers", () => {
// Ordinary provider management must stay remotely reachable.
assert.equal(isLocalOnlyPath("/api/providers"), false);
assert.equal(isLocalOnlyPath("/api/providers/"), false);
assert.equal(isLocalOnlyPath("/api/providers/claude-web"), false);
assert.equal(isLocalOnlyPath("/api/providers/claude-web/test"), false);
assert.equal(isLocalOnlyPath("/api/providers/claude-web/models"), false);
// Anchored: extra segments after /login are not the spawn route.
assert.equal(isLocalOnlyPath("/api/providers/claude-web/login/extra"), false);
// "login" must be its own segment, not a substring of the id.
assert.equal(isLocalOnlyPath("/api/providers/login-helper/status"), false);
});

View File

@@ -0,0 +1,158 @@
/**
* Tests for open-sse/services/tokenExtractionConfig.ts
*
* Validates that all web-cookie provider configs are well-formed,
* have valid login URLs, and include at least one extraction source.
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
const {
TOKEN_EXTRACTION_CONFIGS,
getExtractionConfig,
listExtractionConfigs,
} = await import("../../open-sse/services/tokenExtractionConfig.ts");
describe("tokenExtractionConfig", () => {
it("exports TOKEN_EXTRACTION_CONFIGS as a Map", () => {
assert.ok(TOKEN_EXTRACTION_CONFIGS instanceof Map);
});
it("has at least 21 registered providers (18 existing + 3 new)", () => {
assert.ok(TOKEN_EXTRACTION_CONFIGS.size >= 21);
});
it("every config has required fields", () => {
for (const [providerId, cfg] of TOKEN_EXTRACTION_CONFIGS) {
assert.ok(typeof cfg.providerId === "string", `${providerId}: missing providerId`);
assert.ok(cfg.providerId.length > 0, `${providerId}: empty providerId`);
assert.ok(typeof cfg.displayName === "string", `${providerId}: missing displayName`);
assert.ok(cfg.displayName.length > 0, `${providerId}: empty displayName`);
assert.ok(
cfg.loginUrl.startsWith("http"),
`${providerId}: loginUrl "${cfg.loginUrl}" must start with http`
);
assert.ok(
cfg.homeUrl.startsWith("http"),
`${providerId}: homeUrl "${cfg.homeUrl}" must start with http`
);
assert.ok(Array.isArray(cfg.tokenSources), `${providerId}: tokenSources must be an array`);
assert.ok(cfg.tokenSources.length > 0, `${providerId}: must have at least one tokenSource`);
assert.ok(typeof cfg.instructions === "string", `${providerId}: missing instructions`);
assert.ok(cfg.instructions.length > 0, `${providerId}: empty instructions`);
}
});
it("every tokenSource has a valid type", () => {
const validTypes = ["cookie", "localStorage", "sessionStorage", "header"];
for (const [providerId, cfg] of TOKEN_EXTRACTION_CONFIGS) {
for (const src of cfg.tokenSources) {
assert.ok(
validTypes.includes(src.type),
`${providerId}: invalid tokenSource type "${src.type}"`
);
if (src.type === "cookie") {
assert.ok(typeof src.name === "string", `${providerId}: cookie source missing name`);
assert.ok(src.name.length > 0, `${providerId}: cookie source has empty name`);
}
if (src.type === "localStorage" || src.type === "sessionStorage") {
assert.ok(typeof src.key === "string", `${providerId}: storage source missing key`);
assert.ok(src.key.length > 0, `${providerId}: storage source has empty key`);
}
}
}
});
it("loginUrl and homeUrl share the same root domain", () => {
function extractDomain(url: string): string {
try {
const u = new URL(url);
return u.hostname;
} catch {
return "";
}
}
for (const [providerId, cfg] of TOKEN_EXTRACTION_CONFIGS) {
const loginDomain = extractDomain(cfg.loginUrl);
const homeDomain = extractDomain(cfg.homeUrl);
// Allow different subdomains but same root
const loginParts = loginDomain.split(".");
const homeParts = homeDomain.split(".");
const loginRoot = loginParts.slice(-2).join(".");
const homeRoot = homeParts.slice(-2).join(".");
assert.equal(
loginRoot,
homeRoot,
`${providerId}: loginUrl (${cfg.loginUrl}) and homeUrl (${cfg.homeUrl}) should share the same root domain`
);
}
});
it("getExtractionConfig returns undefined for unknown provider", () => {
const result = getExtractionConfig("nonexistent-provider");
assert.equal(result, undefined);
});
it("getExtractionConfig returns config for known providers", () => {
const providers = ["claude-web", "chatgpt-web", "gemini-web", "grok-web", "deepseek-web"];
for (const id of providers) {
const cfg = getExtractionConfig(id);
assert.ok(cfg !== undefined, `getExtractionConfig("${id}") returned undefined`);
assert.equal(cfg?.providerId, id);
}
});
it("listExtractionConfigs returns all configs as an array", () => {
const all = listExtractionConfigs();
assert.ok(Array.isArray(all));
assert.equal(all.length, TOKEN_EXTRACTION_CONFIGS.size);
});
it("includes the 3 new missing providers", () => {
const newProviders = ["chatglm-web", "xiaomimimo-web", "manus-web"];
for (const id of newProviders) {
const cfg = getExtractionConfig(id);
assert.ok(cfg !== undefined, `Missing provider "${id}" not found in config`);
}
});
it("every provider ID matches the executor naming convention", () => {
for (const providerId of TOKEN_EXTRACTION_CONFIGS.keys()) {
assert.ok(
providerId.endsWith("-web"),
`Provider ID "${providerId}" should follow the "-web" naming convention`
);
}
});
it("each cookie token source has a valid domain when specified", () => {
for (const [providerId, cfg] of TOKEN_EXTRACTION_CONFIGS) {
for (const src of cfg.tokenSources) {
if (src.type === "cookie" && src.domain) {
assert.ok(
src.domain.startsWith(".") || src.domain.startsWith("http"),
`${providerId}: cookie domain "${src.domain}" should start with "." or "http"`
);
}
}
}
});
it("pollingConfig has valid values", () => {
for (const [providerId, cfg] of TOKEN_EXTRACTION_CONFIGS) {
assert.ok(
cfg.pollingConfig.pollInterval >= 100,
`${providerId}: pollInterval too low (${cfg.pollingConfig.pollInterval})`
);
assert.ok(
cfg.pollingConfig.timeout >= 10000,
`${providerId}: timeout too low (${cfg.pollingConfig.timeout})`
);
assert.ok(
cfg.pollingConfig.minLoginTime >= 1000,
`${providerId}: minLoginTime too low (${cfg.pollingConfig.minLoginTime})`
);
}
});
});