+ A password has been successfully added to your account. You can now sign
+ in using your email and password in addition to your existing
+ authentication methods.
+
+
+
+
+ Time: {{SET_TIME}}
+
+
+ Device: {{DEVICE_INFO}}
+
+
+ IP Address: {{IP_ADDRESS}}
+
+
+
+
+ This provides you with an additional way to access your account and
+ ensures you can still sign in even if you lose access to your
+ {{PROVIDER_NAME}} account.
+
+
+
+
+ ⚠️ Didn't set this password?
+ If you didn't perform this action, your account security may be at
+ risk. Please sign in immediately, change your password, and review
+ your account settings.
+
+
+
+
Best regards
+
+
+
+
+ This is an automated security notification from freno.me
+
+ A new authentication provider has been linked to your account:
+
+
+
+
+ Provider: {{PROVIDER_NAME}}
+
+
+ Email: {{PROVIDER_EMAIL}}
+
+
+ Time: {{LINK_TIME}}
+
+
+ Device: {{DEVICE_INFO}}
+
+
+
+
+ You can now sign in to your account using {{PROVIDER_NAME}}.
+
+
+
+
+ ⚠️ Didn't link this provider?
+ If you didn't perform this action, your account security may be at
+ risk. Please sign in and remove this provider immediately, then change
+ your password.
+
+
+
+
Best regards
+
+
+
+
+ This is an automated security notification from freno.me
+
+
+
+
diff --git a/src/server/email.ts b/src/server/email.ts
index 7f9bd42..02fbd17 100644
--- a/src/server/email.ts
+++ b/src/server/email.ts
@@ -1,9 +1,76 @@
import { SignJWT } from "jose";
import { env } from "~/env/server";
-import { AUTH_CONFIG } from "~/config";
+import { AUTH_CONFIG, NETWORK_CONFIG } from "~/config";
+import {
+ fetchWithTimeout,
+ checkResponse,
+ fetchWithRetry
+} from "~/server/fetch-utils";
export const LINEAGE_JWT_EXPIRY = AUTH_CONFIG.LINEAGE_JWT_EXPIRY;
+/**
+ * Generic email sending function
+ * @param to - Recipient email address
+ * @param subject - Email subject
+ * @param htmlContent - HTML content of the email
+ * @returns Success status
+ */
+export default async function sendEmail(
+ to: string,
+ subject: string,
+ htmlContent: string
+): Promise<{ success: boolean; messageId?: string; message?: string }> {
+ const apiKey = env.SENDINBLUE_KEY;
+ const apiUrl = "https://api.sendinblue.com/v3/smtp/email";
+
+ const emailPayload = {
+ sender: {
+ name: "freno.me",
+ email: "no_reply@freno.me"
+ },
+ to: [{ email: to }],
+ htmlContent,
+ subject
+ };
+
+ try {
+ const response = await fetchWithRetry(
+ async () => {
+ const res = await fetchWithTimeout(apiUrl, {
+ method: "POST",
+ headers: {
+ accept: "application/json",
+ "api-key": apiKey,
+ "content-type": "application/json"
+ },
+ body: JSON.stringify(emailPayload),
+ timeout: NETWORK_CONFIG.EMAIL_API_TIMEOUT_MS
+ });
+
+ await checkResponse(res);
+ return res;
+ },
+ {
+ maxRetries: NETWORK_CONFIG.MAX_RETRIES,
+ retryDelay: NETWORK_CONFIG.RETRY_DELAY_MS
+ }
+ );
+
+ const json = (await response.json()) as { messageId?: string };
+ if (json.messageId) {
+ return { success: true, messageId: json.messageId };
+ }
+ return { success: false, message: "No messageId in response" };
+ } catch (error) {
+ console.error("Email sending error:", error);
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : "Email service error"
+ };
+ }
+}
+
export async function sendEmailVerification(userEmail: string): Promise<{
success: boolean;
messageId?: string;
diff --git a/src/server/migrate-multi-auth.ts b/src/server/migrate-multi-auth.ts
new file mode 100644
index 0000000..6505289
--- /dev/null
+++ b/src/server/migrate-multi-auth.ts
@@ -0,0 +1,244 @@
+import { ConnectionFactory } from "./database";
+import { v4 as uuidV4 } from "uuid";
+
+/**
+ * Migration script to add multi-provider and enhanced session support
+ * Run this script once to migrate existing database
+ */
+
+export async function migrateMultiAuth() {
+ const conn = ConnectionFactory();
+ console.log("[Migration] Starting multi-auth migration...");
+
+ try {
+ // Step 1: Check if UserProvider table exists
+ const tableCheck = await conn.execute({
+ sql: "SELECT name FROM sqlite_master WHERE type='table' AND name='UserProvider'"
+ });
+
+ if (tableCheck.rows.length > 0) {
+ console.log(
+ "[Migration] UserProvider table already exists, skipping creation"
+ );
+ } else {
+ console.log("[Migration] Creating UserProvider table...");
+ await conn.execute(`
+ CREATE TABLE UserProvider (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ provider TEXT NOT NULL CHECK(provider IN ('email', 'google', 'github', 'apple')),
+ provider_user_id TEXT,
+ email TEXT,
+ display_name TEXT,
+ image TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ last_used_at TEXT NOT NULL DEFAULT (datetime('now')),
+ FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE
+ )
+ `);
+
+ console.log("[Migration] Creating UserProvider indexes...");
+ await conn.execute(
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_user_provider_provider_user ON UserProvider (provider, provider_user_id)"
+ );
+ await conn.execute(
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_user_provider_provider_email ON UserProvider (provider, email)"
+ );
+ await conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_user_provider_user_id ON UserProvider (user_id)"
+ );
+ await conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_user_provider_provider ON UserProvider (provider)"
+ );
+ await conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_user_provider_email ON UserProvider (email)"
+ );
+ }
+
+ // Step 2: Check if Session table has device columns
+ const sessionColumnsCheck = await conn.execute({
+ sql: "PRAGMA table_info(Session)"
+ });
+ const hasDeviceName = sessionColumnsCheck.rows.some(
+ (row: any) => row.name === "device_name"
+ );
+
+ if (hasDeviceName) {
+ console.log(
+ "[Migration] Session table already has device columns, skipping"
+ );
+ } else {
+ console.log("[Migration] Adding device columns to Session table...");
+ await conn.execute("ALTER TABLE Session ADD COLUMN device_name TEXT");
+ await conn.execute("ALTER TABLE Session ADD COLUMN device_type TEXT");
+ await conn.execute("ALTER TABLE Session ADD COLUMN browser TEXT");
+ await conn.execute("ALTER TABLE Session ADD COLUMN os TEXT");
+
+ // SQLite doesn't support non-constant defaults in ALTER TABLE
+ // Add column with NULL default, then update existing rows
+ await conn.execute("ALTER TABLE Session ADD COLUMN last_active_at TEXT");
+
+ // Update existing rows to set last_active_at = last_used
+ console.log(
+ "[Migration] Updating existing sessions with last_active_at..."
+ );
+ await conn.execute(
+ "UPDATE Session SET last_active_at = COALESCE(last_used, created_at) WHERE last_active_at IS NULL"
+ );
+
+ console.log("[Migration] Creating Session indexes...");
+ await conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_session_last_active ON Session (last_active_at)"
+ );
+ await conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_session_user_active ON Session (user_id, revoked, last_active_at)"
+ );
+ }
+
+ // Step 3: Migrate existing users to UserProvider table
+ console.log("[Migration] Checking for users to migrate...");
+ const usersResult = await conn.execute({
+ sql: "SELECT id, email, provider, display_name, image, apple_user_string FROM User WHERE provider IS NOT NULL"
+ });
+
+ console.log(
+ `[Migration] Found ${usersResult.rows.length} users to migrate`
+ );
+
+ let migratedCount = 0;
+ for (const row of usersResult.rows) {
+ const user = row as any;
+
+ // Skip apple provider users (they're for Life and Lineage mobile app, not website auth)
+ if (user.provider === "apple") {
+ console.log(
+ `[Migration] Skipping user ${user.id} with apple provider (mobile app only)`
+ );
+ continue;
+ }
+
+ // Check if already migrated
+ const existingProvider = await conn.execute({
+ sql: "SELECT id FROM UserProvider WHERE user_id = ? AND provider = ?",
+ args: [user.id, user.provider || "email"]
+ });
+
+ if (existingProvider.rows.length > 0) {
+ console.log(
+ `[Migration] User ${user.id} already migrated, skipping`
+ );
+ continue;
+ }
+
+ // Determine provider_user_id based on provider type
+ let providerUserId: string | null = null;
+ if (user.provider === "github") {
+ providerUserId = user.display_name;
+ } else if (user.provider === "google") {
+ providerUserId = user.email;
+ } else {
+ providerUserId = user.email;
+ }
+
+ try {
+ await conn.execute({
+ sql: `INSERT INTO UserProvider (id, user_id, provider, provider_user_id, email, display_name, image)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ args: [
+ uuidV4(),
+ user.id,
+ user.provider || "email",
+ providerUserId,
+ user.email,
+ user.display_name,
+ user.image
+ ]
+ });
+ migratedCount++;
+ } catch (error: any) {
+ console.error(
+ `[Migration] Failed to migrate user ${user.id}:`,
+ error.message
+ );
+ }
+ }
+
+ // Determine provider_user_id based on provider type
+ let providerUserId: string | null = null;
+ if (user.provider === "github") {
+ providerUserId = user.display_name;
+ } else if (user.provider === "google") {
+ providerUserId = user.email;
+ } else if (user.provider === "apple") {
+ providerUserId = user.apple_user_string;
+ } else {
+ providerUserId = user.email;
+ }
+
+ try {
+ await conn.execute({
+ sql: `INSERT INTO UserProvider (id, user_id, provider, provider_user_id, email, display_name, image)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ args: [
+ uuidV4(),
+ user.id,
+ user.provider || "email",
+ providerUserId,
+ user.email,
+ user.display_name,
+ user.image
+ ]
+ });
+ migratedCount++;
+ } catch (error: any) {
+ console.error(
+ `[Migration] Failed to migrate user ${user.id}:`,
+ error.message
+ );
+ }
+ }
+
+ console.log(`[Migration] Migrated ${migratedCount} users successfully`);
+
+ // Step 4: Verification
+ console.log("[Migration] Running verification queries...");
+ const providerCount = await conn.execute({
+ sql: "SELECT COUNT(*) as count FROM UserProvider"
+ });
+ console.log(
+ `[Migration] Total providers in UserProvider table: ${(providerCount.rows[0] as any).count}`
+ );
+
+ const multiProviderUsers = await conn.execute({
+ sql: `SELECT COUNT(*) as count FROM (
+ SELECT user_id FROM UserProvider GROUP BY user_id HAVING COUNT(*) > 1
+ )`
+ });
+ console.log(
+ `[Migration] Users with multiple providers: ${(multiProviderUsers.rows[0] as any).count}`
+ );
+
+ console.log("[Migration] Multi-auth migration completed successfully!");
+ return {
+ success: true,
+ migratedUsers: migratedCount,
+ totalProviders: (providerCount.rows[0] as any).count
+ };
+ } catch (error) {
+ console.error("[Migration] Migration failed:", error);
+ throw error;
+ }
+}
+
+// Run migration if called directly
+if (require.main === module) {
+ migrateMultiAuth()
+ .then((result) => {
+ console.log("[Migration] Result:", result);
+ process.exit(0);
+ })
+ .catch((error) => {
+ console.error("[Migration] Error:", error);
+ process.exit(1);
+ });
+}
diff --git a/src/server/provider-helpers.ts b/src/server/provider-helpers.ts
new file mode 100644
index 0000000..d669f77
--- /dev/null
+++ b/src/server/provider-helpers.ts
@@ -0,0 +1,350 @@
+import { ConnectionFactory } from "./database";
+import { v4 as uuidV4 } from "uuid";
+import type { UserProvider } from "~/db/types";
+import { logAuditEvent } from "./audit";
+import { generateProviderLinkedEmail } from "./email-templates";
+import { formatDeviceDescription } from "./device-utils";
+
+/**
+ * Link a new authentication provider to an existing user account
+ * @param userId - User ID to link provider to
+ * @param provider - Provider type
+ * @param providerData - Provider-specific data
+ * @param options - Optional parameters (deviceInfo, sendEmail)
+ * @returns Created UserProvider record
+ */
+export async function linkProvider(
+ userId: string,
+ provider: "email" | "google" | "github",
+ providerData: {
+ providerUserId?: string;
+ email?: string;
+ displayName?: string;
+ image?: string;
+ },
+ options?: {
+ deviceInfo?: {
+ deviceName?: string;
+ deviceType?: string;
+ browser?: string;
+ os?: string;
+ };
+ sendEmail?: boolean;
+ }
+): Promise {
+ const conn = ConnectionFactory();
+
+ // Check if provider already linked to this user
+ const existing = await conn.execute({
+ sql: "SELECT * FROM UserProvider WHERE user_id = ? AND provider = ?",
+ args: [userId, provider]
+ });
+
+ if (existing.rows.length > 0) {
+ throw new Error(`Provider ${provider} already linked to this account`);
+ }
+
+ // Check if provider identity is already used by another user
+ if (providerData.providerUserId) {
+ const conflictCheck = await conn.execute({
+ sql: "SELECT user_id FROM UserProvider WHERE provider = ? AND provider_user_id = ?",
+ args: [provider, providerData.providerUserId]
+ });
+
+ if (conflictCheck.rows.length > 0) {
+ const conflictUserId = (conflictCheck.rows[0] as any).user_id;
+ if (conflictUserId !== userId) {
+ throw new Error(
+ `This ${provider} account is already linked to a different user`
+ );
+ }
+ }
+ }
+
+ // Create new provider link
+ const id = uuidV4();
+ await conn.execute({
+ sql: `INSERT INTO UserProvider (id, user_id, provider, provider_user_id, email, display_name, image)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ args: [
+ id,
+ userId,
+ provider,
+ providerData.providerUserId || null,
+ providerData.email || null,
+ providerData.displayName || null,
+ providerData.image || null
+ ]
+ });
+
+ // Fetch created record
+ const result = await conn.execute({
+ sql: "SELECT * FROM UserProvider WHERE id = ?",
+ args: [id]
+ });
+
+ const userProvider = result.rows[0] as unknown as UserProvider;
+
+ // Log audit event
+ await logAuditEvent({
+ userId,
+ eventType: "auth.provider.linked",
+ eventData: {
+ provider,
+ providerEmail: providerData.email
+ },
+ success: true
+ });
+
+ // Send notification email if requested and user has email
+ if (options?.sendEmail !== false) {
+ try {
+ // Get user email
+ const userResult = await conn.execute({
+ sql: "SELECT email FROM User WHERE id = ?",
+ args: [userId]
+ });
+
+ const userEmail = userResult.rows[0]
+ ? ((userResult.rows[0] as any).email as string)
+ : null;
+
+ if (userEmail) {
+ const deviceDescription = options?.deviceInfo
+ ? formatDeviceDescription(options.deviceInfo)
+ : "Unknown Device";
+
+ const htmlContent = generateProviderLinkedEmail({
+ providerName: provider.charAt(0).toUpperCase() + provider.slice(1),
+ providerEmail: providerData.email,
+ linkTime: new Date().toLocaleString(),
+ deviceInfo: deviceDescription
+ });
+
+ // Import sendEmail dynamically to avoid circular dependency
+ const { default: sendEmail } = await import("./email");
+ await sendEmail(
+ userEmail,
+ "New Authentication Provider Linked",
+ htmlContent
+ );
+ }
+ } catch (emailError) {
+ // Don't fail the operation if email fails
+ console.error("Failed to send provider linked email:", emailError);
+ }
+ }
+
+ return userProvider;
+}
+
+/**
+ * Unlink an authentication provider from a user account
+ * @param userId - User ID
+ * @param provider - Provider to unlink
+ * @throws Error if trying to remove last provider
+ */
+export async function unlinkProvider(
+ userId: string,
+ provider: "email" | "google" | "github"
+): Promise {
+ const conn = ConnectionFactory();
+
+ // Check how many providers this user has
+ const providersResult = await conn.execute({
+ sql: "SELECT COUNT(*) as count FROM UserProvider WHERE user_id = ?",
+ args: [userId]
+ });
+
+ const providerCount = (providersResult.rows[0] as any).count;
+
+ if (providerCount <= 1) {
+ throw new Error(
+ "Cannot remove last authentication method. Add another provider first."
+ );
+ }
+
+ // Delete provider
+ const result = await conn.execute({
+ sql: "DELETE FROM UserProvider WHERE user_id = ? AND provider = ?",
+ args: [userId, provider]
+ });
+
+ if ((result as any).rowsAffected === 0) {
+ throw new Error(`Provider ${provider} not found for this user`);
+ }
+
+ // Log audit event
+ await logAuditEvent({
+ userId,
+ eventType: "auth.provider.unlinked",
+ eventData: {
+ provider
+ },
+ success: true
+ });
+}
+
+/**
+ * Get all authentication providers for a user
+ * @param userId - User ID
+ * @returns Array of UserProvider records
+ */
+export async function getUserProviders(
+ userId: string
+): Promise {
+ const conn = ConnectionFactory();
+
+ const result = await conn.execute({
+ sql: "SELECT * FROM UserProvider WHERE user_id = ? ORDER BY created_at ASC",
+ args: [userId]
+ });
+
+ return result.rows as unknown as UserProvider[];
+}
+
+/**
+ * Find user by provider and provider-specific identifier
+ * @param provider - Provider type
+ * @param providerUserId - Provider-specific user ID
+ * @returns User ID if found, null otherwise
+ */
+export async function findUserByProvider(
+ provider: "email" | "google" | "github",
+ providerUserId: string
+): Promise {
+ const conn = ConnectionFactory();
+
+ const result = await conn.execute({
+ sql: "SELECT user_id FROM UserProvider WHERE provider = ? AND provider_user_id = ?",
+ args: [provider, providerUserId]
+ });
+
+ if (result.rows.length === 0) {
+ return null;
+ }
+
+ return (result.rows[0] as any).user_id;
+}
+
+/**
+ * Find user by provider and email
+ * Used for account linking when email matches
+ * @param provider - Provider type
+ * @param email - Email address
+ * @returns User ID if found, null otherwise
+ */
+export async function findUserByProviderEmail(
+ provider: "email" | "google" | "github",
+ email: string
+): Promise {
+ const conn = ConnectionFactory();
+
+ const result = await conn.execute({
+ sql: "SELECT user_id FROM UserProvider WHERE provider = ? AND email = ?",
+ args: [provider, email]
+ });
+
+ if (result.rows.length === 0) {
+ return null;
+ }
+
+ return (result.rows[0] as any).user_id;
+}
+
+/**
+ * Find any user by email across all providers
+ * Used for cross-provider account linking
+ * @param email - Email address
+ * @returns User ID if found, null otherwise
+ */
+export async function findUserByEmail(email: string): Promise {
+ const conn = ConnectionFactory();
+
+ // First check User table
+ const userResult = await conn.execute({
+ sql: "SELECT id FROM User WHERE email = ?",
+ args: [email]
+ });
+
+ if (userResult.rows.length > 0) {
+ return (userResult.rows[0] as any).id;
+ }
+
+ // Then check UserProvider table
+ const providerResult = await conn.execute({
+ sql: "SELECT user_id FROM UserProvider WHERE email = ? LIMIT 1",
+ args: [email]
+ });
+
+ if (providerResult.rows.length > 0) {
+ return (providerResult.rows[0] as any).user_id;
+ }
+
+ return null;
+}
+
+/**
+ * Update last_used_at timestamp for a provider
+ * Call this on successful login with that provider
+ * @param userId - User ID
+ * @param provider - Provider that was used
+ */
+export async function updateProviderLastUsed(
+ userId: string,
+ provider: "email" | "google" | "github"
+): Promise {
+ const conn = ConnectionFactory();
+
+ await conn.execute({
+ sql: "UPDATE UserProvider SET last_used_at = datetime('now') WHERE user_id = ? AND provider = ?",
+ args: [userId, provider]
+ });
+}
+
+/**
+ * Check if a user has a specific provider linked
+ * @param userId - User ID
+ * @param provider - Provider to check
+ * @returns true if linked, false otherwise
+ */
+export async function hasProvider(
+ userId: string,
+ provider: "email" | "google" | "github"
+): Promise {
+ const conn = ConnectionFactory();
+
+ const result = await conn.execute({
+ sql: "SELECT id FROM UserProvider WHERE user_id = ? AND provider = ?",
+ args: [userId, provider]
+ });
+
+ return result.rows.length > 0;
+}
+
+/**
+ * Get provider summary for a user (for display purposes)
+ * @param userId - User ID
+ * @returns Summary of linked providers
+ */
+export async function getProviderSummary(userId: string): Promise<{
+ providers: Array<{
+ provider: string;
+ email?: string;
+ displayName?: string;
+ lastUsed: string;
+ }>;
+ count: number;
+}> {
+ const providers = await getUserProviders(userId);
+
+ return {
+ providers: providers.map((p) => ({
+ provider: p.provider,
+ email: p.email || undefined,
+ displayName: p.display_name || undefined,
+ lastUsed: p.last_used_at
+ })),
+ count: providers.length
+ };
+}
diff --git a/src/server/session-helpers.ts b/src/server/session-helpers.ts
index 4d12a86..5069f08 100644
--- a/src/server/session-helpers.ts
+++ b/src/server/session-helpers.ts
@@ -14,6 +14,7 @@ import { AUTH_CONFIG, expiryToSeconds } from "~/config";
import { logAuditEvent } from "./audit";
import type { SessionData } from "./session-config";
import { sessionConfig } from "./session-config";
+import { getDeviceInfo } from "./device-utils";
/**
* Generate a cryptographically secure refresh token
@@ -61,6 +62,9 @@ export async function createAuthSession(
const refreshToken = generateRefreshToken();
const tokenHash = hashRefreshToken(refreshToken);
+ // Parse device information
+ const deviceInfo = getDeviceInfo(event);
+
// Calculate refresh token expiration
const refreshExpiry = rememberMe
? AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_LONG
@@ -102,12 +106,13 @@ export async function createAuthSession(
}
}
- // Insert session into database
+ // Insert session into database with device metadata
await conn.execute({
sql: `INSERT INTO Session
(id, user_id, token_family, refresh_token_hash, parent_session_id,
- rotation_count, expires_at, access_token_expires_at, ip_address, user_agent)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ rotation_count, expires_at, access_token_expires_at, ip_address, user_agent,
+ device_name, device_type, browser, os, last_active_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
args: [
sessionId,
userId,
@@ -118,7 +123,11 @@ export async function createAuthSession(
expiresAt.toISOString(),
accessExpiresAt.toISOString(),
ipAddress,
- userAgent
+ userAgent,
+ deviceInfo.deviceName || null,
+ deviceInfo.deviceType || null,
+ deviceInfo.browser || null,
+ deviceInfo.os || null
]
});
@@ -152,7 +161,9 @@ export async function createAuthSession(
sessionId,
tokenFamily: family,
rememberMe,
- parentSessionId
+ parentSessionId,
+ deviceName: deviceInfo.deviceName,
+ deviceType: deviceInfo.deviceType
},
success: true
});
@@ -299,14 +310,14 @@ async function validateSessionInDB(
return false;
}
- // Update last_used timestamp (fire and forget)
+ // Update last_used and last_active_at timestamps (fire and forget)
conn
.execute({
- sql: "UPDATE Session SET last_used = datetime('now') WHERE id = ?",
+ sql: "UPDATE Session SET last_used = datetime('now'), last_active_at = datetime('now') WHERE id = ?",
args: [sessionId]
})
.catch((err) =>
- console.error("Failed to update session last_used:", err)
+ console.error("Failed to update session timestamps:", err)
);
return true;
diff --git a/src/server/session-management.ts b/src/server/session-management.ts
new file mode 100644
index 0000000..96b4907
--- /dev/null
+++ b/src/server/session-management.ts
@@ -0,0 +1,195 @@
+import { ConnectionFactory } from "./database";
+import type { Session } from "~/db/types";
+import { formatDeviceDescription } from "./device-utils";
+
+/**
+ * Get all active sessions for a user
+ * @param userId - User ID
+ * @returns Array of active sessions with formatted device info
+ */
+export async function getUserActiveSessions(userId: string): Promise<
+ Array<{
+ sessionId: string;
+ deviceDescription: string;
+ deviceType?: string;
+ browser?: string;
+ os?: string;
+ ipAddress?: string;
+ lastActive: string;
+ createdAt: string;
+ current: boolean;
+ }>
+> {
+ const conn = ConnectionFactory();
+
+ const result = await conn.execute({
+ sql: `SELECT
+ id, device_name, device_type, browser, os,
+ ip_address, last_active_at, created_at, token_family
+ FROM Session
+ WHERE user_id = ? AND revoked = 0 AND expires_at > datetime('now')
+ ORDER BY last_active_at DESC`,
+ args: [userId]
+ });
+
+ return result.rows.map((row: any) => {
+ const deviceInfo = {
+ deviceName: row.device_name,
+ deviceType: row.device_type,
+ browser: row.browser,
+ os: row.os
+ };
+
+ return {
+ sessionId: row.id,
+ deviceDescription: formatDeviceDescription(deviceInfo),
+ deviceType: row.device_type,
+ browser: row.browser,
+ os: row.os,
+ ipAddress: row.ip_address,
+ lastActive: row.last_active_at,
+ createdAt: row.created_at,
+ current: false // Will be set by caller if needed
+ };
+ });
+}
+
+/**
+ * Revoke a specific session (not entire token family)
+ * Useful for "logout from this device" functionality
+ * @param userId - User ID (for verification)
+ * @param sessionId - Session ID to revoke
+ * @throws Error if session not found or doesn't belong to user
+ */
+export async function revokeUserSession(
+ userId: string,
+ sessionId: string
+): Promise {
+ const conn = ConnectionFactory();
+
+ // Verify session belongs to user
+ const verifyResult = await conn.execute({
+ sql: "SELECT user_id FROM Session WHERE id = ?",
+ args: [sessionId]
+ });
+
+ if (verifyResult.rows.length === 0) {
+ throw new Error("Session not found");
+ }
+
+ const sessionUserId = (verifyResult.rows[0] as any).user_id;
+ if (sessionUserId !== userId) {
+ throw new Error("Session does not belong to this user");
+ }
+
+ // Revoke the session
+ await conn.execute({
+ sql: "UPDATE Session SET revoked = 1 WHERE id = ?",
+ args: [sessionId]
+ });
+}
+
+/**
+ * Revoke all sessions for a user EXCEPT the current one
+ * Useful for "logout from all other devices"
+ * @param userId - User ID
+ * @param currentSessionId - Current session ID to keep active
+ * @returns Number of sessions revoked
+ */
+export async function revokeOtherUserSessions(
+ userId: string,
+ currentSessionId: string
+): Promise {
+ const conn = ConnectionFactory();
+
+ const result = await conn.execute({
+ sql: "UPDATE Session SET revoked = 1 WHERE user_id = ? AND id != ? AND revoked = 0",
+ args: [userId, currentSessionId]
+ });
+
+ return (result as any).rowsAffected || 0;
+}
+
+/**
+ * Get session count by device type for a user
+ * @param userId - User ID
+ * @returns Object with counts by device type
+ */
+export async function getSessionCountByDevice(userId: string): Promise<{
+ desktop: number;
+ mobile: number;
+ tablet: number;
+ unknown: number;
+ total: number;
+}> {
+ const conn = ConnectionFactory();
+
+ const result = await conn.execute({
+ sql: `SELECT
+ device_type,
+ COUNT(*) as count
+ FROM Session
+ WHERE user_id = ? AND revoked = 0 AND expires_at > datetime('now')
+ GROUP BY device_type`,
+ args: [userId]
+ });
+
+ const counts = {
+ desktop: 0,
+ mobile: 0,
+ tablet: 0,
+ unknown: 0,
+ total: 0
+ };
+
+ for (const row of result.rows) {
+ const deviceType = (row as any).device_type;
+ const count = (row as any).count;
+
+ if (deviceType === "desktop") {
+ counts.desktop = count;
+ } else if (deviceType === "mobile") {
+ counts.mobile = count;
+ } else if (deviceType === "tablet") {
+ counts.tablet = count;
+ } else {
+ counts.unknown = count;
+ }
+
+ counts.total += count;
+ }
+
+ return counts;
+}
+
+/**
+ * Check if a specific device fingerprint already has an active session
+ * Can be used to show "You're already logged in on this device" messages
+ * @param userId - User ID
+ * @param deviceType - Device type
+ * @param browser - Browser name
+ * @param os - OS name
+ * @returns true if device has active session
+ */
+export async function hasActiveSessionOnDevice(
+ userId: string,
+ deviceType?: string,
+ browser?: string,
+ os?: string
+): Promise {
+ const conn = ConnectionFactory();
+
+ const result = await conn.execute({
+ sql: `SELECT id FROM Session
+ WHERE user_id = ?
+ AND device_type = ?
+ AND browser = ?
+ AND os = ?
+ AND revoked = 0
+ AND expires_at > datetime('now')
+ LIMIT 1`,
+ args: [userId, deviceType || null, browser || null, os || null]
+ });
+
+ return result.rows.length > 0;
+}