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
+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;
}