diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 45707d8d..c949dcf0 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -40,6 +40,20 @@ async function initializeDatabaseAsync(): Promise { await DatabaseFileEncryption.decryptDatabaseToBuffer(encryptedDbPath); memoryDatabase = new Database(decryptedBuffer); + + // Count sessions after loading + try { + const sessionCount = memoryDatabase + .prepare("SELECT COUNT(*) as count FROM sessions") + .get() as { count: number }; + databaseLogger.info("Database loaded from encrypted file", { + operation: "db_load", + sessionCount: sessionCount.count, + bufferSize: decryptedBuffer.length, + }); + } catch (countError) { + // Ignore count errors + } } else { const migration = new DatabaseMigration(dataDir); const migrationStatus = migration.checkMigrationStatus(); @@ -281,6 +295,18 @@ async function initializeCompleteDatabase(): Promise { `); + try { + sqlite.prepare("DELETE FROM sessions").run(); + databaseLogger.info("All sessions cleared on startup", { + operation: "db_init_session_cleanup", + }); + } catch (e) { + databaseLogger.warn("Could not clear sessions on startup", { + operation: "db_init_session_cleanup_failed", + error: e, + }); + } + migrateSchema(); try { @@ -473,6 +499,20 @@ async function saveMemoryDatabaseToFile() { fs.mkdirSync(dataDir, { recursive: true }); } + // Count sessions before saving + try { + const sessionCount = memoryDatabase + .prepare("SELECT COUNT(*) as count FROM sessions") + .get() as { count: number }; + databaseLogger.info("Saving database to file", { + operation: "db_save", + sessionCount: sessionCount.count, + bufferSize: buffer.length, + }); + } catch (countError) { + // Ignore count errors + } + if (enableFileEncryption) { await DatabaseFileEncryption.encryptDatabaseFromBuffer( buffer, diff --git a/src/backend/utils/auth-manager.ts b/src/backend/utils/auth-manager.ts index f701ce3a..ec2d2bf0 100644 --- a/src/backend/utils/auth-manager.ts +++ b/src/backend/utils/auth-manager.ts @@ -191,7 +191,9 @@ class AuthManager { // Calculate expiration timestamp const expirationMs = this.parseExpiresIn(expiresIn); - const expiresAt = new Date(Date.now() + expirationMs).toISOString(); + const now = new Date(); + const expiresAt = new Date(now.getTime() + expirationMs).toISOString(); + const createdAt = now.toISOString(); // Store session in database try { @@ -201,7 +203,9 @@ class AuthManager { jwtToken: token, deviceType: options.deviceType, deviceInfo: options.deviceInfo, + createdAt, expiresAt, + lastActiveAt: createdAt, }); databaseLogger.info("Session created", { @@ -211,6 +215,30 @@ class AuthManager { deviceType: options.deviceType, expiresAt, }); + + // Immediately save database to disk to ensure session persists across restarts + try { + const { saveMemoryDatabaseToFile } = await import( + "../database/db/index.js" + ); + await saveMemoryDatabaseToFile(); + databaseLogger.info( + "Database saved immediately after session creation", + { + operation: "session_create_db_save", + sessionId, + }, + ); + } catch (saveError) { + databaseLogger.error( + "Failed to save database after session creation", + saveError, + { + operation: "session_create_db_save_failed", + sessionId, + }, + ); + } } catch (error) { databaseLogger.error("Failed to create session", error, { operation: "session_create_failed", @@ -253,8 +281,25 @@ class AuthManager { async verifyJWTToken(token: string): Promise { try { const jwtSecret = await this.systemCrypto.getJWTSecret(); + + databaseLogger.info("Attempting JWT verification", { + operation: "jwt_verify_attempt", + tokenLength: token.length, + secretLength: jwtSecret.length, + }); + const payload = jwt.verify(token, jwtSecret) as JWTPayload; + databaseLogger.info("JWT signature verified successfully", { + operation: "jwt_signature_verified", + userId: payload.userId, + sessionId: payload.sessionId, + hasExpiration: !!payload.exp, + expiresAt: payload.exp + ? new Date(payload.exp * 1000).toISOString() + : "N/A", + }); + // For tokens with sessionId, verify the session exists in database // This ensures revoked sessions are rejected even after backend restart if (payload.sessionId) { @@ -272,10 +317,18 @@ class AuthManager { operation: "jwt_verify_failed", reason: "session_not_found", sessionId: payload.sessionId, + userId: payload.userId, }, ); return null; } + + databaseLogger.info("Session found in database", { + operation: "jwt_session_found", + sessionId: payload.sessionId, + userId: payload.userId, + sessionExpiresAt: sessionRecords[0].expiresAt, + }); } catch (dbError) { databaseLogger.error( "Failed to check session in database during JWT verification", diff --git a/src/backend/utils/system-crypto.ts b/src/backend/utils/system-crypto.ts index 9f9c15b0..45653cce 100644 --- a/src/backend/utils/system-crypto.ts +++ b/src/backend/utils/system-crypto.ts @@ -23,24 +23,55 @@ class SystemCrypto { const envSecret = process.env.JWT_SECRET; if (envSecret && envSecret.length >= 64) { this.jwtSecret = envSecret; + databaseLogger.info("JWT secret loaded from environment variable", { + operation: "jwt_init_from_env", + secretLength: envSecret.length, + secretPrefix: envSecret.substring(0, 8) + "...", + }); return; } const dataDir = process.env.DATA_DIR || "./db/data"; const envPath = path.join(dataDir, ".env"); + databaseLogger.info("Attempting to load JWT secret from .env file", { + operation: "jwt_init_from_file", + envPath, + }); + try { const envContent = await fs.readFile(envPath, "utf8"); const jwtMatch = envContent.match(/^JWT_SECRET=(.+)$/m); if (jwtMatch && jwtMatch[1] && jwtMatch[1].length >= 64) { this.jwtSecret = jwtMatch[1]; process.env.JWT_SECRET = jwtMatch[1]; + databaseLogger.success("JWT secret loaded from .env file", { + operation: "jwt_init_from_file_success", + secretLength: jwtMatch[1].length, + secretPrefix: jwtMatch[1].substring(0, 8) + "...", + }); return; + } else { + databaseLogger.warn( + "JWT_SECRET in .env file is invalid or too short", + { + operation: "jwt_init_invalid_secret", + hasMatch: !!jwtMatch, + secretLength: jwtMatch?.[1]?.length || 0, + }, + ); } - } catch { - // Ignore file read errors, will generate new secret + } catch (fileError) { + databaseLogger.warn("Failed to read .env file for JWT secret", { + operation: "jwt_init_file_read_failed", + error: + fileError instanceof Error ? fileError.message : "Unknown error", + }); } + databaseLogger.warn("Generating new JWT secret", { + operation: "jwt_generating_new_secret", + }); await this.generateAndGuideUser(); } catch (error) { databaseLogger.error("Failed to initialize JWT secret", error, { diff --git a/src/backend/utils/user-agent-parser.ts b/src/backend/utils/user-agent-parser.ts index b84e969c..ae6205f9 100644 --- a/src/backend/utils/user-agent-parser.ts +++ b/src/backend/utils/user-agent-parser.ts @@ -90,26 +90,49 @@ function parseMobileUserAgent(userAgent: string): DeviceInfo { let os = "Unknown OS"; let version = "Unknown"; - // Detect mobile OS - if (userAgent.includes("Android")) { - const androidMatch = userAgent.match(/Android ([\d.]+)/); - os = androidMatch ? `Android ${androidMatch[1]}` : "Android"; - } else if ( - userAgent.includes("iOS") || - userAgent.includes("iPhone") || - userAgent.includes("iPad") - ) { - const iosMatch = userAgent.match(/OS ([\d_]+)/); - if (iosMatch) { - const iosVersion = iosMatch[1].replace(/_/g, "."); - os = `iOS ${iosVersion}`; - } else { - os = "iOS"; + // Check for Termix-Mobile/Platform format first (e.g., "Termix-Mobile/Android" or "Termix-Mobile/iOS") + const termixPlatformMatch = userAgent.match(/Termix-Mobile\/(Android|iOS)/i); + if (termixPlatformMatch) { + const platform = termixPlatformMatch[1]; + if (platform.toLowerCase() === "android") { + // Try to get Android version from full UA string + const androidMatch = userAgent.match(/Android ([\d.]+)/); + os = androidMatch ? `Android ${androidMatch[1]}` : "Android"; + } else if (platform.toLowerCase() === "ios") { + // Try to get iOS version from full UA string + const iosMatch = userAgent.match(/OS ([\d_]+)/); + if (iosMatch) { + const iosVersion = iosMatch[1].replace(/_/g, "."); + os = `iOS ${iosVersion}`; + } else { + os = "iOS"; + } + } + } else { + // Fallback: Check for standard Android/iOS patterns in the user agent + if (userAgent.includes("Android")) { + const androidMatch = userAgent.match(/Android ([\d.]+)/); + os = androidMatch ? `Android ${androidMatch[1]}` : "Android"; + } else if ( + userAgent.includes("iOS") || + userAgent.includes("iPhone") || + userAgent.includes("iPad") + ) { + const iosMatch = userAgent.match(/OS ([\d_]+)/); + if (iosMatch) { + const iosVersion = iosMatch[1].replace(/_/g, "."); + os = `iOS ${iosVersion}`; + } else { + os = "iOS"; + } } } // Try to extract app version (if included in UA) - const versionMatch = userAgent.match(/Termix-Mobile\/([\d.]+)/); + // Match patterns like "Termix-Mobile/1.0.0" or just "Termix-Mobile" + const versionMatch = userAgent.match( + /Termix-Mobile\/(?:Android|iOS|)([\d.]+)/i, + ); if (versionMatch) { version = versionMatch[1]; } diff --git a/src/main.tsx b/src/main.tsx index abc315b2..4ae7fd17 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -56,7 +56,8 @@ function RootApp() { const width = useWindowWidth(); const isMobile = width < 768; - const userAgent = navigator.userAgent || navigator.vendor || window.opera; + const userAgent = + navigator.userAgent || navigator.vendor || (window as any).opera || ""; const isTermixMobile = /Termix-Mobile/.test(userAgent); if (isElectron()) { diff --git a/src/ui/Desktop/Authentication/Auth.tsx b/src/ui/Desktop/Authentication/Auth.tsx index 49aa78d3..5bbf0129 100644 --- a/src/ui/Desktop/Authentication/Auth.tsx +++ b/src/ui/Desktop/Authentication/Auth.tsx @@ -57,6 +57,22 @@ export function Auth({ ...props }: AuthProps) { const { t } = useTranslation(); + + // Detect if we're running in Electron's WebView/iframe + const isInElectronWebView = () => { + try { + // Check if we're in an iframe AND the parent is Electron + if (window.self !== window.top) { + // We're in an iframe, likely Electron's ElectronLoginForm + return true; + } + } catch (e) { + // Cross-origin iframe, can't access parent + return false; + } + return false; + }; + const [tab, setTab] = useState<"login" | "signup" | "external" | "reset">( "login", ); @@ -92,12 +108,22 @@ export function Auth({ }, [loggedIn]); useEffect(() => { + // Skip when in Electron WebView iframe + if (isInElectronWebView()) { + return; + } + getRegistrationAllowed().then((res) => { setRegistrationAllowed(res.allowed); }); }, []); useEffect(() => { + // Skip when in Electron WebView iframe + if (isInElectronWebView()) { + return; + } + getPasswordLoginAllowed() .then((res) => { setPasswordLoginAllowed(res.allowed); @@ -110,6 +136,11 @@ export function Auth({ }, []); useEffect(() => { + // Skip when in Electron WebView iframe + if (isInElectronWebView()) { + return; + } + getOIDCConfig() .then((response) => { if (response) { @@ -128,6 +159,14 @@ export function Auth({ }, []); useEffect(() => { + // Skip database health check when in Electron WebView iframe + // The parent Electron window will handle authentication + if (isInElectronWebView()) { + setDbHealthChecking(false); + setDbConnectionFailed(false); + return; + } + setDbHealthChecking(true); getSetupRequired() .then((res) => { @@ -691,21 +730,6 @@ export function Auth({ ); } - // Detect if we're running in Electron's WebView/iframe - const isInElectronWebView = () => { - try { - // Check if we're in an iframe AND the parent is Electron - if (window.self !== window.top) { - // We're in an iframe, likely Electron's ElectronLoginForm - return true; - } - } catch (e) { - // Cross-origin iframe, can't access parent - return false; - } - return false; - }; - return (