mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(web-cookie): self-service login infrastructure + auto-refresh daemon (#3292)
Integrated into release/v3.8.13
This commit is contained in:
386
electron/loginManager.js
Normal file
386
electron/loginManager.js
Normal 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() };
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
|
||||
204
open-sse/services/autoRefreshDaemon.ts
Normal file
204
open-sse/services/autoRefreshDaemon.ts
Normal 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();
|
||||
256
open-sse/services/inAppLoginService.ts
Normal file
256
open-sse/services/inAppLoginService.ts
Normal 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();
|
||||
411
open-sse/services/tokenExtractionConfig.ts
Normal file
411
open-sse/services/tokenExtractionConfig.ts
Normal 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;
|
||||
@@ -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(
|
||||
|
||||
75
src/app/api/providers/[id]/login/route.ts
Normal file
75
src/app/api/providers/[id]/login/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
87
tests/unit/autoRefreshDaemon.test.ts
Normal file
87
tests/unit/autoRefreshDaemon.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
38
tests/unit/route-guard-provider-login-local-only.test.ts
Normal file
38
tests/unit/route-guard-provider-login-local-only.test.ts
Normal 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);
|
||||
});
|
||||
158
tests/unit/tokenExtractionConfig.test.ts
Normal file
158
tests/unit/tokenExtractionConfig.test.ts
Normal 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})`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user