Google Calendar MCP Server
by takumi0706
Verified
// src/auth/oauth-handler.ts
import crypto from 'crypto';
import { Express, Request, Response } from 'express';
import { google } from 'googleapis';
import { tokenManager } from './token-manager';
import logger from '../utils/logger';
import { AppError, ErrorCode } from '../utils/error-handler';
import { CodeChallengeMethod } from 'google-auth-library/build/src/auth/oauth2client';
import { escapeHtml } from '../utils/html-sanitizer';
/**
* OAuthHandler - Secure OAuth authentication flow management class
*
* Uses state parameter for CSRF protection and
* implements PKCE (Proof Key for Code Exchange) to enhance authentication
*/
export class OAuthHandler {
private stateMap: Map<string, { expiry: number, redirectUri: string, codeVerifier: string }> = new Map();
constructor(private app: Express) {
this.setupRoutes();
// Periodically clean up expired state values
setInterval(this.cleanupExpiredStates.bind(this), 30 * 60 * 1000); // every 30 minutes
}
/**
* Set up OAuth-related routes
*/
private setupRoutes() {
// OAuth redirect endpoint
this.app.get('/oauth2callback', this.handleOAuthCallback.bind(this));
}
/**
* Generate authentication URL
*
* @param userId User ID
* @param redirectUri Redirect URI after successful authentication
* @returns Authentication URL
*/
public generateAuthUrl(userId: string, redirectUri: string): string {
// Random state value for CSRF protection
const state = crypto.randomBytes(32).toString('hex');
// Generate code_verifier for PKCE
const codeVerifier = this.generateCodeVerifier();
const codeChallenge = this.generateCodeChallenge(codeVerifier);
// Set to expire after 10 minutes
const expiry = Date.now() + 10 * 60 * 1000;
this.stateMap.set(state, { expiry, redirectUri, codeVerifier });
logger.info('Generated auth URL with PKCE', { state, redirectUri });
// Generate OAuth authentication URL
const oauth2Client = this.getOAuthClient();
return oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: ['https://www.googleapis.com/auth/calendar'],
state,
// Always display consent screen to force obtaining a new refresh token
prompt: 'consent',
// Implementation of PKCE extension
code_challenge_method: CodeChallengeMethod.S256,
code_challenge: codeChallenge
});
}
/**
* OAuth authentication callback handler
*/
private async handleOAuthCallback(req: Request, res: Response) {
const { code, state, error } = req.query;
// Error check
if (error) {
logger.error('OAuth error', { error });
return res.status(400).send(`Authentication error: ${escapeHtml(error)}`);
}
// State parameter check
if (!state || typeof state !== 'string') {
logger.error('Missing state parameter');
return res.status(400).send('Invalid request: state parameter is missing');
}
// Validate state
const stateData = this.stateMap.get(state);
if (!stateData) {
logger.error('Invalid state parameter', { state });
return res.status(400).send('Authentication failed: Invalid state parameter');
}
// Expiration check
if (stateData.expiry < Date.now()) {
logger.error('Expired state parameter', { state, expiry: stateData.expiry });
this.stateMap.delete(state);
return res.status(400).send('Authentication failed: Authentication flow has expired');
}
// Code parameter check
if (!code || typeof code !== 'string') {
logger.error('Missing code parameter');
return res.status(400).send('Invalid request: code parameter is missing');
}
try {
const oauth2Client = this.getOAuthClient();
// Exchange authorization code for token using PKCE code_verifier
const { tokens } = await oauth2Client.getToken({
code,
codeVerifier: stateData.codeVerifier
});
// Delete state after it's been used
this.stateMap.delete(state);
// Identify user ID (actual implementation would require user authentication)
// Using 'default-user' as a simple implementation for now
const userId = 'default-user';
// Encrypt and store token
if (tokens.refresh_token) {
tokenManager.storeToken(userId, tokens.refresh_token);
logger.info('Successfully obtained and stored refresh token', { userId });
} else {
logger.warn('No refresh token in the response', { userId });
}
// Store access token for a short period as needed
if (tokens.access_token) {
const expiresIn = tokens.expiry_date ? tokens.expiry_date - Date.now() : 3600 * 1000;
tokenManager.storeToken(`${userId}_access`, tokens.access_token, expiresIn);
logger.debug('Stored access token', { userId, expiresIn });
}
// Redirect
res.redirect(stateData.redirectUri || '/auth-success');
} catch (err: unknown) {
const error = err as Error;
logger.error('OAuth token exchange failed', { error: error.message, stack: error.stack });
res.status(500).send('Token exchange failed.');
}
}
/**
* Generate code_verifier for PKCE
* @returns Random code_verifier string
*/
private generateCodeVerifier(): string {
return crypto.randomBytes(32)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
/**
* Generate code_challenge for PKCE
* @param codeVerifier code_verifier
* @returns SHA-256 hashed code_challenge
*/
private generateCodeChallenge(codeVerifier: string): string {
return crypto.createHash('sha256')
.update(codeVerifier)
.digest('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
/**
* Clean up expired state values
*/
private cleanupExpiredStates(): void {
const now = Date.now();
let expiredCount = 0;
for (const [state, data] of this.stateMap.entries()) {
if (data.expiry < now) {
this.stateMap.delete(state);
expiredCount++;
}
}
if (expiredCount > 0) {
logger.info(`Cleaned up ${expiredCount} expired OAuth states`);
}
}
/**
* Get OAuth2 client
*/
private getOAuthClient() {
const clientId = process.env.GOOGLE_CLIENT_ID;
const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
const redirectUri = process.env.GOOGLE_REDIRECT_URI;
if (!clientId || !clientSecret || !redirectUri) {
throw new AppError(
ErrorCode.CONFIGURATION_ERROR,
'Google OAuth configuration is missing',
500,
{ missingVars: [
!clientId ? 'GOOGLE_CLIENT_ID' : null,
!clientSecret ? 'GOOGLE_CLIENT_SECRET' : null,
!redirectUri ? 'GOOGLE_REDIRECT_URI' : null
].filter(Boolean) }
);
}
return new google.auth.OAuth2(clientId, clientSecret, redirectUri);
}
}