258 lines
8.7 KiB
TypeScript
258 lines
8.7 KiB
TypeScript
import type { Command } from 'commander';
|
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs';
|
|
import { dirname, resolve } from 'path';
|
|
import { homedir } from 'os';
|
|
import { createServer, type Server } from 'http';
|
|
import { URL } from 'url';
|
|
import { loadConfigSafe } from './shared.js';
|
|
|
|
const SCOPES = ['https://www.googleapis.com/auth/gmail.readonly'];
|
|
const REDIRECT_PORT = 3000;
|
|
const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}`;
|
|
|
|
/** Expand ~ to the user's home directory. */
|
|
function expandPath(p: string): string {
|
|
if (p.startsWith('~/') || p === '~') {
|
|
return resolve(homedir(), p.slice(2));
|
|
}
|
|
return resolve(p);
|
|
}
|
|
|
|
/** Read and parse the OAuth2 credentials file. */
|
|
export function readCredentials(credentialsPath: string): {
|
|
client_id: string;
|
|
client_secret: string;
|
|
redirect_uris?: string[];
|
|
} {
|
|
if (!existsSync(credentialsPath)) {
|
|
throw new Error(`Credentials file not found: ${credentialsPath}`);
|
|
}
|
|
|
|
const credentials = JSON.parse(readFileSync(credentialsPath, 'utf-8'));
|
|
const { client_id, client_secret, redirect_uris } = credentials.installed ?? credentials.web ?? {};
|
|
|
|
if (!client_id || !client_secret) {
|
|
throw new Error('Invalid credentials file — missing client_id or client_secret');
|
|
}
|
|
|
|
return { client_id, client_secret, redirect_uris };
|
|
}
|
|
|
|
/** Generate the OAuth2 authorization URL. */
|
|
export function generateAuthUrl(clientId: string, clientSecret: string, redirectUri: string): string {
|
|
const params = new URLSearchParams({
|
|
client_id: clientId,
|
|
redirect_uri: redirectUri,
|
|
response_type: 'code',
|
|
scope: SCOPES.join(' '),
|
|
access_type: 'offline',
|
|
prompt: 'consent',
|
|
});
|
|
return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
|
|
}
|
|
|
|
/** Exchange authorization code for tokens using Google's token endpoint. */
|
|
async function exchangeCodeForTokens(
|
|
code: string,
|
|
clientId: string,
|
|
clientSecret: string,
|
|
redirectUri: string,
|
|
): Promise<Record<string, unknown>> {
|
|
const response = await fetch('https://oauth2.googleapis.com/token', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({
|
|
code,
|
|
client_id: clientId,
|
|
client_secret: clientSecret,
|
|
redirect_uri: redirectUri,
|
|
grant_type: 'authorization_code',
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const body = await response.text();
|
|
throw new Error(`Token exchange failed (${response.status}): ${body}`);
|
|
}
|
|
|
|
return response.json() as Promise<Record<string, unknown>>;
|
|
}
|
|
|
|
/** Save token to disk with restrictive permissions (0o600). */
|
|
export function saveToken(tokenPath: string, token: unknown): void {
|
|
const dir = dirname(tokenPath);
|
|
if (!existsSync(dir)) {
|
|
mkdirSync(dir, { recursive: true });
|
|
}
|
|
writeFileSync(tokenPath, JSON.stringify(token, null, 2), 'utf-8');
|
|
try {
|
|
chmodSync(tokenPath, 0o600);
|
|
} catch {
|
|
// chmod may fail on some filesystems — not critical
|
|
}
|
|
}
|
|
|
|
/** Start a temporary HTTP server to receive the OAuth callback. */
|
|
function waitForCallback(port: number): Promise<{ code: string; server: Server }> {
|
|
return new Promise((resolve, reject) => {
|
|
const server = createServer((req, res) => {
|
|
const url = new URL(req.url ?? '/', `http://localhost:${port}`);
|
|
const code = url.searchParams.get('code');
|
|
const error = url.searchParams.get('error');
|
|
|
|
if (error) {
|
|
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
res.end(`<h1>Authorization failed</h1><p>${error}</p>`);
|
|
reject(new Error(`OAuth error: ${error}`));
|
|
server.close();
|
|
return;
|
|
}
|
|
|
|
if (code) {
|
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
res.end('<h1>Authorization successful!</h1><p>You can close this tab and return to the terminal.</p>');
|
|
resolve({ code, server });
|
|
return;
|
|
}
|
|
|
|
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
res.end('<h1>Missing authorization code</h1>');
|
|
});
|
|
|
|
server.listen(port, () => {});
|
|
server.on('error', reject);
|
|
});
|
|
}
|
|
|
|
/** Try to open a URL in the user's browser. */
|
|
async function openBrowser(url: string): Promise<boolean> {
|
|
const { exec } = await import('child_process');
|
|
const command = process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
return new Promise((resolve) => {
|
|
exec(`${command} ${JSON.stringify(url)}`, (error) => {
|
|
resolve(!error);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function promptYesNo(question: string): Promise<boolean> {
|
|
const readline = await import('readline');
|
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
const answer = await new Promise<string>((resolve) => rl.question(question, resolve));
|
|
rl.close();
|
|
const normalized = answer.trim().toLowerCase();
|
|
return normalized === 'y' || normalized === 'yes';
|
|
}
|
|
|
|
/** Manual code entry via stdin. */
|
|
async function promptForCode(): Promise<string> {
|
|
const readline = await import('readline');
|
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
return new Promise((resolve) => {
|
|
rl.question('Enter the authorization code: ', (answer) => {
|
|
rl.close();
|
|
resolve(answer.trim());
|
|
});
|
|
});
|
|
}
|
|
|
|
export function registerGmailAuthCommand(program: Command): void {
|
|
program
|
|
.command('gmail-auth')
|
|
.description('Authenticate with Gmail via OAuth2')
|
|
.option('-c, --config <path>', 'Config file path')
|
|
.option('--manual', 'Manually paste the authorization code instead of using a local server')
|
|
.action(async (opts: { config?: string; manual?: boolean }) => {
|
|
// 1. Load config
|
|
const { config, error } = loadConfigSafe(opts.config);
|
|
if (error || !config) {
|
|
console.error(`Error: ${error ?? 'Could not load config'}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const gmailConfig = config.automation.gmail;
|
|
if (!gmailConfig) {
|
|
console.error('Error: automation.gmail is not configured in config.yaml');
|
|
process.exit(1);
|
|
}
|
|
|
|
// 2. Read credentials
|
|
const credentialsPath = expandPath(gmailConfig.credentials_file ?? '~/.config/flynn/gmail-credentials.json');
|
|
let creds: ReturnType<typeof readCredentials>;
|
|
try {
|
|
creds = readCredentials(credentialsPath);
|
|
} catch (err) {
|
|
console.error(`Error: ${err instanceof Error ? err.message : err}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const tokenPath = expandPath(gmailConfig.token_file ?? '~/.config/flynn/gmail-token.json');
|
|
|
|
// 3. Check if already authenticated
|
|
if (existsSync(tokenPath)) {
|
|
console.log(`Token already exists at ${tokenPath}`);
|
|
const confirmed = await promptYesNo('Re-authenticate and replace it? (y/N): ');
|
|
if (!confirmed) {
|
|
console.log('Cancelled.');
|
|
process.exit(0);
|
|
}
|
|
}
|
|
|
|
const redirectUri = opts.manual
|
|
? (creds.redirect_uris?.[0] ?? 'urn:ietf:wg:oauth:2.0:oob')
|
|
: REDIRECT_URI;
|
|
|
|
// 4. Generate auth URL
|
|
const authUrl = generateAuthUrl(creds.client_id, creds.client_secret, redirectUri);
|
|
|
|
if (opts.manual) {
|
|
// Manual flow
|
|
console.log('\nOpen this URL in your browser:\n');
|
|
console.log(authUrl);
|
|
console.log('');
|
|
const code = await promptForCode();
|
|
const token = await exchangeCodeForTokens(code, creds.client_id, creds.client_secret, redirectUri);
|
|
saveToken(tokenPath, token);
|
|
console.log(`\nToken saved to ${tokenPath}`);
|
|
} else {
|
|
// Local server flow
|
|
console.log('Starting local server for OAuth callback...');
|
|
|
|
let callbackResult: { code: string; server: Server };
|
|
try {
|
|
const callbackPromise = waitForCallback(REDIRECT_PORT);
|
|
|
|
const opened = await openBrowser(authUrl);
|
|
if (!opened) {
|
|
console.log('\nCould not open browser. Open this URL manually:\n');
|
|
console.log(authUrl);
|
|
} else {
|
|
console.log('\nBrowser opened. Complete the authorization flow...');
|
|
}
|
|
|
|
console.log(`\nWaiting for callback on http://localhost:${REDIRECT_PORT}...`);
|
|
callbackResult = await callbackPromise;
|
|
} catch (err) {
|
|
console.error(`\nError: ${err instanceof Error ? err.message : err}`);
|
|
console.log('\nTry again with --manual flag: flynn gmail-auth --manual');
|
|
process.exit(1);
|
|
}
|
|
|
|
try {
|
|
const token = await exchangeCodeForTokens(
|
|
callbackResult.code,
|
|
creds.client_id,
|
|
creds.client_secret,
|
|
redirectUri,
|
|
);
|
|
saveToken(tokenPath, token);
|
|
console.log(`\nToken saved to ${tokenPath}`);
|
|
} finally {
|
|
callbackResult.server.close();
|
|
}
|
|
}
|
|
|
|
console.log('Gmail authentication complete!');
|
|
});
|
|
}
|