feat(google-auth): centralize oauth token store and service checks
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user