Google Calendar MCP Server
by takumi0706
Verified
import { google } from 'googleapis';
import { OAuth2Client } from 'google-auth-library';
import config from '../config/config';
import logger from '../utils/logger';
import { createServer } from 'http';
import { parse } from 'url';
import open from 'open';
import * as crypto from 'crypto';
import { escapeHtml } from '../utils/html-sanitizer';
import { CodeChallengeMethod } from 'google-auth-library/build/src/auth/oauth2client';
class GoogleAuth {
private oauth2Client: OAuth2Client;
private authUrl: string;
private authorizationPromise: Promise<OAuth2Client> | null = null;
private codeVerifier: string | null = null;
private state: string | null = null;
constructor() {
// OAuth2クライアントの初期化
this.oauth2Client = new google.auth.OAuth2(
config.google.clientId,
config.google.clientSecret,
config.google.redirectUri
);
// PKCE用のcode_verifierとstate parameterを生成
this.codeVerifier = this.generateCodeVerifier();
this.state = this.generateState();
// code_challengeの生成
const codeChallenge = this.generateCodeChallenge(this.codeVerifier);
// 認証URLの生成(PKCEとstate parameterを含む)
this.authUrl = this.oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: config.google.scopes,
prompt: 'consent',
code_challenge_method: CodeChallengeMethod.S256,
code_challenge: codeChallenge,
state: this.state
});
}
// PKCE用のcode_verifierを生成
private generateCodeVerifier(): string {
return crypto.randomBytes(32)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
// code_verifierからcode_challengeを生成
private generateCodeChallenge(verifier: string): string {
const hash = crypto.createHash('sha256')
.update(verifier)
.digest('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
return hash;
}
// CSRF対策用のstate parameterを生成
private generateState(): string {
return crypto.randomBytes(16).toString('hex');
}
// トークンを取得または更新(ファイル保存は行わず、メモリ上に保持)
async getAuthenticatedClient(): Promise<OAuth2Client> {
// すでに資格情報が設定されていればそのまま返す
if (this.oauth2Client.credentials && this.oauth2Client.credentials.access_token) {
// 有効期限切れチェックが必要な場合はここで実施
if (this.isTokenExpired(this.oauth2Client.credentials)) {
logger.info('Token expired, refreshing...');
await this.refreshToken();
}
return this.oauth2Client;
}
// 新規認証
return await this.initiateAuthorization();
}
// トークンの有効期限チェック
private isTokenExpired(token: any): boolean {
if (!token.expiry_date) return true;
return token.expiry_date <= Date.now();
}
// トークンの更新(ファイル保存は行わない)
private async refreshToken(): Promise<void> {
try {
const { credentials } = await this.oauth2Client.refreshAccessToken();
this.oauth2Client.setCredentials(credentials);
} catch (error) {
logger.error(`Failed to refresh token: ${error}`);
throw error;
}
}
// 認証フローの開始
private initiateAuthorization(): Promise<OAuth2Client> {
if (this.authorizationPromise) {
return this.authorizationPromise;
}
this.authorizationPromise = new Promise((resolve, reject) => {
logger.info(`Please authorize this app by visiting this URL: ${this.authUrl}`);
try {
open(this.authUrl);
logger.info('Opening browser for authorization...');
} catch (error) {
logger.warn(`Failed to open browser automatically: ${error}`);
logger.info(`Please open this URL manually: ${this.authUrl}`);
}
const server = createServer(async (req, res) => {
try {
const url = parse(req.url || '', true);
if (url.pathname === '/oauth2callback') {
// 認証コードの取得
const code = url.query.code as string;
if (!code) {
throw new Error('No code parameter in callback URL');
}
// state パラメータの検証(CSRF対策)
const returnedState = url.query.state as string;
if (!returnedState || returnedState !== this.state) {
throw new Error('Invalid state parameter - possible CSRF attack');
}
// PKCE を使用してコードからトークン取得
const { tokens } = await this.oauth2Client.getToken({
code: code,
codeVerifier: this.codeVerifier || undefined
});
this.oauth2Client.setCredentials(tokens);
// 認証成功後、セキュリティのためにcode_verifierとstateをクリア
this.codeVerifier = null;
this.state = null;
// レスポンスを返す
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`<html lang="en"><body><h3>Authentication was successful. Please close this window and continue.</h3></body></html>`);
server.close(() => {
this.authorizationPromise = null;
resolve(this.oauth2Client);
});
} else {
res.writeHead(404);
res.end();
}
} catch (error) {
logger.error(`Error in authorization callback: ${error}`);
res.writeHead(500, { 'Content-Type': 'text/html' });
res.end(`<html lang="en"><body><h3>Authentication error: ${escapeHtml(error)}</h3></body></html>`);
server.close(() => {
this.authorizationPromise = null;
reject(error);
});
}
});
server.listen(config.server.port, config.server.host, () => {
logger.info(`Waiting for authorization on ${config.server.host}:${config.server.port}...`);
});
server.on('error', (error) => {
logger.error(`Server error: ${error}`);
this.authorizationPromise = null;
reject(error);
});
});
return this.authorizationPromise;
}
}
export default new GoogleAuth();