From d425cee6e292087fa7e92f1af68acf238a549751 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Sat, 15 Nov 2025 02:11:54 -0600 Subject: [PATCH] fix: OIDC/local account linking breaking both logins --- electron/preload.js | 1 - src/backend/database/routes/users.ts | 194 ++++++++++++++++++++++++- src/backend/utils/user-crypto.ts | 84 +++++++++++ src/ui/desktop/admin/AdminSettings.tsx | 40 +++++ src/ui/main-axios.ts | 13 ++ 5 files changed, 324 insertions(+), 8 deletions(-) diff --git a/electron/preload.js b/electron/preload.js index e8b8655a..1db1b356 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -22,7 +22,6 @@ contextBridge.exposeInMainWorld("electronAPI", { isElectron: true, isDev: process.env.NODE_ENV === "development", - // Settings/preferences storage getSetting: (key) => ipcRenderer.invoke("get-setting", key), setSetting: (key, value) => ipcRenderer.invoke("set-setting", key, value), diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 11fbdc8e..02a7a2b5 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -862,6 +862,7 @@ router.get("/oidc/callback", async (req, res) => { const userRecord = user[0]; + // For all OIDC logins (including dual-auth), use OIDC encryption try { await authManager.authenticateOIDCUser(userRecord.id, deviceInfo.type); } catch (setupError) { @@ -994,8 +995,13 @@ router.post("/login", async (req, res) => { const userRecord = user[0]; - if (userRecord.is_oidc) { - authLogger.warn("OIDC user attempted traditional login", { + // Only reject if user is OIDC-only (has no password set) + // Empty string "" is treated as no password + if ( + userRecord.is_oidc && + (!userRecord.password_hash || userRecord.password_hash.trim() === "") + ) { + authLogger.warn("OIDC-only user attempted traditional login", { operation: "user_login", username, userId: userRecord.id, @@ -1033,11 +1039,25 @@ router.post("/login", async (req, res) => { } catch (error) {} const deviceInfo = parseUserAgent(req); - const dataUnlocked = await authManager.authenticateUser( - userRecord.id, - password, - deviceInfo.type, - ); + + // For dual-auth users (has both password and OIDC), use OIDC encryption + // For password-only users, use password-based encryption + let dataUnlocked = false; + if (userRecord.is_oidc) { + // Dual-auth user: verify password then use OIDC encryption + dataUnlocked = await authManager.authenticateOIDCUser( + userRecord.id, + deviceInfo.type, + ); + } else { + // Password-only user: use password-based encryption + dataUnlocked = await authManager.authenticateUser( + userRecord.id, + password, + deviceInfo.type, + ); + } + if (!dataUnlocked) { return res.status(401).json({ error: "Incorrect password" }); } @@ -2646,6 +2666,50 @@ router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => { }) .where(eq(users.id, targetUser.id)); + // Re-encrypt the user's DEK with OIDC system key for dual-auth support + // This allows OIDC login to decrypt user data without requiring password + try { + await authManager.convertToOIDCEncryption(targetUser.id); + authLogger.info("Converted user encryption to OIDC for dual-auth", { + operation: "link_convert_encryption", + userId: targetUser.id, + }); + } catch (encryptionError) { + authLogger.error( + "Failed to convert encryption to OIDC during linking", + encryptionError, + { + operation: "link_convert_encryption_failed", + userId: targetUser.id, + }, + ); + // Rollback the OIDC configuration + await db + .update(users) + .set({ + is_oidc: false, + oidc_identifier: null, + client_id: "", + client_secret: "", + issuer_url: "", + authorization_url: "", + token_url: "", + identifier_path: "", + name_path: "", + scopes: "openid email profile", + }) + .where(eq(users.id, targetUser.id)); + + return res.status(500).json({ + error: + "Failed to convert encryption for dual-auth. Please ensure the password account has encryption setup.", + details: + encryptionError instanceof Error + ? encryptionError.message + : "Unknown error", + }); + } + // Revoke all sessions for the OIDC user before deletion await authManager.revokeAllUserSessions(oidcUserId); authManager.logoutUser(oidcUserId); @@ -2704,4 +2768,120 @@ router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => { } }); +// Route: Unlink OIDC from password account (admin only) +// POST /users/unlink-oidc-from-password +router.post("/unlink-oidc-from-password", authenticateJWT, async (req, res) => { + const adminUserId = (req as AuthenticatedRequest).userId; + const { userId } = req.body; + + if (!userId) { + return res.status(400).json({ + error: "User ID is required", + }); + } + + try { + const adminUser = await db + .select() + .from(users) + .where(eq(users.id, adminUserId)); + + if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) { + authLogger.warn("Non-admin attempted to unlink OIDC from password", { + operation: "unlink_oidc_unauthorized", + adminUserId, + targetUserId: userId, + }); + return res.status(403).json({ + error: "Admin privileges required", + }); + } + + const targetUserRecords = await db + .select() + .from(users) + .where(eq(users.id, userId)); + + if (!targetUserRecords || targetUserRecords.length === 0) { + return res.status(404).json({ + error: "User not found", + }); + } + + const targetUser = targetUserRecords[0]; + + if (!targetUser.is_oidc) { + return res.status(400).json({ + error: "User does not have OIDC authentication enabled", + }); + } + + if (!targetUser.password_hash || targetUser.password_hash === "") { + return res.status(400).json({ + error: + "Cannot unlink OIDC from a user without password authentication. This would leave the user unable to login.", + }); + } + + authLogger.info("Unlinking OIDC from password account", { + operation: "unlink_oidc_from_password_start", + targetUserId: targetUser.id, + targetUsername: targetUser.username, + adminUserId, + }); + + await db + .update(users) + .set({ + is_oidc: false, + oidc_identifier: null, + client_id: "", + client_secret: "", + issuer_url: "", + authorization_url: "", + token_url: "", + identifier_path: "", + name_path: "", + scopes: "openid email profile", + }) + .where(eq(users.id, targetUser.id)); + + try { + const { saveMemoryDatabaseToFile } = await import("../db/index.js"); + await saveMemoryDatabaseToFile(); + } catch (saveError) { + authLogger.error( + "Failed to save database after unlinking OIDC", + saveError, + { + operation: "unlink_oidc_save_failed", + targetUserId: targetUser.id, + }, + ); + } + + authLogger.success("OIDC unlinked from password account successfully", { + operation: "unlink_oidc_from_password_success", + targetUserId: targetUser.id, + targetUsername: targetUser.username, + adminUserId, + }); + + res.json({ + success: true, + message: `OIDC authentication has been removed from ${targetUser.username}. User can now only login with password.`, + }); + } catch (err) { + authLogger.error("Failed to unlink OIDC from password account", err, { + operation: "unlink_oidc_from_password_failed", + targetUserId: userId, + adminUserId, + }); + res.status(500).json({ + error: "Failed to unlink OIDC", + details: err instanceof Error ? err.message : "Unknown error", + }); + } +}); + export default router; diff --git a/src/backend/utils/user-crypto.ts b/src/backend/utils/user-crypto.ts index 513d5984..46248d65 100644 --- a/src/backend/utils/user-crypto.ts +++ b/src/backend/utils/user-crypto.ts @@ -326,6 +326,90 @@ class UserCrypto { } } + /** + * Convert a password-based user's encryption to OIDC encryption. + * This is used when linking an OIDC account to a password account for dual-auth. + * + * If user has no existing encryption setup, this does nothing. + * If user has encryption but no active session, we delete the old keys + * and let OIDC create new ones on next login (data will be lost). + * If user has an active session, we re-encrypt their DEK with OIDC system key (data preserved). + */ + async convertToOIDCEncryption(userId: string): Promise { + try { + const { getDb } = await import("../database/db/index.js"); + const { settings } = await import("../database/db/schema.js"); + const { eq } = await import("drizzle-orm"); + + const existingEncryptedDEK = await this.getEncryptedDEK(userId); + const existingKEKSalt = await this.getKEKSalt(userId); + + // If no existing encryption, nothing to convert + if (!existingEncryptedDEK && !existingKEKSalt) { + databaseLogger.info("No existing encryption to convert for user", { + operation: "convert_to_oidc_encryption_skip", + userId, + }); + return; + } + + // Try to get the DEK from active session + const existingDEK = this.getUserDataKey(userId); + + if (existingDEK) { + // User has active session - preserve their data by re-encrypting DEK + const systemKey = this.deriveOIDCSystemKey(userId); + const oidcEncryptedDEK = this.encryptDEK(existingDEK, systemKey); + systemKey.fill(0); + + await this.storeEncryptedDEK(userId, oidcEncryptedDEK); + + databaseLogger.info( + "Converted user encryption from password to OIDC (data preserved)", + { + operation: "convert_to_oidc_encryption_preserved", + userId, + }, + ); + } else { + // No active session - delete old encryption keys + // OIDC will create new keys on next login, but old encrypted data will be inaccessible + if (existingEncryptedDEK) { + await getDb() + .delete(settings) + .where(eq(settings.key, `user_encrypted_dek_${userId}`)); + } + + databaseLogger.warn( + "Deleted old encryption keys during OIDC conversion - user data may be lost", + { + operation: "convert_to_oidc_encryption_data_loss", + userId, + }, + ); + } + + // Always remove password-based KEK salt + if (existingKEKSalt) { + await getDb() + .delete(settings) + .where(eq(settings.key, `user_kek_salt_${userId}`)); + } + + const { saveMemoryDatabaseToFile } = await import( + "../database/db/index.js" + ); + await saveMemoryDatabaseToFile(); + } catch (error) { + databaseLogger.error("Failed to convert to OIDC encryption", error, { + operation: "convert_to_oidc_encryption_error", + userId, + error: error instanceof Error ? error.message : "Unknown error", + }); + throw error; + } + } + private async validatePassword( userId: string, password: string, diff --git a/src/ui/desktop/admin/AdminSettings.tsx b/src/ui/desktop/admin/AdminSettings.tsx index 5d0999cf..bccfa730 100644 --- a/src/ui/desktop/admin/AdminSettings.tsx +++ b/src/ui/desktop/admin/AdminSettings.tsx @@ -35,6 +35,7 @@ import { Users, Database, Link2, + Unlink, Download, Upload, Monitor, @@ -64,6 +65,7 @@ import { revokeSession, revokeAllUserSessions, linkOIDCToPasswordAccount, + unlinkOIDCFromPasswordAccount, } from "@/ui/main-axios.ts"; interface AdminSettingsProps { @@ -692,6 +694,31 @@ export function AdminSettings({ } }; + const handleUnlinkOIDC = async (userId: string, username: string) => { + const confirmed = await confirm( + { + title: "Unlink OIDC Authentication", + description: `Remove OIDC authentication from ${username}? The user will only be able to login with username/password after this.`, + }, + "default", + ); + + if (!confirmed) return; + + try { + const result = await unlinkOIDCFromPasswordAccount(userId); + + toast.success(result.message || `OIDC unlinked from ${username}`); + fetchUsers(); + fetchSessions(); + } catch (error: unknown) { + const err = error as { + response?: { data?: { error?: string; code?: string } }; + }; + toast.error(err.response?.data?.error || "Failed to unlink OIDC"); + } + }; + const topMarginPx = isTopbarOpen ? 74 : 26; const leftMarginPx = sidebarState === "collapsed" ? 26 : 8; const bottomMarginPx = 8; @@ -1105,6 +1132,19 @@ export function AdminSettings({ )} + {user.is_oidc && user.password_hash && ( + + )}