import { OAuth2Client } from 'google-auth-library';
import jwt from 'jsonwebtoken';
import { DatabaseConnection } from '../database/connection.js';
import { Logger } from '../utils/logger.js';
export interface AuthSession {
id: string;
userId: string;
email: string;
accessToken: string;
refreshToken?: string | undefined;
expiresAt: Date;
permissions: string[];
createdAt: Date;
updatedAt: Date;
}
export interface GoogleUserInfo {
sub: string; // Google user ID
email: string;
name: string;
picture?: string | undefined;
email_verified: boolean;
}
export interface JWTPayload {
userId: string;
email: string;
permissions: string[];
sessionId: string;
iat: number;
exp: number;
}
export class AuthManager {
private static instance: AuthManager;
private googleClient: OAuth2Client;
private db!: DatabaseConnection;
private logger: Logger;
private jwtSecret: string;
private sessionDuration: number; // in milliseconds
private constructor() {
this.logger = Logger.getInstance();
this.jwtSecret = process.env.JWT_SECRET || this.generateJWTSecret();
this.sessionDuration = 24 * 60 * 60 * 1000; // 24 hours
const clientId = process.env.GOOGLE_CLIENT_ID;
const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
const redirectUri = process.env.GOOGLE_REDIRECT_URI || 'http://localhost:3000/auth/callback';
if (!clientId || !clientSecret) {
this.logger.warn('Google OAuth credentials not configured. Authentication will not work.');
}
this.googleClient = new OAuth2Client(clientId, clientSecret, redirectUri);
}
public static async getInstance(): Promise<AuthManager> {
if (!AuthManager.instance) {
AuthManager.instance = new AuthManager();
AuthManager.instance.db = await DatabaseConnection.getInstance();
}
return AuthManager.instance;
}
private generateJWTSecret(): string {
const secret = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
this.logger.warn('Generated new JWT secret. In production, use a secure secret from environment variables.');
return secret;
}
public getAuthUrl(scopes: string[] = ['openid', 'email', 'profile']): string {
const authUrl = this.googleClient.generateAuthUrl({
access_type: 'offline',
scope: scopes,
prompt: 'consent'
});
this.logger.info('Generated Google OAuth URL', { scopes });
return authUrl;
}
public async handleCallback(code: string): Promise<AuthSession> {
try {
// Exchange code for tokens
const { tokens } = await this.googleClient.getToken(code);
this.googleClient.setCredentials(tokens);
// Get user info
const clientId = process.env.GOOGLE_CLIENT_ID;
if (!clientId) {
throw new Error('Google Client ID not configured');
}
const ticket = await this.googleClient.verifyIdToken({
idToken: tokens.id_token!,
audience: clientId
});
const payload = ticket.getPayload();
if (!payload) {
throw new Error('Invalid ID token payload');
}
const userInfo: GoogleUserInfo = {
sub: payload.sub,
email: payload.email!,
name: payload.name!,
picture: payload.picture,
email_verified: payload.email_verified || false
};
if (!userInfo.email_verified) {
throw new Error('Email not verified');
}
// Create session
const session = await this.createSession(userInfo, tokens.access_token!, tokens.refresh_token || undefined);
this.logger.logAuthEvent('login', userInfo.sub, true);
this.logger.info('User authenticated successfully', {
userId: userInfo.sub,
email: userInfo.email
});
return session;
} catch (error: any) {
this.logger.logAuthEvent('login', undefined, false);
this.logger.error('Authentication failed', { error: error.message });
throw error;
}
}
private async createSession(
userInfo: GoogleUserInfo,
accessToken: string,
refreshToken?: string
): Promise<AuthSession> {
const sessionId = this.generateSessionId();
const expiresAt = new Date(Date.now() + this.sessionDuration);
const permissions = await this.getUserPermissions(userInfo.email);
const session: AuthSession = {
id: sessionId,
userId: userInfo.sub,
email: userInfo.email,
accessToken,
refreshToken,
expiresAt,
permissions,
createdAt: new Date(),
updatedAt: new Date()
};
// Store session in database
await this.db.run(
`INSERT INTO auth_sessions (id, user_id, email, access_token, refresh_token, expires_at, permissions, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
session.id,
session.userId,
session.email,
session.accessToken,
session.refreshToken || null,
session.expiresAt.toISOString(),
JSON.stringify(session.permissions),
session.createdAt.toISOString(),
session.updatedAt.toISOString()
]
);
return session;
}
public async validateSession(sessionId: string): Promise<AuthSession | null> {
const result = await this.db.get(
'SELECT * FROM auth_sessions WHERE id = ?',
[sessionId]
) as any;
if (!result) {
this.logger.logAuthEvent('session_validation', sessionId, false);
return null;
}
const session: AuthSession = {
id: result.id,
userId: result.user_id,
email: result.email,
accessToken: result.access_token,
refreshToken: result.refresh_token,
expiresAt: new Date(result.expires_at),
permissions: JSON.parse(result.permissions || '[]'),
createdAt: new Date(result.created_at),
updatedAt: new Date(result.updated_at)
};
// Check if session is expired
if (session.expiresAt < new Date()) {
this.logger.logAuthEvent('session_expired', session.userId, false);
await this.deleteSession(sessionId);
return null;
}
this.logger.logAuthEvent('session_validation', session.userId, true);
return session;
}
public async refreshSession(sessionId: string): Promise<AuthSession | null> {
const session = await this.validateSession(sessionId);
if (!session || !session.refreshToken) {
return null;
}
try {
// Use refresh token to get new access token
this.googleClient.setCredentials({
refresh_token: session.refreshToken
});
const { credentials } = await this.googleClient.refreshAccessToken();
// Update session with new tokens
const newExpiresAt = new Date(Date.now() + this.sessionDuration);
await this.db.run(
'UPDATE auth_sessions SET access_token = ?, expires_at = ?, updated_at = ? WHERE id = ?',
[
credentials.access_token!,
newExpiresAt.toISOString(),
new Date().toISOString(),
sessionId
]
);
session.accessToken = credentials.access_token!;
session.expiresAt = newExpiresAt;
session.updatedAt = new Date();
this.logger.logAuthEvent('token_refresh', session.userId, true);
return session;
} catch (error: any) {
this.logger.logAuthEvent('token_refresh', session.userId, false);
this.logger.error('Token refresh failed', { error: error.message, sessionId });
await this.deleteSession(sessionId);
return null;
}
}
public async deleteSession(sessionId: string): Promise<void> {
const result = await this.db.run(
'DELETE FROM auth_sessions WHERE id = ?',
[sessionId]
);
if (result.changes > 0) {
this.logger.logAuthEvent('logout', sessionId, true);
}
}
public generateJWT(session: AuthSession): string {
const payload: JWTPayload = {
userId: session.userId,
email: session.email,
permissions: session.permissions,
sessionId: session.id,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(session.expiresAt.getTime() / 1000)
};
return jwt.sign(payload, this.jwtSecret);
}
public verifyJWT(token: string): JWTPayload | null {
try {
const payload = jwt.verify(token, this.jwtSecret) as JWTPayload;
return payload;
} catch (error: any) {
this.logger.logSecurityEvent('invalid_jwt', { error: error.message });
return null;
}
}
public async hasPermission(sessionId: string, requiredPermission: string): Promise<boolean> {
const session = await this.validateSession(sessionId);
if (!session) {
return false;
}
const hasPermission = session.permissions.includes(requiredPermission) ||
session.permissions.includes('admin');
this.logger.info('Permission check', {
userId: session.userId,
requiredPermission,
hasPermission,
userPermissions: session.permissions
});
return hasPermission;
}
public async hasAnyPermission(sessionId: string, requiredPermissions: string[]): Promise<boolean> {
const session = await this.validateSession(sessionId);
if (!session) {
return false;
}
const hasPermission = requiredPermissions.some(permission =>
session.permissions.includes(permission)
) || session.permissions.includes('admin');
this.logger.info('Multiple permission check', {
userId: session.userId,
requiredPermissions,
hasPermission,
userPermissions: session.permissions
});
return hasPermission;
}
private async getUserPermissions(email: string): Promise<string[]> {
// In a real implementation, this would check against a user permissions database
// For now, we'll use a simple email-based permission system
const adminEmails = (process.env.ADMIN_EMAILS || '').split(',').map(e => e.trim());
if (adminEmails.includes(email)) {
return ['admin', 'billing:read', 'billing:write', 'accounts:manage'];
}
// Default permissions for authenticated users
return ['billing:read'];
}
public async cleanupExpiredSessions(): Promise<number> {
const result = await this.db.run(
'DELETE FROM auth_sessions WHERE expires_at < ?',
[new Date().toISOString()]
);
const deletedCount = result.changes || 0;
if (deletedCount > 0) {
this.logger.info('Cleaned up expired sessions', { count: deletedCount });
}
return deletedCount;
}
public startSessionCleanup(intervalMinutes: number = 60): void {
setInterval(async () => {
try {
await this.cleanupExpiredSessions();
} catch (error: any) {
this.logger.error('Session cleanup failed', { error: error.message });
}
}, intervalMinutes * 60 * 1000);
this.logger.info('Session cleanup started', { intervalMinutes });
}
private generateSessionId(): string {
return 'sess_' + Date.now().toString(36) + Math.random().toString(36).substr(2);
}
}