fix: OIDC/local account linking breaking both logins
This commit is contained in:
@@ -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),
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user