feat(auth): allow selecting cookie source

This commit is contained in:
Peter Steinberger
2025-12-26 21:17:20 +01:00
parent bd777f6acc
commit 1744f4cc24
5 changed files with 165 additions and 121 deletions
+3
View File
@@ -2,6 +2,9 @@
## 0.3.1 — Unreleased
### Added
- Cookie source selection: `--cookie-source auto|safari|chrome|firefox` + `cookieSource` config option.
## 0.3.0 — 2025-12-26
### Added
+3 -1
View File
@@ -62,6 +62,7 @@ Global options:
- `--plain`: stable output (no emoji, no color).
- `--no-emoji`: disable emoji output.
- `--no-color`: disable ANSI colors (or set `NO_COLOR=1`).
- `--cookie-source <auto|safari|chrome|firefox>`: choose which browser cookies to use (default `auto`).
## Authentication (GraphQL)
@@ -72,7 +73,7 @@ X endpoints and authenticates via cookies (`auth_token`, `ct0`).
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
3. Browser cookies (macOS): Safari, Chrome, Firefox (override via `--cookie-source`)
Browser cookie sources:
- Safari: `~/Library/Cookies/Cookies.binarycookies` (fallback: `~/Library/Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies`)
@@ -90,6 +91,7 @@ Example `~/.config/bird/config.json5`:
```json5
{
cookieSource: "auto", // or: "firefox" / "chrome" / "safari"
firefoxProfile: "default-release",
allowSafari: true,
allowFirefox: true,
+38 -83
View File
@@ -12,11 +12,11 @@
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { Command, Option } from 'commander';
import JSON5 from 'json5';
import kleur from 'kleur';
import { resolveCliInvocation } from './lib/cli-args.js';
import { resolveCredentials } from './lib/cookies.js';
import { type CookieSource, resolveCredentials } from './lib/cookies.js';
import { extractTweetId } from './lib/extract-tweet-id.js';
import { mentionsQueryFromUserOption, normalizeHandle } from './lib/normalize-handle.js';
import {
@@ -70,6 +70,7 @@ const colors = {
type BirdConfig = {
chromeProfile?: string;
firefoxProfile?: string;
cookieSource?: CookieSource;
allowSafari?: boolean;
allowChrome?: boolean;
allowFirefox?: boolean;
@@ -145,6 +146,11 @@ program
.option('--ct0 <token>', 'Twitter ct0 cookie')
.option('--chrome-profile <name>', 'Chrome profile name for cookie extraction', config.chromeProfile)
.option('--firefox-profile <name>', 'Firefox profile name for cookie extraction', config.firefoxProfile)
.addOption(
new Option('--cookie-source <source>', 'Cookie source for browser cookie extraction')
.choices(['auto', 'safari', 'chrome', 'firefox'])
.default(config.cookieSource ?? 'auto'),
)
.option('--media <path>', 'Attach media file (repeatable, up to 4 images or 1 video)', collect, [])
.option('--alt <text>', 'Alt text for the corresponding --media (repeatable)', collect, [])
.option('--timeout <ms>', 'Request timeout in milliseconds')
@@ -152,6 +158,27 @@ program
.option('--no-emoji', 'Disable emoji output')
.option('--no-color', 'Disable ANSI colors (or set NO_COLOR)');
type CredentialsOptions = {
authToken?: string;
ct0?: string;
chromeProfile?: string;
firefoxProfile?: string;
cookieSource?: CookieSource;
};
function resolveCredentialsFromOptions(opts: CredentialsOptions) {
return resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
cookieSource: opts.cookieSource ?? config.cookieSource ?? 'auto',
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
allowSafari: config.allowSafari ?? true,
allowChrome: config.allowChrome ?? true,
allowFirefox: config.allowFirefox ?? true,
});
}
program.hook('preAction', (_thisCommand, actionCommand) => {
applyOutputFromCommand(actionCommand);
});
@@ -318,15 +345,7 @@ program
process.exit(1);
}
const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
allowSafari: config.allowSafari ?? true,
allowChrome: config.allowChrome ?? true,
allowFirefox: config.allowFirefox ?? true,
});
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${p('warn')}${warning}`);
@@ -385,15 +404,7 @@ program
}
const tweetId = extractTweetId(tweetIdOrUrl);
const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
allowSafari: config.allowSafari ?? true,
allowChrome: config.allowChrome ?? true,
allowFirefox: config.allowFirefox ?? true,
});
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${p('warn')}${warning}`);
@@ -448,15 +459,7 @@ program
const tweetId = extractTweetId(tweetIdOrUrl);
const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
allowSafari: config.allowSafari ?? true,
allowChrome: config.allowChrome ?? true,
allowFirefox: config.allowFirefox ?? true,
});
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${p('warn')}${warning}`);
@@ -498,15 +501,7 @@ program
const timeoutMs = resolveTimeoutFromOptions(opts);
const tweetId = extractTweetId(tweetIdOrUrl);
const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
allowSafari: config.allowSafari ?? true,
allowChrome: config.allowChrome ?? true,
allowFirefox: config.allowFirefox ?? true,
});
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${p('warn')}${warning}`);
@@ -539,15 +534,7 @@ program
const timeoutMs = resolveTimeoutFromOptions(opts);
const tweetId = extractTweetId(tweetIdOrUrl);
const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
allowSafari: config.allowSafari ?? true,
allowChrome: config.allowChrome ?? true,
allowFirefox: config.allowFirefox ?? true,
});
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${p('warn')}${warning}`);
@@ -581,15 +568,7 @@ program
const timeoutMs = resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '10', 10);
const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
allowSafari: config.allowSafari ?? true,
allowChrome: config.allowChrome ?? true,
allowFirefox: config.allowFirefox ?? true,
});
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${p('warn')}${warning}`);
@@ -631,15 +610,7 @@ program
let query: string | null = fromUserOpt.query;
const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
allowSafari: config.allowSafari ?? true,
allowChrome: config.allowChrome ?? true,
allowFirefox: config.allowFirefox ?? true,
});
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${p('warn')}${warning}`);
@@ -683,15 +654,7 @@ program
const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts);
const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
allowSafari: config.allowSafari ?? true,
allowChrome: config.allowChrome ?? true,
allowFirefox: config.allowFirefox ?? true,
});
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${p('warn')}${warning}`);
@@ -728,15 +691,7 @@ program
.description('Check credential availability')
.action(async () => {
const opts = program.opts();
const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
allowSafari: config.allowSafari ?? true,
allowChrome: config.allowChrome ?? true,
allowFirefox: config.allowFirefox ?? true,
});
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
console.log(`${p('info')}Credential check`);
console.log('─'.repeat(40));
+63 -37
View File
@@ -20,6 +20,8 @@ export interface CookieExtractionResult {
warnings: string[];
}
export type CookieSource = 'auto' | 'safari' | 'chrome' | 'firefox';
function normalizeValue(value: unknown): string | null {
if (typeof value === 'string') {
const trimmed = value.trim();
@@ -444,6 +446,7 @@ export async function extractCookiesFromFirefox(profile?: string): Promise<Cooki
export async function resolveCredentials(options: {
authToken?: string;
ct0?: string;
cookieSource?: CookieSource;
allowSafari?: boolean;
chromeProfile?: string;
firefoxProfile?: string;
@@ -457,6 +460,8 @@ export async function resolveCredentials(options: {
source: null,
};
const cookieSource: CookieSource = options.cookieSource ?? 'auto';
// 1. CLI arguments (highest priority)
if (options.authToken) {
cookies.authToken = options.authToken;
@@ -497,48 +502,69 @@ export async function resolveCredentials(options: {
const allowChrome = options.allowChrome ?? true;
const allowFirefox = options.allowFirefox ?? true;
// 3. Safari cookies (preferred browser fallback)
if (allowSafari && (!cookies.authToken || !cookies.ct0)) {
const safariResult = await extractCookiesFromSafari();
warnings.push(...safariResult.warnings);
const sourcesToTry: Array<Exclude<CookieSource, 'auto'>> =
cookieSource === 'auto' ? ['safari', 'chrome', 'firefox'] : [cookieSource];
if (!cookies.authToken && safariResult.cookies.authToken) {
cookies.authToken = safariResult.cookies.authToken;
cookies.source = safariResult.cookies.source;
}
if (!cookies.ct0 && safariResult.cookies.ct0) {
cookies.ct0 = safariResult.cookies.ct0;
if (!cookies.source) cookies.source = safariResult.cookies.source;
}
}
for (const source of sourcesToTry) {
if (cookies.authToken && cookies.ct0) break;
// 4. Chrome cookies (secondary browser fallback)
if (allowChrome && (!cookies.authToken || !cookies.ct0)) {
const chromeResult = await extractCookiesFromChrome(options.chromeProfile);
warnings.push(...chromeResult.warnings);
if (source === 'safari') {
if (!allowSafari) {
warnings.push('Safari cookie extraction disabled (allowSafari=false).');
continue;
}
if (!cookies.authToken && chromeResult.cookies.authToken) {
cookies.authToken = chromeResult.cookies.authToken;
cookies.source = chromeResult.cookies.source;
}
if (!cookies.ct0 && chromeResult.cookies.ct0) {
cookies.ct0 = chromeResult.cookies.ct0;
if (!cookies.source) cookies.source = chromeResult.cookies.source;
}
}
const safariResult = await extractCookiesFromSafari();
warnings.push(...safariResult.warnings);
// 5. Firefox cookies (tertiary browser fallback)
if (allowFirefox && (!cookies.authToken || !cookies.ct0)) {
const firefoxResult = await extractCookiesFromFirefox(options.firefoxProfile);
warnings.push(...firefoxResult.warnings);
if (!cookies.authToken && firefoxResult.cookies.authToken) {
cookies.authToken = firefoxResult.cookies.authToken;
cookies.source = firefoxResult.cookies.source;
if (!cookies.authToken && safariResult.cookies.authToken) {
cookies.authToken = safariResult.cookies.authToken;
cookies.source = safariResult.cookies.source;
}
if (!cookies.ct0 && safariResult.cookies.ct0) {
cookies.ct0 = safariResult.cookies.ct0;
if (!cookies.source) cookies.source = safariResult.cookies.source;
}
continue;
}
if (!cookies.ct0 && firefoxResult.cookies.ct0) {
cookies.ct0 = firefoxResult.cookies.ct0;
if (!cookies.source) cookies.source = firefoxResult.cookies.source;
if (source === 'chrome') {
if (!allowChrome) {
warnings.push('Chrome cookie extraction disabled (allowChrome=false).');
continue;
}
const chromeResult = await extractCookiesFromChrome(options.chromeProfile);
warnings.push(...chromeResult.warnings);
if (!cookies.authToken && chromeResult.cookies.authToken) {
cookies.authToken = chromeResult.cookies.authToken;
cookies.source = chromeResult.cookies.source;
}
if (!cookies.ct0 && chromeResult.cookies.ct0) {
cookies.ct0 = chromeResult.cookies.ct0;
if (!cookies.source) cookies.source = chromeResult.cookies.source;
}
continue;
}
if (source === 'firefox') {
if (!allowFirefox) {
warnings.push('Firefox cookie extraction disabled (allowFirefox=false).');
continue;
}
const firefoxResult = await extractCookiesFromFirefox(options.firefoxProfile);
warnings.push(...firefoxResult.warnings);
if (!cookies.authToken && firefoxResult.cookies.authToken) {
cookies.authToken = firefoxResult.cookies.authToken;
cookies.source = firefoxResult.cookies.source;
}
if (!cookies.ct0 && firefoxResult.cookies.ct0) {
cookies.ct0 = firefoxResult.cookies.ct0;
if (!cookies.source) cookies.source = firefoxResult.cookies.source;
}
}
}
+58
View File
@@ -105,6 +105,64 @@ describe('cookies', () => {
});
describe('resolveCredentials', () => {
it('honors cookieSource=firefox even when Safari has cookies', async () => {
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', allowChrome: false });
expect(result.cookies.authToken).toBe('firefox_auth');
expect(result.cookies.ct0).toBe('firefox_ct0');
expect(result.cookies.source).toContain('Firefox');
});
it('honors cookieSource=safari', async () => {
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', allowChrome: false, allowFirefox: false });
expect(result.cookies.authToken).toBe('safari_auth');
expect(result.cookies.ct0).toBe('safari_ct0');
expect(result.cookies.source).toBe('Safari');
});
it('uses firefox when enabled and returns cookies', async () => {
const { resolveCredentials } = await import('../src/lib/cookies.js');
const fs = await import('node:fs');