refactor(cookies): use sweet-cookie
This commit is contained in:
@@ -87,7 +87,7 @@ Write operations:
|
|||||||
|
|
||||||
1. CLI flags: `--auth-token`, `--ct0`
|
1. CLI flags: `--auth-token`, `--ct0`
|
||||||
2. Environment variables: `AUTH_TOKEN`, `CT0` (fallback: `TWITTER_AUTH_TOKEN`, `TWITTER_CT0`)
|
2. Environment variables: `AUTH_TOKEN`, `CT0` (fallback: `TWITTER_AUTH_TOKEN`, `TWITTER_CT0`)
|
||||||
3. Browser cookies (macOS): Safari, Chrome, Firefox (override via `--cookie-source` order)
|
3. Browser cookies via `@steipete/sweet-cookie` (override via `--cookie-source` order)
|
||||||
|
|
||||||
Browser cookie sources:
|
Browser cookie sources:
|
||||||
- Safari: `~/Library/Cookies/Cookies.binarycookies` (fallback: `~/Library/Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies`)
|
- Safari: `~/Library/Cookies/Cookies.binarycookies` (fallback: `~/Library/Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies`)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"graphql:update": "tsx scripts/update-query-ids.ts"
|
"graphql:update": "tsx scripts/update-query-ids.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@steipete/sweet-cookie": "file:../sweet-cookie/packages/core",
|
||||||
"commander": "^14.0.2",
|
"commander": "^14.0.2",
|
||||||
"json5": "^2.2.3",
|
"json5": "^2.2.3",
|
||||||
"kleur": "^4.1.5"
|
"kleur": "^4.1.5"
|
||||||
|
|||||||
Generated
+15
-1
@@ -1,13 +1,16 @@
|
|||||||
lockfileVersion: '9.0'
|
lockfileVersion: '9.0'
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
autoInstallPeers: true
|
autoInstallPeers: false
|
||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@steipete/sweet-cookie':
|
||||||
|
specifier: file:../sweet-cookie/packages/core
|
||||||
|
version: file:../sweet-cookie/packages/core
|
||||||
commander:
|
commander:
|
||||||
specifier: ^14.0.2
|
specifier: ^14.0.2
|
||||||
version: 14.0.2
|
version: 14.0.2
|
||||||
@@ -392,6 +395,15 @@ packages:
|
|||||||
'@standard-schema/[email protected]':
|
'@standard-schema/[email protected]':
|
||||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||||
|
|
||||||
|
'@steipete/sweet-cookie@file:../sweet-cookie/packages/core':
|
||||||
|
resolution: {directory: ../sweet-cookie/packages/core, type: directory}
|
||||||
|
engines: {node: '>=22'}
|
||||||
|
peerDependencies:
|
||||||
|
chrome-cookies-secure: ^3.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
chrome-cookies-secure:
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||||
|
|
||||||
@@ -912,6 +924,8 @@ snapshots:
|
|||||||
|
|
||||||
'@standard-schema/[email protected]': {}
|
'@standard-schema/[email protected]': {}
|
||||||
|
|
||||||
|
'@steipete/sweet-cookie@file:../sweet-cookie/packages/core': {}
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/deep-eql': 4.0.2
|
'@types/deep-eql': 4.0.2
|
||||||
|
|||||||
+108
-497
@@ -1,13 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Browser cookie extraction for Twitter authentication
|
* Browser cookie extraction for Twitter authentication.
|
||||||
* Uses sqlite3 CLI where possible and binarycookies parsing for Safari.
|
* Delegates to @steipete/sweet-cookie for Safari/Chrome/Firefox reads.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync } from 'node:child_process';
|
import { getCookies } from '@steipete/sweet-cookie';
|
||||||
import { createDecipheriv, pbkdf2Sync } from 'node:crypto';
|
|
||||||
import { copyFileSync, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
|
|
||||||
export interface TwitterCookies {
|
export interface TwitterCookies {
|
||||||
authToken: string | null;
|
authToken: string | null;
|
||||||
@@ -23,457 +19,126 @@ export interface CookieExtractionResult {
|
|||||||
|
|
||||||
export type CookieSource = 'safari' | 'chrome' | 'firefox';
|
export type CookieSource = 'safari' | 'chrome' | 'firefox';
|
||||||
|
|
||||||
|
const TWITTER_COOKIE_NAMES = ['auth_token', 'ct0'] as const;
|
||||||
|
const TWITTER_URL = 'https://x.com/';
|
||||||
|
const TWITTER_ORIGINS = ['https://x.com/', 'https://twitter.com/'];
|
||||||
|
|
||||||
function normalizeValue(value: unknown): string | null {
|
function normalizeValue(value: unknown): string | null {
|
||||||
if (typeof value === 'string') {
|
if (typeof value !== 'string') return null;
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
return trimmed.length > 0 ? trimmed : null;
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getChromeCookiesPath(profile?: string): string {
|
function cookieHeader(authToken: string, ct0: string): string {
|
||||||
const home = process.env.HOME || '';
|
return `auth_token=${authToken}; ct0=${ct0}`;
|
||||||
const profileDir = profile || 'Default';
|
|
||||||
return join(home, 'Library', 'Application Support', 'Google', 'Chrome', profileDir, 'Cookies');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFirefoxProfilesRoot(): string | null {
|
function buildEmpty(): TwitterCookies {
|
||||||
const home = process.env.HOME || '';
|
return { authToken: null, ct0: null, cookieHeader: null, source: null };
|
||||||
if (process.platform === 'darwin') {
|
|
||||||
return join(home, 'Library', 'Application Support', 'Firefox', 'Profiles');
|
|
||||||
}
|
|
||||||
if (process.platform === 'linux') {
|
|
||||||
return join(home, '.mozilla', 'firefox');
|
|
||||||
}
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
const appData = process.env.APPDATA;
|
|
||||||
if (!appData) return null;
|
|
||||||
return join(appData, 'Mozilla', 'Firefox', 'Profiles');
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickFirefoxProfile(profilesRoot: string, profile?: string): string | null {
|
function readEnvCookie(cookies: TwitterCookies, keys: readonly string[], field: 'authToken' | 'ct0'): void {
|
||||||
if (profile) {
|
if (cookies[field]) return;
|
||||||
const candidate = join(profilesRoot, profile, 'cookies.sqlite');
|
for (const key of keys) {
|
||||||
return existsSync(candidate) ? candidate : null;
|
const value = normalizeValue(process.env[key]);
|
||||||
}
|
if (!value) continue;
|
||||||
|
cookies[field] = value;
|
||||||
const entries = readdirSync(profilesRoot, { withFileTypes: true });
|
if (!cookies.source) cookies.source = `env ${key}`;
|
||||||
const defaultRelease = entries.find((entry) => entry.isDirectory() && entry.name.includes('default-release'));
|
break;
|
||||||
const targetDir = defaultRelease?.name ?? entries.find((e) => e.isDirectory())?.name;
|
|
||||||
if (!targetDir) return null;
|
|
||||||
|
|
||||||
const candidate = join(profilesRoot, targetDir, 'cookies.sqlite');
|
|
||||||
return existsSync(candidate) ? candidate : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFirefoxCookiesPath(profile?: string): string | null {
|
|
||||||
const profilesRoot = getFirefoxProfilesRoot();
|
|
||||||
if (!profilesRoot || !existsSync(profilesRoot)) return null;
|
|
||||||
return pickFirefoxProfile(profilesRoot, profile);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSafariCookiesPath(): string | null {
|
|
||||||
if (process.platform !== 'darwin') return null;
|
|
||||||
const home = process.env.HOME || '';
|
|
||||||
const candidates = [
|
|
||||||
join(home, 'Library', 'Cookies', 'Cookies.binarycookies'),
|
|
||||||
join(home, 'Library', 'Containers', 'com.apple.Safari', 'Data', 'Library', 'Cookies', 'Cookies.binarycookies'),
|
|
||||||
];
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (existsSync(candidate)) return candidate;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SAFARI_COOKIE_DOMAINS = ['x.com', 'twitter.com'];
|
|
||||||
const SAFARI_PAGE_SIGNATURE = Buffer.from([0x00, 0x00, 0x01, 0x00]);
|
|
||||||
|
|
||||||
function matchesSafariDomain(domain: string | null): boolean {
|
|
||||||
if (!domain) return false;
|
|
||||||
const normalized = (domain.startsWith('.') ? domain.slice(1) : domain).toLowerCase();
|
|
||||||
return SAFARI_COOKIE_DOMAINS.some((target) => normalized === target || normalized.endsWith(`.${target}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
function readSafariCString(buffer: Buffer, start: number, end: number): string | null {
|
|
||||||
if (start < 0 || start >= end) return null;
|
|
||||||
let cursor = start;
|
|
||||||
while (cursor < end && buffer[cursor] !== 0) cursor += 1;
|
|
||||||
if (cursor >= end) return null;
|
|
||||||
return buffer.toString('utf8', start, cursor);
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeCookieJar(jar: Record<string, string>): string {
|
|
||||||
const entries = Object.entries(jar)
|
|
||||||
.filter(([, value]) => typeof value === 'string' && value.length > 0)
|
|
||||||
.sort(([a], [b]) => a.localeCompare(b));
|
|
||||||
return entries.map(([name, value]) => `${name}=${value}`).join('; ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSafariCookieRecord(
|
|
||||||
page: Buffer,
|
|
||||||
offset: number,
|
|
||||||
jar: Record<string, string>,
|
|
||||||
cookies: TwitterCookies,
|
|
||||||
): void {
|
|
||||||
if (offset < 0 || offset + 4 > page.length) return;
|
|
||||||
const recordSize = page.readUInt32LE(offset);
|
|
||||||
const recordEnd = offset + recordSize;
|
|
||||||
if (recordSize <= 0 || recordEnd > page.length) return;
|
|
||||||
|
|
||||||
const headerStart = offset + 4;
|
|
||||||
const headerSize = 4 + 4 + 4 + 4 + 4 + 4 + 4; // unknown1 + flags + unknown2 + domain + name + path + value
|
|
||||||
if (headerStart + headerSize > recordEnd) return;
|
|
||||||
|
|
||||||
const domainOffset = page.readUInt32LE(headerStart + 12);
|
|
||||||
const nameOffset = page.readUInt32LE(headerStart + 16);
|
|
||||||
const valueOffset = page.readUInt32LE(headerStart + 24);
|
|
||||||
|
|
||||||
const domain = readSafariCString(page, offset + domainOffset, recordEnd);
|
|
||||||
const name = readSafariCString(page, offset + nameOffset, recordEnd);
|
|
||||||
const value = readSafariCString(page, offset + valueOffset, recordEnd);
|
|
||||||
|
|
||||||
if (!name || !value || !matchesSafariDomain(domain)) return;
|
|
||||||
|
|
||||||
const normalizedValue = normalizeValue(value);
|
|
||||||
if (!normalizedValue) return;
|
|
||||||
|
|
||||||
jar[name] = normalizedValue;
|
|
||||||
|
|
||||||
if (name === 'auth_token' && !cookies.authToken) {
|
|
||||||
cookies.authToken = normalizedValue;
|
|
||||||
} else if (name === 'ct0' && !cookies.ct0) {
|
|
||||||
cookies.ct0 = normalizedValue;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSafariCookiePage(page: Buffer, jar: Record<string, string>, cookies: TwitterCookies): void {
|
function resolveSources(cookieSource?: CookieSource | CookieSource[]): CookieSource[] {
|
||||||
if (page.length < 12) return;
|
if (Array.isArray(cookieSource)) return cookieSource;
|
||||||
if (!page.subarray(0, 4).equals(SAFARI_PAGE_SIGNATURE)) return;
|
if (cookieSource) return [cookieSource];
|
||||||
const cookieCount = page.readUInt32LE(4);
|
return ['safari', 'chrome', 'firefox'];
|
||||||
if (!cookieCount) return;
|
|
||||||
|
|
||||||
const offsets: number[] = [];
|
|
||||||
let cursor = 8;
|
|
||||||
for (let i = 0; i < cookieCount; i += 1) {
|
|
||||||
if (cursor + 4 > page.length) return;
|
|
||||||
offsets.push(page.readUInt32LE(cursor));
|
|
||||||
cursor += 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const offset of offsets) {
|
|
||||||
parseSafariCookieRecord(page, offset, jar, cookies);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSafariCookies(data: Buffer, jar: Record<string, string>, cookies: TwitterCookies): void {
|
function labelForSource(source: CookieSource, profile?: string): string {
|
||||||
if (data.length < 8) return;
|
if (source === 'safari') return 'Safari';
|
||||||
if (data.subarray(0, 4).toString('utf8') !== 'cook') return;
|
if (source === 'chrome') {
|
||||||
const pageCount = data.readUInt32BE(4);
|
return profile ? `Chrome profile "${profile}"` : 'Chrome default profile';
|
||||||
let cursor = 8;
|
|
||||||
const pageSizes: number[] = [];
|
|
||||||
for (let i = 0; i < pageCount; i += 1) {
|
|
||||||
if (cursor + 4 > data.length) return;
|
|
||||||
pageSizes.push(data.readUInt32BE(cursor));
|
|
||||||
cursor += 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const pageSize of pageSizes) {
|
|
||||||
if (cursor + pageSize > data.length) return;
|
|
||||||
const page = data.subarray(cursor, cursor + pageSize);
|
|
||||||
parseSafariCookiePage(page, jar, cookies);
|
|
||||||
cursor += pageSize;
|
|
||||||
}
|
}
|
||||||
|
return profile ? `Firefox profile "${profile}"` : 'Firefox default profile';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function pickCookieValue(
|
||||||
* Extract Twitter cookies from Safari browser using Cookies.binarycookies
|
cookies: Array<{ name?: string; value?: string; domain?: string }>,
|
||||||
*/
|
name: (typeof TWITTER_COOKIE_NAMES)[number],
|
||||||
export async function extractCookiesFromSafari(): Promise<CookieExtractionResult> {
|
): string | null {
|
||||||
|
const matches = cookies.filter((c) => c?.name === name && typeof c.value === 'string');
|
||||||
|
if (matches.length === 0) return null;
|
||||||
|
|
||||||
|
const preferred = matches.find((c) => (c.domain ?? '').endsWith('x.com'));
|
||||||
|
if (preferred?.value) return preferred.value;
|
||||||
|
|
||||||
|
const twitter = matches.find((c) => (c.domain ?? '').endsWith('twitter.com'));
|
||||||
|
if (twitter?.value) return twitter.value;
|
||||||
|
|
||||||
|
return matches[0]?.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readTwitterCookiesFromBrowser(options: {
|
||||||
|
source: CookieSource;
|
||||||
|
chromeProfile?: string;
|
||||||
|
firefoxProfile?: string;
|
||||||
|
}): Promise<CookieExtractionResult> {
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const cookies: TwitterCookies = {
|
const out = buildEmpty();
|
||||||
authToken: null,
|
|
||||||
ct0: null,
|
|
||||||
cookieHeader: null,
|
|
||||||
source: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const cookiesPath = getSafariCookiesPath();
|
const { cookies, warnings: providerWarnings } = await getCookies({
|
||||||
if (!cookiesPath) {
|
url: TWITTER_URL,
|
||||||
warnings.push('Safari cookies database not found.');
|
origins: TWITTER_ORIGINS,
|
||||||
return { cookies, warnings };
|
names: [...TWITTER_COOKIE_NAMES],
|
||||||
|
browsers: [options.source],
|
||||||
|
mode: 'merge',
|
||||||
|
chromeProfile: options.chromeProfile,
|
||||||
|
firefoxProfile: options.firefoxProfile,
|
||||||
|
});
|
||||||
|
warnings.push(...providerWarnings);
|
||||||
|
|
||||||
|
const authToken = pickCookieValue(cookies, 'auth_token');
|
||||||
|
const ct0 = pickCookieValue(cookies, 'ct0');
|
||||||
|
if (authToken) out.authToken = authToken;
|
||||||
|
if (ct0) out.ct0 = ct0;
|
||||||
|
|
||||||
|
if (out.authToken && out.ct0) {
|
||||||
|
out.cookieHeader = cookieHeader(out.authToken, out.ct0);
|
||||||
|
out.source = labelForSource(
|
||||||
|
options.source,
|
||||||
|
options.source === 'chrome' ? options.chromeProfile : options.firefoxProfile,
|
||||||
|
);
|
||||||
|
return { cookies: out, warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
let tempDir: string | null = null;
|
if (options.source === 'safari') {
|
||||||
|
|
||||||
try {
|
|
||||||
const jar: Record<string, string> = {};
|
|
||||||
tempDir = mkdtempSync(join(tmpdir(), 'twitter-cli-'));
|
|
||||||
const tempCookiesPath = join(tempDir, 'Cookies.binarycookies');
|
|
||||||
copyFileSync(cookiesPath, tempCookiesPath);
|
|
||||||
const data = readFileSync(tempCookiesPath);
|
|
||||||
parseSafariCookies(data, jar, cookies);
|
|
||||||
if (Object.keys(jar).length > 0) {
|
|
||||||
cookies.cookieHeader = serializeCookieJar(jar);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cookies.authToken || cookies.ct0) {
|
|
||||||
cookies.source = 'Safari';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
warnings.push(`Failed to read Safari cookies: ${message}`);
|
|
||||||
} finally {
|
|
||||||
if (tempDir) {
|
|
||||||
try {
|
|
||||||
rmSync(tempDir, { recursive: true, force: true });
|
|
||||||
} catch {
|
|
||||||
// ignore cleanup errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cookies.authToken && !cookies.ct0) {
|
|
||||||
warnings.push('No Twitter cookies found in Safari. Make sure you are logged into x.com in Safari.');
|
warnings.push('No Twitter cookies found in Safari. Make sure you are logged into x.com in Safari.');
|
||||||
}
|
} else if (options.source === 'chrome') {
|
||||||
|
|
||||||
return { cookies, warnings };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decrypt Chrome cookie value using macOS keychain
|
|
||||||
* Chrome encrypts cookies with a key stored in the keychain
|
|
||||||
*/
|
|
||||||
function decryptCookieValue(encryptedHex: string): string | null {
|
|
||||||
try {
|
|
||||||
// Convert hex to buffer
|
|
||||||
const encryptedValue = Buffer.from(encryptedHex, 'hex');
|
|
||||||
|
|
||||||
if (encryptedValue.length < 4) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const version = encryptedValue.subarray(0, 3).toString('utf8');
|
|
||||||
if (version !== 'v10' && version !== 'v11') {
|
|
||||||
// Not encrypted, just return as string
|
|
||||||
return encryptedValue.toString('utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get encryption key from keychain
|
|
||||||
const keyOutput = execSync('security find-generic-password -s "Chrome Safe Storage" -w 2>/dev/null || echo ""', {
|
|
||||||
encoding: 'utf8',
|
|
||||||
}).trim();
|
|
||||||
|
|
||||||
if (!keyOutput) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive the key using PBKDF2
|
|
||||||
const salt = 'saltysalt';
|
|
||||||
const iterations = 1003;
|
|
||||||
const keyLength = 16;
|
|
||||||
|
|
||||||
const derivedKey = pbkdf2Sync(keyOutput, salt, iterations, keyLength, 'sha1');
|
|
||||||
|
|
||||||
// Decrypt using AES-128-CBC with empty IV (16 bytes of 0x20/space)
|
|
||||||
const iv = Buffer.alloc(16, 0x20);
|
|
||||||
const encryptedData = encryptedValue.subarray(3); // Skip "v10" or "v11" prefix
|
|
||||||
|
|
||||||
const decipher = createDecipheriv('aes-128-cbc', derivedKey, iv);
|
|
||||||
decipher.setAutoPadding(true);
|
|
||||||
|
|
||||||
let decrypted = decipher.update(encryptedData);
|
|
||||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
|
||||||
|
|
||||||
// Chrome v10 cookies have key material prepended before the actual value
|
|
||||||
// Twitter cookies are hex strings, so extract the longest hex sequence
|
|
||||||
const decryptedStr = decrypted.toString('utf8');
|
|
||||||
const hexMatch = decryptedStr.match(/[a-f0-9]{32,}/i);
|
|
||||||
if (hexMatch) {
|
|
||||||
return hexMatch[0];
|
|
||||||
}
|
|
||||||
// Fallback: keep only printable ASCII characters
|
|
||||||
return decryptedStr.replace(/[^\x20-\x7E]/g, '');
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract Twitter cookies from Chrome browser using sqlite3 CLI
|
|
||||||
* @param profile - Chrome profile name (optional, uses Default if not specified)
|
|
||||||
*/
|
|
||||||
export async function extractCookiesFromChrome(profile?: string): Promise<CookieExtractionResult> {
|
|
||||||
const warnings: string[] = [];
|
|
||||||
const cookies: TwitterCookies = {
|
|
||||||
authToken: null,
|
|
||||||
ct0: null,
|
|
||||||
cookieHeader: null,
|
|
||||||
source: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const cookiesPath = getChromeCookiesPath(profile);
|
|
||||||
|
|
||||||
if (!existsSync(cookiesPath)) {
|
|
||||||
warnings.push(`Chrome cookies database not found at: ${cookiesPath}`);
|
|
||||||
return { cookies, warnings };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chrome locks the database, so we need to copy it
|
|
||||||
let tempDir: string | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
tempDir = mkdtempSync(join(tmpdir(), 'twitter-cli-'));
|
|
||||||
const tempDbPath = join(tempDir, 'Cookies');
|
|
||||||
copyFileSync(cookiesPath, tempDbPath);
|
|
||||||
|
|
||||||
// Also copy the WAL and SHM files if they exist
|
|
||||||
const walPath = `${cookiesPath}-wal`;
|
|
||||||
const shmPath = `${cookiesPath}-shm`;
|
|
||||||
if (existsSync(walPath)) {
|
|
||||||
copyFileSync(walPath, `${tempDbPath}-wal`);
|
|
||||||
}
|
|
||||||
if (existsSync(shmPath)) {
|
|
||||||
copyFileSync(shmPath, `${tempDbPath}-shm`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const jar: Record<string, string> = {};
|
|
||||||
|
|
||||||
// Use sqlite3 CLI to query cookies (no native deps required!)
|
|
||||||
const query = `SELECT name, hex(encrypted_value) as encrypted_hex FROM cookies WHERE host_key IN ('.x.com', '.twitter.com', 'x.com', 'twitter.com');`;
|
|
||||||
|
|
||||||
const result = execSync(`sqlite3 -separator '|' "${tempDbPath}" "${query}"`, {
|
|
||||||
encoding: 'utf8',
|
|
||||||
maxBuffer: 1024 * 1024,
|
|
||||||
}).trim();
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
for (const line of result.split('\n')) {
|
|
||||||
const [name, encryptedHex] = line.split('|');
|
|
||||||
if (!name || !encryptedHex) continue;
|
|
||||||
|
|
||||||
const decryptedValue = decryptCookieValue(encryptedHex);
|
|
||||||
if (decryptedValue) {
|
|
||||||
jar[name] = decryptedValue;
|
|
||||||
if (name === 'auth_token' && !cookies.authToken) {
|
|
||||||
cookies.authToken = decryptedValue;
|
|
||||||
} else if (name === 'ct0' && !cookies.ct0) {
|
|
||||||
cookies.ct0 = decryptedValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(jar).length > 0) {
|
|
||||||
cookies.cookieHeader = serializeCookieJar(jar);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cookies.authToken || cookies.ct0) {
|
|
||||||
cookies.source = profile ? `Chrome profile "${profile}"` : 'Chrome default profile';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
warnings.push(`Failed to read Chrome cookies: ${message}`);
|
|
||||||
} finally {
|
|
||||||
// Cleanup temp files
|
|
||||||
if (tempDir) {
|
|
||||||
try {
|
|
||||||
rmSync(tempDir, { recursive: true, force: true });
|
|
||||||
} catch {
|
|
||||||
// Ignore cleanup errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cookies.authToken && !cookies.ct0) {
|
|
||||||
warnings.push('No Twitter cookies found in Chrome. Make sure you are logged into x.com in Chrome.');
|
warnings.push('No Twitter cookies found in Chrome. Make sure you are logged into x.com in Chrome.');
|
||||||
}
|
} else {
|
||||||
|
|
||||||
return { cookies, warnings };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract Twitter cookies from Firefox browser using sqlite3 CLI
|
|
||||||
* @param profile - Firefox profile directory name (optional, auto-detected)
|
|
||||||
*/
|
|
||||||
export async function extractCookiesFromFirefox(profile?: string): Promise<CookieExtractionResult> {
|
|
||||||
const warnings: string[] = [];
|
|
||||||
const cookies: TwitterCookies = {
|
|
||||||
authToken: null,
|
|
||||||
ct0: null,
|
|
||||||
cookieHeader: null,
|
|
||||||
source: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const cookiesPath = getFirefoxCookiesPath(profile);
|
|
||||||
|
|
||||||
if (!cookiesPath) {
|
|
||||||
warnings.push('Firefox cookies database not found.');
|
|
||||||
return { cookies, warnings };
|
|
||||||
}
|
|
||||||
|
|
||||||
let tempDir: string | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
tempDir = mkdtempSync(join(tmpdir(), 'twitter-cli-'));
|
|
||||||
const tempDbPath = join(tempDir, 'cookies.sqlite');
|
|
||||||
copyFileSync(cookiesPath, tempDbPath);
|
|
||||||
|
|
||||||
const jar: Record<string, string> = {};
|
|
||||||
|
|
||||||
const query = `SELECT name, value FROM moz_cookies WHERE host IN ('.x.com', '.twitter.com', 'x.com', 'twitter.com');`;
|
|
||||||
|
|
||||||
const result = execSync(`sqlite3 -separator '|' "${tempDbPath}" "${query}"`, {
|
|
||||||
encoding: 'utf8',
|
|
||||||
maxBuffer: 1024 * 1024,
|
|
||||||
}).trim();
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
for (const line of result.split('\n')) {
|
|
||||||
const [name, value] = line.split('|');
|
|
||||||
if (!name || !value) continue;
|
|
||||||
jar[name] = value;
|
|
||||||
if (name === 'auth_token' && !cookies.authToken) {
|
|
||||||
cookies.authToken = value;
|
|
||||||
} else if (name === 'ct0' && !cookies.ct0) {
|
|
||||||
cookies.ct0 = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(jar).length > 0) {
|
|
||||||
cookies.cookieHeader = serializeCookieJar(jar);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cookies.authToken || cookies.ct0) {
|
|
||||||
cookies.source = profile ? `Firefox profile "${profile}"` : 'Firefox default profile';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
warnings.push(`Failed to read Firefox cookies: ${message}`);
|
|
||||||
} finally {
|
|
||||||
if (tempDir) {
|
|
||||||
try {
|
|
||||||
rmSync(tempDir, { recursive: true, force: true });
|
|
||||||
} catch {
|
|
||||||
// ignore cleanup errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cookies.authToken && !cookies.ct0) {
|
|
||||||
warnings.push(
|
warnings.push(
|
||||||
'No Twitter cookies found in Firefox. Make sure you are logged into x.com in Firefox and the profile exists.',
|
'No Twitter cookies found in Firefox. Make sure you are logged into x.com in Firefox and the profile exists.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { cookies, warnings };
|
return { cookies: out, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractCookiesFromSafari(): Promise<CookieExtractionResult> {
|
||||||
|
return readTwitterCookiesFromBrowser({ source: 'safari' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractCookiesFromChrome(profile?: string): Promise<CookieExtractionResult> {
|
||||||
|
return readTwitterCookiesFromBrowser({ source: 'chrome', chromeProfile: profile });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractCookiesFromFirefox(profile?: string): Promise<CookieExtractionResult> {
|
||||||
|
return readTwitterCookiesFromBrowser({ source: 'firefox', firefoxProfile: profile });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve Twitter credentials from multiple sources
|
* Resolve Twitter credentials from multiple sources.
|
||||||
* Priority: CLI args > environment variables > Safari > Chrome > Firefox
|
* Priority: CLI args > environment variables > browsers (ordered).
|
||||||
*/
|
*/
|
||||||
export async function resolveCredentials(options: {
|
export async function resolveCredentials(options: {
|
||||||
authToken?: string;
|
authToken?: string;
|
||||||
@@ -483,16 +148,8 @@ export async function resolveCredentials(options: {
|
|||||||
firefoxProfile?: string;
|
firefoxProfile?: string;
|
||||||
}): Promise<CookieExtractionResult> {
|
}): Promise<CookieExtractionResult> {
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const cookies: TwitterCookies = {
|
const cookies = buildEmpty();
|
||||||
authToken: null,
|
|
||||||
ct0: null,
|
|
||||||
cookieHeader: null,
|
|
||||||
source: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const cookieSource = options.cookieSource;
|
|
||||||
|
|
||||||
// 1. CLI arguments (highest priority)
|
|
||||||
if (options.authToken) {
|
if (options.authToken) {
|
||||||
cookies.authToken = options.authToken;
|
cookies.authToken = options.authToken;
|
||||||
cookies.source = 'CLI argument';
|
cookies.source = 'CLI argument';
|
||||||
@@ -502,72 +159,27 @@ export async function resolveCredentials(options: {
|
|||||||
if (!cookies.source) cookies.source = 'CLI argument';
|
if (!cookies.source) cookies.source = 'CLI argument';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Environment variables
|
readEnvCookie(cookies, ['AUTH_TOKEN', 'TWITTER_AUTH_TOKEN'], 'authToken');
|
||||||
const envAuthKeys = ['AUTH_TOKEN', 'TWITTER_AUTH_TOKEN'];
|
readEnvCookie(cookies, ['CT0', 'TWITTER_CT0'], 'ct0');
|
||||||
const envCt0Keys = ['CT0', 'TWITTER_CT0'];
|
|
||||||
|
|
||||||
if (!cookies.authToken) {
|
|
||||||
for (const key of envAuthKeys) {
|
|
||||||
const value = normalizeValue(process.env[key]);
|
|
||||||
if (value) {
|
|
||||||
cookies.authToken = value;
|
|
||||||
cookies.source = `env ${key}`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cookies.ct0) {
|
|
||||||
for (const key of envCt0Keys) {
|
|
||||||
const value = normalizeValue(process.env[key]);
|
|
||||||
if (value) {
|
|
||||||
cookies.ct0 = value;
|
|
||||||
if (!cookies.source) cookies.source = `env ${key}`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cookies.authToken && cookies.ct0) {
|
if (cookies.authToken && cookies.ct0) {
|
||||||
cookies.cookieHeader = `auth_token=${cookies.authToken}; ct0=${cookies.ct0}`;
|
cookies.cookieHeader = cookieHeader(cookies.authToken, cookies.ct0);
|
||||||
return { cookies, warnings };
|
return { cookies, warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourcesToTry: CookieSource[] = Array.isArray(cookieSource)
|
const sourcesToTry = resolveSources(options.cookieSource);
|
||||||
? cookieSource
|
|
||||||
: cookieSource
|
|
||||||
? [cookieSource]
|
|
||||||
: ['safari', 'chrome', 'firefox'];
|
|
||||||
|
|
||||||
for (const source of sourcesToTry) {
|
for (const source of sourcesToTry) {
|
||||||
if (source === 'safari') {
|
const res = await readTwitterCookiesFromBrowser({
|
||||||
const safariResult = await extractCookiesFromSafari();
|
source,
|
||||||
warnings.push(...safariResult.warnings);
|
chromeProfile: options.chromeProfile,
|
||||||
if (safariResult.cookies.authToken && safariResult.cookies.ct0) {
|
firefoxProfile: options.firefoxProfile,
|
||||||
return { cookies: safariResult.cookies, warnings };
|
});
|
||||||
}
|
warnings.push(...res.warnings);
|
||||||
continue;
|
if (res.cookies.authToken && res.cookies.ct0) {
|
||||||
}
|
return { cookies: res.cookies, warnings };
|
||||||
|
|
||||||
if (source === 'chrome') {
|
|
||||||
const chromeResult = await extractCookiesFromChrome(options.chromeProfile);
|
|
||||||
warnings.push(...chromeResult.warnings);
|
|
||||||
if (chromeResult.cookies.authToken && chromeResult.cookies.ct0) {
|
|
||||||
return { cookies: chromeResult.cookies, warnings };
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (source === 'firefox') {
|
|
||||||
const firefoxResult = await extractCookiesFromFirefox(options.firefoxProfile);
|
|
||||||
warnings.push(...firefoxResult.warnings);
|
|
||||||
if (firefoxResult.cookies.authToken && firefoxResult.cookies.ct0) {
|
|
||||||
return { cookies: firefoxResult.cookies, warnings };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validation
|
|
||||||
if (!cookies.authToken) {
|
if (!cookies.authToken) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
'Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Safari/Chrome/Firefox',
|
'Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Safari/Chrome/Firefox',
|
||||||
@@ -576,9 +188,8 @@ export async function resolveCredentials(options: {
|
|||||||
if (!cookies.ct0) {
|
if (!cookies.ct0) {
|
||||||
warnings.push('Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Safari/Chrome/Firefox');
|
warnings.push('Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Safari/Chrome/Firefox');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cookies.authToken && cookies.ct0) {
|
if (cookies.authToken && cookies.ct0) {
|
||||||
cookies.cookieHeader = `auth_token=${cookies.authToken}; ct0=${cookies.ct0}`;
|
cookies.cookieHeader = cookieHeader(cookies.authToken, cookies.ct0);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { cookies, warnings };
|
return { cookies, warnings };
|
||||||
|
|||||||
+91
-212
@@ -1,98 +1,32 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
// Mock child_process.execSync to prevent actual shell commands
|
type SweetCookieResult = { cookies: Array<{ name: string; value: string; domain?: string }>; warnings: string[] };
|
||||||
vi.mock('node:child_process', () => ({
|
|
||||||
execSync: vi.fn(() => ''),
|
const sweet = vi.hoisted(() => ({
|
||||||
|
results: new Map<string, SweetCookieResult>(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock fs to prevent actual file operations
|
vi.mock('@steipete/sweet-cookie', () => ({
|
||||||
vi.mock('node:fs', () => {
|
getCookies: vi.fn(async (options: { browsers?: string[] }) => {
|
||||||
const fs = vi.importActual<typeof import('node:fs')>('node:fs');
|
const browser = options.browsers?.[0] ?? 'unknown';
|
||||||
return {
|
return (
|
||||||
...fs,
|
sweet.results.get(browser) ?? {
|
||||||
existsSync: vi.fn(() => false),
|
cookies: [],
|
||||||
copyFileSync: vi.fn(),
|
warnings: [],
|
||||||
mkdtempSync: vi.fn(() => '/tmp/test-dir'),
|
}
|
||||||
readdirSync: vi.fn(() => []),
|
);
|
||||||
readFileSync: vi.fn(() => Buffer.alloc(0)),
|
}),
|
||||||
rmSync: vi.fn(),
|
}));
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const itIfDarwin = process.platform === 'darwin' ? it : it.skip;
|
const itIfDarwin = process.platform === 'darwin' ? it : it.skip;
|
||||||
|
|
||||||
function buildSafariCookieRecord(input: { domain: string; name: string; value: string; path?: string }): Buffer {
|
|
||||||
const domain = Buffer.from(input.domain, 'utf8');
|
|
||||||
const name = Buffer.from(input.name, 'utf8');
|
|
||||||
const path = Buffer.from(input.path ?? '/', 'utf8');
|
|
||||||
const value = Buffer.from(input.value, 'utf8');
|
|
||||||
|
|
||||||
const headerSize = 56;
|
|
||||||
const domainOffset = headerSize;
|
|
||||||
const nameOffset = domainOffset + domain.length + 1;
|
|
||||||
const pathOffset = nameOffset + name.length + 1;
|
|
||||||
const valueOffset = pathOffset + path.length + 1;
|
|
||||||
const recordSize = valueOffset + value.length + 1;
|
|
||||||
|
|
||||||
const record = Buffer.alloc(recordSize);
|
|
||||||
record.writeUInt32LE(recordSize, 0);
|
|
||||||
record.writeUInt32LE(0, 4);
|
|
||||||
record.writeUInt32LE(0, 8);
|
|
||||||
record.writeUInt32LE(0, 12);
|
|
||||||
record.writeUInt32LE(domainOffset, 16);
|
|
||||||
record.writeUInt32LE(nameOffset, 20);
|
|
||||||
record.writeUInt32LE(pathOffset, 24);
|
|
||||||
record.writeUInt32LE(valueOffset, 28);
|
|
||||||
|
|
||||||
domain.copy(record, domainOffset);
|
|
||||||
record[domainOffset + domain.length] = 0;
|
|
||||||
name.copy(record, nameOffset);
|
|
||||||
record[nameOffset + name.length] = 0;
|
|
||||||
path.copy(record, pathOffset);
|
|
||||||
record[pathOffset + path.length] = 0;
|
|
||||||
value.copy(record, valueOffset);
|
|
||||||
record[valueOffset + value.length] = 0;
|
|
||||||
|
|
||||||
return record;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSafariCookiesFile(records: Buffer[]): Buffer {
|
|
||||||
const cookieCount = records.length;
|
|
||||||
const headerSize = 4 + 4 + 4 * cookieCount + 4;
|
|
||||||
const offsets: number[] = [];
|
|
||||||
let cursor = headerSize;
|
|
||||||
for (const record of records) {
|
|
||||||
offsets.push(cursor);
|
|
||||||
cursor += record.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageSize = cursor;
|
|
||||||
const page = Buffer.alloc(pageSize);
|
|
||||||
page.writeUInt32BE(0x00000100, 0);
|
|
||||||
page.writeUInt32LE(cookieCount, 4);
|
|
||||||
offsets.forEach((offset, index) => {
|
|
||||||
page.writeUInt32LE(offset, 8 + index * 4);
|
|
||||||
});
|
|
||||||
page.writeUInt32LE(0, 8 + cookieCount * 4);
|
|
||||||
offsets.forEach((offset, index) => {
|
|
||||||
records[index].copy(page, offset);
|
|
||||||
});
|
|
||||||
|
|
||||||
const header = Buffer.alloc(12);
|
|
||||||
header.write('cook', 0, 'ascii');
|
|
||||||
header.writeUInt32BE(1, 4);
|
|
||||||
header.writeUInt32BE(pageSize, 8);
|
|
||||||
|
|
||||||
return Buffer.concat([header, page]);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('cookies', () => {
|
describe('cookies', () => {
|
||||||
const originalEnv = process.env;
|
const originalEnv = process.env;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
sweet.results.clear();
|
||||||
process.env = { ...originalEnv };
|
process.env = { ...originalEnv };
|
||||||
// Clear Twitter-related env vars
|
|
||||||
process.env.AUTH_TOKEN = undefined;
|
process.env.AUTH_TOKEN = undefined;
|
||||||
process.env.TWITTER_AUTH_TOKEN = undefined;
|
process.env.TWITTER_AUTH_TOKEN = undefined;
|
||||||
process.env.CT0 = undefined;
|
process.env.CT0 = undefined;
|
||||||
@@ -106,58 +40,41 @@ describe('cookies', () => {
|
|||||||
|
|
||||||
describe('resolveCredentials', () => {
|
describe('resolveCredentials', () => {
|
||||||
it('honors cookieSource=firefox even when Safari has cookies', async () => {
|
it('honors cookieSource=firefox even when Safari has cookies', async () => {
|
||||||
|
sweet.results.set('safari', {
|
||||||
|
cookies: [
|
||||||
|
{ name: 'auth_token', value: 'safari_auth', domain: 'x.com' },
|
||||||
|
{ name: 'ct0', value: 'safari_ct0', domain: 'x.com' },
|
||||||
|
],
|
||||||
|
warnings: [],
|
||||||
|
});
|
||||||
|
sweet.results.set('firefox', {
|
||||||
|
cookies: [
|
||||||
|
{ name: 'auth_token', value: 'firefox_auth', domain: 'x.com' },
|
||||||
|
{ name: 'ct0', value: 'firefox_ct0', domain: 'x.com' },
|
||||||
|
],
|
||||||
|
warnings: [],
|
||||||
|
});
|
||||||
|
|
||||||
const { resolveCredentials } = await import('../src/lib/cookies.js');
|
const { resolveCredentials } = await import('../src/lib/cookies.js');
|
||||||
const fs = await import('node:fs');
|
|
||||||
const { execSync } = await import('node:child_process');
|
|
||||||
|
|
||||||
// Safari cookie file exists + contains different cookies
|
|
||||||
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => {
|
|
||||||
const lower = path.toLowerCase();
|
|
||||||
if (lower.endsWith('cookies.binarycookies')) return true;
|
|
||||||
if (lower.endsWith('cookies.sqlite')) return true;
|
|
||||||
if (lower.includes('firefox')) return true;
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
(fs.readdirSync as unknown as vi.Mock).mockReturnValue([
|
|
||||||
{ isDirectory: () => true, name: 'abc.default-release' },
|
|
||||||
]);
|
|
||||||
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {});
|
|
||||||
(fs.readFileSync as unknown as vi.Mock).mockReturnValue(
|
|
||||||
buildSafariCookiesFile([
|
|
||||||
buildSafariCookieRecord({ domain: '.x.com', name: 'auth_token', value: 'safari_auth' }),
|
|
||||||
buildSafariCookieRecord({ domain: '.x.com', name: 'ct0', value: 'safari_ct0' }),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Firefox sqlite3 extraction returns different cookies
|
|
||||||
(execSync as unknown as vi.Mock).mockImplementation((cmd: string) => {
|
|
||||||
if (cmd.includes('sqlite3')) return 'auth_token|firefox_auth\nct0|firefox_ct0';
|
|
||||||
return '';
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await resolveCredentials({ cookieSource: 'firefox' });
|
const result = await resolveCredentials({ cookieSource: 'firefox' });
|
||||||
|
|
||||||
expect(result.cookies.authToken).toBe('firefox_auth');
|
expect(result.cookies.authToken).toBe('firefox_auth');
|
||||||
expect(result.cookies.ct0).toBe('firefox_ct0');
|
expect(result.cookies.ct0).toBe('firefox_ct0');
|
||||||
expect(result.cookies.source).toContain('Firefox');
|
expect(result.cookies.source).toContain('Firefox');
|
||||||
});
|
});
|
||||||
|
|
||||||
itIfDarwin('honors cookieSource=safari', async () => {
|
itIfDarwin('honors cookieSource=safari', async () => {
|
||||||
|
sweet.results.set('safari', {
|
||||||
|
cookies: [
|
||||||
|
{ name: 'auth_token', value: 'safari_auth', domain: 'x.com' },
|
||||||
|
{ name: 'ct0', value: 'safari_ct0', domain: 'x.com' },
|
||||||
|
],
|
||||||
|
warnings: [],
|
||||||
|
});
|
||||||
|
|
||||||
const { resolveCredentials } = await import('../src/lib/cookies.js');
|
const { resolveCredentials } = await import('../src/lib/cookies.js');
|
||||||
const fs = await import('node:fs');
|
|
||||||
|
|
||||||
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) =>
|
|
||||||
path.toLowerCase().endsWith('cookies.binarycookies'),
|
|
||||||
);
|
|
||||||
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {});
|
|
||||||
(fs.mkdtempSync as unknown as vi.Mock).mockReturnValue('/tmp/test-dir');
|
|
||||||
(fs.readFileSync as unknown as vi.Mock).mockReturnValue(
|
|
||||||
buildSafariCookiesFile([
|
|
||||||
buildSafariCookieRecord({ domain: '.x.com', name: 'auth_token', value: 'safari_auth' }),
|
|
||||||
buildSafariCookieRecord({ domain: '.x.com', name: 'ct0', value: 'safari_ct0' }),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await resolveCredentials({ cookieSource: 'safari' });
|
const result = await resolveCredentials({ cookieSource: 'safari' });
|
||||||
|
|
||||||
expect(result.cookies.authToken).toBe('safari_auth');
|
expect(result.cookies.authToken).toBe('safari_auth');
|
||||||
expect(result.cookies.ct0).toBe('safari_ct0');
|
expect(result.cookies.ct0).toBe('safari_ct0');
|
||||||
expect(result.cookies.cookieHeader).toContain('auth_token=safari_auth');
|
expect(result.cookies.cookieHeader).toContain('auth_token=safari_auth');
|
||||||
@@ -166,26 +83,15 @@ describe('cookies', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('uses firefox when enabled and returns cookies', async () => {
|
it('uses firefox when enabled and returns cookies', async () => {
|
||||||
const { resolveCredentials } = await import('../src/lib/cookies.js');
|
sweet.results.set('firefox', {
|
||||||
const fs = await import('node:fs');
|
cookies: [
|
||||||
|
{ name: 'auth_token', value: 'firefox_auth', domain: 'x.com' },
|
||||||
// Firefox present with cookies.sqlite
|
{ name: 'ct0', value: 'firefox_ct0', domain: 'x.com' },
|
||||||
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => {
|
],
|
||||||
const lower = path.toLowerCase();
|
warnings: [],
|
||||||
if (lower.endsWith('cookies.sqlite')) return true;
|
|
||||||
if (lower.includes('firefox')) return true;
|
|
||||||
return false;
|
|
||||||
});
|
});
|
||||||
(fs.readdirSync as unknown as vi.Mock).mockReturnValue([
|
|
||||||
{ isDirectory: () => true, name: 'abc.default-release' },
|
|
||||||
]);
|
|
||||||
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {});
|
|
||||||
(fs.mkdtempSync as unknown as vi.Mock).mockReturnValue('/tmp/test-dir');
|
|
||||||
|
|
||||||
// sqlite3 output for firefox
|
|
||||||
const { execSync } = await import('node:child_process');
|
|
||||||
(execSync as unknown as vi.Mock).mockReturnValue('auth_token|firefox_auth\nct0|firefox_ct0');
|
|
||||||
|
|
||||||
|
const { resolveCredentials } = await import('../src/lib/cookies.js');
|
||||||
const result = await resolveCredentials({ cookieSource: 'firefox', firefoxProfile: 'abc.default-release' });
|
const result = await resolveCredentials({ cookieSource: 'firefox', firefoxProfile: 'abc.default-release' });
|
||||||
|
|
||||||
expect(result.cookies.authToken).toBe('firefox_auth');
|
expect(result.cookies.authToken).toBe('firefox_auth');
|
||||||
@@ -270,15 +176,12 @@ describe('cookies', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to Chrome when enabled and Firefox disabled', async () => {
|
it('falls back to Chrome when enabled and Firefox disabled', async () => {
|
||||||
const fs = await import('node:fs');
|
sweet.results.set('chrome', {
|
||||||
const { execSync } = await import('node:child_process');
|
cookies: [
|
||||||
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => path.includes('Cookies'));
|
{ name: 'auth_token', value: 'test_auth', domain: 'x.com' },
|
||||||
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {});
|
{ name: 'ct0', value: 'test_ct0', domain: 'x.com' },
|
||||||
(execSync as unknown as vi.Mock).mockImplementation((cmd: string) => {
|
],
|
||||||
if (cmd.includes('sqlite3')) {
|
warnings: [],
|
||||||
return 'auth_token|746573745f61757468\nct0|746573745f637430';
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { resolveCredentials } = await import('../src/lib/cookies.js');
|
const { resolveCredentials } = await import('../src/lib/cookies.js');
|
||||||
@@ -291,19 +194,14 @@ describe('cookies', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('extractCookiesFromSafari', () => {
|
describe('extractCookiesFromSafari', () => {
|
||||||
itIfDarwin('returns cookies from Safari binarycookies', async () => {
|
itIfDarwin('returns cookies from Safari', async () => {
|
||||||
const fs = await import('node:fs');
|
sweet.results.set('safari', {
|
||||||
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) =>
|
cookies: [
|
||||||
path.toLowerCase().endsWith('cookies.binarycookies'),
|
{ name: 'auth_token', value: 'safari_auth', domain: 'x.com' },
|
||||||
);
|
{ name: 'ct0', value: 'safari_ct0', domain: 'x.com' },
|
||||||
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {});
|
],
|
||||||
(fs.mkdtempSync as unknown as vi.Mock).mockReturnValue('/tmp/test-dir');
|
warnings: [],
|
||||||
(fs.readFileSync as unknown as vi.Mock).mockReturnValue(
|
});
|
||||||
buildSafariCookiesFile([
|
|
||||||
buildSafariCookieRecord({ domain: '.x.com', name: 'auth_token', value: 'safari_auth' }),
|
|
||||||
buildSafariCookieRecord({ domain: '.x.com', name: 'ct0', value: 'safari_ct0' }),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { extractCookiesFromSafari } = await import('../src/lib/cookies.js');
|
const { extractCookiesFromSafari } = await import('../src/lib/cookies.js');
|
||||||
const result = await extractCookiesFromSafari();
|
const result = await extractCookiesFromSafari();
|
||||||
@@ -314,22 +212,20 @@ describe('cookies', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
itIfDarwin('prefers Safari over Chrome when both are available', async () => {
|
itIfDarwin('prefers Safari over Chrome when both are available', async () => {
|
||||||
const fs = await import('node:fs');
|
sweet.results.set('safari', {
|
||||||
|
cookies: [
|
||||||
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => {
|
{ name: 'auth_token', value: 'safari_auth', domain: 'x.com' },
|
||||||
const lower = path.toLowerCase();
|
{ name: 'ct0', value: 'safari_ct0', domain: 'x.com' },
|
||||||
if (lower.endsWith('cookies.binarycookies')) return true;
|
],
|
||||||
if (lower.includes('chrome') && lower.endsWith('cookies')) return true;
|
warnings: [],
|
||||||
return false;
|
});
|
||||||
|
sweet.results.set('chrome', {
|
||||||
|
cookies: [
|
||||||
|
{ name: 'auth_token', value: 'chrome_auth', domain: 'x.com' },
|
||||||
|
{ name: 'ct0', value: 'chrome_ct0', domain: 'x.com' },
|
||||||
|
],
|
||||||
|
warnings: [],
|
||||||
});
|
});
|
||||||
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {});
|
|
||||||
(fs.mkdtempSync as unknown as vi.Mock).mockReturnValue('/tmp/test-dir');
|
|
||||||
(fs.readFileSync as unknown as vi.Mock).mockReturnValue(
|
|
||||||
buildSafariCookiesFile([
|
|
||||||
buildSafariCookieRecord({ domain: '.x.com', name: 'auth_token', value: 'safari_auth' }),
|
|
||||||
buildSafariCookieRecord({ domain: '.x.com', name: 'ct0', value: 'safari_ct0' }),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { resolveCredentials } = await import('../src/lib/cookies.js');
|
const { resolveCredentials } = await import('../src/lib/cookies.js');
|
||||||
const result = await resolveCredentials({ cookieSource: ['safari', 'chrome'] });
|
const result = await resolveCredentials({ cookieSource: ['safari', 'chrome'] });
|
||||||
@@ -340,18 +236,13 @@ describe('cookies', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('extractCookiesFromChrome', () => {
|
describe('extractCookiesFromChrome', () => {
|
||||||
it('returns cookies when sqlite yields hex values', async () => {
|
it('returns cookies when Chrome yields values', async () => {
|
||||||
const fs = await import('node:fs');
|
sweet.results.set('chrome', {
|
||||||
const { execSync } = await import('node:child_process');
|
cookies: [
|
||||||
// Pretend Chrome cookie DB exists
|
{ name: 'auth_token', value: 'test_auth', domain: 'x.com' },
|
||||||
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => path.includes('Cookies'));
|
{ name: 'ct0', value: 'test_ct0', domain: 'x.com' },
|
||||||
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {});
|
],
|
||||||
// sqlite output with hex strings ("test_auth", "test_ct0")
|
warnings: [],
|
||||||
(execSync as unknown as vi.Mock).mockImplementation((cmd: string) => {
|
|
||||||
if (cmd.includes('sqlite3')) {
|
|
||||||
return 'auth_token|746573745f61757468\nct0|746573745f637430';
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { extractCookiesFromChrome } = await import('../src/lib/cookies.js');
|
const { extractCookiesFromChrome } = await import('../src/lib/cookies.js');
|
||||||
@@ -363,12 +254,8 @@ describe('cookies', () => {
|
|||||||
expect(result.warnings).toHaveLength(0);
|
expect(result.warnings).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('warns when Chrome DB exists but contains no cookies', async () => {
|
it('warns when Chrome returns no cookies', async () => {
|
||||||
const fs = await import('node:fs');
|
sweet.results.set('chrome', { cookies: [], warnings: [] });
|
||||||
const { execSync } = await import('node:child_process');
|
|
||||||
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => path.includes('Cookies'));
|
|
||||||
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {});
|
|
||||||
(execSync as unknown as vi.Mock).mockReturnValue('');
|
|
||||||
|
|
||||||
const { extractCookiesFromChrome } = await import('../src/lib/cookies.js');
|
const { extractCookiesFromChrome } = await import('../src/lib/cookies.js');
|
||||||
const result = await extractCookiesFromChrome('Default');
|
const result = await extractCookiesFromChrome('Default');
|
||||||
@@ -381,6 +268,11 @@ describe('cookies', () => {
|
|||||||
|
|
||||||
describe('extractCookiesFromFirefox', () => {
|
describe('extractCookiesFromFirefox', () => {
|
||||||
it('warns when Firefox cookies database is missing', async () => {
|
it('warns when Firefox cookies database is missing', async () => {
|
||||||
|
sweet.results.set('firefox', {
|
||||||
|
cookies: [],
|
||||||
|
warnings: ['Firefox cookies database not found.'],
|
||||||
|
});
|
||||||
|
|
||||||
const { extractCookiesFromFirefox } = await import('../src/lib/cookies.js');
|
const { extractCookiesFromFirefox } = await import('../src/lib/cookies.js');
|
||||||
const result = await extractCookiesFromFirefox('missing-profile');
|
const result = await extractCookiesFromFirefox('missing-profile');
|
||||||
|
|
||||||
@@ -389,21 +281,8 @@ describe('cookies', () => {
|
|||||||
expect(result.warnings).toContain('Firefox cookies database not found.');
|
expect(result.warnings).toContain('Firefox cookies database not found.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('warns when Firefox DB exists but contains no cookies', async () => {
|
it('warns when Firefox returns no cookies', async () => {
|
||||||
const fs = await import('node:fs');
|
sweet.results.set('firefox', { cookies: [], warnings: [] });
|
||||||
const { execSync } = await import('node:child_process');
|
|
||||||
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => {
|
|
||||||
const lower = path.toLowerCase();
|
|
||||||
if (lower.endsWith('cookies.sqlite')) return true;
|
|
||||||
if (lower.includes('firefox')) return true;
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
(fs.readdirSync as unknown as vi.Mock).mockReturnValue([
|
|
||||||
{ isDirectory: () => true, name: 'abc.default-release' },
|
|
||||||
]);
|
|
||||||
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {});
|
|
||||||
(fs.mkdtempSync as unknown as vi.Mock).mockReturnValue('/tmp/test-dir');
|
|
||||||
(execSync as unknown as vi.Mock).mockReturnValue('');
|
|
||||||
|
|
||||||
const { extractCookiesFromFirefox } = await import('../src/lib/cookies.js');
|
const { extractCookiesFromFirefox } = await import('../src/lib/cookies.js');
|
||||||
const result = await extractCookiesFromFirefox('abc.default-release');
|
const result = await extractCookiesFromFirefox('abc.default-release');
|
||||||
|
|||||||
Reference in New Issue
Block a user