fix: OIDC/local account linking breaking both logins

This commit is contained in:
LukeGus
2025-11-15 02:11:54 -06:00
parent 5ce2cae4ff
commit d425cee6e2
5 changed files with 324 additions and 8 deletions

View File

@@ -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),

View File

@@ -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;

View File

@@ -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<void> {
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,

View File

@@ -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({
<Link2 className="h-4 w-4" />
</Button>
)}
{user.is_oidc && user.password_hash && (
<Button
variant="ghost"
size="sm"
onClick={() =>
handleUnlinkOIDC(user.id, user.username)
}
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
title="Unlink OIDC (keep password only)"
>
<Unlink className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"

View File

@@ -3054,3 +3054,16 @@ export async function linkOIDCToPasswordAccount(
throw handleApiError(error, "link OIDC account to password account");
}
}
export async function unlinkOIDCFromPasswordAccount(
userId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await authApi.post("/users/unlink-oidc-from-password", {
userId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "unlink OIDC from password account");
}
}