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`
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:
- 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"
},
"dependencies": {
"@steipete/sweet-cookie": "file:../sweet-cookie/packages/core",
"commander": "^14.0.2",
"json5": "^2.2.3",
"kleur": "^4.1.5"
+15 -1
View File
@@ -1,13 +1,16 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
autoInstallPeers: false
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@steipete/sweet-cookie':
specifier: file:../sweet-cookie/packages/core
version: file:../sweet-cookie/packages/core
commander:
specifier: ^14.0.2
version: 14.0.2
@@ -392,6 +395,15 @@ packages:
'@standard-schema/[email protected]':
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]':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@@ -912,6 +924,8 @@ snapshots:
'@standard-schema/[email protected]': {}
'@steipete/sweet-cookie@file:../sweet-cookie/packages/core': {}
'@types/[email protected]':
dependencies:
'@types/deep-eql': 4.0.2
+105 -494
View File
@@ -1,13 +1,9 @@
/**
* Browser cookie extraction for Twitter authentication
* Uses sqlite3 CLI where possible and binarycookies parsing for Safari.
* Browser cookie extraction for Twitter authentication.
* Delegates to @steipete/sweet-cookie for Safari/Chrome/Firefox reads.
*/
import { execSync } from 'node:child_process';
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';
import { getCookies } from '@steipete/sweet-cookie';
export interface TwitterCookies {
authToken: string | null;
@@ -23,457 +19,126 @@ export interface CookieExtractionResult {
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 {
if (typeof value === 'string') {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
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 {
const home = process.env.HOME || '';
const profileDir = profile || 'Default';
return join(home, 'Library', 'Application Support', 'Google', 'Chrome', profileDir, 'Cookies');
function buildEmpty(): TwitterCookies {
return { authToken: null, ct0: null, cookieHeader: null, source: null };
}
function getFirefoxProfilesRoot(): string | null {
const home = process.env.HOME || '';
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 {
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 readEnvCookie(cookies: TwitterCookies, keys: readonly string[], field: 'authToken' | 'ct0'): void {
if (cookies[field]) return;
for (const key of keys) {
const value = normalizeValue(process.env[key]);
if (!value) continue;
cookies[field] = value;
if (!cookies.source) cookies.source = `env ${key}`;
break;
}
}
function parseSafariCookiePage(page: Buffer, jar: Record<string, string>, cookies: TwitterCookies): void {
if (page.length < 12) return;
if (!page.subarray(0, 4).equals(SAFARI_PAGE_SIGNATURE)) return;
const cookieCount = page.readUInt32LE(4);
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;
function resolveSources(cookieSource?: CookieSource | CookieSource[]): CookieSource[] {
if (Array.isArray(cookieSource)) return cookieSource;
if (cookieSource) return [cookieSource];
return ['safari', 'chrome', 'firefox'];
}
for (const offset of offsets) {
parseSafariCookieRecord(page, offset, jar, cookies);
function labelForSource(source: CookieSource, profile?: string): string {
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 {
if (data.length < 8) return;
if (data.subarray(0, 4).toString('utf8') !== 'cook') return;
const pageCount = data.readUInt32BE(4);
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;
function pickCookieValue(
cookies: Array<{ name?: string; value?: string; domain?: string }>,
name: (typeof TWITTER_COOKIE_NAMES)[number],
): 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;
}
for (const pageSize of pageSizes) {
if (cursor + pageSize > data.length) return;
const page = data.subarray(cursor, cursor + pageSize);
parseSafariCookiePage(page, jar, cookies);
cursor += pageSize;
}
}
/**
* Extract Twitter cookies from Safari browser using Cookies.binarycookies
*/
export async function extractCookiesFromSafari(): Promise<CookieExtractionResult> {
async function readTwitterCookiesFromBrowser(options: {
source: CookieSource;
chromeProfile?: string;
firefoxProfile?: string;
}): Promise<CookieExtractionResult> {
const warnings: string[] = [];
const cookies: TwitterCookies = {
authToken: null,
ct0: null,
cookieHeader: null,
source: null,
};
const out = buildEmpty();
const cookiesPath = getSafariCookiesPath();
if (!cookiesPath) {
warnings.push('Safari cookies database not found.');
return { cookies, warnings };
const { cookies, warnings: providerWarnings } = await getCookies({
url: TWITTER_URL,
origins: TWITTER_ORIGINS,
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;
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) {
if (options.source === 'safari') {
warnings.push('No Twitter cookies found in Safari. Make sure you are logged into x.com in Safari.');
}
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) {
} else if (options.source === 'chrome') {
warnings.push('No Twitter cookies found in Chrome. Make sure you are logged into x.com in Chrome.');
}
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) {
} else {
warnings.push(
'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
* Priority: CLI args > environment variables > Safari > Chrome > Firefox
* Resolve Twitter credentials from multiple sources.
* Priority: CLI args > environment variables > browsers (ordered).
*/
export async function resolveCredentials(options: {
authToken?: string;
@@ -483,16 +148,8 @@ export async function resolveCredentials(options: {
firefoxProfile?: string;
}): Promise<CookieExtractionResult> {
const warnings: string[] = [];
const cookies: TwitterCookies = {
authToken: null,
ct0: null,
cookieHeader: null,
source: null,
};
const cookies = buildEmpty();
const cookieSource = options.cookieSource;
// 1. CLI arguments (highest priority)
if (options.authToken) {
cookies.authToken = options.authToken;
cookies.source = 'CLI argument';
@@ -502,72 +159,27 @@ export async function resolveCredentials(options: {
if (!cookies.source) cookies.source = 'CLI argument';
}
// 2. Environment variables
const envAuthKeys = ['AUTH_TOKEN', 'TWITTER_AUTH_TOKEN'];
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;
}
}
}
readEnvCookie(cookies, ['AUTH_TOKEN', 'TWITTER_AUTH_TOKEN'], 'authToken');
readEnvCookie(cookies, ['CT0', 'TWITTER_CT0'], '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 };
}
const sourcesToTry: CookieSource[] = Array.isArray(cookieSource)
? cookieSource
: cookieSource
? [cookieSource]
: ['safari', 'chrome', 'firefox'];
const sourcesToTry = resolveSources(options.cookieSource);
for (const source of sourcesToTry) {
if (source === 'safari') {
const safariResult = await extractCookiesFromSafari();
warnings.push(...safariResult.warnings);
if (safariResult.cookies.authToken && safariResult.cookies.ct0) {
return { cookies: safariResult.cookies, warnings };
}
continue;
}
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 };
}
const res = await readTwitterCookiesFromBrowser({
source,
chromeProfile: options.chromeProfile,
firefoxProfile: options.firefoxProfile,
});
warnings.push(...res.warnings);
if (res.cookies.authToken && res.cookies.ct0) {
return { cookies: res.cookies, warnings };
}
}
// Validation
if (!cookies.authToken) {
warnings.push(
'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) {
warnings.push('Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Safari/Chrome/Firefox');
}
if (cookies.authToken && cookies.ct0) {
cookies.cookieHeader = `auth_token=${cookies.authToken}; ct0=${cookies.ct0}`;
cookies.cookieHeader = cookieHeader(cookies.authToken, cookies.ct0);
}
return { cookies, warnings };
+91 -212
View File
@@ -1,98 +1,32 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock child_process.execSync to prevent actual shell commands
vi.mock('node:child_process', () => ({
execSync: vi.fn(() => ''),
type SweetCookieResult = { cookies: Array<{ name: string; value: string; domain?: string }>; warnings: string[] };
const sweet = vi.hoisted(() => ({
results: new Map<string, SweetCookieResult>(),
}));
// Mock fs to prevent actual file operations
vi.mock('node:fs', () => {
const fs = vi.importActual<typeof import('node:fs')>('node:fs');
return {
...fs,
existsSync: vi.fn(() => false),
copyFileSync: vi.fn(),
mkdtempSync: vi.fn(() => '/tmp/test-dir'),
readdirSync: vi.fn(() => []),
readFileSync: vi.fn(() => Buffer.alloc(0)),
rmSync: vi.fn(),
};
});
vi.mock('@steipete/sweet-cookie', () => ({
getCookies: vi.fn(async (options: { browsers?: string[] }) => {
const browser = options.browsers?.[0] ?? 'unknown';
return (
sweet.results.get(browser) ?? {
cookies: [],
warnings: [],
}
);
}),
}));
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', () => {
const originalEnv = process.env;
beforeEach(() => {
vi.resetModules();
sweet.results.clear();
process.env = { ...originalEnv };
// Clear Twitter-related env vars
process.env.AUTH_TOKEN = undefined;
process.env.TWITTER_AUTH_TOKEN = undefined;
process.env.CT0 = undefined;
@@ -106,58 +40,41 @@ describe('cookies', () => {
describe('resolveCredentials', () => {
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 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' });
expect(result.cookies.authToken).toBe('firefox_auth');
expect(result.cookies.ct0).toBe('firefox_ct0');
expect(result.cookies.source).toContain('Firefox');
});
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 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' });
expect(result.cookies.authToken).toBe('safari_auth');
expect(result.cookies.ct0).toBe('safari_ct0');
expect(result.cookies.cookieHeader).toContain('auth_token=safari_auth');
@@ -166,26 +83,15 @@ describe('cookies', () => {
});
it('uses firefox when enabled and returns cookies', async () => {
const { resolveCredentials } = await import('../src/lib/cookies.js');
const fs = await import('node:fs');
// Firefox present with cookies.sqlite
(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;
sweet.results.set('firefox', {
cookies: [
{ name: 'auth_token', value: 'firefox_auth', domain: 'x.com' },
{ name: 'ct0', value: 'firefox_ct0', domain: 'x.com' },
],
warnings: [],
});
(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' });
expect(result.cookies.authToken).toBe('firefox_auth');
@@ -270,15 +176,12 @@ describe('cookies', () => {
});
it('falls back to Chrome when enabled and Firefox disabled', async () => {
const fs = await import('node:fs');
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).mockImplementation((cmd: string) => {
if (cmd.includes('sqlite3')) {
return 'auth_token|746573745f61757468\nct0|746573745f637430';
}
return '';
sweet.results.set('chrome', {
cookies: [
{ name: 'auth_token', value: 'test_auth', domain: 'x.com' },
{ name: 'ct0', value: 'test_ct0', domain: 'x.com' },
],
warnings: [],
});
const { resolveCredentials } = await import('../src/lib/cookies.js');
@@ -291,19 +194,14 @@ describe('cookies', () => {
});
describe('extractCookiesFromSafari', () => {
itIfDarwin('returns cookies from Safari binarycookies', async () => {
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' }),
]),
);
itIfDarwin('returns cookies from 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 { extractCookiesFromSafari } = await import('../src/lib/cookies.js');
const result = await extractCookiesFromSafari();
@@ -314,22 +212,20 @@ describe('cookies', () => {
});
itIfDarwin('prefers Safari over Chrome when both are available', async () => {
const fs = await import('node:fs');
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => {
const lower = path.toLowerCase();
if (lower.endsWith('cookies.binarycookies')) return true;
if (lower.includes('chrome') && lower.endsWith('cookies')) return true;
return false;
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('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 result = await resolveCredentials({ cookieSource: ['safari', 'chrome'] });
@@ -340,18 +236,13 @@ describe('cookies', () => {
});
describe('extractCookiesFromChrome', () => {
it('returns cookies when sqlite yields hex values', async () => {
const fs = await import('node:fs');
const { execSync } = await import('node:child_process');
// Pretend Chrome cookie DB exists
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => path.includes('Cookies'));
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {});
// sqlite output with hex strings ("test_auth", "test_ct0")
(execSync as unknown as vi.Mock).mockImplementation((cmd: string) => {
if (cmd.includes('sqlite3')) {
return 'auth_token|746573745f61757468\nct0|746573745f637430';
}
return '';
it('returns cookies when Chrome yields values', async () => {
sweet.results.set('chrome', {
cookies: [
{ name: 'auth_token', value: 'test_auth', domain: 'x.com' },
{ name: 'ct0', value: 'test_ct0', domain: 'x.com' },
],
warnings: [],
});
const { extractCookiesFromChrome } = await import('../src/lib/cookies.js');
@@ -363,12 +254,8 @@ describe('cookies', () => {
expect(result.warnings).toHaveLength(0);
});
it('warns when Chrome DB exists but contains no cookies', async () => {
const fs = await import('node:fs');
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('');
it('warns when Chrome returns no cookies', async () => {
sweet.results.set('chrome', { cookies: [], warnings: [] });
const { extractCookiesFromChrome } = await import('../src/lib/cookies.js');
const result = await extractCookiesFromChrome('Default');
@@ -381,6 +268,11 @@ describe('cookies', () => {
describe('extractCookiesFromFirefox', () => {
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 result = await extractCookiesFromFirefox('missing-profile');
@@ -389,21 +281,8 @@ describe('cookies', () => {
expect(result.warnings).toContain('Firefox cookies database not found.');
});
it('warns when Firefox DB exists but contains no cookies', async () => {
const fs = await import('node:fs');
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('');
it('warns when Firefox returns no cookies', async () => {
sweet.results.set('firefox', { cookies: [], warnings: [] });
const { extractCookiesFromFirefox } = await import('../src/lib/cookies.js');
const result = await extractCookiesFromFirefox('abc.default-release');