mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
chore: merge dev-2.9.1 into dev-26.10.0
This commit is contained in:
commit
77bcbdb3d6
1310 files changed
+102834
-51010
No files matched your search
@@ -9,6 +9,9 @@ import { createCurrentHostResolutionRepository } from "../repositories/factory.j
|
||||
import { connectHost } from "../../hosts/connect/connect-host.js";
|
||||
import type { SshConnectHost } from "../../hosts/connect/types.js";
|
||||
import { resolveHostById } from "../../hosts/host-resolver.js";
|
||||
import { isSecretReference } from "../../hosts/external-secrets.js";
|
||||
import { resolveKeyReferences } from "../../hosts/stored-ssh-key.js";
|
||||
import { parseSSHKey } from "../../utils/ssh-key-utils.js";
|
||||
|
||||
function describeDeployError(err: unknown): string {
|
||||
const message = getErrorMessage(err, "Connection failed");
|
||||
@@ -363,7 +366,20 @@ export function registerCredentialDeployRoutes(
|
||||
});
|
||||
}
|
||||
|
||||
const publicKey = credData.publicKey;
|
||||
let publicKey = credData.publicKey;
|
||||
const storedKey = credData.privateKey || credData.key;
|
||||
if (!publicKey && isSecretReference(storedKey)) {
|
||||
// A key kept in a secret source has no stored public key; derive
|
||||
// it from the resolved key.
|
||||
const resolved = await resolveKeyReferences(
|
||||
userId,
|
||||
storedKey,
|
||||
(credData as { keyPassword?: string | null }).keyPassword,
|
||||
);
|
||||
publicKey =
|
||||
parseSSHKey(resolved.key, resolved.passphrase).publicKey ||
|
||||
undefined;
|
||||
}
|
||||
if (!publicKey) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
parseSSHKey,
|
||||
validateKeyPair,
|
||||
} from "../../utils/ssh-key-utils.js";
|
||||
import { resolveKeyReferences } from "../../hosts/stored-ssh-key.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
|
||||
const { utils: ssh2Utils } = ssh2Pkg;
|
||||
|
||||
@@ -99,13 +101,17 @@ export function registerCredentialKeyRoutes(
|
||||
requireCredentialViewPermission,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const { privateKey, keyPassword } = req.body;
|
||||
|
||||
if (!privateKey || typeof privateKey !== "string") {
|
||||
if (!req.body?.privateKey || typeof req.body.privateKey !== "string") {
|
||||
return res.status(400).json({ error: "Private key is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { key: privateKey, passphrase: keyPassword } =
|
||||
await resolveKeyReferences(
|
||||
(req as AuthenticatedRequest).userId,
|
||||
req.body.privateKey,
|
||||
req.body.keyPassword,
|
||||
);
|
||||
const keyInfo = parseSSHKey(privateKey, keyPassword);
|
||||
|
||||
const response = {
|
||||
@@ -352,13 +358,17 @@ export function registerCredentialKeyRoutes(
|
||||
requireCredentialViewPermission,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const { privateKey, keyPassword } = req.body;
|
||||
|
||||
if (!privateKey || typeof privateKey !== "string") {
|
||||
if (!req.body?.privateKey || typeof req.body.privateKey !== "string") {
|
||||
return res.status(400).json({ error: "Private key is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { key: privateKey, passphrase: keyPassword } =
|
||||
await resolveKeyReferences(
|
||||
(req as AuthenticatedRequest).userId,
|
||||
req.body.privateKey,
|
||||
req.body.keyPassword,
|
||||
);
|
||||
let privateKeyObj;
|
||||
const parseAttempts = [];
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import express, { type Request, type Response } from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { parseSSHKey } from "../../utils/ssh-key-utils.js";
|
||||
import { parseKeyForStorage } from "../../hosts/stored-ssh-key.js";
|
||||
import { registerCredentialKeyRoutes } from "./credential-key-routes.js";
|
||||
import { registerCredentialDeployRoutes } from "./credential-deploy-routes.js";
|
||||
import { registerCredentialBulkRoutes } from "./credential-bulk-routes.js";
|
||||
@@ -165,7 +165,7 @@ router.post(
|
||||
|
||||
let keyInfo = null;
|
||||
if (authType === "key" && plainKey) {
|
||||
keyInfo = parseSSHKey(plainKey, plainKeyPassword);
|
||||
keyInfo = parseKeyForStorage(plainKey, plainKeyPassword);
|
||||
if (!keyInfo.success) {
|
||||
authLogger.warn("SSH key parsing failed", {
|
||||
operation: "credential_create",
|
||||
@@ -192,7 +192,11 @@ router.post(
|
||||
password: plainPassword,
|
||||
key: plainKey,
|
||||
privateKey: keyInfo?.privateKey || plainKey,
|
||||
publicKey: keyInfo?.publicKey || null,
|
||||
publicKey:
|
||||
keyInfo?.publicKey ||
|
||||
(keyInfo?.reference && typeof req.body?.publicKey === "string"
|
||||
? req.body.publicKey.trim() || null
|
||||
: null),
|
||||
keyPassword: plainKeyPassword,
|
||||
keyType: keyType || null,
|
||||
detectedKeyType: keyInfo?.keyType || null,
|
||||
@@ -502,7 +506,7 @@ router.post(
|
||||
|
||||
let keyInfo = null;
|
||||
if (authType === "key" && plainKey) {
|
||||
keyInfo = parseSSHKey(plainKey, plainKeyPassword);
|
||||
keyInfo = parseKeyForStorage(plainKey, plainKeyPassword);
|
||||
if (!keyInfo.success) {
|
||||
return res.status(400).json({
|
||||
error: keyInfo.error
|
||||
@@ -524,7 +528,11 @@ router.post(
|
||||
password: authType === "password" ? plainPassword : null,
|
||||
key: authType === "key" ? plainKey : null,
|
||||
privateKey: authType === "key" ? keyInfo?.privateKey || plainKey : null,
|
||||
publicKey: authType === "key" ? keyInfo?.publicKey || null : null,
|
||||
publicKey:
|
||||
authType === "key"
|
||||
? keyInfo?.publicKey ||
|
||||
(keyInfo?.reference ? source.publicKey || null : null)
|
||||
: null,
|
||||
keyPassword: authType === "key" ? plainKeyPassword : null,
|
||||
keyType: source.keyType,
|
||||
detectedKeyType: authType === "key" ? keyInfo?.keyType || null : null,
|
||||
@@ -688,8 +696,14 @@ router.put(
|
||||
if (updateData.key !== undefined) {
|
||||
updateFields.key = updateData.key || null;
|
||||
|
||||
if (updateData.key && existingCredential.authType === "key") {
|
||||
const keyInfo = parseSSHKey(updateData.key, updateData.keyPassword);
|
||||
if (
|
||||
updateData.key &&
|
||||
(updateData.authType ?? existingCredential.authType) === "key"
|
||||
) {
|
||||
const keyInfo = parseKeyForStorage(
|
||||
updateData.key,
|
||||
updateData.keyPassword,
|
||||
);
|
||||
if (!keyInfo.success) {
|
||||
authLogger.warn("SSH key parsing failed during update", {
|
||||
operation: "credential_update",
|
||||
@@ -704,8 +718,10 @@ router.put(
|
||||
});
|
||||
}
|
||||
updateFields.privateKey = keyInfo.privateKey;
|
||||
updateFields.publicKey = keyInfo.publicKey;
|
||||
updateFields.detectedKeyType = keyInfo.keyType;
|
||||
updateFields.publicKey = keyInfo.reference
|
||||
? updateData.publicKey?.trim() || null
|
||||
: keyInfo.publicKey;
|
||||
updateFields.detectedKeyType = keyInfo.keyType || null;
|
||||
}
|
||||
}
|
||||
if (updateData.keyPassword !== undefined) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from "./host-import-order.js";
|
||||
import { sshOptionsForWrite } from "../../hosts/ssh-options.js";
|
||||
import {
|
||||
applyDefaultsAfterHostWrite,
|
||||
applyDefaultsAfterHostWrites,
|
||||
applyHostDefaultsToWrite,
|
||||
} from "../../hosts/defaults/index.js";
|
||||
import {
|
||||
@@ -22,6 +22,7 @@ import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
import {
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentHostRepository,
|
||||
@@ -183,8 +184,6 @@ export function registerHostBulkRoutes(
|
||||
* patch:
|
||||
* summary: Bulk update partial fields on multiple SSH hosts
|
||||
* tags: [SSH]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
@@ -239,7 +238,7 @@ export function registerHostBulkRoutes(
|
||||
authenticateJWT,
|
||||
requireEditPermission,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
DatabaseSaveTrigger.batched(async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostIds, updates } = req.body;
|
||||
|
||||
@@ -368,7 +367,7 @@ export function registerHostBulkRoutes(
|
||||
sshLogger.error("Failed to bulk update hosts:", error);
|
||||
return res.status(500).json({ error: "Failed to bulk update hosts" });
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -457,7 +456,7 @@ export function registerHostBulkRoutes(
|
||||
requireCreatePermission,
|
||||
requireEditPermission,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
DatabaseSaveTrigger.batched(async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const {
|
||||
hosts: hostsToImport,
|
||||
@@ -577,6 +576,7 @@ export function registerHostBulkRoutes(
|
||||
}
|
||||
|
||||
const knownAuthTypes = listKnownAuthTypes();
|
||||
const writtenHostIds: number[] = [];
|
||||
for (const { host: hostData, index: i, exportId } of orderedHosts) {
|
||||
try {
|
||||
const effectiveConnectionType = hostData.connectionType || "ssh";
|
||||
@@ -812,13 +812,15 @@ export function registerHostBulkRoutes(
|
||||
savedHostId,
|
||||
hostData as Record<string, unknown>,
|
||||
);
|
||||
await applyDefaultsAfterHostWrite(savedHostId);
|
||||
writtenHostIds.push(savedHostId);
|
||||
} catch (error) {
|
||||
results.failed++;
|
||||
results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
await applyDefaultsAfterHostWrites(writtenHostIds);
|
||||
|
||||
res.json({
|
||||
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
|
||||
success: results.success,
|
||||
@@ -827,7 +829,7 @@ export function registerHostBulkRoutes(
|
||||
failed: results.failed,
|
||||
errors: results.errors,
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -864,7 +866,7 @@ export function registerHostBulkRoutes(
|
||||
requireCreatePermission,
|
||||
requireEditPermission,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
DatabaseSaveTrigger.batched(async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { content, overwrite } = req.body;
|
||||
|
||||
@@ -936,6 +938,7 @@ export function registerHostBulkRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
const writtenHostIds: number[] = [];
|
||||
for (let i = 0; i < hostsToImport.length; i++) {
|
||||
const hostData = normalizeImportedHost(
|
||||
hostsToImport[i] as Record<string, unknown>,
|
||||
@@ -1003,7 +1006,7 @@ export function registerHostBulkRoutes(
|
||||
existing.id,
|
||||
sshDataObj,
|
||||
);
|
||||
await applyDefaultsAfterHostWrite(existing.id);
|
||||
writtenHostIds.push(existing.id);
|
||||
results.updated++;
|
||||
} else {
|
||||
sshDataObj.createdAt = new Date().toISOString();
|
||||
@@ -1011,7 +1014,7 @@ export function registerHostBulkRoutes(
|
||||
userId,
|
||||
sshDataObj,
|
||||
);
|
||||
await applyDefaultsAfterHostWrite(saved.id);
|
||||
writtenHostIds.push(saved.id);
|
||||
results.success++;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1022,6 +1025,8 @@ export function registerHostBulkRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
await applyDefaultsAfterHostWrites(writtenHostIds);
|
||||
|
||||
res.json({
|
||||
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
|
||||
success: results.success,
|
||||
@@ -1030,7 +1035,7 @@ export function registerHostBulkRoutes(
|
||||
failed: results.failed,
|
||||
errors: results.errors,
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
import { createCurrentSettingsRepository } from "../repositories/factory.js";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
|
||||
const CATALOG_KEY = "host_tag_catalog";
|
||||
|
||||
function parseCatalog(value: string | null | undefined): string[] {
|
||||
if (!value) return [];
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((tag): tag is string => typeof tag === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function registerHostTagRoutes(
|
||||
router: Router,
|
||||
authenticate: RequestHandler,
|
||||
requireAdmin: RequestHandler,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /host/tags:
|
||||
* get:
|
||||
* summary: Get the predefined host tags
|
||||
* description: The instance-wide tag list host editors suggest. Any signed-in user can read it.
|
||||
* tags:
|
||||
* - SSH
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The predefined tags.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* tags:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* 500:
|
||||
* description: Failed to read the tags.
|
||||
*/
|
||||
router.get("/tags", authenticate, async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const value = await createCurrentSettingsRepository().get(CATALOG_KEY);
|
||||
res.json({ tags: parseCatalog(value) });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to read host tags", error);
|
||||
res.status(500).json({ error: "Failed to read host tags" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/tags:
|
||||
* put:
|
||||
* summary: Replace the predefined host tags
|
||||
* description: Sets the instance-wide tag list. Tags are trimmed and duplicates dropped. Tags already on hosts are not changed. Requires admin.settings.manage.
|
||||
* tags:
|
||||
* - SSH
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* tags:
|
||||
* type: array
|
||||
* maxItems: 500
|
||||
* items:
|
||||
* type: string
|
||||
* maxLength: 100
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The saved tags.
|
||||
* 400:
|
||||
* description: Not an array of up to 500 non-empty tags of at most 100 characters.
|
||||
* 403:
|
||||
* description: Missing admin.settings.manage.
|
||||
* 500:
|
||||
* description: Failed to save the tags.
|
||||
*/
|
||||
router.put(
|
||||
"/tags",
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
async (req: Request, res: Response) => {
|
||||
const tags: unknown = req.body?.tags;
|
||||
if (
|
||||
!Array.isArray(tags) ||
|
||||
tags.length > 500 ||
|
||||
tags.some(
|
||||
(tag) =>
|
||||
typeof tag !== "string" || !tag.trim() || tag.trim().length > 100,
|
||||
)
|
||||
) {
|
||||
res.status(400).json({
|
||||
error: "Expected up to 500 non-empty tags of at most 100 characters",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const normalized = [
|
||||
...new Set((tags as string[]).map((tag) => tag.trim())),
|
||||
];
|
||||
try {
|
||||
await createCurrentSettingsRepository().set(
|
||||
CATALOG_KEY,
|
||||
JSON.stringify(normalized),
|
||||
);
|
||||
res.json({ tags: normalized });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to save host tags", error);
|
||||
res.status(500).json({ error: "Failed to save host tags" });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { pluginEvents, TOPICS } from "../../plugins/events.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { parseSSHKey } from "../../utils/ssh-key-utils.js";
|
||||
import { parseKeyForStorage } from "../../hosts/stored-ssh-key.js";
|
||||
import {
|
||||
pickResolvedPassword,
|
||||
pickResolvedUsername,
|
||||
@@ -53,6 +53,7 @@ import { registerHostFolderRoutes } from "./host-folder-routes.js";
|
||||
import { registerHostNetworkRoutes } from "./host-network-routes.js";
|
||||
import { registerHostBulkRoutes } from "./host-bulk-routes.js";
|
||||
import { registerHostDefaultsRoutes } from "./host-defaults-routes.js";
|
||||
import { registerHostTagRoutes } from "./host-tag-routes.js";
|
||||
import { registerHostStatusRoutes } from "./host-status-routes.js";
|
||||
import {
|
||||
applyHostEnrollmentDefaults,
|
||||
@@ -136,6 +137,12 @@ const permissionManager = PermissionManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||
|
||||
registerHostTagRoutes(
|
||||
router,
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("admin.settings.manage"),
|
||||
);
|
||||
|
||||
registerHostStatusRoutes(router, {
|
||||
authenticateJWT,
|
||||
requireAdmin: authManager.createAdminMiddleware(),
|
||||
@@ -351,7 +358,7 @@ router.post(
|
||||
sshDataObj.keyType = null;
|
||||
} else if (effectiveAuthType === "key") {
|
||||
if (key && typeof key === "string") {
|
||||
const keyValidation = parseSSHKey(
|
||||
const keyValidation = parseKeyForStorage(
|
||||
key,
|
||||
typeof keyPassword === "string" ? keyPassword : undefined,
|
||||
);
|
||||
@@ -493,8 +500,6 @@ router.post(
|
||||
* description: Creates a host owned by the user assigned to the API key. The user's encrypted data must be unlocked by an active sign-in.
|
||||
* tags:
|
||||
* - Host Enrollment
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
@@ -935,7 +940,7 @@ router.put(
|
||||
sshDataObj.keyType = null;
|
||||
} else if (effectiveAuthType === "key") {
|
||||
if (key && typeof key === "string") {
|
||||
const keyValidation = parseSSHKey(
|
||||
const keyValidation = parseKeyForStorage(
|
||||
key,
|
||||
typeof keyPassword === "string" ? keyPassword : undefined,
|
||||
);
|
||||
|
||||
@@ -1990,8 +1990,6 @@ router.delete(
|
||||
* summary: Set personal authentication for a shared host protocol
|
||||
* description: Selects one of the authenticated recipient's own credentials, or clears the selection with null. The protocol is "ssh" or one a plugin declares.
|
||||
* tags: [RBAC]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
*/
|
||||
router.put(
|
||||
"/host-access/:hostId/auth/:protocol",
|
||||
@@ -2047,8 +2045,6 @@ router.put(
|
||||
* get:
|
||||
* summary: Get the current recipient's shared-host protocol authentication override
|
||||
* tags: [RBAC]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
*/
|
||||
router.get(
|
||||
"/host-access/:hostId/auth/:protocol",
|
||||
|
||||
@@ -20,6 +20,30 @@ function isNonEmptyString(val: unknown): val is string {
|
||||
return typeof val === "string" && val.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 2.9.0 could not match a 2.8 OIDC account to its provider and made a second
|
||||
* account on first sign-in. The original keeps only its "legacy-oidc" link
|
||||
* with the same subject, so the duplicate may be merged back into it even
|
||||
* though it has no password.
|
||||
*/
|
||||
async function isLegacyDuplicate(
|
||||
externalUserId: string,
|
||||
targetLinks: Array<{ providerId: string; subject: string }>,
|
||||
): Promise<boolean> {
|
||||
if (targetLinks.length === 0) return false;
|
||||
const sourceSubjects = new Set(
|
||||
(
|
||||
await createCurrentUserAuthRepository().listIdentitiesForUser(
|
||||
externalUserId,
|
||||
)
|
||||
).map((link) => link.subject),
|
||||
);
|
||||
return targetLinks.every(
|
||||
(link) =>
|
||||
link.providerId === "legacy-oidc" && sourceSubjects.has(link.subject),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merging an account that only signs in externally (SSO, LDAP or any login
|
||||
* plugin) into a password account, and taking the external sign-in away
|
||||
@@ -34,7 +58,7 @@ export function registerUserExternalAccountRoutes(
|
||||
* /users/link-external-to-password:
|
||||
* post:
|
||||
* summary: Merge an external account into a password account
|
||||
* description: Moves an external-only account's sign-in identities onto a password account and deletes the external-only account (admin only). The 2.8 path /users/link-oidc-to-password and its oidcUserId field are accepted too.
|
||||
* description: Moves an external-only account's sign-in identities onto a password account and deletes the external-only account (admin only). A 2.8 SSO account whose only sign-in is the matching legacy OIDC link is accepted as the target too, to merge a duplicate made by 2.9.0. The 2.8 path /users/link-oidc-to-password and its oidcUserId field are accepted too.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
@@ -98,15 +122,18 @@ export function registerUserExternalAccountRoutes(
|
||||
.status(404)
|
||||
.json({ error: "Target password user not found" });
|
||||
}
|
||||
if (isExternalAccount(targetUser) || !targetUser.passwordHash) {
|
||||
return res.status(400).json({
|
||||
error: "Target user must be a password-based account",
|
||||
});
|
||||
}
|
||||
if ((await identities.listIdentitiesForUser(targetUser.id)).length > 0) {
|
||||
return res.status(400).json({
|
||||
error: "Target user already has an external sign-in",
|
||||
});
|
||||
const targetLinks = await identities.listIdentitiesForUser(targetUser.id);
|
||||
if (!(await isLegacyDuplicate(externalUserId, targetLinks))) {
|
||||
if (isExternalAccount(targetUser) || !targetUser.passwordHash) {
|
||||
return res.status(400).json({
|
||||
error: "Target user must be a password-based account",
|
||||
});
|
||||
}
|
||||
if (targetLinks.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: "Target user already has an external sign-in",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
authLogger.info("Linking an external account to a password account", {
|
||||
@@ -118,7 +145,8 @@ export function registerUserExternalAccountRoutes(
|
||||
|
||||
await userRepository.update(targetUser.id, {
|
||||
isOidc: true,
|
||||
oidcIdentifier: externalUser.oidcIdentifier,
|
||||
oidcIdentifier:
|
||||
externalUser.oidcIdentifier ?? targetUser.oidcIdentifier,
|
||||
});
|
||||
await identities.moveIdentities(externalUserId, targetUser.id);
|
||||
|
||||
|
||||
Reference in new issue
Block a user