feat: Step CA SSH certificates as a host authentication type (#1340)

* feat: Step CA SSH certificates as a host authentication type

Issue short-lived SSH user certificates from a smallstep CA through its
OIDC provisioner, over the CA's HTTP API rather than the step binary.
Everything after issuance reuses the OPKSSH plumbing: the same encrypted
per-user/host token store, WebSocket dialog and ssh2 certificate
injection, with the connect paths branching on a shared
usesIssuedCertificate() predicate. Instance-wide CA settings live in the
admin panel, with a private-host allowlist for the SSRF guard.

* fix: harden Step CA callback flow

* style: format Step CA changes
This commit is contained in:
ZacharyZcR
2026-08-25 02:56:44 +08:00
committed by GitHub
parent 0ab7cf2ab8
commit 32d77fc6d0
38 changed files with 1791 additions and 48 deletions
+6 -3
View File
@@ -356,9 +356,12 @@ networks:
For multiple Termix backend instances, set the same `REDIS_URL` and optional For multiple Termix backend instances, set the same `REDIS_URL` and optional
`TERMIX_REDIS_PREFIX` on every instance. Redis synchronizes collaboration room `TERMIX_REDIS_PREFIX` on every instance. Redis synchronizes collaboration room
presence, control requests, controller state, and events; keep WebSocket session presence, control requests, controller state, and events. It also routes Step CA
affinity enabled because live SSH and remote desktop transports remain attached OAuth callbacks back to the instance holding the user's terminal; the optional
to the backend instance that opened them. A single instance needs no Redis. `TERMIX_STEP_CA_REDIS_PREFIX` isolates those short-lived encrypted messages.
Keep WebSocket session affinity enabled because live SSH and remote desktop
transports remain attached to the backend instance that opened them. A single
instance needs no Redis.
### Command Line Interface ### Command Line Interface
+1
View File
@@ -18,6 +18,7 @@ services:
GUACD_DRIVE_PATH: "/termix-data/rdp-drive" GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
# REDIS_URL: "redis://redis:6379" # REDIS_URL: "redis://redis:6379"
# TERMIX_REDIS_PREFIX: "termix:collab" # TERMIX_REDIS_PREFIX: "termix:collab"
# TERMIX_STEP_CA_REDIS_PREFIX: "termix:step-ca"
depends_on: depends_on:
- guacd-dev - guacd-dev
networks: networks:
+1
View File
@@ -20,6 +20,7 @@ services:
# in-memory fallback. # in-memory fallback.
# REDIS_URL: "redis://redis:6379" # REDIS_URL: "redis://redis:6379"
# TERMIX_REDIS_PREFIX: "termix:collab" # TERMIX_REDIS_PREFIX: "termix:collab"
# TERMIX_STEP_CA_REDIS_PREFIX: "termix:step-ca"
# Hardened deployments can require keys from environment variables or # Hardened deployments can require keys from environment variables or
# Docker secrets mounted through JWT_SECRET_FILE, DATABASE_KEY_FILE, # Docker secrets mounted through JWT_SECRET_FILE, DATABASE_KEY_FILE,
# ENCRYPTION_KEY_FILE and INTERNAL_AUTH_TOKEN_FILE. # ENCRYPTION_KEY_FILE and INTERNAL_AUTH_TOKEN_FILE.
+5 -3
View File
@@ -351,9 +351,11 @@ networks:
``` ```
部署多个 Termix 后端实例时,请为所有实例配置相同的 `REDIS_URL`,并可选配置 部署多个 Termix 后端实例时,请为所有实例配置相同的 `REDIS_URL`,并可选配置
`TERMIX_REDIS_PREFIX`。Redis 会同步协作房间的在线成员、控制请求、控制权和事件 `TERMIX_REDIS_PREFIX`。Redis 会同步协作房间的在线成员、控制请求、控制权和事件
实时 SSH 与远程桌面传输仍依附于创建连接的后端实例,因此负载均衡器需要保持 也会把 Step CA OAuth 回调路由回持有用户终端的实例;可通过
WebSocket 会话亲和性。单实例部署无需 Redis。 `TERMIX_STEP_CA_REDIS_PREFIX` 隔离这些短期加密消息。实时 SSH 与远程桌面传输仍
依附于创建连接的后端实例,因此负载均衡器需要保持 WebSocket 会话亲和性。
单实例部署无需 Redis。
### 命令行工具 ### 命令行工具
+2
View File
@@ -49,6 +49,8 @@ export const FORBIDDEN_DOMAINS = [
"identity", "identity",
"certificate", "certificate",
"opkssh", "opkssh",
"stepca",
"step_ca",
"acme", "acme",
"ssl", "ssl",
"audit", "audit",
@@ -600,13 +600,14 @@ export function registerHostBulkRoutes(
"credential", "credential",
"none", "none",
"opkssh", "opkssh",
"stepca",
"tailscale", "tailscale",
"vault", "vault",
].includes(hostData.authType) ].includes(hostData.authType)
) { ) {
results.failed++; results.failed++;
results.errors.push( results.errors.push(
`Host ${i + 1}: Invalid authType. Must be 'password', 'key', 'credential', 'none', 'opkssh', 'tailscale', or 'vault'`, `Host ${i + 1}: Invalid authType. Must be 'password', 'key', 'credential', 'none', 'opkssh', 'stepca', 'tailscale', or 'vault'`,
); );
continue; continue;
} }
@@ -0,0 +1,34 @@
import type { Request, Response, Router } from "express";
import { escapeHtml } from "./opkssh-html.js";
function resultPage(ok: boolean, message: string): string {
return `<!doctype html><html><head><meta charset="utf-8"><title>Termix</title>
<style>body{font-family:system-ui,sans-serif;background:#111;color:#eee;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
main{max-width:28rem;padding:2rem;border:1px solid #333;background:#181818}h1{font-size:1.1rem;margin:0 0 .5rem}p{margin:0;color:#aaa}</style></head>
<body><main><h1>${ok ? "Signed in" : "Sign-in failed"}</h1><p>${escapeHtml(message)}</p></main></body></html>`;
}
/**
* The OIDC redirect target for Step CA sign-ins. Unauthenticated on purpose:
* the browser that finishes the sign-in may not be the one running Termix,
* so the request is matched to its session by the OAuth state.
*/
export function registerHostStepCaRoutes(router: Router): void {
router.get("/step-ca-callback", async (req: Request, res: Response) => {
const { completeStepCaAuth } = await import("../../hosts/step-ca-auth.js");
const stringQuery = (name: string): string | undefined => {
const value = req.query[name];
return typeof value === "string" ? value : undefined;
};
const result = await completeStepCaAuth({
state: stringQuery("state"),
code: stringQuery("code"),
error: stringQuery("error"),
error_description: stringQuery("error_description"),
});
res
.status(result.ok ? 200 : 400)
.type("html")
.send(resultPage(result.ok, result.message));
});
}
+2
View File
@@ -43,6 +43,7 @@ import {
} from "./host-normalizers.js"; } from "./host-normalizers.js";
import { validateParentHostId } from "./host-parent-validation.js"; import { validateParentHostId } from "./host-parent-validation.js";
import { registerHostOpksshRoutes } from "./host-opkssh-routes.js"; import { registerHostOpksshRoutes } from "./host-opkssh-routes.js";
import { registerHostStepCaRoutes } from "./host-step-ca-routes.js";
import { registerHostFolderRoutes } from "./host-folder-routes.js"; import { registerHostFolderRoutes } from "./host-folder-routes.js";
import { registerHostFileManagerBookmarkRoutes } from "./host-file-manager-bookmark-routes.js"; import { registerHostFileManagerBookmarkRoutes } from "./host-file-manager-bookmark-routes.js";
import { registerHostCommandHistoryRoutes } from "./host-command-history-routes.js"; import { registerHostCommandHistoryRoutes } from "./host-command-history-routes.js";
@@ -2899,6 +2900,7 @@ router.delete(
); );
registerHostOpksshRoutes(router); registerHostOpksshRoutes(router);
registerHostStepCaRoutes(router);
registerHostNetworkRoutes(router, { registerHostNetworkRoutes(router, {
authenticateJWT, authenticateJWT,
+1 -1
View File
@@ -1,6 +1,6 @@
import { sshLogger } from "../../utils/logger.js"; import { sshLogger } from "../../utils/logger.js";
function escapeHtml(value: string): string { export function escapeHtml(value: string): string {
return value return value
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
.replace(/</g, "&lt;") .replace(/</g, "&lt;")
@@ -1,4 +1,5 @@
import type { AuthenticatedRequest } from "../../../types/index.js"; import type { AuthenticatedRequest } from "../../../types/index.js";
import { getErrorMessage } from "../../utils/error-message.js";
import type { RequestHandler, Router } from "express"; import type { RequestHandler, Router } from "express";
import { restartGuacServer } from "../../hosts/guacamole/guacamole-server.js"; import { restartGuacServer } from "../../hosts/guacamole/guacamole-server.js";
import { import {
@@ -14,6 +15,7 @@ import {
} from "../../utils/audit-forwarder.js"; } from "../../utils/audit-forwarder.js";
import { getTelemetryEnvOverride } from "../../utils/analytics.js"; import { getTelemetryEnvOverride } from "../../utils/analytics.js";
import { AI_PRIVATE_ALLOWLIST_KEY, parseAllowlist } from "../../ai/egress.js"; import { AI_PRIVATE_ALLOWLIST_KEY, parseAllowlist } from "../../ai/egress.js";
import { STEP_CA_PRIVATE_ALLOWLIST_KEY } from "../../utils/step-ca-egress.js";
import { import {
NOTIFICATION_PRIVATE_ALLOWLIST_KEY, NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
parseNotificationAllowlist, parseNotificationAllowlist,
@@ -1083,30 +1085,31 @@ export function registerUserSettingsRoutes(
} }
}); });
router.get( /**
"/notification-private-endpoints", * GET/PATCH a comma-list of private hosts an outbound feature may reach.
authenticateJWT, * Shared by notifications and Step CA; each keeps its own setting key.
async (req, res) => { */
const registerPrivateEndpointAllowlist = (
path: string,
settingKey: string,
auditAction: string,
label: string,
) => {
router.get(path, authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
try { try {
if (!(await getAdminActor(userId))) { if (!(await getAdminActor(userId))) {
return res.status(403).json({ error: "Not authorized" }); return res.status(403).json({ error: "Not authorized" });
} }
const raw = await createCurrentSettingsRepository().get( const raw = await createCurrentSettingsRepository().get(settingKey);
NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
);
res.json({ hosts: parseNotificationAllowlist(raw) }); res.json({ hosts: parseNotificationAllowlist(raw) });
} catch (err) { } catch (err) {
authLogger.error("Failed to get notification endpoint allowlist", err); authLogger.error(`Failed to get ${label} allowlist`, err);
res.status(500).json({ error: "Failed to get the allowlist" }); res.status(500).json({ error: "Failed to get the allowlist" });
} }
}, });
);
router.patch( router.patch(path, authenticateJWT, async (req, res) => {
"/notification-private-endpoints",
authenticateJWT,
async (req, res) => {
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
try { try {
const actor = await getAdminActor(userId); const actor = await getAdminActor(userId);
@@ -1123,7 +1126,6 @@ export function registerUserSettingsRoutes(
.status(400) .status(400)
.json({ error: "At most 50 hosts are allowed" }); .json({ error: "At most 50 hosts are allowed" });
} }
const cleaned: string[] = []; const cleaned: string[] = [];
for (const entry of hosts) { for (const entry of hosts) {
if (typeof entry !== "string") { if (typeof entry !== "string") {
@@ -1142,14 +1144,14 @@ export function registerUserSettingsRoutes(
} }
await createCurrentSettingsRepository().set( await createCurrentSettingsRepository().set(
NOTIFICATION_PRIVATE_ALLOWLIST_KEY, settingKey,
JSON.stringify(cleaned), JSON.stringify(cleaned),
); );
const { ipAddress, userAgent } = getRequestMeta(req); const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({ await logAudit({
userId, userId,
username: actor.username ?? userId, username: actor.username ?? userId,
action: "update_notification_private_endpoints", action: auditAction,
resourceType: "setting", resourceType: "setting",
details: JSON.stringify({ hosts: cleaned }), details: JSON.stringify({ hosts: cleaned }),
ipAddress, ipAddress,
@@ -1158,15 +1160,156 @@ export function registerUserSettingsRoutes(
}); });
res.json({ hosts: cleaned }); res.json({ hosts: cleaned });
} catch (err) { } catch (err) {
authLogger.error( authLogger.error(`Failed to update ${label} allowlist`, err);
"Failed to update notification endpoint allowlist",
err,
);
res.status(500).json({ error: "Failed to update the allowlist" }); res.status(500).json({ error: "Failed to update the allowlist" });
} }
}, });
};
/**
* @openapi
* /users/notification-private-endpoints:
* get:
* summary: Get the private hosts notification channels may contact (admin only)
* tags:
* - Users
* patch:
* summary: Replace that allowlist (admin only)
* tags:
* - Users
*/
registerPrivateEndpointAllowlist(
"/notification-private-endpoints",
NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
"update_notification_private_endpoints",
"notification endpoint",
); );
/**
* @openapi
* /users/step-ca-private-endpoints:
* get:
* summary: Get the private hosts the Step CA certificate flow may contact (admin only)
* tags:
* - Users
* patch:
* summary: Replace that allowlist (admin only)
* tags:
* - Users
*/
registerPrivateEndpointAllowlist(
"/step-ca-private-endpoints",
STEP_CA_PRIVATE_ALLOWLIST_KEY,
"update_step_ca_private_endpoints",
"Step CA endpoint",
);
/**
* @openapi
* /users/step-ca-settings:
* get:
* summary: Step CA settings. Admins get the values; everyone else only whether it is configured.
* tags:
* - Users
* patch:
* summary: Set the Step CA URL, root fingerprint and OIDC provisioner (admin only). Empty values clear the configuration.
* tags:
* - Users
*/
router.get("/step-ca-settings", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const { readStepCaSettings } =
await import("../../hosts/step-ca-auth.js");
const settings = await readStepCaSettings();
if (!(await getAdminActor(userId))) {
return res.json({ configured: settings !== null });
}
res.json({
configured: settings !== null,
caUrl: settings?.caUrl ?? "",
fingerprint: settings?.fingerprint ?? "",
provisioner: settings?.provisioner ?? "",
});
} catch (err) {
authLogger.error("Failed to get Step CA settings", err);
res.status(500).json({ error: "Failed to get Step CA settings" });
}
});
router.patch("/step-ca-settings", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const actor = await getAdminActor(userId);
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const { caUrl, fingerprint, provisioner } = req.body ?? {};
if (
[caUrl, fingerprint, provisioner].some((v) => typeof v !== "string")
) {
return res.status(400).json({
error: "caUrl, fingerprint and provisioner must be strings",
});
}
const values = {
caUrl: caUrl.trim(),
fingerprint: fingerprint.trim(),
provisioner: provisioner.trim(),
};
const clearing =
!values.caUrl && !values.fingerprint && !values.provisioner;
if (!clearing) {
const { normalizeCaUrl, normalizeFingerprint } =
await import("../../utils/step-ca-client.js");
try {
values.caUrl = normalizeCaUrl(values.caUrl);
values.fingerprint = normalizeFingerprint(values.fingerprint);
} catch (err) {
return res.status(400).json({ error: getErrorMessage(err) });
}
if (!values.provisioner) {
return res.status(400).json({ error: "provisioner is required" });
}
}
const { STEP_CA_SETTING_KEYS } =
await import("../../hosts/step-ca-auth.js");
const settings = createCurrentSettingsRepository();
if (clearing) {
await settings.delete(STEP_CA_SETTING_KEYS.url);
await settings.delete(STEP_CA_SETTING_KEYS.fingerprint);
await settings.delete(STEP_CA_SETTING_KEYS.provisioner);
} else {
await settings.set(STEP_CA_SETTING_KEYS.url, values.caUrl);
await settings.set(
STEP_CA_SETTING_KEYS.fingerprint,
values.fingerprint,
);
await settings.set(
STEP_CA_SETTING_KEYS.provisioner,
values.provisioner,
);
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor.username ?? userId,
action: "update_step_ca_settings",
resourceType: "setting",
details: JSON.stringify({ configured: !clearing, caUrl: values.caUrl }),
ipAddress,
userAgent,
success: true,
});
res.json({ configured: !clearing, ...values });
} catch (err) {
authLogger.error("Failed to update Step CA settings", err);
res.status(500).json({ error: "Failed to update Step CA settings" });
}
});
/** /**
* @openapi * @openapi
* /users/host-defaults: * /users/host-defaults:
+2 -1
View File
@@ -1,4 +1,5 @@
import { getErrorMessage } from "../../utils/error-message.js"; import { getErrorMessage } from "../../utils/error-message.js";
import { usesIssuedCertificate } from "../issued-certificate-auth.js";
import express from "express"; import express from "express";
import axios from "axios"; import axios from "axios";
import { Client as SSHClient } from "ssh2"; import { Client as SSHClient } from "ssh2";
@@ -300,7 +301,7 @@ export function registerDockerSshRoutes(app: express.Express): void {
if (resolvedCredentials.password) { if (resolvedCredentials.password) {
config.password = resolvedCredentials.password; config.password = resolvedCredentials.password;
} }
} else if (resolvedCredentials.authType === "opkssh") { } else if (usesIssuedCertificate(resolvedCredentials.authType)) {
try { try {
const { getOPKSSHToken } = await import("../opkssh-auth.js"); const { getOPKSSHToken } = await import("../opkssh-auth.js");
const token = await getOPKSSHToken(userId, hostId); const token = await getOPKSSHToken(userId, hostId);
+3 -2
View File
@@ -1,4 +1,5 @@
import { getErrorMessage } from "../../utils/error-message.js"; import { getErrorMessage } from "../../utils/error-message.js";
import { usesIssuedCertificate } from "../issued-certificate-auth.js";
import express from "express"; import express from "express";
import { import {
logAudit, logAudit,
@@ -289,7 +290,7 @@ async function buildDedicatedTransferConnectConfig(
throw new Error("Password required for transfer connection"); throw new Error("Password required for transfer connection");
} }
config.password = host.password; config.password = host.password;
} else if (authType === "opkssh") { } else if (usesIssuedCertificate(authType)) {
const { getOPKSSHToken } = await import("../opkssh-auth.js"); const { getOPKSSHToken } = await import("../opkssh-auth.js");
const token = await getOPKSSHToken(userId, host.id); const token = await getOPKSSHToken(userId, host.id);
if (!token) { if (!token) {
@@ -1120,7 +1121,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
connectionLogs.push( connectionLogs.push(
createConnectionLog("info", "sftp_auth", "Using password authentication"), createConnectionLog("info", "sftp_auth", "Using password authentication"),
); );
} else if (resolvedCredentials.authType === "opkssh") { } else if (usesIssuedCertificate(resolvedCredentials.authType)) {
try { try {
const { getOPKSSHToken } = await import("../opkssh-auth.js"); const { getOPKSSHToken } = await import("../opkssh-auth.js");
const token = await getOPKSSHToken(userId, hostId); const token = await getOPKSSHToken(userId, hostId);
@@ -0,0 +1,12 @@
/**
* Auth types whose SSH certificate is issued on demand through a browser
* sign-in and cached per user and host (opkssh_tokens). They share the
* whole connect path; only the issuing flow differs.
*/
export const ISSUED_CERTIFICATE_AUTH_TYPES = ["opkssh", "stepca"] as const;
export function usesIssuedCertificate(authType: string | null | undefined) {
return (ISSUED_CERTIFICATE_AUTH_TYPES as readonly string[]).includes(
authType ?? "",
);
}
+3 -1
View File
@@ -1,4 +1,5 @@
import type { Client } from "ssh2"; import type { Client } from "ssh2";
import { usesIssuedCertificate } from "../issued-certificate-auth.js";
export type StatsCapableHost = { export type StatsCapableHost = {
connectionType?: string; connectionType?: string;
@@ -13,7 +14,8 @@ export type TcpPingStatsConfig = {
export function supportsMetrics(host: StatsCapableHost): boolean { export function supportsMetrics(host: StatsCapableHost): boolean {
const connectionType = host.connectionType || "ssh"; const connectionType = host.connectionType || "ssh";
if (connectionType !== "ssh") return false; if (connectionType !== "ssh") return false;
if (host.authType === "none" || host.authType === "opkssh") return false; if (host.authType === "none" || usesIssuedCertificate(host.authType))
return false;
return true; return true;
} }
+4 -3
View File
@@ -1,4 +1,5 @@
import { getErrorMessage } from "../../utils/error-message.js"; import { getErrorMessage } from "../../utils/error-message.js";
import { usesIssuedCertificate } from "../issued-certificate-auth.js";
import express from "express"; import express from "express";
import net from "net"; import net from "net";
import { createCorsMiddleware } from "../../utils/cors-config.js"; import { createCorsMiddleware } from "../../utils/cors-config.js";
@@ -1396,7 +1397,7 @@ async function buildSshConfig(
host.authType === "warpgate" host.authType === "warpgate"
) { ) {
// no credentials needed // no credentials needed
} else if (host.authType === "opkssh") { } else if (usesIssuedCertificate(host.authType)) {
// cert auth setup happens in createSshFactory (needs client instance) // cert auth setup happens in createSshFactory (needs client instance)
} else if (host.authType === "vault") { } else if (host.authType === "vault") {
// cert auth setup happens in createSshFactory (needs client instance) // cert auth setup happens in createSshFactory (needs client instance)
@@ -1440,7 +1441,7 @@ function createSshFactory(host: SSHHostWithCredentials): () => Promise<Client> {
const client = new Client(); const client = new Client();
// Set up OPKSSH cert auth if needed (requires client instance) // Set up OPKSSH cert auth if needed (requires client instance)
if (host.authType === "opkssh" && host.userId) { if (usesIssuedCertificate(host.authType) && host.userId) {
const { getOPKSSHToken } = await import("../opkssh-auth.js"); const { getOPKSSHToken } = await import("../opkssh-auth.js");
const token = await getOPKSSHToken(host.userId, host.id); const token = await getOPKSSHToken(host.userId, host.id);
if (!token) { if (!token) {
@@ -2359,7 +2360,7 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
const config = await buildSshConfig(host); const config = await buildSshConfig(host);
const client = new Client(); const client = new Client();
if (host.authType === "opkssh" && host.userId) { if (usesIssuedCertificate(host.authType) && host.userId) {
const { getOPKSSHToken } = await import("../opkssh-auth.js"); const { getOPKSSHToken } = await import("../opkssh-auth.js");
const token = await getOPKSSHToken(host.userId, host.id); const token = await getOPKSSHToken(host.userId, host.id);
if (!token) { if (!token) {
+372
View File
@@ -0,0 +1,372 @@
import { randomBytes } from "crypto";
import type { WebSocket } from "ws";
import { sshLogger } from "../utils/logger.js";
import { getErrorMessage } from "../utils/error-message.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { FieldCrypto } from "../utils/field-crypto.js";
import {
createCurrentOpksshTokenRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
import { readStepCaPrivateAllowlist } from "../utils/step-ca-egress.js";
import {
stepCaRuntime,
type StepCaCallbackQuery,
type StepCaCallbackResult,
} from "./step-ca-runtime.js";
import {
buildAuthorizationUrl,
createPkce,
decodeJwtClaims,
discoverOidcEndpoints,
exchangeCodeForIdToken,
fetchRootCertificate,
findOidcProvisioner,
generateSshKeyPair,
parseSshCertificate,
signSshCertificate,
type StepCaTarget,
} from "../utils/step-ca-client.js";
/**
* Step CA (smallstep) SSH user certificates through its OIDC provisioner.
*
* Mirrors the OPKSSH flow and reuses its storage, WS messages and connect
* path; the only difference is how the certificate is obtained: no binary,
* just the CA's HTTP API plus one OIDC redirect back to Termix.
*/
export const STEP_CA_CALLBACK_PATH = "/host/step-ca-callback";
export const STEP_CA_SETTING_KEYS = {
url: "step_ca_url",
fingerprint: "step_ca_fingerprint",
provisioner: "step_ca_provisioner",
} as const;
const AUTH_TIMEOUT_MS = 5 * 60 * 1000;
const REMOTE_CALLBACK_WAIT_MS = 30_000;
const REMOTE_POLL_MS = 250;
export interface StepCaSettings {
caUrl: string;
fingerprint: string;
provisioner: string;
}
export async function readStepCaSettings(): Promise<StepCaSettings | null> {
const settings = createCurrentSettingsRepository();
const [caUrl, fingerprint, provisioner] = await Promise.all([
settings.get(STEP_CA_SETTING_KEYS.url),
settings.get(STEP_CA_SETTING_KEYS.fingerprint),
settings.get(STEP_CA_SETTING_KEYS.provisioner),
]);
if (!caUrl || !fingerprint || !provisioner) return null;
return { caUrl, fingerprint, provisioner };
}
interface StepCaAuthSession {
state: string;
userId: string;
hostId: number;
username: string;
ws: WebSocket;
target: StepCaTarget;
rootPem: string;
clientId: string;
clientSecret?: string;
tokenEndpoint: string;
redirectUri: string;
codeVerifier: string;
nonce: string;
keyPair: { publicKeyLine: string; privateKeyPem: string };
timeout: NodeJS.Timeout;
commandPoll: NodeJS.Timeout | null;
processing: boolean;
completed: boolean;
}
const sessions = new Map<string, StepCaAuthSession>();
function send(ws: WebSocket, message: object): void {
try {
ws.send(JSON.stringify(message));
} catch {
/* socket already gone */
}
}
function endSession(session: StepCaAuthSession, removeRuntime = true): void {
clearTimeout(session.timeout);
if (session.commandPoll) clearInterval(session.commandPoll);
sessions.delete(session.state);
if (removeRuntime) void stepCaRuntime.remove(session.state);
}
export async function startStepCaAuth(
userId: string,
hostId: number,
username: string,
ws: WebSocket,
requestOrigin: string,
): Promise<void> {
const settings = await readStepCaSettings();
if (!settings) {
send(ws, {
type: "opkssh_config_error",
requestId: "",
error:
"Step CA is not configured. An administrator must set the CA URL, root fingerprint and OIDC provisioner under Admin Settings.",
});
return;
}
const state = randomBytes(24).toString("base64url");
try {
const target: StepCaTarget = {
caUrl: settings.caUrl,
fingerprint: settings.fingerprint,
allowedPrivateHosts: await readStepCaPrivateAllowlist(),
};
const rootPem = await fetchRootCertificate(target);
const provisioner = await findOidcProvisioner(
target,
rootPem,
settings.provisioner,
);
const endpoints = await discoverOidcEndpoints(
provisioner.configurationEndpoint,
target.allowedPrivateHosts,
);
const pkce = createPkce();
const nonce = randomBytes(16).toString("base64url");
const redirectUri = `${requestOrigin}${STEP_CA_CALLBACK_PATH}`;
const session: StepCaAuthSession = {
state,
userId,
hostId,
username,
ws,
target,
rootPem,
clientId: provisioner.clientID,
clientSecret: provisioner.clientSecret,
tokenEndpoint: endpoints.tokenEndpoint,
redirectUri,
codeVerifier: pkce.verifier,
nonce,
keyPair: generateSshKeyPair(),
processing: false,
completed: false,
commandPoll: null,
timeout: setTimeout(() => {
const current = sessions.get(state);
if (!current || current.completed || current.processing) return;
send(ws, { type: "opkssh_timeout", requestId: state });
endSession(current);
}, AUTH_TIMEOUT_MS),
};
sessions.set(state, session);
await stepCaRuntime.register(state);
session.commandPoll = setInterval(() => {
if (session.processing || session.completed) return;
void stepCaRuntime.takeCommand(state).then(async (query) => {
if (!query || session.processing || session.completed) return;
session.processing = true;
const result = await finishStepCaAuth(session, query, false);
await stepCaRuntime.complete(state, result);
});
}, REMOTE_POLL_MS);
session.commandPoll.unref();
ws.once("close", () => {
const current = sessions.get(state);
if (current && !current.completed) endSession(current);
});
send(ws, {
type: "opkssh_status",
requestId: state,
stage: "chooser",
label: "Step CA",
url: buildAuthorizationUrl({
authorizationEndpoint: endpoints.authorizationEndpoint,
clientId: provisioner.clientID,
redirectUri,
state,
nonce,
codeChallenge: pkce.challenge,
}),
providers: [],
});
} catch (error) {
sshLogger.error("Failed to start Step CA authentication", error, {
operation: "step_ca_start_error",
userId,
hostId,
});
send(ws, {
type: "opkssh_error",
requestId: state,
error: `Step CA: ${getErrorMessage(error)}`,
});
}
}
export function cancelStepCaAuth(requestId: string): boolean {
const session = sessions.get(requestId);
if (!session) return false;
endSession(session);
return true;
}
/**
* Finishes the flow once the identity provider redirects back: exchanges
* the code, has the CA sign the key, stores the certificate the way OPKSSH
* does (same table, same encryption, same token id) and tells the terminal
* to reconnect.
*/
export async function completeStepCaAuth(
query: StepCaCallbackQuery,
): Promise<StepCaCallbackResult> {
const session = query.state ? sessions.get(query.state) : undefined;
if (!session) {
if (!query.state || !(await stepCaRuntime.submit(query.state, query))) {
return {
ok: false,
message: "This sign-in request is no longer active.",
};
}
const deadline = Date.now() + REMOTE_CALLBACK_WAIT_MS;
while (Date.now() < deadline) {
const result = await stepCaRuntime.takeResult(query.state);
if (result) return result;
await new Promise((resolve) => setTimeout(resolve, REMOTE_POLL_MS));
}
return {
ok: false,
message: "The Termix instance handling this sign-in did not respond.",
};
}
if (session.processing || session.completed) {
return { ok: false, message: "This sign-in request was already used." };
}
session.processing = true;
return finishStepCaAuth(session, query);
}
async function finishStepCaAuth(
session: StepCaAuthSession,
query: StepCaCallbackQuery,
removeRuntime = true,
): Promise<StepCaCallbackResult> {
if (query.error || !query.code) {
const message = query.error_description || query.error || "Sign-in failed";
send(session.ws, {
type: "opkssh_error",
requestId: session.state,
error: `Step CA: ${message}`,
});
endSession(session, removeRuntime);
return { ok: false, message };
}
try {
send(session.ws, {
type: "opkssh_status",
requestId: session.state,
stage: "authenticating",
});
const idToken = await exchangeCodeForIdToken({
tokenEndpoint: session.tokenEndpoint,
clientId: session.clientId,
clientSecret: session.clientSecret,
code: query.code,
redirectUri: session.redirectUri,
codeVerifier: session.codeVerifier,
allowedPrivateHosts: session.target.allowedPrivateHosts,
});
const claims = decodeJwtClaims(idToken);
if (claims.nonce !== session.nonce) {
throw new Error("The identity token does not match this sign-in");
}
const email = typeof claims.email === "string" ? claims.email : undefined;
const certificate = await signSshCertificate(
session.target,
session.rootPem,
{
publicKeyLine: session.keyPair.publicKeyLine,
ott: idToken,
principals: [session.username],
keyId: email ?? session.username,
},
);
const certificateInfo = parseSshCertificate(certificate);
if (certificateInfo.publicKeyLine !== session.keyPair.publicKeyLine) {
throw new Error("The CA returned a certificate for a different key");
}
if (!certificateInfo.principals.includes(session.username)) {
throw new Error("The CA certificate does not include the host username");
}
const now = Date.now();
if (
certificateInfo.validBefore.getTime() <= now ||
certificateInfo.validAfter.getTime() > now + 60_000
) {
throw new Error(
"The CA returned a certificate outside its validity window",
);
}
const expiresAt = certificateInfo.validBefore;
const userDataKey = DataCrypto.getUserDataKey(session.userId);
if (!userDataKey) throw new Error("User data key not found");
// Same token id as OPKSSH: getOPKSSHToken decrypts with it.
const tokenId = `opkssh-${session.userId}-${session.hostId}`;
await createCurrentOpksshTokenRepository().upsert({
userId: session.userId,
hostId: session.hostId,
sshCert: FieldCrypto.encryptField(
certificate,
userDataKey,
tokenId,
"ssh_cert",
),
privateKey: FieldCrypto.encryptField(
session.keyPair.privateKeyPem,
userDataKey,
tokenId,
"private_key",
),
email,
sub: typeof claims.sub === "string" ? claims.sub : undefined,
issuer: typeof claims.iss === "string" ? claims.iss : undefined,
audience: typeof claims.aud === "string" ? claims.aud : undefined,
expiresAt: expiresAt.toISOString(),
});
session.completed = true;
send(session.ws, {
type: "opkssh_completed",
requestId: session.state,
expiresAt: expiresAt.toISOString(),
});
endSession(session, removeRuntime);
return { ok: true, message: "Signed in. You can close this window." };
} catch (error) {
sshLogger.error("Step CA certificate issuance failed", error, {
operation: "step_ca_complete_error",
userId: session.userId,
hostId: session.hostId,
});
const message = getErrorMessage(error);
send(session.ws, {
type: "opkssh_error",
requestId: session.state,
error: `Step CA: ${message}`,
});
endSession(session, removeRuntime);
return { ok: false, message };
}
}
+172
View File
@@ -0,0 +1,172 @@
import { createClient } from "redis";
import { sshLogger } from "../utils/logger.js";
import {
decryptSystemSecret,
encryptSystemSecret,
} from "../utils/system-secret-crypto.js";
export interface StepCaCallbackQuery {
state?: string;
code?: string;
error?: string;
error_description?: string;
}
export interface StepCaCallbackResult {
ok: boolean;
message: string;
}
const PREFIX =
process.env.TERMIX_STEP_CA_REDIS_PREFIX?.trim() || "termix:step-ca";
const SESSION_TTL_SECONDS = 5 * 60;
const RESULT_TTL_SECONDS = 60;
const CONNECT_RETRY_MS = 15_000;
export class StepCaRuntime {
private client: ReturnType<typeof createClient> | null = null;
private connecting: Promise<boolean> | null = null;
private nextConnectAttempt = 0;
async register(state: string): Promise<void> {
if (!(await this.ensureConnected()) || !this.client) return;
await this.client
.set(this.routeKey(state), "active", { EX: SESSION_TTL_SECONDS })
.catch((error) => this.logFailure("register", error));
}
async submit(state: string, query: StepCaCallbackQuery): Promise<boolean> {
if (!(await this.ensureConnected()) || !this.client) return false;
try {
if (!(await this.client.exists(this.routeKey(state)))) return false;
const encrypted = await encryptSystemSecret(JSON.stringify(query));
const stored = await this.client.set(this.commandKey(state), encrypted, {
EX: SESSION_TTL_SECONDS,
NX: true,
});
return stored === "OK";
} catch (error) {
this.logFailure("submit", error);
return false;
}
}
async takeCommand(state: string): Promise<StepCaCallbackQuery | null> {
if (!(await this.ensureConnected()) || !this.client) return null;
try {
const encrypted = await this.client.getDel(this.commandKey(state));
if (!encrypted) return null;
return this.decode<StepCaCallbackQuery>(
await decryptSystemSecret(encrypted.toString()),
);
} catch (error) {
this.logFailure("take_command", error);
return null;
}
}
async complete(state: string, result: StepCaCallbackResult): Promise<void> {
if (!(await this.ensureConnected()) || !this.client) return;
try {
const encrypted = await encryptSystemSecret(JSON.stringify(result));
await this.client
.multi()
.set(this.resultKey(state), encrypted, { EX: RESULT_TTL_SECONDS })
.del(this.routeKey(state))
.del(this.commandKey(state))
.exec();
} catch (error) {
this.logFailure("complete", error);
}
}
async takeResult(state: string): Promise<StepCaCallbackResult | null> {
if (!(await this.ensureConnected()) || !this.client) return null;
try {
const encrypted = await this.client.getDel(this.resultKey(state));
if (!encrypted) return null;
return this.decode<StepCaCallbackResult>(
await decryptSystemSecret(encrypted.toString()),
);
} catch (error) {
this.logFailure("take_result", error);
return null;
}
}
async remove(state: string): Promise<void> {
if (!(await this.ensureConnected()) || !this.client) return;
await this.client
.del([
this.routeKey(state),
this.commandKey(state),
this.resultKey(state),
])
.catch((error) => this.logFailure("remove", error));
}
async close(): Promise<void> {
if (this.client?.isOpen) await this.client.quit();
this.client = null;
}
private async ensureConnected(): Promise<boolean> {
const url = process.env.REDIS_URL?.trim();
if (!url) return false;
if (this.client?.isReady) return true;
if (Date.now() < this.nextConnectAttempt) return false;
if (this.connecting) return this.connecting;
this.connecting = this.connect(url).finally(() => {
this.connecting = null;
});
return this.connecting;
}
private async connect(url: string): Promise<boolean> {
try {
this.client = createClient({
url,
socket: { connectTimeout: 1500, reconnectStrategy: false },
});
this.client.on("error", (error) => this.logFailure("client", error));
await this.client.connect();
this.nextConnectAttempt = 0;
return true;
} catch (error) {
this.nextConnectAttempt = Date.now() + CONNECT_RETRY_MS;
this.logFailure("connect", error);
await this.client?.disconnect().catch(() => {});
this.client = null;
return false;
}
}
private routeKey(state: string): string {
return `${PREFIX}:route:${state}`;
}
private commandKey(state: string): string {
return `${PREFIX}:command:${state}`;
}
private resultKey(state: string): string {
return `${PREFIX}:result:${state}`;
}
private decode<T>(raw: string): T | null {
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
private logFailure(operation: string, error: unknown): void {
sshLogger.warn("Step CA Redis runtime unavailable", {
operation: `step_ca_redis_${operation}`,
error: error instanceof Error ? error.message : String(error),
});
}
}
export const stepCaRuntime = new StepCaRuntime();
+23 -5
View File
@@ -1,5 +1,6 @@
import { getErrorMessage } from "../../utils/error-message.js"; import { getErrorMessage } from "../../utils/error-message.js";
import { getAuditUsername } from "../../utils/audit-logger.js"; import { getAuditUsername } from "../../utils/audit-logger.js";
import { usesIssuedCertificate } from "../issued-certificate-auth.js";
import { collabRoomHub } from "../collab/room-hub.js"; import { collabRoomHub } from "../collab/room-hub.js";
import type { SessionShareRecord } from "../../database/repositories/session-share-repository.js"; import type { SessionShareRecord } from "../../database/repositories/session-share-repository.js";
import { createCurrentCollabRoomRepository } from "../../database/repositories/factory.js"; import { createCurrentCollabRoomRepository } from "../../database/repositories/factory.js";
@@ -1088,6 +1089,17 @@ wss.on("connection", async (ws: WebSocket, req) => {
} }
const hostname = host.name || host.ip; const hostname = host.name || host.ip;
const requestOrigin = getRequestOrigin(req); const requestOrigin = getRequestOrigin(req);
if (host.authType === "stepca") {
const { startStepCaAuth } = await import("../step-ca-auth.js");
await startStepCaAuth(
userId,
opksshData.hostId,
host.username,
ws,
requestOrigin,
);
break;
}
await startOPKSSHAuth( await startOPKSSHAuth(
userId, userId,
opksshData.hostId, opksshData.hostId,
@@ -1113,6 +1125,12 @@ wss.on("connection", async (ws: WebSocket, req) => {
} }
case "opkssh_cancel": { case "opkssh_cancel": {
{
const { cancelStepCaAuth } = await import("../step-ca-auth.js");
cancelStepCaAuth(
String((data as { requestId?: string })?.requestId ?? ""),
);
}
const cancelData = data as { requestId: string }; const cancelData = data as { requestId: string };
try { try {
const { cancelAuthSession } = await import("../opkssh-auth.js"); const { cancelAuthSession } = await import("../opkssh-auth.js");
@@ -2457,7 +2475,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
}); });
if ( if (
resolvedCredentials.authType === "opkssh" && usesIssuedCertificate(resolvedCredentials.authType) &&
err.message.includes("All configured authentication methods failed") err.message.includes("All configured authentication methods failed")
) { ) {
sshLogger.warn("OPKSSH authentication failed - invalidating token", { sshLogger.warn("OPKSSH authentication failed - invalidating token", {
@@ -3064,8 +3082,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
}), }),
); );
return; return;
} else if (resolvedCredentials.authType === "opkssh") { } else if (usesIssuedCertificate(resolvedCredentials.authType)) {
sendLog("auth", "info", "Using OPKSSH certificate authentication"); sendLog("auth", "info", "Using issued SSH certificate authentication");
try { try {
const { getOPKSSHToken } = await import("../opkssh-auth.js"); const { getOPKSSHToken } = await import("../opkssh-auth.js");
const token = await getOPKSSHToken(userId, id); const token = await getOPKSSHToken(userId, id);
@@ -3074,7 +3092,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
sendLog( sendLog(
"auth", "auth",
"info", "info",
"No valid OPKSSH token found, requesting authentication", "No valid certificate found, requesting sign-in",
); );
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
@@ -3085,7 +3103,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
return; return;
} }
sendLog("auth", "info", "Using cached OPKSSH certificate"); sendLog("auth", "info", "Using cached SSH certificate");
const { setupOPKSSHCertAuth } = await import("../opkssh-cert-auth.js"); const { setupOPKSSHCertAuth } = await import("../opkssh-cert-auth.js");
await setupOPKSSHCertAuth(connectConfig, sshConn, token, username); await setupOPKSSHCertAuth(connectConfig, sshConn, token, username);
@@ -0,0 +1,188 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WebSocket } from "ws";
const state = vi.hoisted(() => ({
sent: [] as Array<Record<string, unknown>>,
upsert: vi.fn(async () => undefined),
parseInfo: {
publicKeyLine: "ssh-ed25519 TESTKEY",
principals: ["alice"],
validAfter: new Date(Date.now() - 60_000),
validBefore: new Date(Date.now() + 60 * 60_000),
},
idToken: "",
command: null as Record<string, string> | null,
remoteResult: null as { ok: boolean; message: string } | null,
runtimeComplete: vi.fn(async () => undefined),
}));
vi.mock("../../utils/logger.js", () => ({
sshLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() },
}));
vi.mock("../../utils/data-crypto.js", () => ({
DataCrypto: { getUserDataKey: () => Buffer.alloc(32, 1) },
}));
vi.mock("../../utils/field-crypto.js", () => ({
FieldCrypto: {
encryptField: (value: string, _key: Buffer, _id: string, field: string) =>
`${field}:${value}`,
},
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({
get: async (key: string) =>
({
step_ca_url: "https://ca.example",
step_ca_fingerprint: "a".repeat(64),
step_ca_provisioner: "oidc",
})[key],
}),
createCurrentOpksshTokenRepository: () => ({ upsert: state.upsert }),
}));
vi.mock("../../utils/step-ca-egress.js", () => ({
readStepCaPrivateAllowlist: async () => [],
}));
vi.mock("../../utils/step-ca-client.js", () => ({
fetchRootCertificate: async () => "ROOT",
findOidcProvisioner: async () => ({
clientID: "client",
configurationEndpoint: "https://idp.example/.well-known/openid",
}),
discoverOidcEndpoints: async () => ({
authorizationEndpoint: "https://idp.example/auth",
tokenEndpoint: "https://idp.example/token",
}),
createPkce: () => ({ verifier: "verifier", challenge: "challenge" }),
generateSshKeyPair: () => ({
publicKeyLine: "ssh-ed25519 TESTKEY",
privateKeyPem: "PRIVATE",
}),
buildAuthorizationUrl: (input: Record<string, string>) => {
const url = new URL("https://idp.example/auth");
url.searchParams.set("state", input.state);
url.searchParams.set("nonce", input.nonce);
return url.toString();
},
exchangeCodeForIdToken: async () => state.idToken,
decodeJwtClaims: (token: string) => {
const payload = token.split(".")[1];
return payload
? JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))
: {};
},
signSshCertificate: async () => "CERT",
parseSshCertificate: () => state.parseInfo,
}));
vi.mock("../../hosts/step-ca-runtime.js", () => ({
stepCaRuntime: {
register: vi.fn(async () => undefined),
remove: vi.fn(async () => undefined),
submit: vi.fn(async () => true),
takeCommand: vi.fn(async () => {
const command = state.command;
state.command = null;
return command;
}),
complete: state.runtimeComplete,
takeResult: vi.fn(async () => {
const result = state.remoteResult;
state.remoteResult = null;
return result;
}),
},
}));
const { cancelStepCaAuth, completeStepCaAuth, startStepCaAuth } =
await import("../../hosts/step-ca-auth.js");
function jwt(payload: object): string {
return `x.${Buffer.from(JSON.stringify(payload)).toString("base64url")}.y`;
}
function fakeWs(): WebSocket {
return {
send: (raw: string) => state.sent.push(JSON.parse(raw)),
once: vi.fn(),
} as unknown as WebSocket;
}
async function start(): Promise<{ requestId: string; nonce: string }> {
await startStepCaAuth(
"user-1",
7,
"alice",
fakeWs(),
"https://termix.example",
);
const chooser = state.sent.at(-1)!;
const url = new URL(String(chooser.url));
return {
requestId: String(chooser.requestId),
nonce: url.searchParams.get("nonce")!,
};
}
beforeEach(() => {
state.sent.length = 0;
state.upsert.mockClear();
state.runtimeComplete.mockClear();
state.command = null;
state.remoteResult = null;
state.parseInfo = {
publicKeyLine: "ssh-ed25519 TESTKEY",
principals: ["alice"],
validAfter: new Date(Date.now() - 60_000),
validBefore: new Date(Date.now() + 60 * 60_000),
};
});
describe("Step CA authentication", () => {
it("stores only a certificate bound to the nonce, key, principal and validity window", async () => {
const { requestId, nonce } = await start();
state.idToken = jwt({ nonce, email: "alice@example.com" });
await expect(
completeStepCaAuth({ state: requestId, code: "code" }),
).resolves.toEqual({
ok: true,
message: "Signed in. You can close this window.",
});
expect(state.upsert).toHaveBeenCalledWith(
expect.objectContaining({ userId: "user-1", hostId: 7 }),
);
});
it("rejects a token without the requested nonce", async () => {
const { requestId } = await start();
state.idToken = jwt({ email: "alice@example.com" });
const result = await completeStepCaAuth({ state: requestId, code: "code" });
expect(result.ok).toBe(false);
expect(result.message).toMatch(/does not match/);
expect(state.upsert).not.toHaveBeenCalled();
});
it("routes a callback through Redis to the instance holding the WebSocket", async () => {
const { requestId, nonce } = await start();
state.idToken = jwt({ nonce });
state.command = { state: requestId, code: "remote-code" };
await vi.waitFor(() => expect(state.runtimeComplete).toHaveBeenCalled(), {
timeout: 2000,
});
expect(state.upsert).toHaveBeenCalled();
});
it("returns a result produced by another instance", async () => {
state.remoteResult = { ok: true, message: "remote success" };
await expect(
completeStepCaAuth({ state: "remote-state", code: "code" }),
).resolves.toEqual({ ok: true, message: "remote success" });
});
it("cancels local sessions", async () => {
const { requestId } = await start();
expect(cancelStepCaAuth(requestId)).toBe(true);
expect(cancelStepCaAuth(requestId)).toBe(false);
});
});
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import crypto from "crypto";
import {
buildAuthorizationUrl,
certificateFingerprint,
createPkce,
decodeJwtClaims,
generateSshKeyPair,
normalizeCaUrl,
normalizeFingerprint,
parseSshCertificate,
} from "../../utils/step-ca-client.js";
import {
generateCa,
signUserCertificate,
} from "../../database/routes/ssh-certificate.js";
describe("step-ca client helpers", () => {
it("normalizes the CA url and fingerprint the way step does", () => {
expect(normalizeCaUrl(" https://ca.internal:9000/ ")).toBe(
"https://ca.internal:9000",
);
expect(() => normalizeCaUrl("http://ca.internal")).toThrow(/https/);
const fp = "AB:cd".repeat(16).replace(/:/g, "") + "";
expect(normalizeFingerprint("AB:cd".repeat(16))).toBe(fp.toLowerCase());
expect(() => normalizeFingerprint("abcd")).toThrow(/SHA-256/);
});
it("fingerprints a PEM certificate by the sha256 of its DER", () => {
const der = crypto.randomBytes(64);
const pem = `-----BEGIN CERTIFICATE-----\n${der.toString("base64")}\n-----END CERTIFICATE-----\n`;
expect(certificateFingerprint(pem)).toBe(
crypto.createHash("sha256").update(der).digest("hex"),
);
});
it("builds a PKCE authorization request", () => {
const { verifier, challenge } = createPkce();
expect(challenge).toBe(
crypto.createHash("sha256").update(verifier).digest("base64url"),
);
const url = new URL(
buildAuthorizationUrl({
authorizationEndpoint: "https://idp.example/auth?tenant=x",
clientId: "cid",
redirectUri: "https://termix.example/callback",
state: "s",
nonce: "n",
codeChallenge: challenge,
}),
);
expect(url.searchParams.get("tenant")).toBe("x");
expect(url.searchParams.get("response_type")).toBe("code");
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
expect(url.searchParams.get("scope")).toContain("openid");
});
it("generates an ed25519 key whose public line a CA can certify, and reads the cert back", () => {
const { publicKeyLine, privateKeyPem } = generateSshKeyPair();
expect(publicKeyLine).toMatch(/^ssh-ed25519 [A-Za-z0-9+/=]+$/);
expect(privateKeyPem).toContain("BEGIN PRIVATE KEY");
const ca = generateCa();
const cert = signUserCertificate({
userPublicKeyLine: publicKeyLine,
caPrivateKeyPem: ca.privateKeyPem,
caPublicKeyLine: ca.publicKeyLine,
keyId: "alice@example",
principals: ["alice", "ops"],
validAfter: 1_700_000_000,
validBefore: 1_700_057_600,
});
expect(cert).not.toBeNull();
const info = parseSshCertificate(cert!);
expect(info).toMatchObject({
keyType: "ssh-ed25519-cert-v01@openssh.com",
publicKeyLine,
keyId: "alice@example",
principals: ["alice", "ops"],
});
expect(info.validAfter.toISOString()).toBe("2023-11-14T22:13:20.000Z");
expect(info.validBefore.getTime() - info.validAfter.getTime()).toBe(
16 * 3600 * 1000,
);
expect(() => parseSshCertificate(publicKeyLine)).toThrow(/certificate/);
expect(() =>
parseSshCertificate("ssh-ed25519-cert-v01@openssh.com AAAA"),
).toThrow(/certificate/);
});
});
describe("decodeJwtClaims", () => {
it("reads the payload without verifying and tolerates junk", () => {
const payload = Buffer.from(
JSON.stringify({ email: "a@b.c", nonce: "n1" }),
).toString("base64url");
expect(decodeJwtClaims(`x.${payload}.y`)).toEqual({
email: "a@b.c",
nonce: "n1",
});
expect(decodeJwtClaims("not-a-jwt")).toEqual({});
});
});
+15
View File
@@ -147,10 +147,21 @@ export function createDnsLookupHook(
}; };
} }
export interface OutboundTlsOptions {
/** PEM bundle to trust instead of the system store (private CAs). */
ca?: string;
/**
* Skip certificate verification. Only for the one request that fetches a
* private CA's root by fingerprint, where the caller verifies the result.
*/
rejectUnauthorized?: boolean;
}
export async function safeOutboundFetch( export async function safeOutboundFetch(
rawUrl: string, rawUrl: string,
options: RequestInit, options: RequestInit,
allowedPrivateHosts: readonly string[] = [], allowedPrivateHosts: readonly string[] = [],
tls: OutboundTlsOptions = {},
): Promise<Response> { ): Promise<Response> {
const url = new URL(rawUrl); const url = new URL(rawUrl);
if ( if (
@@ -172,6 +183,10 @@ export async function safeOutboundFetch(
const dispatcher = new Agent({ const dispatcher = new Agent({
connect: { connect: {
lookup: createDnsLookupHook(lookup, allowPrivate), lookup: createDnsLookupHook(lookup, allowPrivate),
...(tls.ca ? { ca: tls.ca } : {}),
...(tls.rejectUnauthorized === false
? { rejectUnauthorized: false }
: {}),
}, },
}); });
+394
View File
@@ -0,0 +1,394 @@
import crypto from "crypto";
import { safeOutboundFetch } from "./safe-outbound-fetch.js";
/**
* A minimal client for smallstep's step-ca SSH user-certificate flow, done
* over its HTTP API rather than the `step` binary:
*
* 1. bootstrap the CA's root certificate by fingerprint (GET /root/{fp})
* 2. read the OIDC provisioner's client settings (GET /provisioners)
* 3. run the OIDC authorization-code flow against the provider
* 4. POST the id_token as the one-time token to /1.0/ssh/sign
*/
export interface StepCaTarget {
caUrl: string;
fingerprint: string;
/** Hosts the SSRF guard may reach even when they resolve to private ranges. */
allowedPrivateHosts: readonly string[];
}
export interface StepCaOidcProvisioner {
name: string;
clientID: string;
clientSecret?: string;
configurationEndpoint: string;
}
export interface OidcEndpoints {
authorizationEndpoint: string;
tokenEndpoint: string;
}
const FETCH_TIMEOUT_MS = 15_000;
/** Display-only claims; the CA is the component that verifies the token. */
export function decodeJwtClaims(token: string): Record<string, unknown> {
const payload = token.split(".")[1];
if (!payload) return {};
try {
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
} catch {
return {};
}
}
export function normalizeCaUrl(raw: string): string {
const url = new URL(raw.trim());
if (url.protocol !== "https:") {
throw new Error("Step CA URL must use https");
}
return url.toString().replace(/\/+$/, "");
}
export function normalizeFingerprint(raw: string): string {
const hex = raw.replace(/[^0-9a-fA-F]/g, "").toLowerCase();
if (hex.length !== 64) {
throw new Error("CA fingerprint must be a SHA-256 hex digest");
}
return hex;
}
export function pemToDer(pem: string): Buffer {
const body = pem
.replace(/-----BEGIN [^-]+-----/g, "")
.replace(/-----END [^-]+-----/g, "")
.replace(/\s+/g, "");
return Buffer.from(body, "base64");
}
export function certificateFingerprint(pem: string): string {
return crypto.createHash("sha256").update(pemToDer(pem)).digest("hex");
}
async function readJson<T>(response: Response, what: string): Promise<T> {
if (!response.ok) {
throw new Error(`${what} failed: HTTP ${response.status}`);
}
return (await response.json()) as T;
}
/**
* The root endpoint is served under the CA's own TLS certificate, which
* nothing trusts yet - so this one request skips verification and trusts
* the fingerprint instead, exactly like `step ca bootstrap`.
*/
export async function fetchRootCertificate(
target: StepCaTarget,
): Promise<string> {
const fingerprint = normalizeFingerprint(target.fingerprint);
const response = await safeOutboundFetch(
`${normalizeCaUrl(target.caUrl)}/root/${fingerprint}`,
{ method: "GET", signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) },
target.allowedPrivateHosts,
{ rejectUnauthorized: false },
);
const { ca } = await readJson<{ ca?: string }>(
response,
"Fetching the CA root",
);
if (!ca || certificateFingerprint(ca) !== fingerprint) {
throw new Error("CA root certificate does not match the fingerprint");
}
return ca;
}
export async function findOidcProvisioner(
target: StepCaTarget,
rootPem: string,
name: string,
): Promise<StepCaOidcProvisioner> {
const base = normalizeCaUrl(target.caUrl);
let cursor = "";
for (let page = 0; page < 20; page++) {
const response = await safeOutboundFetch(
`${base}/provisioners?limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`,
{ method: "GET", signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) },
target.allowedPrivateHosts,
{ ca: rootPem },
);
const body = await readJson<{
provisioners?: Array<Record<string, unknown>>;
nextCursor?: string;
}>(response, "Listing CA provisioners");
const match = (body.provisioners ?? []).find(
(p) => p.name === name && p.type === "OIDC",
);
if (match) {
if (
typeof match.clientID !== "string" ||
typeof match.configurationEndpoint !== "string"
) {
throw new Error("The OIDC provisioner is missing its client settings");
}
return {
name,
clientID: match.clientID,
clientSecret:
typeof match.clientSecret === "string"
? match.clientSecret
: undefined,
configurationEndpoint: match.configurationEndpoint,
};
}
if (!body.nextCursor) break;
cursor = body.nextCursor;
}
throw new Error(`OIDC provisioner "${name}" not found on the CA`);
}
export async function discoverOidcEndpoints(
configurationEndpoint: string,
allowedPrivateHosts: readonly string[],
): Promise<OidcEndpoints> {
const response = await safeOutboundFetch(
configurationEndpoint,
{ method: "GET", signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) },
allowedPrivateHosts,
);
const doc = await readJson<{
authorization_endpoint?: string;
token_endpoint?: string;
}>(response, "OIDC discovery");
if (!doc.authorization_endpoint || !doc.token_endpoint) {
throw new Error("OIDC discovery document is incomplete");
}
return {
authorizationEndpoint: doc.authorization_endpoint,
tokenEndpoint: doc.token_endpoint,
};
}
export function createPkce(): { verifier: string; challenge: string } {
const verifier = crypto.randomBytes(48).toString("base64url");
const challenge = crypto
.createHash("sha256")
.update(verifier)
.digest("base64url");
return { verifier, challenge };
}
export function buildAuthorizationUrl(input: {
authorizationEndpoint: string;
clientId: string;
redirectUri: string;
state: string;
nonce: string;
codeChallenge: string;
}): string {
const url = new URL(input.authorizationEndpoint);
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", input.clientId);
url.searchParams.set("redirect_uri", input.redirectUri);
url.searchParams.set("scope", "openid email profile");
url.searchParams.set("state", input.state);
url.searchParams.set("nonce", input.nonce);
url.searchParams.set("code_challenge", input.codeChallenge);
url.searchParams.set("code_challenge_method", "S256");
return url.toString();
}
export async function exchangeCodeForIdToken(input: {
tokenEndpoint: string;
clientId: string;
clientSecret?: string;
code: string;
redirectUri: string;
codeVerifier: string;
allowedPrivateHosts: readonly string[];
}): Promise<string> {
const form = new URLSearchParams({
grant_type: "authorization_code",
code: input.code,
redirect_uri: input.redirectUri,
client_id: input.clientId,
code_verifier: input.codeVerifier,
});
if (input.clientSecret) form.set("client_secret", input.clientSecret);
const response = await safeOutboundFetch(
input.tokenEndpoint,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form.toString(),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
},
input.allowedPrivateHosts,
);
const body = await readJson<{ id_token?: string }>(
response,
"OIDC token exchange",
);
if (!body.id_token) {
throw new Error("The identity provider returned no id_token");
}
return body.id_token;
}
// --- SSH keys and certificates ---------------------------------------------
function sshString(value: Buffer | string): Buffer {
const data = Buffer.isBuffer(value) ? value : Buffer.from(value, "utf8");
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
return Buffer.concat([len, data]);
}
export function generateSshKeyPair(): {
publicKeyLine: string;
privateKeyPem: string;
} {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const jwk = publicKey.export({ format: "jwk" }) as { x: string };
const raw = Buffer.from(jwk.x, "base64url");
const blob = Buffer.concat([sshString("ssh-ed25519"), sshString(raw)]);
return {
publicKeyLine: `ssh-ed25519 ${blob.toString("base64")}`,
privateKeyPem: privateKey
.export({ format: "pem", type: "pkcs8" })
.toString(),
};
}
export async function signSshCertificate(
target: StepCaTarget,
rootPem: string,
input: {
publicKeyLine: string;
ott: string;
principals: string[];
keyId: string;
},
): Promise<string> {
const blob = input.publicKeyLine.trim().split(/\s+/)[1];
if (!blob) throw new Error("Invalid public key line");
const response = await safeOutboundFetch(
`${normalizeCaUrl(target.caUrl)}/1.0/ssh/sign`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
publicKey: blob,
ott: input.ott,
certType: "user",
principals: input.principals,
keyID: input.keyId,
}),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
},
target.allowedPrivateHosts,
{ ca: rootPem },
);
if (!response.ok) {
let detail = "";
try {
const err = (await response.json()) as { message?: string };
detail = err.message ? `: ${err.message}` : "";
} catch {
/* no body */
}
throw new Error(
`The CA refused to sign the key (HTTP ${response.status})${detail}`,
);
}
const body = (await response.json()) as { crt?: string };
if (!body.crt) throw new Error("The CA returned no certificate");
return body.crt.trim();
}
export interface SshCertificateInfo {
keyType: string;
publicKeyLine: string;
keyId: string;
principals: string[];
validAfter: Date;
validBefore: Date;
}
/** Reads the identity and validity window out of an OpenSSH certificate line. */
export function parseSshCertificate(line: string): SshCertificateInfo {
const blob = Buffer.from(line.trim().split(/\s+/)[1] ?? "", "base64");
if (blob.length === 0) throw new Error("Invalid SSH certificate");
let offset = 0;
const readString = (): Buffer => {
if (offset + 4 > blob.length) throw new Error("Truncated SSH certificate");
const len = blob.readUInt32BE(offset);
offset += 4;
if (offset + len > blob.length) {
throw new Error("Truncated SSH certificate");
}
const value = blob.subarray(offset, offset + len);
offset += len;
return value;
};
const readUint64 = (): bigint => {
if (offset + 8 > blob.length) throw new Error("Truncated SSH certificate");
const value = blob.readBigUInt64BE(offset);
offset += 8;
return value;
};
const keyType = readString().toString();
if (!keyType.endsWith("-cert-v01@openssh.com")) {
throw new Error("The CA returned a public key instead of a certificate");
}
readString(); // nonce
// Public key fields differ by algorithm; consume them by shape.
const publicKeyParts: Buffer[] = [];
let plainKeyType: string;
if (keyType.startsWith("ssh-rsa")) {
plainKeyType = "ssh-rsa";
publicKeyParts.push(readString(), readString()); // e, n
} else if (keyType.startsWith("ecdsa-")) {
plainKeyType = keyType.replace(/-cert-v01@openssh\.com$/, "");
publicKeyParts.push(readString(), readString()); // curve, Q
} else {
plainKeyType = keyType.replace(/-cert-v01@openssh\.com$/, "");
publicKeyParts.push(readString()); // ed25519 pk
}
const publicKeyBlob = Buffer.concat([
sshString(plainKeyType),
...publicKeyParts.map(sshString),
]);
readUint64(); // serial
offset += 4; // type
const keyId = readString().toString();
const principalsBlob = readString();
const principals: string[] = [];
for (let p = 0; p < principalsBlob.length;) {
if (p + 4 > principalsBlob.length) {
throw new Error("Invalid SSH certificate principals");
}
const len = principalsBlob.readUInt32BE(p);
if (p + 4 + len > principalsBlob.length) {
throw new Error("Invalid SSH certificate principals");
}
principals.push(principalsBlob.subarray(p + 4, p + 4 + len).toString());
p += 4 + len;
}
const validAfter = readUint64();
const validBefore = readUint64();
const toDate = (seconds: bigint) =>
new Date(
Number(seconds > 8_640_000_000_000n ? 8_640_000_000_000n : seconds) *
1000,
);
return {
keyType,
publicKeyLine: `${plainKeyType} ${publicKeyBlob.toString("base64")}`,
keyId,
principals,
validAfter: toDate(validAfter),
validBefore: toDate(validBefore),
};
}
+13
View File
@@ -0,0 +1,13 @@
import { createCurrentSettingsRepository } from "../database/repositories/factory.js";
import { parseNotificationAllowlist } from "./notification-egress.js";
/** Private hosts (a CA on the LAN, an internal IdP) the Step CA flow may reach. */
export const STEP_CA_PRIVATE_ALLOWLIST_KEY =
"step_ca_private_endpoint_allowlist";
export async function readStepCaPrivateAllowlist(): Promise<string[]> {
const raw = await createCurrentSettingsRepository().get(
STEP_CA_PRIVATE_ALLOWLIST_KEY,
);
return parseNotificationAllowlist(raw);
}
+4 -1
View File
@@ -34,7 +34,9 @@ export type HostTrayTrigger = "always" | "hover" | "click" | "actionsOnly";
export interface HostSidebarFilterState { export interface HostSidebarFilterState {
status: ("online" | "offline" | "pinned")[]; status: ("online" | "offline" | "pinned")[];
authType: ("password" | "key" | "credential" | "none" | "opkssh")[]; authType: (
"password" | "key" | "credential" | "none" | "opkssh" | "stepca"
)[];
protocol: ("ssh" | "rdp" | "vnc" | "telnet")[]; protocol: ("ssh" | "rdp" | "vnc" | "telnet")[];
features: ("terminal" | "fileManager" | "tunnel" | "docker")[]; features: ("terminal" | "fileManager" | "tunnel" | "docker")[];
tags: string[]; tags: string[];
@@ -94,6 +96,7 @@ const FILTER_AUTH_TYPE: HostSidebarFilterState["authType"] = [
"credential", "credential",
"none", "none",
"opkssh", "opkssh",
"stepca",
]; ];
const FILTER_PROTOCOL: HostSidebarFilterState["protocol"] = [ const FILTER_PROTOCOL: HostSidebarFilterState["protocol"] = [
"ssh", "ssh",
+16 -2
View File
@@ -68,7 +68,13 @@ export interface LDAPProviderConfig {
export type ConnectionType = "ssh" | "rdp" | "vnc" | "telnet"; export type ConnectionType = "ssh" | "rdp" | "vnc" | "telnet";
export type SSHAuthType = export type SSHAuthType =
"password" | "key" | "credential" | "none" | "opkssh" | "tailscale"; | "password"
| "key"
| "credential"
| "none"
| "opkssh"
| "stepca"
| "tailscale";
export type GuacamoleAuthType = "password" | "credential"; export type GuacamoleAuthType = "password" | "credential";
@@ -133,6 +139,7 @@ export type Host = {
| "credential" | "credential"
| "none" | "none"
| "opkssh" | "opkssh"
| "stepca"
| "tailscale" | "tailscale"
| "agent" | "agent"
| "vault"; | "vault";
@@ -298,6 +305,7 @@ export interface HostData {
| "credential" | "credential"
| "none" | "none"
| "opkssh" | "opkssh"
| "stepca"
| "tailscale" | "tailscale"
| "agent" | "agent"
| "vault"; | "vault";
@@ -832,7 +840,13 @@ export type ErrorType =
// ============================================================================ // ============================================================================
export type AuthType = export type AuthType =
"password" | "key" | "credential" | "none" | "opkssh" | "tailscale"; | "password"
| "key"
| "credential"
| "none"
| "opkssh"
| "stepca"
| "tailscale";
export type KeyType = "rsa" | "ecdsa" | "ed25519"; export type KeyType = "rsa" | "ecdsa" | "ed25519";
+1
View File
@@ -31,6 +31,7 @@ export type Host = {
| "credential" | "credential"
| "none" | "none"
| "opkssh" | "opkssh"
| "stepca"
| "tailscale" | "tailscale"
| "vault" | "vault"
| "agent"; | "agent";
+19
View File
@@ -226,6 +226,25 @@ export async function getNotificationPrivateEndpoints(): Promise<string[]> {
} }
} }
export async function getStepCaPrivateEndpoints(): Promise<string[]> {
try {
return (await authApi.get("/users/step-ca-private-endpoints")).data.hosts;
} catch (error) {
throw handleApiError(error, "get Step CA endpoint allowlist");
}
}
export async function setStepCaPrivateEndpoints(
hosts: string[],
): Promise<string[]> {
try {
return (await authApi.patch("/users/step-ca-private-endpoints", { hosts }))
.data.hosts;
} catch (error) {
throw handleApiError(error, "update Step CA endpoint allowlist");
}
}
export async function setNotificationPrivateEndpoints( export async function setNotificationPrivateEndpoints(
hosts: string[], hosts: string[],
): Promise<string[]> { ): Promise<string[]> {
+33
View File
@@ -85,6 +85,39 @@ export async function updateTerminalSessionSettings(input: {
} }
} }
export interface StepCaSettings {
configured: boolean;
caUrl: string;
fingerprint: string;
provisioner: string;
}
export async function getStepCaSettings(): Promise<StepCaSettings> {
try {
const response = await authApi.get("/users/step-ca-settings");
return {
configured: !!response.data.configured,
caUrl: response.data.caUrl ?? "",
fingerprint: response.data.fingerprint ?? "",
provisioner: response.data.provisioner ?? "",
};
} catch (error) {
handleApiError(error, "fetch Step CA settings");
}
}
export async function updateStepCaSettings(input: {
caUrl: string;
fingerprint: string;
provisioner: string;
}): Promise<void> {
try {
await authApi.patch("/users/step-ca-settings", input);
} catch (error) {
handleApiError(error, "update Step CA settings");
}
}
export async function updateSessionTimeout( export async function updateSessionTimeout(
timeoutHours: number, timeoutHours: number,
): Promise<void> { ): Promise<void> {
@@ -3,6 +3,7 @@ const SECRETLESS_AUTH_TYPES = new Set([
"none", "none",
"agent", "agent",
"opkssh", "opkssh",
"stepca",
"tailscale", "tailscale",
"vault", "vault",
]); ]);
+6 -1
View File
@@ -1357,7 +1357,12 @@ export function DashboardTab({
const hostId = Number(host.id); const hostId = Number(host.id);
const knownStatus = statuses?.[hostId]?.status; const knownStatus = statuses?.[hostId]?.status;
if (knownStatus === "offline") return null; if (knownStatus === "offline") return null;
if (host.authType === "none" || host.authType === "opkssh") return null; if (
host.authType === "none" ||
host.authType === "opkssh" ||
host.authType === "stepca"
)
return null;
try { try {
const existing = newSessions.get(hostId); const existing = newSessions.get(hostId);
+4
View File
@@ -270,6 +270,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
stage: "chooser" | "waiting" | "authenticating" | "completed" | "error"; stage: "chooser" | "waiting" | "authenticating" | "completed" | "error";
error?: string; error?: string;
providers?: Array<{ alias: string; issuer: string }>; providers?: Array<{ alias: string; issuer: string }>;
/** Which issuer is asking (OPKSSH by default, "Step CA", ...). */
label?: string;
} | null>(null); } | null>(null);
const opksshTimeoutRef = useRef<NodeJS.Timeout | null>(null); const opksshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -1823,6 +1825,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
requestId: msg.requestId || "", requestId: msg.requestId || "",
stage: "chooser", stage: "chooser",
providers: msg.providers, providers: msg.providers,
label: typeof msg.label === "string" ? msg.label : undefined,
}); });
if (opksshTimeoutRef.current) { if (opksshTimeoutRef.current) {
clearTimeout(opksshTimeoutRef.current); clearTimeout(opksshTimeoutRef.current);
@@ -3598,6 +3601,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
stage={opksshDialog.stage} stage={opksshDialog.stage}
error={opksshDialog.error} error={opksshDialog.error}
providers={opksshDialog.providers} providers={opksshDialog.providers}
label={opksshDialog.label}
onCancel={() => { onCancel={() => {
if (webSocketRef.current) { if (webSocketRef.current) {
webSocketRef.current.send( webSocketRef.current.send(
+14
View File
@@ -920,6 +920,9 @@
"keyPassphraseSaved": "Passphrase saved, type to change", "keyPassphraseSaved": "Passphrase saved, type to change",
"replaceKey": "Replace key", "replaceKey": "Replace key",
"docsLink": "View docs", "docsLink": "View docs",
"stepcaLabel": "Step CA",
"stepcaDesc": "Sign in through your identity provider and let your Step CA issue a short-lived SSH certificate for this host. The CA URL, root fingerprint and OIDC provisioner are set by an administrator under Admin Settings.",
"authTypeStepca": "Step CA",
"opksshLabel": "OPKSSH", "opksshLabel": "OPKSSH",
"opksshDesc": "Sign in to this host using your identity provider instead of a password or key. Requires OPKSSH set up on the server.", "opksshDesc": "Sign in to this host using your identity provider instead of a password or key. Requires OPKSSH set up on the server.",
"warpgateLabel": "Warpgate Gateway", "warpgateLabel": "Warpgate Gateway",
@@ -1503,6 +1506,7 @@
"filterAuthCredential": "Credential", "filterAuthCredential": "Credential",
"filterAuthNone": "None", "filterAuthNone": "None",
"filterAuthOpkssh": "OPKSSH", "filterAuthOpkssh": "OPKSSH",
"filterAuthStepca": "Step CA",
"filterProtocolGroup": "Protocol", "filterProtocolGroup": "Protocol",
"filterProtocolSsh": "SSH", "filterProtocolSsh": "SSH",
"filterProtocolRdp": "RDP", "filterProtocolRdp": "RDP",
@@ -2068,6 +2072,7 @@
"warpgateAuthUrl": "Authentication URL", "warpgateAuthUrl": "Authentication URL",
"warpgateOpenBrowser": "Open in Browser", "warpgateOpenBrowser": "Open in Browser",
"warpgateContinue": "I've Completed Authentication", "warpgateContinue": "I've Completed Authentication",
"certAuthRequired": "{{provider}} Sign-in Required",
"opksshAuthRequired": "OPKSSH Authentication Required", "opksshAuthRequired": "OPKSSH Authentication Required",
"opksshAuthDescription": "Complete authentication in your browser to continue. This session will remain valid for 24 hours.", "opksshAuthDescription": "Complete authentication in your browser to continue. This session will remain valid for 24 hours.",
"opksshOpenBrowser": "Open Browser to Authenticate", "opksshOpenBrowser": "Open Browser to Authenticate",
@@ -3689,6 +3694,15 @@
"aiGloballyEnabledDesc": "Let users turn on the AI assistant. While this is off, the assistant is hidden and blocked for everyone.", "aiGloballyEnabledDesc": "Let users turn on the AI assistant. While this is off, the assistant is hidden and blocked for everyone.",
"aiPrivateEndpoints": "Allowed private AI hosts", "aiPrivateEndpoints": "Allowed private AI hosts",
"aiPrivateEndpointsDesc": "Hosts on your private network that users may point a provider at, such as a self-hosted Ollama. Separate them with commas.", "aiPrivateEndpointsDesc": "Hosts on your private network that users may point a provider at, such as a self-hosted Ollama. Separate them with commas.",
"stepCa": "Step CA",
"stepCaDesc": "Issue short-lived SSH certificates from a smallstep CA. Enter the CA URL, its root fingerprint (as shown by step ca bootstrap) and the name of the OIDC provisioner; then choose \"Step CA\" as a host's authentication type. Leave all three empty to disable.",
"stepCaFingerprint": "Root fingerprint (SHA-256)",
"stepCaProvisioner": "OIDC provisioner name",
"stepCaSaved": "Step CA settings saved",
"stepCaSaveFailed": "Failed to save Step CA settings",
"stepCaPrivateEndpoints": "Allowed private Step CA hosts",
"stepCaPrivateEndpointsDesc": "Private hosts the Step CA certificate flow may contact: the CA itself and, if internal, your identity provider. Separate them with commas.",
"updateStepCaEndpointsFailed": "Failed to update the Step CA endpoint allowlist",
"notificationPrivateEndpoints": "Allowed private notification hosts", "notificationPrivateEndpoints": "Allowed private notification hosts",
"notificationPrivateEndpointsDesc": "Exact private hosts that notification channels may contact. Separate them with commas.", "notificationPrivateEndpointsDesc": "Exact private hosts that notification channels may contact. Separate them with commas.",
"updateNotificationEndpointsFailed": "Failed to update notification endpoint allowlist", "updateNotificationEndpointsFailed": "Failed to update notification endpoint allowlist",
+55
View File
@@ -5,6 +5,8 @@ import {
getAiGloballyEnabled, getAiGloballyEnabled,
getAiPrivateEndpoints, getAiPrivateEndpoints,
getNotificationPrivateEndpoints, getNotificationPrivateEndpoints,
getStepCaPrivateEndpoints,
setStepCaPrivateEndpoints as setStepCaPrivateEndpointsApi,
setAiGloballyEnabled as setAiGloballyEnabledApi, setAiGloballyEnabled as setAiGloballyEnabledApi,
setAiPrivateEndpoints as setAiPrivateEndpointsApi, setAiPrivateEndpoints as setAiPrivateEndpointsApi,
setNotificationPrivateEndpoints as setNotificationPrivateEndpointsApi, setNotificationPrivateEndpoints as setNotificationPrivateEndpointsApi,
@@ -97,6 +99,8 @@ import { toast } from "sonner";
import { import {
getTerminalSessionSettings, getTerminalSessionSettings,
updateTerminalSessionSettings, updateTerminalSessionSettings,
getStepCaSettings,
updateStepCaSettings,
} from "@/api/settings-api"; } from "@/api/settings-api";
import { getDatabaseTransferUrl } from "@/lib/database-transfer-url"; import { getDatabaseTransferUrl } from "@/lib/database-transfer-url";
import { import {
@@ -181,6 +185,25 @@ export function AdminSettingsPanel({
useState(true); useState(true);
const [aiGloballyEnabled, setAiGloballyEnabled] = useState(false); const [aiGloballyEnabled, setAiGloballyEnabled] = useState(false);
const [aiPrivateEndpoints, setAiPrivateEndpoints] = useState<string[]>([]); const [aiPrivateEndpoints, setAiPrivateEndpoints] = useState<string[]>([]);
const [stepCaPrivateEndpoints, setStepCaPrivateEndpoints] = useState<
string[]
>([]);
const [stepCaSettings, setStepCaSettings] = useState({
caUrl: "",
fingerprint: "",
provisioner: "",
});
useEffect(() => {
getStepCaSettings()
.then((s) =>
setStepCaSettings({
caUrl: s.caUrl,
fingerprint: s.fingerprint,
provisioner: s.provisioner,
}),
)
.catch(() => {});
}, []);
const [notificationPrivateEndpoints, setNotificationPrivateEndpoints] = const [notificationPrivateEndpoints, setNotificationPrivateEndpoints] =
useState<string[]>([]); useState<string[]>([]);
const [hostDefaults, setHostDefaults] = useState<HostDefaults>({}); const [hostDefaults, setHostDefaults] = useState<HostDefaults>({});
@@ -380,6 +403,7 @@ export function AdminSettingsPanel({
aiEnabled, aiEnabled,
aiEndpoints, aiEndpoints,
notificationEndpoints, notificationEndpoints,
stepCaEndpoints,
imageStorage, imageStorage,
] = await Promise.allSettled([ ] = await Promise.allSettled([
getRegistrationAllowed(), getRegistrationAllowed(),
@@ -399,6 +423,7 @@ export function AdminSettingsPanel({
getAiGloballyEnabled(), getAiGloballyEnabled(),
getAiPrivateEndpoints(), getAiPrivateEndpoints(),
getNotificationPrivateEndpoints(), getNotificationPrivateEndpoints(),
getStepCaPrivateEndpoints(),
getTerminalImageStorageSettings(), getTerminalImageStorageSettings(),
]); ]);
@@ -451,6 +476,9 @@ export function AdminSettingsPanel({
if (aiEndpoints.status === "fulfilled") { if (aiEndpoints.status === "fulfilled") {
setAiPrivateEndpoints(aiEndpoints.value); setAiPrivateEndpoints(aiEndpoints.value);
} }
if (stepCaEndpoints.status === "fulfilled") {
setStepCaPrivateEndpoints(stepCaEndpoints.value);
}
if (notificationEndpoints.status === "fulfilled") { if (notificationEndpoints.status === "fulfilled") {
setNotificationPrivateEndpoints(notificationEndpoints.value); setNotificationPrivateEndpoints(notificationEndpoints.value);
} }
@@ -613,6 +641,28 @@ export function AdminSettingsPanel({
} }
} }
async function handleSaveStepCaSettings() {
try {
await updateStepCaSettings(stepCaSettings);
toast.success(t("admin.stepCaSaved"));
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("admin.stepCaSaveFailed"),
);
}
}
async function handleSaveStepCaPrivateEndpoints(hosts: string[]) {
const previous = stepCaPrivateEndpoints;
setStepCaPrivateEndpoints(hosts);
try {
setStepCaPrivateEndpoints(await setStepCaPrivateEndpointsApi(hosts));
} catch {
setStepCaPrivateEndpoints(previous);
toast.error(t("admin.updateStepCaEndpointsFailed"));
}
}
async function handleSaveNotificationPrivateEndpoints(hosts: string[]) { async function handleSaveNotificationPrivateEndpoints(hosts: string[]) {
const previous = notificationPrivateEndpoints; const previous = notificationPrivateEndpoints;
setNotificationPrivateEndpoints(hosts); setNotificationPrivateEndpoints(hosts);
@@ -1166,6 +1216,11 @@ export function AdminSettingsPanel({
aiPrivateEndpoints={aiPrivateEndpoints} aiPrivateEndpoints={aiPrivateEndpoints}
onSaveAiPrivateEndpoints={handleSaveAiPrivateEndpoints} onSaveAiPrivateEndpoints={handleSaveAiPrivateEndpoints}
notificationPrivateEndpoints={notificationPrivateEndpoints} notificationPrivateEndpoints={notificationPrivateEndpoints}
stepCaPrivateEndpoints={stepCaPrivateEndpoints}
onSaveStepCaPrivateEndpoints={handleSaveStepCaPrivateEndpoints}
stepCaSettings={stepCaSettings}
setStepCaSettings={setStepCaSettings}
handleSaveStepCaSettings={handleSaveStepCaSettings}
onSaveNotificationPrivateEndpoints={ onSaveNotificationPrivateEndpoints={
handleSaveNotificationPrivateEndpoints handleSaveNotificationPrivateEndpoints
} }
+79
View File
@@ -33,6 +33,13 @@ type GeneralSettingsSectionProps = {
onSaveAiPrivateEndpoints: (hosts: string[]) => void; onSaveAiPrivateEndpoints: (hosts: string[]) => void;
notificationPrivateEndpoints: string[]; notificationPrivateEndpoints: string[];
onSaveNotificationPrivateEndpoints: (hosts: string[]) => void; onSaveNotificationPrivateEndpoints: (hosts: string[]) => void;
stepCaPrivateEndpoints: string[];
onSaveStepCaPrivateEndpoints: (hosts: string[]) => void;
stepCaSettings: { caUrl: string; fingerprint: string; provisioner: string };
setStepCaSettings: Dispatch<
SetStateAction<{ caUrl: string; fingerprint: string; provisioner: string }>
>;
handleSaveStepCaSettings: () => void;
handleToggleSessionSharingGloballyEnabled: () => void; handleToggleSessionSharingGloballyEnabled: () => void;
allowRegistration: boolean; allowRegistration: boolean;
handleToggleRegistration: () => void; handleToggleRegistration: () => void;
@@ -87,6 +94,11 @@ export function AdminGeneralSettingsSection({
onSaveAiPrivateEndpoints, onSaveAiPrivateEndpoints,
notificationPrivateEndpoints, notificationPrivateEndpoints,
onSaveNotificationPrivateEndpoints, onSaveNotificationPrivateEndpoints,
stepCaPrivateEndpoints,
onSaveStepCaPrivateEndpoints,
stepCaSettings,
setStepCaSettings,
handleSaveStepCaSettings,
handleToggleSessionSharingGloballyEnabled, handleToggleSessionSharingGloballyEnabled,
allowRegistration, allowRegistration,
handleToggleRegistration, handleToggleRegistration,
@@ -215,6 +227,73 @@ export function AdminGeneralSettingsSection({
} }
/> />
</div> </div>
<div className="flex flex-col gap-1.5 py-2">
<span className="text-xs font-medium">
{t("admin.stepCaPrivateEndpoints")}
</span>
<span className="text-[11px] leading-snug text-muted-foreground">
{t("admin.stepCaPrivateEndpointsDesc")}
</span>
<Input
className="rounded-none"
defaultValue={stepCaPrivateEndpoints.join(", ")}
placeholder="ca.internal, sso.internal"
onBlur={(event) =>
onSaveStepCaPrivateEndpoints(
event.target.value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean),
)
}
/>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3 mt-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("admin.stepCa")}
</span>
<span className="text-[11px] leading-snug text-muted-foreground">
{t("admin.stepCaDesc")}
</span>
<Input
className="rounded-none"
placeholder="https://ca.internal:9000"
value={stepCaSettings.caUrl}
onChange={(e) =>
setStepCaSettings((p) => ({ ...p, caUrl: e.target.value }))
}
/>
<Input
className="rounded-none font-mono text-xs"
placeholder={t("admin.stepCaFingerprint")}
value={stepCaSettings.fingerprint}
onChange={(e) =>
setStepCaSettings((p) => ({ ...p, fingerprint: e.target.value }))
}
/>
<div className="flex items-center gap-2">
<Input
className="rounded-none"
placeholder={t("admin.stepCaProvisioner")}
value={stepCaSettings.provisioner}
onChange={(e) =>
setStepCaSettings((p) => ({
...p,
provisioner: e.target.value,
}))
}
/>
<Button
variant="outline"
size="sm"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
onClick={handleSaveStepCaSettings}
>
{t("common.save")}
</Button>
</div>
</div>
<SettingRow <SettingRow
label={t("admin.allowRegistration")} label={t("admin.allowRegistration")}
description={t("admin.allowRegistrationDesc")} description={t("admin.allowRegistrationDesc")}
+21
View File
@@ -590,6 +590,7 @@ export function HostEditor({
"vault", "vault",
"none", "none",
"opkssh", "opkssh",
"stepca",
"tailscale", "tailscale",
"agent", "agent",
].map((m) => ( ].map((m) => (
@@ -647,6 +648,26 @@ export function HostEditor({
{t("hosts.oidcUsernameHint")} {t("hosts.oidcUsernameHint")}
</p> </p>
)} )}
{authMethod === "stepca" && (
<div className="flex flex-col gap-2 border-t border-border pt-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.stepcaLabel")}
</span>
<a
href="https://smallstep.com/docs/step-ca/provisioners/#oauthoidc-single-sign-on"
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</div>
<p className="text-[10px] text-muted-foreground">
{t("hosts.stepcaDesc")}
</p>
</div>
)}
{authMethod === "tailscale" && ( {authMethod === "tailscale" && (
<p className="text-[10px] text-muted-foreground/60"> <p className="text-[10px] text-muted-foreground/60">
{t("hosts.tailscaleUsernameHint")} {t("hosts.tailscaleUsernameHint")}
+1
View File
@@ -171,6 +171,7 @@ export function HostProxmoxTab({
{t("hosts.authTypeCredential")} {t("hosts.authTypeCredential")}
</option> </option>
<option value="opkssh">{t("hosts.authTypeOpkssh")}</option> <option value="opkssh">{t("hosts.authTypeOpkssh")}</option>
<option value="stepca">{t("hosts.authTypeStepca")}</option>
<option value="none">{t("hosts.authTypeNone")}</option> <option value="none">{t("hosts.authTypeNone")}</option>
</select> </select>
</SettingRow> </SettingRow>
+8 -1
View File
@@ -766,7 +766,14 @@ export function HostsPanel({
{t("hosts.filterAuthGroup")} {t("hosts.filterAuthGroup")}
</DropdownMenuLabel> </DropdownMenuLabel>
{( {(
["password", "key", "credential", "none", "opkssh"] as const [
"password",
"key",
"credential",
"none",
"opkssh",
"stepca",
] as const
).map((val) => ( ).map((val) => (
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
key={val} key={val}
+6 -1
View File
@@ -10,6 +10,8 @@ interface OPKSSHDialogProps {
stage: "chooser" | "waiting" | "authenticating" | "completed" | "error"; stage: "chooser" | "waiting" | "authenticating" | "completed" | "error";
error?: string; error?: string;
providers?: Array<{ alias: string; issuer: string }>; providers?: Array<{ alias: string; issuer: string }>;
/** Issuer name shown in the title; defaults to OPKSSH. */
label?: string;
onCancel: () => void; onCancel: () => void;
onOpenUrl: () => void; onOpenUrl: () => void;
onSelectProvider?: (alias: string) => void; onSelectProvider?: (alias: string) => void;
@@ -26,6 +28,7 @@ export function OPKSSHDialog({
onOpenUrl, onOpenUrl,
onSelectProvider, onSelectProvider,
backgroundColor, backgroundColor,
label,
}: OPKSSHDialogProps) { }: OPKSSHDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -42,7 +45,9 @@ export function OPKSSHDialog({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Shield className="size-4 text-accent-brand" /> <Shield className="size-4 text-accent-brand" />
<h3 className="text-xs font-bold uppercase tracking-widest"> <h3 className="text-xs font-bold uppercase tracking-widest">
{t("terminal.opksshAuthRequired")} {label
? t("terminal.certAuthRequired", { provider: label })
: t("terminal.opksshAuthRequired")}
</h3> </h3>
</div> </div>
{stage === "chooser" && ( {stage === "chooser" && (