diff --git a/src/auth/google.ts b/src/auth/google.ts new file mode 100644 index 0000000..ce5eec2 --- /dev/null +++ b/src/auth/google.ts @@ -0,0 +1,136 @@ +import { readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs'; +import { resolve } from 'path'; +import { homedir } from 'os'; + +export type GoogleService = 'gmail' | 'gcal' | 'gdocs' | 'gdrive' | 'gtasks'; + +const AUTH_DIR = resolve(homedir(), '.config/flynn'); +const AUTH_FILE = resolve(AUTH_DIR, 'auth.json'); + +export interface GoogleTokenRecord { + token: Record; + scopes?: string[]; + token_file?: string; + credentials_file?: string; + updated_at: string; +} + +export interface GoogleAuthInfo { + services: Partial>; +} + +interface AuthStore { + google?: GoogleAuthInfo; + [key: string]: unknown; +} + +function isIgnorableStoreError(error: unknown): boolean { + if (!(error instanceof Error) || !('code' in error)) { + return false; + } + const code = (error as NodeJS.ErrnoException).code; + return code === 'EACCES' || code === 'EPERM' || code === 'EROFS'; +} + +function safeJsonParse(raw: string): T | null { + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +function readAuthStore(): AuthStore { + try { + const raw = readFileSync(AUTH_FILE, 'utf-8'); + const parsed = safeJsonParse(raw); + return parsed ?? {}; + } catch { + return {}; + } +} + +function writeAuthStore(store: AuthStore): void { + mkdirSync(AUTH_DIR, { recursive: true }); + writeFileSync(AUTH_FILE, JSON.stringify(store, null, 2) + '\n', 'utf-8'); + chmodSync(AUTH_FILE, 0o600); +} + +function parseScopes(token: Record, explicitScopes?: string[]): string[] | undefined { + if (explicitScopes && explicitScopes.length > 0) { + return explicitScopes; + } + const scopeValue = token.scope; + if (typeof scopeValue !== 'string' || scopeValue.trim().length === 0) { + return undefined; + } + return scopeValue.split(/\s+/).filter(Boolean); +} + +function ensureGoogleAuth(store: AuthStore): GoogleAuthInfo { + if (!store.google) { + store.google = { services: {} }; + return store.google; + } + if (!store.google.services) { + store.google.services = {}; + } + return store.google; +} + +export function loadStoredGoogleTokenRecord(service: GoogleService): GoogleTokenRecord | null { + const store = readAuthStore(); + return store.google?.services?.[service] ?? null; +} + +export function loadStoredGoogleToken(service: GoogleService): Record | null { + return loadStoredGoogleTokenRecord(service)?.token ?? null; +} + +export function storeGoogleToken( + service: GoogleService, + token: Record, + options?: { + scopes?: string[]; + tokenFile?: string; + credentialsFile?: string; + }, +): void { + const store = readAuthStore(); + const google = ensureGoogleAuth(store); + google.services[service] = { + token, + scopes: parseScopes(token, options?.scopes), + token_file: options?.tokenFile, + credentials_file: options?.credentialsFile, + updated_at: new Date().toISOString(), + }; + try { + writeAuthStore(store); + } catch (error) { + // Preserve legacy token-file behavior when central store cannot be written. + if (!isIgnorableStoreError(error)) { + throw error; + } + } +} + +export function clearGoogleToken(service: GoogleService): void { + const store = readAuthStore(); + if (!store.google?.services?.[service]) { + return; + } + + delete store.google.services[service]; + + if (Object.keys(store.google.services).length === 0) { + delete store.google; + } + try { + writeAuthStore(store); + } catch (error) { + if (!isIgnorableStoreError(error)) { + throw error; + } + } +} diff --git a/src/auth/index.ts b/src/auth/index.ts index 44ddf5f..c2f87b3 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -53,3 +53,13 @@ export { getZaiApiKey, type ZaiAuthInfo, } from './zai.js'; + +export { + loadStoredGoogleTokenRecord, + loadStoredGoogleToken, + storeGoogleToken, + clearGoogleToken, + type GoogleService, + type GoogleTokenRecord, + type GoogleAuthInfo, +} from './google.js'; diff --git a/src/automation/gmail.ts b/src/automation/gmail.ts index 90ddb75..cb674b9 100644 --- a/src/automation/gmail.ts +++ b/src/automation/gmail.ts @@ -1,13 +1,12 @@ import { google, type Auth } from 'googleapis'; -import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from 'fs'; -import { dirname, resolve } from 'path'; -import { homedir } from 'os'; +import { readFileSync, existsSync } from 'fs'; import type { v1 } from '@google-cloud/pubsub'; import type { GmailConfig } from '../config/schema.js'; import type { ChannelAdapter, ChannelStatus, InboundMessage, OutboundMessage } from '../channels/types.js'; import { parseInterval } from './heartbeat.js'; import { sanitizeHtml } from '../utils/html.js'; import { auditLogger } from '../audit/index.js'; +import { createGoogleOAuth2Client, expandPath } from '../google/oauth.js'; /** Minimal interface for the parts of ChannelRegistry we need. */ interface ChannelLookup { @@ -178,6 +177,13 @@ export class GmailWatcher implements ChannelAdapter { this.messageHandler = handler; } + /** + * Backward-compatible path helper used by tests and callers. + */ + expandPath(p: string): string { + return expandPath(p); + } + /** * Handle a Pub/Sub push notification from Google. * Called by the gateway when POST /gmail/push is received. @@ -218,45 +224,22 @@ export class GmailWatcher implements ChannelAdapter { throw new Error('No credentials_file configured. Set automation.gmail.credentials_file in config.'); } - const expandedCredsPath = this.expandPath(credentialsPath); + const expandedCredsPath = expandPath(credentialsPath); if (!existsSync(expandedCredsPath)) { throw new Error(`Credentials file not found: ${expandedCredsPath}`); } const credentials = JSON.parse(readFileSync(expandedCredsPath, 'utf-8')); - const { client_id, client_secret, redirect_uris, project_id } = credentials.installed ?? credentials.web ?? {}; + const { project_id } = credentials.installed ?? credentials.web ?? {}; if (project_id && typeof project_id === 'string') { this.googleProjectId = project_id; } - if (!client_id || !client_secret) { - throw new Error('Invalid credentials file — missing client_id or client_secret'); - } - - const oauth2Client = new google.auth.OAuth2( - client_id, - client_secret, - redirect_uris?.[0] ?? 'http://localhost', - ); - - // Load stored token - const tokenPath = this.expandPath(this.config.token_file ?? '~/.config/flynn/gmail-token.json'); - if (!existsSync(tokenPath)) { - throw new Error( - `Token file not found: ${tokenPath}. Run "flynn gmail-auth" to authenticate.`, - ); - } - - const token = JSON.parse(readFileSync(tokenPath, 'utf-8')); - oauth2Client.setCredentials(token); - - // Auto-save refreshed tokens - oauth2Client.on('tokens', (newTokens) => { - const merged = { ...token, ...newTokens }; - this.saveToken(merged); + return createGoogleOAuth2Client({ + service: 'gmail', + credentialsFile: this.config.credentials_file, + tokenFile: this.config.token_file, }); - - return oauth2Client; } /** @@ -608,30 +591,4 @@ export class GmailWatcher implements ChannelAdapter { .replace(/\{\{labels\}\}/g, email.labels.join(', ')); } - /** - * Expand ~ to the user's home directory. - */ - expandPath(p: string): string { - if (p.startsWith('~/') || p === '~') { - return resolve(homedir(), p.slice(2)); - } - return resolve(p); - } - - /** - * Save token to disk with restrictive permissions (0o600). - */ - private saveToken(token: unknown): void { - const tokenPath = this.expandPath(this.config.token_file ?? '~/.config/flynn/gmail-token.json'); - 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 - } - } } diff --git a/src/cli/doctor.test.ts b/src/cli/doctor.test.ts index aa73622..9a3911c 100644 --- a/src/cli/doctor.test.ts +++ b/src/cli/doctor.test.ts @@ -6,8 +6,10 @@ import { tmpdir } from 'os'; describe('doctor checks', () => { const testDir = join(tmpdir(), 'flynn-test-doctor'); + const originalHome = process.env.HOME; afterEach(() => { + process.env.HOME = originalHome; try { rmSync(testDir, { recursive: true }); } catch {} }); @@ -201,6 +203,97 @@ automation: expect(gmailCheck?.detail).toContain('flynn gmail-auth'); }); + it('reports SKIP for Google Calendar when not enabled', async () => { + mkdirSync(testDir, { recursive: true }); + const configPath = join(testDir, 'config.yaml'); + writeFileSync(configPath, ` +telegram: + bot_token: "test-token" + allowed_chat_ids: [123] +models: + default: + provider: anthropic + model: claude-sonnet +`); + + const ctx: DoctorContext = { configPath, dataDir: testDir }; + const results = await runChecks(ctx); + + const gcalCheck = results.find(r => r.label.includes('Google Calendar')); + expect(gcalCheck?.status).toBe('skip'); + }); + + it('reports WARN for Google Calendar when token missing in file and auth store', async () => { + mkdirSync(testDir, { recursive: true }); + const configPath = join(testDir, 'config.yaml'); + const credsPath = join(testDir, 'gcal-creds.json'); + writeFileSync(credsPath, '{}'); + writeFileSync(configPath, ` +telegram: + bot_token: "test-token" + allowed_chat_ids: [123] +models: + default: + provider: anthropic + model: claude-sonnet +automation: + gcal: + enabled: true + credentials_file: "${credsPath}" + token_file: "${join(testDir, 'missing-gcal-token.json')}" +`); + + const ctx: DoctorContext = { configPath, dataDir: testDir }; + const results = await runChecks(ctx); + + const gcalCheck = results.find(r => r.label.includes('Google Calendar configured')); + expect(gcalCheck?.status).toBe('warn'); + expect(gcalCheck?.detail).toContain('flynn gcal-auth'); + }); + + it('reports PASS for Google Calendar when token exists in auth store', async () => { + mkdirSync(testDir, { recursive: true }); + process.env.HOME = testDir; + const configPath = join(testDir, 'config.yaml'); + const credsPath = join(testDir, 'gcal-creds.json'); + writeFileSync(credsPath, '{}'); + mkdirSync(join(testDir, '.config/flynn'), { recursive: true }); + writeFileSync( + join(testDir, '.config/flynn/auth.json'), + JSON.stringify({ + google: { + services: { + gcal: { + token: { refresh_token: 'refresh-test' }, + updated_at: '2026-02-24T00:00:00.000Z', + }, + }, + }, + }), + ); + writeFileSync(configPath, ` +telegram: + bot_token: "test-token" + allowed_chat_ids: [123] +models: + default: + provider: anthropic + model: claude-sonnet +automation: + gcal: + enabled: true + credentials_file: "${credsPath}" + token_file: "${join(testDir, 'missing-gcal-token.json')}" +`); + + const ctx: DoctorContext = { configPath, dataDir: testDir }; + const results = await runChecks(ctx); + + const gcalCheck = results.find(r => r.label.includes('Google Calendar configured')); + expect(gcalCheck?.status).toBe('pass'); + expect(gcalCheck?.detail).toContain('auth_store'); + }); + it('reports PASS for Gmail when enabled (poll only)', async () => { mkdirSync(testDir, { recursive: true }); const configPath = join(testDir, 'config.yaml'); diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 1e13d48..e49ed4c 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -31,6 +31,29 @@ const asRecord = (value: unknown): UnknownRecord | undefined => ( value && typeof value === 'object' ? value as UnknownRecord : undefined ); +function loadAuthStore(): Record { + const home = process.env.HOME ?? homedir(); + const authFile = resolve(home, '.config/flynn/auth.json'); + try { + const raw = readFileSync(authFile, 'utf-8'); + const parsed = JSON.parse(raw) as unknown; + return (parsed && typeof parsed === 'object') ? parsed as Record : {}; + } catch { + return {}; + } +} + +function hasGoogleServiceToken(store: Record, service: 'gmail' | 'gcal' | 'gdocs' | 'gdrive' | 'gtasks'): boolean { + const google = asRecord(store.google); + const services = asRecord(google?.services); + const record = asRecord(services?.[service]); + const token = asRecord(record?.token); + return Boolean( + (typeof token?.refresh_token === 'string' && token.refresh_token.length > 0) + || (typeof token?.access_token === 'string' && token.access_token.length > 0), + ); +} + const checkConfigExists: Check = async (ctx) => { if (existsSync(ctx.configPath)) { return { status: 'pass', label: 'Config file exists', detail: `(${ctx.configPath})` }; @@ -184,18 +207,6 @@ const checkModelConnectivity: Check = async (ctx) => { return 'auto'; }; - const loadAuthStore = (): Record => { - const home = process.env.HOME ?? homedir(); - const authFile = resolve(home, '.config/flynn/auth.json'); - try { - const raw = readFileSync(authFile, 'utf-8'); - const parsed = JSON.parse(raw) as unknown; - return (parsed && typeof parsed === 'object') ? parsed as Record : {}; - } catch { - return {}; - } - }; - const store = loadAuthStore(); const storeOpenAIOAuthPresent = (): boolean => { @@ -551,7 +562,10 @@ const checkGmail: Check = async (ctx) => { } const tokenPath = expandPath(gmail.token_file ?? '~/.config/flynn/gmail-token.json'); - if (!existsSync(tokenPath)) { + const store = loadAuthStore(); + const hasStoreToken = hasGoogleServiceToken(store, 'gmail'); + const hasTokenFile = existsSync(tokenPath); + if (!hasTokenFile && !hasStoreToken) { return { status: 'warn', label: 'Gmail configured', detail: 'run `flynn gmail-auth` to authenticate' }; } @@ -592,12 +606,96 @@ const checkGmail: Check = async (ctx) => { } modes.push('poll'); - const detail = `(${modes.join(' + ')} -> ${gmail.output.channel}/${gmail.output.peer})`; + const tokenSources: string[] = []; + if (hasTokenFile) {tokenSources.push('token_file');} + if (hasStoreToken) {tokenSources.push('auth_store');} + const detail = `(${modes.join(' + ')} -> ${gmail.output.channel}/${gmail.output.peer}; token=${tokenSources.join('+')})`; const withWarnings = warnings.length > 0 ? `${detail} — ${warnings.join('; ')}` : detail; return { status: warnings.length > 0 ? 'warn' : 'pass', label: 'Gmail configured', detail: withWarnings }; }; +function checkGoogleAutomationService( + ctx: DoctorContext, + opts: { + service: 'gcal' | 'gdocs' | 'gdrive' | 'gtasks'; + label: string; + authCommand: string; + credentialsFile?: string; + tokenFile?: string; + enabled?: boolean; + }, +): CheckResult { + if (!ctx.config) { + return { status: 'skip', label: opts.label, detail: '(config invalid)' }; + } + if (!opts.enabled) { + return { status: 'skip', label: opts.label, detail: '(not enabled)' }; + } + + const credentialsPath = expandPath(opts.credentialsFile ?? `~/.config/flynn/${opts.service}-credentials.json`); + if (!existsSync(credentialsPath)) { + return { status: 'fail', label: opts.label, detail: `credentials file not found: ${credentialsPath}` }; + } + + const tokenPath = expandPath(opts.tokenFile ?? `~/.config/flynn/${opts.service}-token.json`); + const hasTokenFile = existsSync(tokenPath); + const store = loadAuthStore(); + const hasStoreToken = hasGoogleServiceToken(store, opts.service); + if (!hasTokenFile && !hasStoreToken) { + return { + status: 'warn', + label: opts.label, + detail: `run \`flynn ${opts.authCommand}\` to authenticate`, + }; + } + + const tokenSources: string[] = []; + if (hasTokenFile) {tokenSources.push('token_file');} + if (hasStoreToken) {tokenSources.push('auth_store');} + return { + status: 'pass', + label: opts.label, + detail: `(token=${tokenSources.join('+')})`, + }; +} + +const checkGcal: Check = async (ctx) => checkGoogleAutomationService(ctx, { + service: 'gcal', + label: 'Google Calendar configured', + authCommand: 'gcal-auth', + enabled: ctx.config?.automation.gcal?.enabled, + credentialsFile: ctx.config?.automation.gcal?.credentials_file, + tokenFile: ctx.config?.automation.gcal?.token_file, +}); + +const checkGdocs: Check = async (ctx) => checkGoogleAutomationService(ctx, { + service: 'gdocs', + label: 'Google Docs configured', + authCommand: 'gdocs-auth', + enabled: ctx.config?.automation.gdocs?.enabled, + credentialsFile: ctx.config?.automation.gdocs?.credentials_file, + tokenFile: ctx.config?.automation.gdocs?.token_file, +}); + +const checkGdrive: Check = async (ctx) => checkGoogleAutomationService(ctx, { + service: 'gdrive', + label: 'Google Drive configured', + authCommand: 'gdrive-auth', + enabled: ctx.config?.automation.gdrive?.enabled, + credentialsFile: ctx.config?.automation.gdrive?.credentials_file, + tokenFile: ctx.config?.automation.gdrive?.token_file, +}); + +const checkGtasks: Check = async (ctx) => checkGoogleAutomationService(ctx, { + service: 'gtasks', + label: 'Google Tasks configured', + authCommand: 'gtasks-auth', + enabled: ctx.config?.automation.gtasks?.enabled, + credentialsFile: ctx.config?.automation.gtasks?.credentials_file, + tokenFile: ctx.config?.automation.gtasks?.token_file, +}); + const checkMinioExtractors: Check = async (ctx) => { if (!ctx.config) { return { status: 'skip', label: 'MinIO ingest extractors', detail: '(config invalid)' }; @@ -634,6 +732,10 @@ const allChecks: Check[] = [ checkModelConnectivity, checkTelegram, checkGmail, + checkGcal, + checkGdocs, + checkGdrive, + checkGtasks, checkMinioExtractors, checkMcpServers, checkSkills, diff --git a/src/cli/gcal-auth.ts b/src/cli/gcal-auth.ts index 7efcf95..5f011bd 100644 --- a/src/cli/gcal-auth.ts +++ b/src/cli/gcal-auth.ts @@ -5,6 +5,7 @@ import { homedir } from 'os'; import { createServer, type Server } from 'http'; import { URL } from 'url'; import { loadConfigSafe } from './shared.js'; +import { storeGoogleToken } from '../auth/google.js'; const SCOPES = ['https://www.googleapis.com/auth/calendar.readonly']; const REDIRECT_PORT = 3000; @@ -90,6 +91,10 @@ function saveToken(tokenPath: string, token: unknown): void { } catch { // chmod may fail on some filesystems — not critical } + + if (token && typeof token === 'object' && !Array.isArray(token)) { + storeGoogleToken('gcal', token as Record, { tokenFile: tokenPath }); + } } /** Start a temporary HTTP server to receive the OAuth callback. */ diff --git a/src/cli/gdocs-auth.ts b/src/cli/gdocs-auth.ts index 16c0434..42c2b13 100644 --- a/src/cli/gdocs-auth.ts +++ b/src/cli/gdocs-auth.ts @@ -5,6 +5,7 @@ import { homedir } from 'os'; import { createServer, type Server } from 'http'; import { URL } from 'url'; import { loadConfigSafe } from './shared.js'; +import { storeGoogleToken } from '../auth/google.js'; const SCOPES = [ 'https://www.googleapis.com/auth/documents.readonly', @@ -93,6 +94,10 @@ function saveToken(tokenPath: string, token: unknown): void { } catch { // chmod may fail on some filesystems — not critical } + + if (token && typeof token === 'object' && !Array.isArray(token)) { + storeGoogleToken('gdocs', token as Record, { tokenFile: tokenPath }); + } } /** Start a temporary HTTP server to receive the OAuth callback. */ diff --git a/src/cli/gdrive-auth.ts b/src/cli/gdrive-auth.ts index f8b13f7..c1b2fcf 100644 --- a/src/cli/gdrive-auth.ts +++ b/src/cli/gdrive-auth.ts @@ -5,6 +5,7 @@ import { homedir } from 'os'; import { createServer, type Server } from 'http'; import { URL } from 'url'; import { loadConfigSafe } from './shared.js'; +import { storeGoogleToken } from '../auth/google.js'; const SCOPES = ['https://www.googleapis.com/auth/drive.readonly']; const REDIRECT_PORT = 3000; @@ -90,6 +91,10 @@ function saveToken(tokenPath: string, token: unknown): void { } catch { // chmod may fail on some filesystems — not critical } + + if (token && typeof token === 'object' && !Array.isArray(token)) { + storeGoogleToken('gdrive', token as Record, { tokenFile: tokenPath }); + } } /** Start a temporary HTTP server to receive the OAuth callback. */ diff --git a/src/cli/gmail-auth.ts b/src/cli/gmail-auth.ts index 24a78a0..3ca0e35 100644 --- a/src/cli/gmail-auth.ts +++ b/src/cli/gmail-auth.ts @@ -5,6 +5,7 @@ import { homedir } from 'os'; import { createServer, type Server } from 'http'; import { URL } from 'url'; import { loadConfigSafe } from './shared.js'; +import { storeGoogleToken } from '../auth/google.js'; const SCOPES = [ // Explicitly request Gmail settings scope required by filters.create. @@ -95,6 +96,10 @@ export function saveToken(tokenPath: string, token: unknown): void { } catch { // chmod may fail on some filesystems — not critical } + + if (token && typeof token === 'object' && !Array.isArray(token)) { + storeGoogleToken('gmail', token as Record, { tokenFile: tokenPath }); + } } /** Start a temporary HTTP server to receive the OAuth callback. */ diff --git a/src/cli/google-auth.ts b/src/cli/google-auth.ts new file mode 100644 index 0000000..ee61c67 --- /dev/null +++ b/src/cli/google-auth.ts @@ -0,0 +1,65 @@ +import type { Command } from 'commander'; +import { Command as CommanderProgram } from 'commander'; +import { registerGmailAuthCommand } from './gmail-auth.js'; +import { registerGcalAuthCommand } from './gcal-auth.js'; +import { registerGdocsAuthCommand } from './gdocs-auth.js'; +import { registerGdriveAuthCommand } from './gdrive-auth.js'; +import { registerGtasksAuthCommand } from './gtasks-auth.js'; + +const GOOGLE_AUTH_SERVICES = ['gmail', 'gcal', 'gdocs', 'gdrive', 'gtasks'] as const; +type GoogleAuthService = (typeof GOOGLE_AUTH_SERVICES)[number]; + +function parseService(input: string): GoogleAuthService | null { + const normalized = input.trim().toLowerCase(); + return GOOGLE_AUTH_SERVICES.find((service) => service === normalized) ?? null; +} + +function registerGoogleServiceCommand(program: Command, service: GoogleAuthService): void { + if (service === 'gmail') { + registerGmailAuthCommand(program); + return; + } + if (service === 'gcal') { + registerGcalAuthCommand(program); + return; + } + if (service === 'gdocs') { + registerGdocsAuthCommand(program); + return; + } + if (service === 'gdrive') { + registerGdriveAuthCommand(program); + return; + } + registerGtasksAuthCommand(program); +} + +export function registerGoogleAuthCommand(program: Command): void { + program + .command('google-auth') + .description('Authenticate Google services via OAuth2 (gmail, gcal, gdocs, gdrive, gtasks)') + .requiredOption('-s, --service ', `Service: ${GOOGLE_AUTH_SERVICES.join('|')}`) + .option('-c, --config ', 'Config file path') + .option('--manual', 'Manually paste the authorization code instead of using a local server') + .action(async (opts: { service: string; config?: string; manual?: boolean }) => { + const service = parseService(opts.service); + if (!service) { + console.error( + `Error: invalid --service "${opts.service}". Use one of: ${GOOGLE_AUTH_SERVICES.join(', ')}`, + ); + process.exit(1); + } + + const scopedProgram = new CommanderProgram(); + registerGoogleServiceCommand(scopedProgram, service); + + const args = ['node', 'flynn', `${service}-auth`]; + if (opts.config) { + args.push('--config', opts.config); + } + if (opts.manual) { + args.push('--manual'); + } + await scopedProgram.parseAsync(args, { from: 'user' }); + }); +} diff --git a/src/cli/gtasks-auth.ts b/src/cli/gtasks-auth.ts index 3a534c8..dea1ce6 100644 --- a/src/cli/gtasks-auth.ts +++ b/src/cli/gtasks-auth.ts @@ -5,6 +5,7 @@ import { homedir } from 'os'; import { createServer, type Server } from 'http'; import { URL } from 'url'; import { loadConfigSafe } from './shared.js'; +import { storeGoogleToken } from '../auth/google.js'; const SCOPES = ['https://www.googleapis.com/auth/tasks.readonly']; const REDIRECT_PORT = 3000; @@ -90,6 +91,10 @@ function saveToken(tokenPath: string, token: unknown): void { } catch { // chmod may fail on some filesystems — not critical } + + if (token && typeof token === 'object' && !Array.isArray(token)) { + storeGoogleToken('gtasks', token as Record, { tokenFile: tokenPath }); + } } /** Start a temporary HTTP server to receive the OAuth callback. */ diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 59c93ba..f9b2f2c 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -23,6 +23,7 @@ describe('CLI program', () => { expect(commandNames).toContain('anthropic-auth'); expect(commandNames).toContain('zai-auth'); expect(commandNames).toContain('gemini-auth'); + expect(commandNames).toContain('google-auth'); }); it('registers doctor strict flag on doctor command', () => { diff --git a/src/cli/index.ts b/src/cli/index.ts index f5f3c4c..6f02258 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -23,6 +23,7 @@ import { registerGcalAuthCommand } from './gcal-auth.js'; import { registerGdocsAuthCommand } from './gdocs-auth.js'; import { registerGdriveAuthCommand } from './gdrive-auth.js'; import { registerGtasksAuthCommand } from './gtasks-auth.js'; +import { registerGoogleAuthCommand } from './google-auth.js'; import { registerOpenaiAuthCommand } from './openai-auth.js'; import { registerOpenaiKeyCommand } from './openai-key.js'; import { registerZaiAuthCommand } from './zai-auth.js'; @@ -54,6 +55,7 @@ export function createProgram(): Command { registerGdocsAuthCommand(program); registerGdriveAuthCommand(program); registerGtasksAuthCommand(program); + registerGoogleAuthCommand(program); registerOpenaiAuthCommand(program); registerOpenaiKeyCommand(program); registerZaiAuthCommand(program); diff --git a/src/google/oauth.ts b/src/google/oauth.ts new file mode 100644 index 0000000..2854759 --- /dev/null +++ b/src/google/oauth.ts @@ -0,0 +1,165 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs'; +import { dirname, resolve } from 'path'; +import { homedir } from 'os'; +import { google, type Auth } from 'googleapis'; +import type { GoogleService } from '../auth/google.js'; +import { loadStoredGoogleToken, storeGoogleToken } from '../auth/google.js'; + +interface GoogleServiceMeta { + tokenFile: string; + command: string; + configPath: string; +} + +const GOOGLE_SERVICE_META: Record = { + gmail: { + tokenFile: '~/.config/flynn/gmail-token.json', + command: 'gmail-auth', + configPath: 'automation.gmail', + }, + gcal: { + tokenFile: '~/.config/flynn/gcal-token.json', + command: 'gcal-auth', + configPath: 'automation.gcal', + }, + gdocs: { + tokenFile: '~/.config/flynn/gdocs-token.json', + command: 'gdocs-auth', + configPath: 'automation.gdocs', + }, + gdrive: { + tokenFile: '~/.config/flynn/gdrive-token.json', + command: 'gdrive-auth', + configPath: 'automation.gdrive', + }, + gtasks: { + tokenFile: '~/.config/flynn/gtasks-token.json', + command: 'gtasks-auth', + configPath: 'automation.gtasks', + }, +}; + +export interface GoogleOAuthCredentials { + client_id: string; + client_secret: string; + redirect_uris?: string[]; +} + +export interface CreateGoogleOAuthClientOptions { + service: GoogleService; + credentialsFile?: string; + tokenFile?: string; +} + +export function expandPath(p: string): string { + if (p.startsWith('~/') || p === '~') { + return resolve(homedir(), p.slice(2)); + } + return resolve(p); +} + +export function readGoogleOAuthCredentials( + service: GoogleService, + credentialsFile?: string, +): GoogleOAuthCredentials { + if (!credentialsFile) { + throw new Error(`No credentials_file configured. Set ${GOOGLE_SERVICE_META[service].configPath}.credentials_file in config.`); + } + + const credentialsPath = expandPath(credentialsFile); + 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 }; +} + +export function tokenPathForGoogleService(service: GoogleService, configuredTokenPath?: string): string { + return expandPath(configuredTokenPath ?? GOOGLE_SERVICE_META[service].tokenFile); +} + +function readTokenFromFile(tokenPath: string): Record | null { + if (!existsSync(tokenPath)) { + return null; + } + try { + return JSON.parse(readFileSync(tokenPath, 'utf-8')) as Record; + } catch { + return null; + } +} + +function writeTokenToFile(tokenPath: string, token: Record): 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 + } +} + +function loadTokenWithMigration(service: GoogleService, tokenPath: string, credentialsPath: string): Record | null { + const fromStore = loadStoredGoogleToken(service); + if (fromStore) { + return fromStore; + } + + const fromFile = readTokenFromFile(tokenPath); + if (!fromFile) { + return null; + } + + storeGoogleToken(service, fromFile, { + tokenFile: tokenPath, + credentialsFile: credentialsPath, + }); + return fromFile; +} + +export function authCommandForGoogleService(service: GoogleService): string { + return GOOGLE_SERVICE_META[service].command; +} + +export function createGoogleOAuth2Client(options: CreateGoogleOAuthClientOptions): Auth.OAuth2Client { + const { service, credentialsFile, tokenFile } = options; + const creds = readGoogleOAuthCredentials(service, credentialsFile); + const credentialsPath = expandPath(credentialsFile ?? ''); + const resolvedTokenPath = tokenPathForGoogleService(service, tokenFile); + + const oauth2Client = new google.auth.OAuth2( + creds.client_id, + creds.client_secret, + creds.redirect_uris?.[0] ?? 'http://localhost', + ); + + let currentToken = loadTokenWithMigration(service, resolvedTokenPath, credentialsPath); + if (!currentToken) { + throw new Error( + `Token file not found: ${resolvedTokenPath}. Run "flynn ${authCommandForGoogleService(service)}" to authenticate.`, + ); + } + + oauth2Client.setCredentials(currentToken); + if (typeof oauth2Client.on === 'function') { + oauth2Client.on('tokens', (newTokens) => { + currentToken = { ...currentToken, ...newTokens }; + storeGoogleToken(service, currentToken, { + tokenFile: resolvedTokenPath, + credentialsFile: credentialsPath, + }); + writeTokenToFile(resolvedTokenPath, currentToken); + }); + } + + return oauth2Client; +} diff --git a/src/tools/builtin/gcal.ts b/src/tools/builtin/gcal.ts index 136446c..9ff9a6a 100644 --- a/src/tools/builtin/gcal.ts +++ b/src/tools/builtin/gcal.ts @@ -1,52 +1,14 @@ -import { google, type Auth } from 'googleapis'; -import { readFileSync, existsSync } from 'fs'; -import { resolve } from 'path'; -import { homedir } from 'os'; +import { google } from 'googleapis'; import type { GcalConfig } from '../../config/schema.js'; +import { createGoogleOAuth2Client } from '../../google/oauth.js'; import type { Tool, ToolResult } from '../types.js'; -/** Expand ~ to home directory. */ -function expandPath(p: string): string { - if (p.startsWith('~/') || p === '~') { - return resolve(homedir(), p.slice(2)); - } - return resolve(p); -} - -/** Create an OAuth2 client from Google Calendar config (credentials + token files). */ -function createOAuth2Client(config: NonNullable): Auth.OAuth2Client { - const credentialsPath = config.credentials_file; - if (!credentialsPath) { - throw new Error('No credentials_file configured. Set automation.gcal.credentials_file in config.'); - } - - const expandedCredsPath = expandPath(credentialsPath); - if (!existsSync(expandedCredsPath)) { - throw new Error(`Credentials file not found: ${expandedCredsPath}`); - } - - const credentials = JSON.parse(readFileSync(expandedCredsPath, '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'); - } - - const oauth2Client = new google.auth.OAuth2( - client_id, - client_secret, - redirect_uris?.[0] ?? 'http://localhost', - ); - - const tokenPath = expandPath(config.token_file ?? '~/.config/flynn/gcal-token.json'); - if (!existsSync(tokenPath)) { - throw new Error(`Token file not found: ${tokenPath}. Run "flynn gcal-auth" to authenticate.`); - } - - const token = JSON.parse(readFileSync(tokenPath, 'utf-8')); - oauth2Client.setCredentials(token); - - return oauth2Client; +function createOAuth2Client(config: NonNullable) { + return createGoogleOAuth2Client({ + service: 'gcal', + credentialsFile: config.credentials_file, + tokenFile: config.token_file, + }); } interface EventSummary { diff --git a/src/tools/builtin/gdocs.ts b/src/tools/builtin/gdocs.ts index bfd9234..3a93b6a 100644 --- a/src/tools/builtin/gdocs.ts +++ b/src/tools/builtin/gdocs.ts @@ -1,52 +1,14 @@ -import { google, type Auth } from 'googleapis'; -import { readFileSync, existsSync } from 'fs'; -import { resolve } from 'path'; -import { homedir } from 'os'; +import { google } from 'googleapis'; import type { GdocsConfig } from '../../config/schema.js'; +import { createGoogleOAuth2Client } from '../../google/oauth.js'; import type { Tool, ToolResult } from '../types.js'; -/** Expand ~ to home directory. */ -function expandPath(p: string): string { - if (p.startsWith('~/') || p === '~') { - return resolve(homedir(), p.slice(2)); - } - return resolve(p); -} - -/** Create an OAuth2 client from Google Docs config (credentials + token files). */ -function createOAuth2Client(config: NonNullable): Auth.OAuth2Client { - const credentialsPath = config.credentials_file; - if (!credentialsPath) { - throw new Error('No credentials_file configured. Set automation.gdocs.credentials_file in config.'); - } - - const expandedCredsPath = expandPath(credentialsPath); - if (!existsSync(expandedCredsPath)) { - throw new Error(`Credentials file not found: ${expandedCredsPath}`); - } - - const credentials = JSON.parse(readFileSync(expandedCredsPath, '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'); - } - - const oauth2Client = new google.auth.OAuth2( - client_id, - client_secret, - redirect_uris?.[0] ?? 'http://localhost', - ); - - const tokenPath = expandPath(config.token_file ?? '~/.config/flynn/gdocs-token.json'); - if (!existsSync(tokenPath)) { - throw new Error(`Token file not found: ${tokenPath}. Run "flynn gdocs-auth" to authenticate.`); - } - - const token = JSON.parse(readFileSync(tokenPath, 'utf-8')); - oauth2Client.setCredentials(token); - - return oauth2Client; +function createOAuth2Client(config: NonNullable) { + return createGoogleOAuth2Client({ + service: 'gdocs', + credentialsFile: config.credentials_file, + tokenFile: config.token_file, + }); } interface DocSummary { diff --git a/src/tools/builtin/gdrive.ts b/src/tools/builtin/gdrive.ts index 8b35251..fe98c69 100644 --- a/src/tools/builtin/gdrive.ts +++ b/src/tools/builtin/gdrive.ts @@ -1,52 +1,14 @@ -import { google, type Auth } from 'googleapis'; -import { readFileSync, existsSync } from 'fs'; -import { resolve } from 'path'; -import { homedir } from 'os'; +import { google } from 'googleapis'; import type { GdriveConfig } from '../../config/schema.js'; +import { createGoogleOAuth2Client } from '../../google/oauth.js'; import type { Tool, ToolResult } from '../types.js'; -/** Expand ~ to home directory. */ -function expandPath(p: string): string { - if (p.startsWith('~/') || p === '~') { - return resolve(homedir(), p.slice(2)); - } - return resolve(p); -} - -/** Create an OAuth2 client from config (credentials + token files). */ -function createOAuth2Client(config: NonNullable): Auth.OAuth2Client { - const credentialsPath = config.credentials_file; - if (!credentialsPath) { - throw new Error('No credentials_file configured. Set automation.gdrive.credentials_file in config.'); - } - - const expandedCredsPath = expandPath(credentialsPath); - if (!existsSync(expandedCredsPath)) { - throw new Error(`Credentials file not found: ${expandedCredsPath}`); - } - - const credentials = JSON.parse(readFileSync(expandedCredsPath, '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'); - } - - const oauth2Client = new google.auth.OAuth2( - client_id, - client_secret, - redirect_uris?.[0] ?? 'http://localhost', - ); - - const tokenPath = expandPath(config.token_file ?? '~/.config/flynn/gdrive-token.json'); - if (!existsSync(tokenPath)) { - throw new Error(`Token file not found: ${tokenPath}. Run "flynn gdrive-auth" to authenticate.`); - } - - const token = JSON.parse(readFileSync(tokenPath, 'utf-8')); - oauth2Client.setCredentials(token); - - return oauth2Client; +function createOAuth2Client(config: NonNullable) { + return createGoogleOAuth2Client({ + service: 'gdrive', + credentialsFile: config.credentials_file, + tokenFile: config.token_file, + }); } interface FileSummary { diff --git a/src/tools/builtin/gmail.ts b/src/tools/builtin/gmail.ts index 2ef67be..f91ecea 100644 --- a/src/tools/builtin/gmail.ts +++ b/src/tools/builtin/gmail.ts @@ -1,53 +1,15 @@ -import { google, type Auth } from 'googleapis'; -import { readFileSync, existsSync } from 'fs'; -import { resolve } from 'path'; -import { homedir } from 'os'; +import { google } from 'googleapis'; import type { GmailConfig } from '../../config/schema.js'; +import { createGoogleOAuth2Client } from '../../google/oauth.js'; import type { Tool, ToolResult } from '../types.js'; import { sanitizeHtml } from '../../utils/html.js'; -/** Expand ~ to home directory. */ -function expandPath(p: string): string { - if (p.startsWith('~/') || p === '~') { - return resolve(homedir(), p.slice(2)); - } - return resolve(p); -} - -/** Create an OAuth2 client from Gmail config (credentials + token files). */ -function createOAuth2Client(config: NonNullable): Auth.OAuth2Client { - const credentialsPath = config.credentials_file; - if (!credentialsPath) { - throw new Error('No credentials_file configured. Set automation.gmail.credentials_file in config.'); - } - - const expandedCredsPath = expandPath(credentialsPath); - if (!existsSync(expandedCredsPath)) { - throw new Error(`Credentials file not found: ${expandedCredsPath}`); - } - - const credentials = JSON.parse(readFileSync(expandedCredsPath, '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'); - } - - const oauth2Client = new google.auth.OAuth2( - client_id, - client_secret, - redirect_uris?.[0] ?? 'http://localhost', - ); - - const tokenPath = expandPath(config.token_file ?? '~/.config/flynn/gmail-token.json'); - if (!existsSync(tokenPath)) { - throw new Error(`Token file not found: ${tokenPath}. Run "flynn gmail-auth" to authenticate.`); - } - - const token = JSON.parse(readFileSync(tokenPath, 'utf-8')); - oauth2Client.setCredentials(token); - - return oauth2Client; +function createOAuth2Client(config: NonNullable) { + return createGoogleOAuth2Client({ + service: 'gmail', + credentialsFile: config.credentials_file, + tokenFile: config.token_file, + }); } interface EmailSummary { diff --git a/src/tools/builtin/gtasks.ts b/src/tools/builtin/gtasks.ts index 1543a55..e687e8e 100644 --- a/src/tools/builtin/gtasks.ts +++ b/src/tools/builtin/gtasks.ts @@ -1,52 +1,14 @@ -import { google, type Auth } from 'googleapis'; -import { readFileSync, existsSync } from 'fs'; -import { resolve } from 'path'; -import { homedir } from 'os'; +import { google } from 'googleapis'; import type { GtasksConfig } from '../../config/schema.js'; +import { createGoogleOAuth2Client } from '../../google/oauth.js'; import type { Tool, ToolResult } from '../types.js'; -/** Expand ~ to home directory. */ -function expandPath(p: string): string { - if (p.startsWith('~/') || p === '~') { - return resolve(homedir(), p.slice(2)); - } - return resolve(p); -} - -/** Create an OAuth2 client from Google Tasks config (credentials + token files). */ -function createOAuth2Client(config: NonNullable): Auth.OAuth2Client { - const credentialsPath = config.credentials_file; - if (!credentialsPath) { - throw new Error('No credentials_file configured. Set automation.gtasks.credentials_file in config.'); - } - - const expandedCredsPath = expandPath(credentialsPath); - if (!existsSync(expandedCredsPath)) { - throw new Error(`Credentials file not found: ${expandedCredsPath}`); - } - - const credentials = JSON.parse(readFileSync(expandedCredsPath, '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'); - } - - const oauth2Client = new google.auth.OAuth2( - client_id, - client_secret, - redirect_uris?.[0] ?? 'http://localhost', - ); - - const tokenPath = expandPath(config.token_file ?? '~/.config/flynn/gtasks-token.json'); - if (!existsSync(tokenPath)) { - throw new Error(`Token file not found: ${tokenPath}. Run "flynn gtasks-auth" to authenticate.`); - } - - const token = JSON.parse(readFileSync(tokenPath, 'utf-8')); - oauth2Client.setCredentials(token); - - return oauth2Client; +function createOAuth2Client(config: NonNullable) { + return createGoogleOAuth2Client({ + service: 'gtasks', + credentialsFile: config.credentials_file, + tokenFile: config.token_file, + }); } interface TaskListSummary {