refactor(cookies): use sweet-cookie

This commit is contained in:
Peter Steinberger
2025-12-27 13:23:39 +01:00
parent c16726e9d8
commit c495fb5078
6 changed files with 217 additions and 711 deletions
+1
View File
@@ -0,0 +1 @@
auto-install-peers=false
+1 -1
View File
@@ -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`)
+1
View File
@@ -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"
+15 -1
View File
@@ -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
+105 -494
View File
@@ -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 cookieHeader(authToken: string, ct0: string): string {
return `auth_token=${authToken}; ct0=${ct0}`;
} }
function getChromeCookiesPath(profile?: string): string { function buildEmpty(): TwitterCookies {
const home = process.env.HOME || ''; return { authToken: null, ct0: null, cookieHeader: null, source: null };
const profileDir = profile || 'Default';
return join(home, 'Library', 'Application Support', 'Google', 'Chrome', profileDir, 'Cookies');
} }
function getFirefoxProfilesRoot(): string | null { function readEnvCookie(cookies: TwitterCookies, keys: readonly string[], field: 'authToken' | 'ct0'): void {
const home = process.env.HOME || ''; if (cookies[field]) return;
if (process.platform === 'darwin') { for (const key of keys) {
return join(home, 'Library', 'Application Support', 'Firefox', 'Profiles'); const value = normalizeValue(process.env[key]);
} if (!value) continue;
if (process.platform === 'linux') { cookies[field] = value;
return join(home, '.mozilla', 'firefox'); if (!cookies.source) cookies.source = `env ${key}`;
} break;
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 {
if (profile) {
const candidate = join(profilesRoot, profile, 'cookies.sqlite');
return existsSync(candidate) ? candidate : null;
}
const entries = readdirSync(profilesRoot, { withFileTypes: true });
const defaultRelease = entries.find((entry) => entry.isDirectory() && entry.name.includes('default-release'));
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) { function labelForSource(source: CookieSource, profile?: string): string {
parseSafariCookieRecord(page, offset, jar, cookies); if (source === 'safari') return 'Safari';
if (source === 'chrome') {
return profile ? `Chrome profile "${profile}"` : 'Chrome default profile';
} }
return profile ? `Firefox profile "${profile}"` : 'Firefox default profile';
} }
function parseSafariCookies(data: Buffer, jar: Record<string, string>, cookies: TwitterCookies): void { function pickCookieValue(
if (data.length < 8) return; cookies: Array<{ name?: string; value?: string; domain?: string }>,
if (data.subarray(0, 4).toString('utf8') !== 'cook') return; name: (typeof TWITTER_COOKIE_NAMES)[number],
const pageCount = data.readUInt32BE(4); ): string | null {
let cursor = 8; const matches = cookies.filter((c) => c?.name === name && typeof c.value === 'string');
const pageSizes: number[] = []; if (matches.length === 0) return null;
for (let i = 0; i < pageCount; i += 1) {
if (cursor + 4 > data.length) return; const preferred = matches.find((c) => (c.domain ?? '').endsWith('x.com'));
pageSizes.push(data.readUInt32BE(cursor)); if (preferred?.value) return preferred.value;
cursor += 4;
const twitter = matches.find((c) => (c.domain ?? '').endsWith('twitter.com'));
if (twitter?.value) return twitter.value;
return matches[0]?.value ?? null;
} }
for (const pageSize of pageSizes) { async function readTwitterCookiesFromBrowser(options: {
if (cursor + pageSize > data.length) return; source: CookieSource;
const page = data.subarray(cursor, cursor + pageSize); chromeProfile?: string;
parseSafariCookiePage(page, jar, cookies); firefoxProfile?: string;
cursor += pageSize; }): Promise<CookieExtractionResult> {
}
}
/**
* Extract Twitter cookies from Safari browser using Cookies.binarycookies
*/
export async function extractCookiesFromSafari(): 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
View File
@@ -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');