import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
import { OAuth2Client } from 'google-auth-library';
import { googleConfig, authConfig } from '../config';
import { DatabaseManager } from '../database';
import { User, ConnectedCalendar, CalendarProvider } from '../types';
export interface AuthRequest extends Request {
userId?: string;
user?: User;
}
export class AuthManager {
private oauth2Client: OAuth2Client;
private database: DatabaseManager;
constructor(database: DatabaseManager) {
this.database = database;
this.oauth2Client = new OAuth2Client(
googleConfig.clientId,
googleConfig.clientSecret,
googleConfig.redirectUri
);
}
// Generate OAuth2 authorization URL
generateAuthUrl(userId?: string): string {
const scopes = googleConfig.scopes;
const state = userId ? Buffer.from(JSON.stringify({ userId })).toString('base64') : undefined;
return this.oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: scopes,
state,
prompt: 'consent' // Force consent to get refresh token
});
}
// Handle OAuth2 callback
async handleOAuthCallback(code: string, state?: string): Promise<{
user: User;
sessionToken: string;
refreshToken: string;
}> {
try {
// Exchange authorization code for tokens
const { tokens } = await this.oauth2Client.getToken(code);
if (!tokens.access_token) {
throw new Error('No access token received');
}
// Get user info from Google
this.oauth2Client.setCredentials(tokens);
const userInfoResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: {
Authorization: `Bearer ${tokens.access_token}`
}
});
if (!userInfoResponse.ok) {
throw new Error('Failed to fetch user info from Google');
}
const userInfo = await userInfoResponse.json();
// Parse state to get existing userId if reconnecting
let existingUserId: string | undefined;
if (state) {
try {
const stateData = JSON.parse(Buffer.from(state, 'base64').toString());
existingUserId = stateData.userId;
} catch (error) {
console.warn('Failed to parse state parameter:', error);
}
}
// Find or create user
let user = await this.database.getUserByEmail(userInfo.email);
if (!user) {
if (existingUserId) {
// Update existing user with email
user = await this.database.updateUser(existingUserId, {
email: userInfo.email,
displayName: userInfo.name
});
} else {
// Create new user
user = await this.database.createUser({
email: userInfo.email,
displayName: userInfo.name,
timezone: 'UTC' // Default, can be updated later
});
}
}
// Add or update connected calendar
const tokenExpiry = tokens.expiry_date ? new Date(tokens.expiry_date) : new Date(Date.now() + 3600000);
const calendarData: Omit<ConnectedCalendar, 'id'> = {
calendarId: 'primary',
provider: CalendarProvider.GOOGLE,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || undefined,
tokenExpiry,
isDefault: true,
syncEnabled: true
};
try {
await this.database.addConnectedCalendar(user.id, calendarData);
} catch (error) {
// If calendar already exists, update it
await this.database.updateConnectedCalendar(user.id, 'primary', calendarData);
}
// Generate session tokens
const sessionToken = this.generateSessionToken(user.id);
const refreshToken = this.generateRefreshToken(user.id);
// Store session in database
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
await this.database.createSession(user.id, sessionToken, refreshToken, expiresAt);
return {
user,
sessionToken,
refreshToken
};
} catch (error) {
console.error('OAuth callback error:', error);
throw new Error('Authentication failed');
}
}
// Middleware to authenticate requests
authenticate = async (req: AuthRequest, res: Response, next: NextFunction): Promise<void> => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing or invalid authorization header' });
return;
}
const token = authHeader.substring(7);
// Verify JWT token
const decoded = jwt.verify(token, authConfig.jwtSecret) as { userId: string; type: string };
if (decoded.type !== 'session') {
res.status(401).json({ error: 'Invalid token type' });
return;
}
// Check if session exists in database
const session = await this.database.getSessionByToken(token);
if (!session) {
res.status(401).json({ error: 'Session not found' });
return;
}
if (session.expiresAt < new Date()) {
res.status(401).json({ error: 'Session expired' });
return;
}
// Get user
const user = await this.database.getUser(session.userId);
if (!user) {
res.status(401).json({ error: 'User not found' });
return;
}
// Attach user info to request
req.userId = user.id;
req.user = user;
next();
} catch (error) {
if (error instanceof jwt.JsonWebTokenError) {
res.status(401).json({ error: 'Invalid token' });
return;
}
console.error('Authentication error:', error);
res.status(500).json({ error: 'Authentication failed' });
}
};
// Generate session token
private generateSessionToken(userId: string): string {
return jwt.sign(
{ userId, type: 'session' },
authConfig.jwtSecret,
{ expiresIn: authConfig.jwtExpiry }
);
}
// Generate refresh token
private generateRefreshToken(userId: string): string {
return jwt.sign(
{ userId, type: 'refresh' },
authConfig.jwtSecret,
{ expiresIn: authConfig.refreshTokenExpiry }
);
}
// Refresh session token
async refreshSession(refreshToken: string): Promise<{
sessionToken: string;
refreshToken: string;
}> {
try {
// Verify refresh token
const decoded = jwt.verify(refreshToken, authConfig.jwtSecret) as { userId: string; type: string };
if (decoded.type !== 'refresh') {
throw new Error('Invalid token type');
}
// Generate new tokens
const newSessionToken = this.generateSessionToken(decoded.userId);
const newRefreshToken = this.generateRefreshToken(decoded.userId);
// Update session in database
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
await this.database.createSession(decoded.userId, newSessionToken, newRefreshToken, expiresAt);
return {
sessionToken: newSessionToken,
refreshToken: newRefreshToken
};
} catch (error) {
throw new Error('Failed to refresh session');
}
}
// Logout user
async logout(sessionToken: string): Promise<boolean> {
return await this.database.deleteSession(sessionToken);
}
// Get current user from token
async getCurrentUser(sessionToken: string): Promise<User | null> {
try {
const session = await this.database.getSessionByToken(sessionToken);
if (!session || session.expiresAt < new Date()) {
return null;
}
return await this.database.getUser(session.userId);
} catch (error) {
console.error('Get current user error:', error);
return null;
}
}
// Hash password (for future use if we add password auth)
async hashPassword(password: string): Promise<string> {
return await bcrypt.hash(password, authConfig.bcryptRounds);
}
// Verify password (for future use)
async verifyPassword(password: string, hash: string): Promise<boolean> {
return await bcrypt.compare(password, hash);
}
// Clean up expired sessions (should be run periodically)
async cleanupExpiredSessions(): Promise<number> {
return await this.database.cleanupExpiredSessions();
}
}
// Helper function to extract user ID from request
export function getUserId(req: AuthRequest): string {
if (!req.userId) {
throw new Error('User not authenticated');
}
return req.userId;
}
// Helper function to get authenticated user
export function getUser(req: AuthRequest): User {
if (!req.user) {
throw new Error('User not authenticated');
}
return req.user;
}