mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
* Add Helm and GitOps deployment setup * fix: build better-sqlite3 from source in Docker (#1267) * fix: preserve runtime SSL settings (#1268) * fix: support forwarding from the memory SSH agent (#1269) * fix: support forwarding from the memory agent * style: format memory agent test * fix: prompt for encrypted SFTP key passphrases (#1270) * fix: prompt for SFTP key passphrases * style: format SSH key utility test * fix: include host context in automation notifications (#1271) * fix: include host context in automation notifications * style: format automation notification changes * fix: reserve sidebar height for host tags (#1272) * fix: keep host action rows stable at large font sizes (#1273) * fix: honor certificate setting during server probe (#1274) * fix: package standard Linux icon sizes (#1275) * fix: avoid duplicate Docker HTTPS listener (#1276) * Fix host status without metrics collection (#1277) * fix: allow eight-digit secure auth codes (#1263) Allow TOTP prompts to accept secure auth codes longer than six digits without blocking valid authentication attempts. Generated with Codebuff 🤖 Co-authored-by: Chetan <chetan.development@gmail.com> Co-authored-by: Codebuff <noreply@codebuff.com> * Harden Helm deployment defaults * Update Helm workflow action * Exclude Helm templates from Prettier * Fix browser RDP file drops (#1279) * Fix Proxmox guest credential usernames (#1280) * Add WSL local terminal option (#1281) * refactor: split the transfer engine into focused modules (#1282) * refactor: extract SFTP promisify helpers into sftp-promisify module * refactor: extract transfer timing and rate stats into transfer-stats module * refactor: extract transfer error classes and recovery checks into transfer-errors module * refactor: extract host/path utility helpers into transfer-host-utils module * refactor: extract SFTP directory tree helpers into transfer-sftp-dir module * refactor: extract segment copy job builder into transfer-segment-copy module * refactor: extract file scan and sample helpers into transfer-scan module * refactor: move throttled progress helper into transfer-stats module * style: format transfer modules * perf: optimize tmux monitor aggregation (#1283) * fix: reserve credential tag row height (#1284) * feat: edit AI provider model settings (#1285) * fix: clarify click-to-expand host setting (#1286) * fix: allow portable imports on remote databases (#1287) * fix: allow HTTPS to share the configured port (#1288) * fix: resolve synced jump hosts on the server (#1289) * fix: make terminal clipboard shortcuts layout independent (#1290) * fix: use compatible fetch dispatcher for Tailscale (#1291) * fix: add OIDC environment recovery override (#1292) * fix: coalesce rapid mobile terminal input (#1293) * fix: coalesce rapid mobile terminal input * fix: support clean xterm patch installs * fix: resolve synced remote desktop host IDs (#1295) * feat: make the SFTP file manager path bar editable (#1294) Co-authored-by: Maxime Bonillo <257463937+dropafterfree@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * feat: add passkey sign in to the login screen * fix: remove rounded corners from the host list search bar * fix: stop image storage settings text wrapping to one word per line * fix: prevent malformed websocket messages from crashing the server * chore: increment version * fix: remove gaps between host rows in the sidebar list Keep sub-pixel row measurements and stop wiping the size cache on hover. * fix: Failed to connect through jump hosts (#1180) https://github.com/Termix-SSH/Support/issues/1180 * feat: Progress bar for file downloads in the file manager (#1158) https://github.com/Termix-SSH/Support/issues/1158 * feat: Allow setting Silent OIDC Login via ENV var (#1174) https://github.com/Termix-SSH/Support/issues/1174 * feat: `IdentityFile` to limit the number of attempts by agents (#1165) https://github.com/Termix-SSH/Support/issues/1165 * feat: Credentials clone (#1159) https://github.com/Termix-SSH/Support/issues/1159 * chore: update release notes * docs: move helm setup guide to the docs site * fix: type errors in FilteredAgent agent identity handling * fix: remove stale better-sqlite3 prebuilds so the source build is used * fix: actually build better-sqlite3 from source so arm64 docker images work * fix: credential edit pencil in host editor and add clone action to credential list * fix: clear editingHost so the credential pencil actually opens the editor * chore: run format and lint * fix: folder drag and drop upload failing in the file manager * chore: sync Crowdin translations for 2.7.1 --------- Co-authored-by: alex-ctms <alex-ctms@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Chetan Kumar <74929596+ckloop@users.noreply.github.com> Co-authored-by: Chetan <chetan.development@gmail.com> Co-authored-by: Codebuff <noreply@codebuff.com> Co-authored-by: ZacharyZcR <payasonorahc@protonmail.com> Co-authored-by: dropafterfree <maxime.bonillo@gmail.com> Co-authored-by: Maxime Bonillo <257463937+dropafterfree@users.noreply.github.com>
487 lines
14 KiB
TypeScript
487 lines
14 KiB
TypeScript
import type {
|
|
AuthenticatedRequest,
|
|
OIDCProviderConfig,
|
|
} from "../../../types/index.js";
|
|
import type { Router } from "express";
|
|
import { authLogger } from "../../utils/logger.js";
|
|
import { AuthManager } from "../../utils/auth-manager.js";
|
|
import type { SSOProviderType } from "../../../types/index.js";
|
|
import { createCurrentSsoProviderRepository } from "../repositories/factory.js";
|
|
import {
|
|
getOIDCConfigFromEnv,
|
|
isOIDCEnvOverrideEnabled,
|
|
} from "./user-oidc-utils.js";
|
|
import {
|
|
decryptSsoConfigSecrets,
|
|
encryptSsoConfigSecrets,
|
|
} from "../../utils/system-secret-crypto.js";
|
|
import { isTrustedProxyAuthEnabled } from "../../utils/trusted-proxy-auth.js";
|
|
|
|
function isOidcLike(type: SSOProviderType): boolean {
|
|
return type === "oidc" || type === "github" || type === "google";
|
|
}
|
|
|
|
export function isValidOidcIssuer(value: unknown): boolean {
|
|
if (typeof value !== "string") return false;
|
|
try {
|
|
const url = new URL(value);
|
|
return (
|
|
["http:", "https:"].includes(url.protocol) &&
|
|
!/\/userinfo\/?$/i.test(url.pathname)
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const authManager = AuthManager.getInstance();
|
|
|
|
/**
|
|
* SSO secrets belong to the installation, not to a user: `sso_providers` has no
|
|
* userId and the values must be readable during login, before anyone is
|
|
* authenticated. They are encrypted with the system key rather than a user DEK.
|
|
* Values written by the previous base64 scheme still decode, and are upgraded
|
|
* the next time the provider is saved.
|
|
*/
|
|
async function decryptProviderConfig(
|
|
configJson: string,
|
|
_userId: string,
|
|
): Promise<Record<string, unknown>> {
|
|
let config: Record<string, unknown>;
|
|
try {
|
|
config = JSON.parse(configJson);
|
|
} catch {
|
|
return {};
|
|
}
|
|
return decryptSsoConfigSecrets(config);
|
|
}
|
|
|
|
async function encryptProviderConfig(
|
|
config: Record<string, unknown>,
|
|
_userId: string,
|
|
_providerId: string,
|
|
): Promise<string> {
|
|
return JSON.stringify(await encryptSsoConfigSecrets(config));
|
|
}
|
|
|
|
function applyProviderDefaults(
|
|
type: SSOProviderType,
|
|
config: Partial<OIDCProviderConfig>,
|
|
): Partial<OIDCProviderConfig> {
|
|
if (type === "github") {
|
|
return {
|
|
authorization_url: "https://github.com/login/oauth/authorize",
|
|
token_url: "https://github.com/login/oauth/access_token",
|
|
issuer_url: "https://github.com",
|
|
identifier_path: "id",
|
|
name_path: "name",
|
|
scopes: "read:user user:email",
|
|
userinfo_url: "https://api.github.com/user",
|
|
...config,
|
|
};
|
|
}
|
|
if (type === "google") {
|
|
return {
|
|
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
token_url: "https://oauth2.googleapis.com/token",
|
|
issuer_url: "https://accounts.google.com",
|
|
identifier_path: "sub",
|
|
name_path: "name",
|
|
scopes: "openid email profile",
|
|
...config,
|
|
};
|
|
}
|
|
return config;
|
|
}
|
|
|
|
export function registerSSOProviderRoutes(router: Router): void {
|
|
const requireAdmin = authManager.createAdminMiddleware();
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/sso-providers:
|
|
* get:
|
|
* summary: List enabled SSO providers (public)
|
|
* description: Returns public info for all enabled SSO providers for the login page.
|
|
* tags:
|
|
* - SSO
|
|
* responses:
|
|
* 200:
|
|
* description: Array of public SSO provider objects.
|
|
*/
|
|
router.get("/sso-providers", async (_req, res) => {
|
|
try {
|
|
const envConfig = getOIDCConfigFromEnv();
|
|
if (envConfig && isOIDCEnvOverrideEnabled()) {
|
|
return res.json([
|
|
{ id: 0, name: "SSO", type: "oidc", displayOrder: 0 },
|
|
]);
|
|
}
|
|
|
|
const providers =
|
|
await createCurrentSsoProviderRepository().listEnabledPublic();
|
|
|
|
// If no DB providers exist, synthesize one from env vars so SSO login
|
|
// remains available when configured purely via environment variables.
|
|
if (providers.length === 0) {
|
|
if (envConfig) {
|
|
providers.push({ id: 0, name: "SSO", type: "oidc", displayOrder: 0 });
|
|
}
|
|
}
|
|
|
|
res.json(providers);
|
|
} catch (err) {
|
|
authLogger.error("Failed to list SSO providers", err);
|
|
res.status(500).json({ error: "Failed to list SSO providers" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/sso-providers/admin:
|
|
* get:
|
|
* summary: List all SSO providers (admin)
|
|
* description: Returns full SSO provider list with decrypted configs for the admin panel.
|
|
* tags:
|
|
* - SSO
|
|
* responses:
|
|
* 200:
|
|
* description: Array of full SSO provider objects with decrypted config.
|
|
*/
|
|
router.get("/sso-providers/admin", requireAdmin, async (req, res) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
try {
|
|
const rows = await createCurrentSsoProviderRepository().listAll();
|
|
|
|
const result = await Promise.all(
|
|
rows.map(async (row) => ({
|
|
...row,
|
|
config: await decryptProviderConfig(row.config, userId),
|
|
})),
|
|
);
|
|
res.json(result);
|
|
} catch (err) {
|
|
authLogger.error("Failed to list SSO providers (admin)", err);
|
|
res.status(500).json({ error: "Failed to list SSO providers" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/sso-providers:
|
|
* post:
|
|
* summary: Create SSO provider
|
|
* description: Creates a new SSO provider configuration.
|
|
* tags:
|
|
* - SSO
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* responses:
|
|
* 201:
|
|
* description: Provider created.
|
|
* 400:
|
|
* description: Validation error.
|
|
*/
|
|
router.post("/sso-providers", requireAdmin, async (req, res) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
try {
|
|
const {
|
|
name,
|
|
type,
|
|
enabled = true,
|
|
displayOrder = 0,
|
|
config: rawConfig = {},
|
|
} = req.body as {
|
|
name: string;
|
|
type: SSOProviderType;
|
|
enabled?: boolean;
|
|
displayOrder?: number;
|
|
config?: Record<string, unknown>;
|
|
};
|
|
|
|
if (!name || typeof name !== "string" || !name.trim()) {
|
|
return res.status(400).json({ error: "Provider name is required" });
|
|
}
|
|
const validTypes: SSOProviderType[] = [
|
|
"oidc",
|
|
"ldap",
|
|
"github",
|
|
"google",
|
|
];
|
|
if (!validTypes.includes(type)) {
|
|
return res.status(400).json({ error: "Invalid provider type" });
|
|
}
|
|
if (isTrustedProxyAuthEnabled() && enabled && isOidcLike(type)) {
|
|
return res.status(409).json({
|
|
error:
|
|
"OIDC providers cannot be enabled with trusted proxy authentication",
|
|
});
|
|
}
|
|
|
|
const configWithDefaults =
|
|
type === "github" || type === "google"
|
|
? applyProviderDefaults(
|
|
type,
|
|
rawConfig as Partial<OIDCProviderConfig>,
|
|
)
|
|
: rawConfig;
|
|
|
|
if (type === "oidc" || type === "github" || type === "google") {
|
|
const c = configWithDefaults as Partial<OIDCProviderConfig>;
|
|
const missing = [
|
|
"client_id",
|
|
"client_secret",
|
|
"issuer_url",
|
|
"authorization_url",
|
|
"token_url",
|
|
].filter((f) => !c[f as keyof OIDCProviderConfig]);
|
|
if (missing.length > 0 && type === "oidc") {
|
|
return res.status(400).json({
|
|
error: `Missing required OIDC fields: ${missing.join(", ")}`,
|
|
});
|
|
}
|
|
if (c.issuer_url && !isValidOidcIssuer(c.issuer_url)) {
|
|
return res.status(400).json({
|
|
error:
|
|
"Issuer URL must be an HTTP(S) issuer and not a userinfo endpoint",
|
|
});
|
|
}
|
|
if (
|
|
(type === "github" || type === "google") &&
|
|
(!c.client_id || !c.client_secret)
|
|
) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Client ID and Client Secret are required" });
|
|
}
|
|
}
|
|
|
|
if (type === "ldap") {
|
|
const c = configWithDefaults as Record<string, unknown>;
|
|
const missing = [
|
|
"host",
|
|
"port",
|
|
"bindDN",
|
|
"bindPassword",
|
|
"userSearchBase",
|
|
"userSearchFilter",
|
|
"usernameAttribute",
|
|
].filter((f) => !c[f]);
|
|
if (missing.length > 0) {
|
|
return res.status(400).json({
|
|
error: `Missing required LDAP fields: ${missing.join(", ")}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
const tempId = `new-${Date.now()}`;
|
|
const encryptedConfig = await encryptProviderConfig(
|
|
configWithDefaults as Record<string, unknown>,
|
|
userId,
|
|
tempId,
|
|
);
|
|
|
|
const inserted = await createCurrentSsoProviderRepository().create({
|
|
name: name.trim(),
|
|
type,
|
|
enabled,
|
|
displayOrder,
|
|
config: encryptedConfig,
|
|
});
|
|
|
|
authLogger.info("SSO provider created", {
|
|
operation: "sso_provider_create",
|
|
userId,
|
|
type,
|
|
providerId: inserted.id,
|
|
});
|
|
res.status(201).json({
|
|
...inserted,
|
|
config: await decryptProviderConfig(inserted.config, userId),
|
|
});
|
|
} catch (err) {
|
|
authLogger.error("Failed to create SSO provider", err);
|
|
res.status(500).json({ error: "Failed to create SSO provider" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/sso-providers/{id}:
|
|
* put:
|
|
* summary: Update SSO provider
|
|
* description: Updates an existing SSO provider configuration.
|
|
* tags:
|
|
* - SSO
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Provider updated.
|
|
* 404:
|
|
* description: Provider not found.
|
|
*/
|
|
router.put("/sso-providers/:id", requireAdmin, async (req, res) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const providerId = parseInt(req.params.id as string, 10);
|
|
if (isNaN(providerId)) {
|
|
return res.status(400).json({ error: "Invalid provider ID" });
|
|
}
|
|
try {
|
|
const providerRepository = createCurrentSsoProviderRepository();
|
|
const existing = await providerRepository.findById(providerId);
|
|
if (!existing) {
|
|
return res.status(404).json({ error: "SSO provider not found" });
|
|
}
|
|
|
|
const {
|
|
name,
|
|
type,
|
|
enabled,
|
|
displayOrder,
|
|
config: rawConfig,
|
|
} = req.body as {
|
|
name?: string;
|
|
type?: SSOProviderType;
|
|
enabled?: boolean;
|
|
displayOrder?: number;
|
|
config?: Record<string, unknown>;
|
|
};
|
|
|
|
const effectiveType = type ?? (existing.type as SSOProviderType);
|
|
const effectiveEnabled = enabled ?? existing.enabled;
|
|
if (
|
|
isTrustedProxyAuthEnabled() &&
|
|
effectiveEnabled &&
|
|
isOidcLike(effectiveType)
|
|
) {
|
|
return res.status(409).json({
|
|
error:
|
|
"OIDC providers cannot be enabled with trusted proxy authentication",
|
|
});
|
|
}
|
|
|
|
let encryptedConfig = existing.config;
|
|
if (rawConfig !== undefined) {
|
|
const existingDecrypted = await decryptProviderConfig(
|
|
existing.config,
|
|
userId,
|
|
);
|
|
const mergedConfig = {
|
|
...JSON.parse(
|
|
existingDecrypted ? JSON.stringify(existingDecrypted) : "{}",
|
|
),
|
|
...rawConfig,
|
|
};
|
|
if (
|
|
isOidcLike(effectiveType) &&
|
|
mergedConfig.issuer_url &&
|
|
!isValidOidcIssuer(mergedConfig.issuer_url)
|
|
) {
|
|
return res.status(400).json({
|
|
error:
|
|
"Issuer URL must be an HTTP(S) issuer and not a userinfo endpoint",
|
|
});
|
|
}
|
|
encryptedConfig = await encryptProviderConfig(
|
|
mergedConfig,
|
|
userId,
|
|
String(providerId),
|
|
);
|
|
}
|
|
|
|
const updated = await providerRepository.update(providerId, {
|
|
...(name !== undefined ? { name: name.trim() } : {}),
|
|
...(type !== undefined ? { type } : {}),
|
|
...(enabled !== undefined ? { enabled } : {}),
|
|
...(displayOrder !== undefined ? { displayOrder } : {}),
|
|
config: encryptedConfig,
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
if (!updated) {
|
|
return res.status(404).json({ error: "SSO provider not found" });
|
|
}
|
|
|
|
authLogger.info("SSO provider updated", {
|
|
operation: "sso_provider_update",
|
|
userId,
|
|
providerId,
|
|
});
|
|
res.json({
|
|
...updated,
|
|
config: await decryptProviderConfig(updated.config, userId),
|
|
});
|
|
} catch (err) {
|
|
authLogger.error("Failed to update SSO provider", err);
|
|
res.status(500).json({ error: "Failed to update SSO provider" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/sso-providers/{id}:
|
|
* delete:
|
|
* summary: Delete SSO provider
|
|
* description: Deletes an SSO provider. Blocked if users are associated.
|
|
* tags:
|
|
* - SSO
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Provider deleted.
|
|
* 409:
|
|
* description: Users are associated with this provider.
|
|
* 404:
|
|
* description: Provider not found.
|
|
*/
|
|
router.delete("/sso-providers/:id", requireAdmin, async (req, res) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const providerId = parseInt(req.params.id as string, 10);
|
|
if (isNaN(providerId)) {
|
|
return res.status(400).json({ error: "Invalid provider ID" });
|
|
}
|
|
try {
|
|
const providerRepository = createCurrentSsoProviderRepository();
|
|
const existing = await providerRepository.findById(providerId);
|
|
if (!existing) {
|
|
return res.status(404).json({ error: "SSO provider not found" });
|
|
}
|
|
|
|
const associatedUserCount =
|
|
await providerRepository.countUsersByProviderId(providerId);
|
|
if (associatedUserCount > 0) {
|
|
return res.status(409).json({
|
|
error: `Cannot delete provider: ${associatedUserCount} user(s) are associated with it`,
|
|
});
|
|
}
|
|
|
|
await providerRepository.delete(providerId);
|
|
authLogger.info("SSO provider deleted", {
|
|
operation: "sso_provider_delete",
|
|
userId,
|
|
providerId,
|
|
});
|
|
res.json({ message: "SSO provider deleted" });
|
|
} catch (err) {
|
|
authLogger.error("Failed to delete SSO provider", err);
|
|
res.status(500).json({ error: "Failed to delete SSO provider" });
|
|
}
|
|
});
|
|
}
|
|
|
|
export { decryptProviderConfig, encryptProviderConfig };
|