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/documents.readonly', 'https://www.googleapis.com/auth/drive.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. */ 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. */ 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> { 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>; } /** Save token to disk with restrictive permissions (0o600). */ 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(`

Authorization failed

${error}

`); reject(new Error(`OAuth error: ${error}`)); server.close(); return; } if (code) { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end('

Authorization successful!

You can close this tab and return to the terminal.

'); resolve({ code, server }); return; } res.writeHead(400, { 'Content-Type': 'text/html' }); res.end('

Missing authorization code

'); }); server.listen(port, () => {}); server.on('error', reject); }); } /** Try to open a URL in the user's browser. */ async function openBrowser(url: string): Promise { 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); }); }); } /** Manual code entry via stdin. */ async function promptForCode(): Promise { 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 registerGdocsAuthCommand(program: Command): void { program .command('gdocs-auth') .description('Authenticate with Google Docs via OAuth2') .option('-c, --config ', '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 gdocsConfig = config.automation.gdocs; if (!gdocsConfig) { console.error('Error: automation.gdocs is not configured in config.yaml'); process.exit(1); } // 2. Read credentials const credentialsPath = expandPath(gdocsConfig.credentials_file ?? '~/.config/flynn/gdocs-credentials.json'); let creds: ReturnType; try { creds = readCredentials(credentialsPath); } catch (err) { console.error(`Error: ${err instanceof Error ? err.message : err}`); process.exit(1); } const tokenPath = expandPath(gdocsConfig.token_file ?? '~/.config/flynn/gdocs-token.json'); // 3. Check if already authenticated if (existsSync(tokenPath)) { console.log(`Token already exists at ${tokenPath}`); console.log('Delete it first if you want to re-authenticate.'); 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 gdocs-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('Google Docs authentication complete!'); }); }