import { google } from 'googleapis';
import { OAuth2Client, Credentials } from 'google-auth-library';
import * as fs from 'fs';
import * as path from 'path';
import * as http from 'http';
import { URL } from 'url';
// Scopes required for Google Sheets API (read + write)
const SCOPES = [
'https://www.googleapis.com/auth/spreadsheets',
'https://www.googleapis.com/auth/drive.file',
];
// Local server port for OAuth callback
const OAUTH_PORT = 3456;
const REDIRECT_URI = `http://localhost:${OAUTH_PORT}`;
interface ClientSecrets {
installed?: {
client_id: string;
client_secret: string;
redirect_uris: string[];
};
web?: {
client_id: string;
client_secret: string;
redirect_uris: string[];
};
}
export class GoogleAuth {
private clientSecretsPath: string;
private credentialStorePath: string;
private oauth2Client: OAuth2Client | null = null;
constructor(clientSecretsPath: string, credentialStorePath: string) {
this.clientSecretsPath = clientSecretsPath;
this.credentialStorePath = credentialStorePath;
}
/**
* Get an authenticated OAuth2 client
*/
async getAuthenticatedClient(): Promise<OAuth2Client> {
if (this.oauth2Client) {
return this.oauth2Client;
}
// Load client secrets
const clientSecrets = await this.loadClientSecrets();
const credentials = clientSecrets.installed || clientSecrets.web;
if (!credentials) {
throw new Error('Invalid client secrets file: missing "installed" or "web" credentials');
}
// Create OAuth2 client with localhost redirect
this.oauth2Client = new google.auth.OAuth2(
credentials.client_id,
credentials.client_secret,
REDIRECT_URI
);
// Try to load existing credentials
const savedCredentials = await this.loadSavedCredentials();
if (savedCredentials) {
this.oauth2Client.setCredentials(savedCredentials);
// Check if token needs refresh
if (this.isTokenExpired(savedCredentials)) {
await this.refreshAccessToken();
}
} else {
// Need to get new credentials via browser auth
await this.getNewCredentials();
}
return this.oauth2Client;
}
/**
* Load client secrets from file
*/
private async loadClientSecrets(): Promise<ClientSecrets> {
try {
const content = await fs.promises.readFile(this.clientSecretsPath, 'utf-8');
return JSON.parse(content);
} catch (error) {
throw new Error(`Failed to load client secrets from ${this.clientSecretsPath}: ${error}`);
}
}
/**
* Load saved credentials from file
*/
private async loadSavedCredentials(): Promise<Credentials | null> {
try {
const content = await fs.promises.readFile(this.credentialStorePath, 'utf-8');
return JSON.parse(content);
} catch {
return null;
}
}
/**
* Save credentials to file
*/
private async saveCredentials(credentials: Credentials): Promise<void> {
const dir = path.dirname(this.credentialStorePath);
await fs.promises.mkdir(dir, { recursive: true });
await fs.promises.writeFile(
this.credentialStorePath,
JSON.stringify(credentials, null, 2)
);
}
/**
* Check if the token is expired
*/
private isTokenExpired(credentials: Credentials): boolean {
if (!credentials.expiry_date) {
return false;
}
// Consider token expired if it expires within 5 minutes
return credentials.expiry_date < Date.now() + 5 * 60 * 1000;
}
/**
* Refresh the access token
*/
private async refreshAccessToken(): Promise<void> {
if (!this.oauth2Client) {
throw new Error('OAuth2 client not initialized');
}
try {
const { credentials } = await this.oauth2Client.refreshAccessToken();
this.oauth2Client.setCredentials(credentials);
await this.saveCredentials(credentials);
console.error('Token refreshed successfully');
} catch (error) {
// If refresh fails, need to re-authenticate
console.error('Failed to refresh token, need to re-authenticate:', error);
await this.getNewCredentials();
}
}
/**
* Get new credentials via browser authentication with local server
*/
private async getNewCredentials(): Promise<void> {
if (!this.oauth2Client) {
throw new Error('OAuth2 client not initialized');
}
const authUrl = this.oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: SCOPES,
prompt: 'consent',
});
console.error('\n===========================================');
console.error('Authorization required!');
console.error('Opening browser for authentication...');
console.error('\nIf browser does not open, please visit this URL:');
console.error(authUrl);
console.error('===========================================\n');
// Get auth code via local server
const code = await this.getAuthCodeViaLocalServer(authUrl);
const { tokens } = await this.oauth2Client.getToken(code);
this.oauth2Client.setCredentials(tokens);
await this.saveCredentials(tokens);
console.error('Authentication successful! Credentials saved.');
}
/**
* Start a local server to receive the OAuth callback
*/
private getAuthCodeViaLocalServer(authUrl: string): Promise<string> {
return new Promise((resolve, reject) => {
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url || '', `http://localhost:${OAUTH_PORT}`);
const code = url.searchParams.get('code');
const error = url.searchParams.get('error');
if (error) {
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`
<html>
<body style="font-family: sans-serif; text-align: center; padding: 50px;">
<h1>❌ 認証エラー</h1>
<p>エラー: ${error}</p>
<p>このウィンドウを閉じて、再度お試しください。</p>
</body>
</html>
`);
server.close();
reject(new Error(`OAuth error: ${error}`));
return;
}
if (code) {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`
<html>
<body style="font-family: sans-serif; text-align: center; padding: 50px;">
<h1>✅ 認証成功!</h1>
<p>このウィンドウを閉じて、ターミナルに戻ってください。</p>
<script>setTimeout(() => window.close(), 3000);</script>
</body>
</html>
`);
server.close();
resolve(code);
return;
}
// Redirect to auth URL if no code
res.writeHead(302, { Location: authUrl });
res.end();
} catch (err) {
reject(err);
}
});
server.listen(OAUTH_PORT, () => {
console.error(`Local auth server started on port ${OAUTH_PORT}`);
console.error(`Please open: http://localhost:${OAUTH_PORT}`);
// Try to open browser automatically
const openCommand = process.platform === 'darwin' ? 'open' :
process.platform === 'win32' ? 'start' : 'xdg-open';
import('child_process').then(({ exec }) => {
exec(`${openCommand} "http://localhost:${OAUTH_PORT}"`);
}).catch(() => {
// Ignore if browser open fails
});
});
server.on('error', (err) => {
reject(new Error(`Failed to start auth server: ${err.message}`));
});
// Timeout after 5 minutes
setTimeout(() => {
server.close();
reject(new Error('Authentication timeout (5 minutes)'));
}, 5 * 60 * 1000);
});
}
}
// Default configuration paths
const DEFAULT_CLIENT_SECRETS_PATH = '~/spreadsheet-mcp/config/client_secrets.json';
const DEFAULT_CREDENTIAL_STORE_PATH = '~/spreadsheet-mcp/config/credentials.json';
let authInstance: GoogleAuth | null = null;
/**
* Get a singleton GoogleAuth instance
*/
export function getGoogleAuth(
clientSecretsPath: string = DEFAULT_CLIENT_SECRETS_PATH,
credentialStorePath: string = DEFAULT_CREDENTIAL_STORE_PATH
): GoogleAuth {
if (!authInstance) {
authInstance = new GoogleAuth(clientSecretsPath, credentialStorePath);
}
return authInstance;
}