feat(google-auth): centralize oauth token store and service checks

This commit is contained in:
William Valentin
2026-02-23 17:11:09 -08:00
parent 076379bfc1
commit 00b2d646f7
19 changed files with 668 additions and 302 deletions
+136
View File
@@ -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<string, unknown>;
scopes?: string[];
token_file?: string;
credentials_file?: string;
updated_at: string;
}
export interface GoogleAuthInfo {
services: Partial<Record<GoogleService, GoogleTokenRecord>>;
}
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<T>(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<AuthStore>(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<string, unknown>, 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<string, unknown> | null {
return loadStoredGoogleTokenRecord(service)?.token ?? null;
}
export function storeGoogleToken(
service: GoogleService,
token: Record<string, unknown>,
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;
}
}
}
+10
View File
@@ -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';
+15 -58
View File
@@ -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
}
}
}
+93
View File
@@ -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');
+116 -14
View File
@@ -31,6 +31,29 @@ const asRecord = (value: unknown): UnknownRecord | undefined => (
value && typeof value === 'object' ? value as UnknownRecord : undefined
);
function loadAuthStore(): Record<string, unknown> {
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<string, unknown> : {};
} catch {
return {};
}
}
function hasGoogleServiceToken(store: Record<string, unknown>, 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<string, unknown> => {
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<string, unknown> : {};
} 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,
+5
View File
@@ -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<string, unknown>, { tokenFile: tokenPath });
}
}
/** Start a temporary HTTP server to receive the OAuth callback. */
+5
View File
@@ -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<string, unknown>, { tokenFile: tokenPath });
}
}
/** Start a temporary HTTP server to receive the OAuth callback. */
+5
View File
@@ -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<string, unknown>, { tokenFile: tokenPath });
}
}
/** Start a temporary HTTP server to receive the OAuth callback. */
+5
View File
@@ -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<string, unknown>, { tokenFile: tokenPath });
}
}
/** Start a temporary HTTP server to receive the OAuth callback. */
+65
View File
@@ -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>', `Service: ${GOOGLE_AUTH_SERVICES.join('|')}`)
.option('-c, --config <path>', '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' });
});
}
+5
View File
@@ -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<string, unknown>, { tokenFile: tokenPath });
}
}
/** Start a temporary HTTP server to receive the OAuth callback. */
+1
View File
@@ -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', () => {
+2
View File
@@ -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);
+165
View File
@@ -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<GoogleService, GoogleServiceMeta> = {
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<string, unknown> | null {
if (!existsSync(tokenPath)) {
return null;
}
try {
return JSON.parse(readFileSync(tokenPath, 'utf-8')) as Record<string, unknown>;
} catch {
return null;
}
}
function writeTokenToFile(tokenPath: string, token: Record<string, 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
}
}
function loadTokenWithMigration(service: GoogleService, tokenPath: string, credentialsPath: string): Record<string, unknown> | 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;
}
+8 -46
View File
@@ -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<GcalConfig>): 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<GcalConfig>) {
return createGoogleOAuth2Client({
service: 'gcal',
credentialsFile: config.credentials_file,
tokenFile: config.token_file,
});
}
interface EventSummary {
+8 -46
View File
@@ -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<GdocsConfig>): 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<GdocsConfig>) {
return createGoogleOAuth2Client({
service: 'gdocs',
credentialsFile: config.credentials_file,
tokenFile: config.token_file,
});
}
interface DocSummary {
+8 -46
View File
@@ -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<GdriveConfig>): 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<GdriveConfig>) {
return createGoogleOAuth2Client({
service: 'gdrive',
credentialsFile: config.credentials_file,
tokenFile: config.token_file,
});
}
interface FileSummary {
+8 -46
View File
@@ -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<GmailConfig>): 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<GmailConfig>) {
return createGoogleOAuth2Client({
service: 'gmail',
credentialsFile: config.credentials_file,
tokenFile: config.token_file,
});
}
interface EmailSummary {
+8 -46
View File
@@ -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<GtasksConfig>): 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<GtasksConfig>) {
return createGoogleOAuth2Client({
service: 'gtasks',
credentialsFile: config.credentials_file,
tokenFile: config.token_file,
});
}
interface TaskListSummary {