← vibesdk
/**
* Session Service for managing user sessions in D1
* Provides session creation, validation, and cleanup
*/
import { AuthSession } from '../../types/auth-types';
import { SecurityError, SecurityErrorType } from 'shared/types/errors';
import * as schema from '../schema';
import { eq, and, lt, gt, desc, ne } from 'drizzle-orm';
import { createLogger } from '../../logger';
import { generateId } from '../../utils/idGenerator';
import { JWTUtils } from '../../utils/jwtUtils';
import { extractRequestMetadata } from '../../utils/authUtils';
import { BaseService } from './BaseService';
const logger = createLogger('SessionService');
/**
* Session configuration
*/
interface SessionConfig {
maxSessions: number; // Max sessions per user
sessionTTL: number; // Session TTL in seconds
cleanupInterval: number; // Cleanup interval in seconds
maxConcurrentDevices: number; // Max concurrent devices per user
}
/**
* Session Service for D1-based session management
*/
export class SessionService extends BaseService {
static readonly config: SessionConfig = {
maxSessions: 5,
sessionTTL: 3 * 24 * 60 * 60,
cleanupInterval: 60 * 60, // 1 hour
maxConcurrentDevices: 3, // Max 3 devices concurrently
};
private jwtUtils: JWTUtils;
constructor(
env: Env
) {
super(env);
this.jwtUtils = JWTUtils.getInstance(env);
}
/**
* Log security event for audit purposes
*/
private async logSecurityEvent(
userId: string,
sessionId: string,
eventType: 'session_hijacking' | 'suspicious_activity' | 'device_change' | 'location_change',
details: Record<string, unknown>,
request?: Request
): Promise<void> {
try {
const metadata = request ? extractRequestMetadata(request) : { ipAddress: 'unknown', userAgent: 'unknown' };
await this.db.db.insert(schema.auditLogs).values({
id: generateId(),
userId: userId,
entityType: 'session',
entityId: sessionId,
action: eventType,
newValues: details,
ipAddress: metadata.ipAddress,
userAgent: metadata.userAgent,
createdAt: new Date()
});
logger.warn('Security event logged', {
userId,
sessionId,
eventType,
details
});
} catch (error) {
logger.error('Failed to log security event', error);
}
}
/**
* Create a new session
*/
async createSession(
userId: string,
request: Request
): Promise<{
session: AuthSession;
accessToken: string;
}> {
try {
// Clean up old sessions for this user
await this.cleanupUserSessions(userId);
// Generate session ID first
const sessionId = generateId();
const userEmail = await this.getUserEmail(userId);
// Generate tokens WITH session ID
const { accessToken } = await this.jwtUtils.createAccessToken(
userId,
userEmail,
sessionId
);
// Hash tokens for storage
const accessTokenHash = await this.jwtUtils.hashToken(accessToken);
// Extract request metadata using centralized utility
const requestMetadata = extractRequestMetadata(request);
// Create device info object
const deviceInfo = requestMetadata.userAgent;
// Create session
const now = new Date();
const expiresAt = new Date(Date.now() + SessionService.config.sessionTTL * 1000);
await this.db.db.insert(schema.sessions).values({
id: sessionId,
userId,
accessTokenHash,
refreshTokenHash: '',
expiresAt,
lastActivity: now,
ipAddress: requestMetadata.ipAddress,
userAgent: requestMetadata.userAgent,
deviceInfo,
createdAt: now
});
const session: AuthSession = {
userId,
email: await this.getUserEmail(userId),
sessionId,
expiresAt: expiresAt,
};
logger.info('Session created', { userId, sessionId });
return {
session,
accessToken,
};
} catch (error) {
logger.error('Error creating session', error);
throw new SecurityError(
SecurityErrorType.UNAUTHORIZED,
'Failed to create session',
500
);
}
}
/**
* Revoke session with ID and userID
*/
async revokeUserSession(sessionId: string, userId: string): Promise<void> {
try {
await this.db.db
.update(schema.sessions)
.set({
isRevoked: true,
revokedAt: new Date(),
revokedReason: 'user_logout'
})
.where(
and(
eq(schema.sessions.id, sessionId),
eq(schema.sessions.userId, userId)
)
);
logger.info('Session revoked', { sessionId });
} catch (error) {
logger.error('Error revoking session', error);
throw new SecurityError(
SecurityErrorType.UNAUTHORIZED,
'Failed to revoke session',
500
);
}
}
/**
* Revoke all sessions for a user
*/
async revokeAllUserSessions(userId: string): Promise<void> {
try {
await this.db.db
.update(schema.sessions)
.set({
isRevoked: true,
revokedAt: new Date(),
revokedReason: 'user_force_logout'
})
.where(eq(schema.sessions.userId, userId));
logger.info('All user sessions revoked', { userId });
} catch (error) {
logger.error('Error revoking user sessions', error);
throw new SecurityError(
SecurityErrorType.UNAUTHORIZED,
'Failed to revoke sessions',
500
);
}
}
/**
* Get all active sessions for a user
*/
async getUserSessions(userId: string): Promise<Array<{
id: string;
userAgent: string | null;
ipAddress: string | null;
lastActivity: Date;
createdAt: Date;
isCurrent?: boolean;
}>> {
try {
const sessions = await this.db.db
.select({
id: schema.sessions.id,
userAgent: schema.sessions.userAgent,
ipAddress: schema.sessions.ipAddress,
lastActivity: schema.sessions.lastActivity,
createdAt: schema.sessions.createdAt
})
.from(schema.sessions)
.where(
and(
eq(schema.sessions.userId, userId),
eq(schema.sessions.isRevoked, false),
gt(schema.sessions.expiresAt, new Date())
)
)
.orderBy(desc(schema.sessions.lastActivity))
.all();
return sessions.map(session => ({
id: session.id,
userAgent: session.userAgent || 'Unknown',
ipAddress: session.ipAddress || 'Unknown',
lastActivity: session.lastActivity || new Date(),
createdAt: session.createdAt || new Date()
}));
} catch (error) {
logger.error('Error getting user sessions', error);
return [];
}
}
/**
* Clean up expired sessions
*/
async cleanupExpiredSessions(): Promise<number> {
try {
const now = new Date();
// Delete expired sessions
await this.db.db
.delete(schema.sessions)
.where(lt(schema.sessions.expiresAt, now));
logger.info('Cleaned up expired sessions');
return 0; // D1 doesn't return count
} catch (error) {
logger.error('Error cleaning up sessions', error);
return 0;
}
}
/**
* Clean up old sessions for a user (keep only most recent)
*/
private async cleanupUserSessions(userId: string): Promise<void> {
try {
// Get all sessions for user, ordered by last activity
const sessions = await this.db.db
.select({ id: schema.sessions.id })
.from(schema.sessions)
.where(eq(schema.sessions.userId, userId))
.orderBy(desc(schema.sessions.lastActivity))
.all();
// Keep only the most recent sessions
if (sessions.length > SessionService.config.maxSessions) {
const sessionsToDelete = sessions.slice(SessionService.config.maxSessions);
for (const session of sessionsToDelete) {
await this.db.db
.delete(schema.sessions)
.where(eq(schema.sessions.id, session.id));
}
logger.debug('Cleaned up old user sessions', {
userId,
deleted: sessionsToDelete.length
});
}
} catch (error) {
logger.error('Error cleaning up user sessions', error);
}
}
/**
* Get user email (helper method)
*/
private async getUserEmail(userId: string): Promise<string> {
const user = await this.db.db
.select({ email: schema.users.email })
.from(schema.users)
.where(eq(schema.users.id, userId))
.get();
return user?.email || '';
}
/**
* Look up a session row by its id (used for auth-time revocation checks)
*/
async getSessionById(sessionId: string): Promise<schema.Session | null> {
try {
const session = await this.db.db
.select()
.from(schema.sessions)
.where(eq(schema.sessions.id, sessionId))
.get();
return session || null;
} catch (error) {
logger.error('Error fetching session by id', error);
return null;
}
}
/**
* Get security status and recent events for a user
*/
async getUserSecurityStatus(userId: string): Promise<{
activeSessions: number;
recentSecurityEvents: number;
lastSecurityEvent?: Date;
riskLevel: 'low' | 'medium' | 'high';
recommendations: string[];
}> {
try {
// Get active sessions count
const activeSessions = await this.db.db
.select({ count: schema.sessions.id })
.from(schema.sessions)
.where(
and(
eq(schema.sessions.userId, userId),
eq(schema.sessions.isRevoked, false),
gt(schema.sessions.expiresAt, new Date())
)
)
.all();
// Get recent security events (last 24 hours)
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const recentEvents = await this.db.db
.select({
createdAt: schema.auditLogs.createdAt,
action: schema.auditLogs.action
})
.from(schema.auditLogs)
.where(
and(
eq(schema.auditLogs.userId, userId),
eq(schema.auditLogs.entityType, 'session'),
gt(schema.auditLogs.createdAt, oneDayAgo)
)
)
.orderBy(desc(schema.auditLogs.createdAt))
.all();
const activeSessionCount = activeSessions.length;
const recentSecurityEvents = recentEvents.length;
const lastSecurityEvent = recentEvents[0]?.createdAt || undefined;
// Determine risk level
let riskLevel: 'low' | 'medium' | 'high' = 'low';
const recommendations: string[] = [];
if (activeSessionCount > SessionService.config.maxConcurrentDevices) {
riskLevel = 'medium';
recommendations.push('Consider revoking old sessions - you have many active sessions');
}
if (recentSecurityEvents > 5) {
riskLevel = 'high';
recommendations.push('Multiple security events detected - review your account activity');
} else if (recentSecurityEvents > 2) {
riskLevel = 'medium';
recommendations.push('Some suspicious activity detected - monitor your account');
}
// Check for session hijacking events
const hijackingEvents = recentEvents.filter(e => e.action === 'session_hijacking');
if (hijackingEvents.length > 0) {
riskLevel = 'high';
recommendations.push('Session hijacking attempts detected - change your password immediately');
}
if (recommendations.length === 0) {
recommendations.push('Your account security looks good');
}
return {
activeSessions: activeSessionCount,
recentSecurityEvents,
lastSecurityEvent,
riskLevel,
recommendations
};
} catch (error) {
logger.error('Error getting user security status', error);
return {
activeSessions: 0,
recentSecurityEvents: 0,
riskLevel: 'low',
recommendations: ['Unable to assess security status']
};
}
}
/**
* Revoke session by ID
*/
async revokeSessionId(sessionId: string): Promise<void> {
try {
await this.db.db
.update(schema.sessions)
.set({
isRevoked: true,
revokedAt: new Date(),
revokedReason: 'user_logout'
})
.where(eq(schema.sessions.id, sessionId));
logger.info('Session revoked by refresh token hash');
} catch (error) {
logger.error('Error revoking session by refresh token hash', error);
// Don't throw error for logout operations
}
}
/**
* Force logout all sessions except current (for security)
*/
async forceLogoutAllOtherSessions(userId: string, currentSessionId: string): Promise<number> {
try {
const result = await this.db.db
.delete(schema.sessions)
.where(
and(
eq(schema.sessions.userId, userId),
ne(schema.sessions.id, currentSessionId)
)
);
const deletedCount = result.meta.changes || 0;
// Log security event
await this.logSecurityEvent(
userId,
currentSessionId,
'device_change',
{
action: 'force_logout_other_sessions',
sessionsRevoked: deletedCount
}
);
logger.info('Force logged out other sessions', { userId, currentSessionId, deletedCount });
return deletedCount;
} catch (error) {
logger.error('Error force logging out other sessions', error);
return 0;
}
}
}