fix: OIDC/local account linking breaking both logins
This commit is contained in:
@@ -22,7 +22,6 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
isElectron: true,
|
isElectron: true,
|
||||||
isDev: process.env.NODE_ENV === "development",
|
isDev: process.env.NODE_ENV === "development",
|
||||||
|
|
||||||
// Settings/preferences storage
|
|
||||||
getSetting: (key) => ipcRenderer.invoke("get-setting", key),
|
getSetting: (key) => ipcRenderer.invoke("get-setting", key),
|
||||||
setSetting: (key, value) => ipcRenderer.invoke("set-setting", key, value),
|
setSetting: (key, value) => ipcRenderer.invoke("set-setting", key, value),
|
||||||
|
|
||||||
|
|||||||
@@ -862,6 +862,7 @@ router.get("/oidc/callback", async (req, res) => {
|
|||||||
|
|
||||||
const userRecord = user[0];
|
const userRecord = user[0];
|
||||||
|
|
||||||
|
// For all OIDC logins (including dual-auth), use OIDC encryption
|
||||||
try {
|
try {
|
||||||
await authManager.authenticateOIDCUser(userRecord.id, deviceInfo.type);
|
await authManager.authenticateOIDCUser(userRecord.id, deviceInfo.type);
|
||||||
} catch (setupError) {
|
} catch (setupError) {
|
||||||
@@ -994,8 +995,13 @@ router.post("/login", async (req, res) => {
|
|||||||
|
|
||||||
const userRecord = user[0];
|
const userRecord = user[0];
|
||||||
|
|
||||||
if (userRecord.is_oidc) {
|
// Only reject if user is OIDC-only (has no password set)
|
||||||
authLogger.warn("OIDC user attempted traditional login", {
|
// 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",
|
operation: "user_login",
|
||||||
username,
|
username,
|
||||||
userId: userRecord.id,
|
userId: userRecord.id,
|
||||||
@@ -1033,11 +1039,25 @@ router.post("/login", async (req, res) => {
|
|||||||
} catch (error) {}
|
} catch (error) {}
|
||||||
|
|
||||||
const deviceInfo = parseUserAgent(req);
|
const deviceInfo = parseUserAgent(req);
|
||||||
const dataUnlocked = await authManager.authenticateUser(
|
|
||||||
userRecord.id,
|
// For dual-auth users (has both password and OIDC), use OIDC encryption
|
||||||
password,
|
// For password-only users, use password-based encryption
|
||||||
deviceInfo.type,
|
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) {
|
if (!dataUnlocked) {
|
||||||
return res.status(401).json({ error: "Incorrect password" });
|
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));
|
.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
|
// Revoke all sessions for the OIDC user before deletion
|
||||||
await authManager.revokeAllUserSessions(oidcUserId);
|
await authManager.revokeAllUserSessions(oidcUserId);
|
||||||
authManager.logoutUser(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;
|
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(
|
private async validatePassword(
|
||||||
userId: string,
|
userId: string,
|
||||||
password: string,
|
password: string,
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
Database,
|
Database,
|
||||||
Link2,
|
Link2,
|
||||||
|
Unlink,
|
||||||
Download,
|
Download,
|
||||||
Upload,
|
Upload,
|
||||||
Monitor,
|
Monitor,
|
||||||
@@ -64,6 +65,7 @@ import {
|
|||||||
revokeSession,
|
revokeSession,
|
||||||
revokeAllUserSessions,
|
revokeAllUserSessions,
|
||||||
linkOIDCToPasswordAccount,
|
linkOIDCToPasswordAccount,
|
||||||
|
unlinkOIDCFromPasswordAccount,
|
||||||
} from "@/ui/main-axios.ts";
|
} from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
interface AdminSettingsProps {
|
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 topMarginPx = isTopbarOpen ? 74 : 26;
|
||||||
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
|
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
|
||||||
const bottomMarginPx = 8;
|
const bottomMarginPx = 8;
|
||||||
@@ -1105,6 +1132,19 @@ export function AdminSettings({
|
|||||||
<Link2 className="h-4 w-4" />
|
<Link2 className="h-4 w-4" />
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -3054,3 +3054,16 @@ export async function linkOIDCToPasswordAccount(
|
|||||||
throw handleApiError(error, "link OIDC account to password account");
|
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