chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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() };
|
||||
Reference in New Issue
Block a user