From 264b6baf318af13a59759bccd62a8cd5bd342df4 Mon Sep 17 00:00:00 2001 From: Paijo <14921983+oyi77@users.noreply.github.com> Date: Sat, 6 Jun 2026 22:27:35 +0700 Subject: [PATCH] feat(web-cookie): self-service login infrastructure + auto-refresh daemon (#3292) Integrated into release/v3.8.13 --- electron/loginManager.js | 386 ++++++++++++++++ electron/main.js | 43 ++ electron/preload.js | 11 +- open-sse/services/autoRefreshDaemon.ts | 204 +++++++++ open-sse/services/inAppLoginService.ts | 256 +++++++++++ open-sse/services/tokenExtractionConfig.ts | 411 ++++++++++++++++++ .../providers/[id]/webSessionCredentials.ts | 30 ++ src/app/api/providers/[id]/login/route.ts | 75 ++++ src/instrumentation-node.ts | 8 + src/server/authz/routeGuard.ts | 21 +- tests/unit/autoRefreshDaemon.test.ts | 87 ++++ ...te-guard-provider-login-local-only.test.ts | 38 ++ tests/unit/tokenExtractionConfig.test.ts | 158 +++++++ 13 files changed, 1726 insertions(+), 2 deletions(-) create mode 100644 electron/loginManager.js create mode 100644 open-sse/services/autoRefreshDaemon.ts create mode 100644 open-sse/services/inAppLoginService.ts create mode 100644 open-sse/services/tokenExtractionConfig.ts create mode 100644 src/app/api/providers/[id]/login/route.ts create mode 100644 tests/unit/autoRefreshDaemon.test.ts create mode 100644 tests/unit/route-guard-provider-login-local-only.test.ts create mode 100644 tests/unit/tokenExtractionConfig.test.ts diff --git a/electron/loginManager.js b/electron/loginManager.js new file mode 100644 index 0000000000..0e0bb2d2f1 --- /dev/null +++ b/electron/loginManager.js @@ -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, 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() }; diff --git a/electron/main.js b/electron/main.js index ddcaa25bb6..ee0e22a783 100644 --- a/electron/main.js +++ b/electron/main.js @@ -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") { diff --git a/electron/preload.js b/electron/preload.js index 1979c2ef79..0eabaa2748 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -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, diff --git a/open-sse/services/autoRefreshDaemon.ts b/open-sse/services/autoRefreshDaemon.ts new file mode 100644 index 0000000000..29c22a4a14 --- /dev/null +++ b/open-sse/services/autoRefreshDaemon.ts @@ -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 | 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(); + + 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 { + 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 { + 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(); diff --git a/open-sse/services/inAppLoginService.ts b/open-sse/services/inAppLoginService.ts new file mode 100644 index 0000000000..0a71b99929 --- /dev/null +++ b/open-sse/services/inAppLoginService.ts @@ -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; + 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 { + 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 { + 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 = {}; + 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 { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// ─── Singleton ────────────────────────────────────────────────────────────── + +export const inAppLoginService = new InAppLoginService(); diff --git a/open-sse/services/tokenExtractionConfig.ts b/open-sse/services/tokenExtractionConfig.ts new file mode 100644 index 0000000000..d9f6f0a86c --- /dev/null +++ b/open-sse/services/tokenExtractionConfig.ts @@ -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; + 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(); + +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; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/webSessionCredentials.ts b/src/app/(dashboard)/dashboard/providers/[id]/webSessionCredentials.ts index e45b580e54..bdd8a9cf14 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/webSessionCredentials.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/webSessionCredentials.ts @@ -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; export function getWebSessionCredentialRequirement( diff --git a/src/app/api/providers/[id]/login/route.ts b/src/app/api/providers/[id]/login/route.ts new file mode 100644 index 0000000000..ccc0719622 --- /dev/null +++ b/src/app/api/providers/[id]/login/route.ts @@ -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 { + 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 } + ); + } +} diff --git a/src/instrumentation-node.ts b/src/instrumentation-node.ts index 7e96e9ab93..965b857a8c 100755 --- a/src/instrumentation-node.ts +++ b/src/instrumentation-node.ts @@ -237,5 +237,13 @@ export async function registerNodejs(): Promise { 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); + } } } diff --git a/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts index 5bbf92930b..f97d7a3f97 100644 --- a/src/server/authz/routeGuard.ts +++ b/src/server/authz/routeGuard.ts @@ -37,6 +37,22 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray = [ "/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 = [ + /^\/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)) + ); } /** diff --git a/tests/unit/autoRefreshDaemon.test.ts b/tests/unit/autoRefreshDaemon.test.ts new file mode 100644 index 0000000000..3ea6cd2a48 --- /dev/null +++ b/tests/unit/autoRefreshDaemon.test.ts @@ -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(); + }); + }); +}); diff --git a/tests/unit/route-guard-provider-login-local-only.test.ts b/tests/unit/route-guard-provider-login-local-only.test.ts new file mode 100644 index 0000000000..c935292c7e --- /dev/null +++ b/tests/unit/route-guard-provider-login-local-only.test.ts @@ -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); +}); diff --git a/tests/unit/tokenExtractionConfig.test.ts b/tests/unit/tokenExtractionConfig.test.ts new file mode 100644 index 0000000000..30443983ee --- /dev/null +++ b/tests/unit/tokenExtractionConfig.test.ts @@ -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})` + ); + } + }); +});