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