diff --git a/README.md b/README.md
index a854608a..e218cc26 100644
--- a/README.md
+++ b/README.md
@@ -356,9 +356,12 @@ networks:
For multiple Termix backend instances, set the same `REDIS_URL` and optional
`TERMIX_REDIS_PREFIX` on every instance. Redis synchronizes collaboration room
-presence, control requests, controller state, and events; 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.
+presence, control requests, controller state, and events. It also routes Step CA
+OAuth callbacks back to the instance holding the user's terminal; the optional
+`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
diff --git a/docker/compose-dev.yml b/docker/compose-dev.yml
index 7ad7a69b..3c181e5a 100644
--- a/docker/compose-dev.yml
+++ b/docker/compose-dev.yml
@@ -18,6 +18,7 @@ services:
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
# REDIS_URL: "redis://redis:6379"
# TERMIX_REDIS_PREFIX: "termix:collab"
+ # TERMIX_STEP_CA_REDIS_PREFIX: "termix:step-ca"
depends_on:
- guacd-dev
networks:
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index 24ee6e95..dd671bcd 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -20,6 +20,7 @@ services:
# in-memory fallback.
# REDIS_URL: "redis://redis:6379"
# TERMIX_REDIS_PREFIX: "termix:collab"
+ # TERMIX_STEP_CA_REDIS_PREFIX: "termix:step-ca"
# Hardened deployments can require keys from environment variables or
# Docker secrets mounted through JWT_SECRET_FILE, DATABASE_KEY_FILE,
# ENCRYPTION_KEY_FILE and INTERNAL_AUTH_TOKEN_FILE.
diff --git a/docs/readme/README-CN.md b/docs/readme/README-CN.md
index e54d025a..6f7db684 100644
--- a/docs/readme/README-CN.md
+++ b/docs/readme/README-CN.md
@@ -351,9 +351,11 @@ networks:
```
部署多个 Termix 后端实例时,请为所有实例配置相同的 `REDIS_URL`,并可选配置
-`TERMIX_REDIS_PREFIX`。Redis 会同步协作房间的在线成员、控制请求、控制权和事件;
-实时 SSH 与远程桌面传输仍依附于创建连接的后端实例,因此负载均衡器需要保持
-WebSocket 会话亲和性。单实例部署无需 Redis。
+`TERMIX_REDIS_PREFIX`。Redis 会同步协作房间的在线成员、控制请求、控制权和事件,
+也会把 Step CA OAuth 回调路由回持有用户终端的实例;可通过
+`TERMIX_STEP_CA_REDIS_PREFIX` 隔离这些短期加密消息。实时 SSH 与远程桌面传输仍
+依附于创建连接的后端实例,因此负载均衡器需要保持 WebSocket 会话亲和性。
+单实例部署无需 Redis。
### 命令行工具
diff --git a/src/backend/ai/tools/catalog.ts b/src/backend/ai/tools/catalog.ts
index 7952ae88..5a13061d 100644
--- a/src/backend/ai/tools/catalog.ts
+++ b/src/backend/ai/tools/catalog.ts
@@ -49,6 +49,8 @@ export const FORBIDDEN_DOMAINS = [
"identity",
"certificate",
"opkssh",
+ "stepca",
+ "step_ca",
"acme",
"ssl",
"audit",
diff --git a/src/backend/database/routes/host-bulk-routes.ts b/src/backend/database/routes/host-bulk-routes.ts
index 8a96fe46..b00f6c19 100644
--- a/src/backend/database/routes/host-bulk-routes.ts
+++ b/src/backend/database/routes/host-bulk-routes.ts
@@ -600,13 +600,14 @@ export function registerHostBulkRoutes(
"credential",
"none",
"opkssh",
+ "stepca",
"tailscale",
"vault",
].includes(hostData.authType)
) {
results.failed++;
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;
}
diff --git a/src/backend/database/routes/host-step-ca-routes.ts b/src/backend/database/routes/host-step-ca-routes.ts
new file mode 100644
index 00000000..16e9cf8d
--- /dev/null
+++ b/src/backend/database/routes/host-step-ca-routes.ts
@@ -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 `
Termix
+
+${ok ? "Signed in" : "Sign-in failed"}
${escapeHtml(message)}
`;
+}
+
+/**
+ * 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));
+ });
+}
diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts
index 211834a0..5a211794 100644
--- a/src/backend/database/routes/host.ts
+++ b/src/backend/database/routes/host.ts
@@ -43,6 +43,7 @@ import {
} from "./host-normalizers.js";
import { validateParentHostId } from "./host-parent-validation.js";
import { registerHostOpksshRoutes } from "./host-opkssh-routes.js";
+import { registerHostStepCaRoutes } from "./host-step-ca-routes.js";
import { registerHostFolderRoutes } from "./host-folder-routes.js";
import { registerHostFileManagerBookmarkRoutes } from "./host-file-manager-bookmark-routes.js";
import { registerHostCommandHistoryRoutes } from "./host-command-history-routes.js";
@@ -2899,6 +2900,7 @@ router.delete(
);
registerHostOpksshRoutes(router);
+registerHostStepCaRoutes(router);
registerHostNetworkRoutes(router, {
authenticateJWT,
diff --git a/src/backend/database/routes/opkssh-html.ts b/src/backend/database/routes/opkssh-html.ts
index fa94ac3a..f865edfd 100644
--- a/src/backend/database/routes/opkssh-html.ts
+++ b/src/backend/database/routes/opkssh-html.ts
@@ -1,6 +1,6 @@
import { sshLogger } from "../../utils/logger.js";
-function escapeHtml(value: string): string {
+export function escapeHtml(value: string): string {
return value
.replace(/&/g, "&")
.replace(/ {
+ /**
+ * GET/PATCH a comma-list of private hosts an outbound feature may reach.
+ * Shared by notifications and Step CA; each keeps its own setting key.
+ */
+ const registerPrivateEndpointAllowlist = (
+ path: string,
+ settingKey: string,
+ auditAction: string,
+ label: string,
+ ) => {
+ router.get(path, authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
if (!(await getAdminActor(userId))) {
return res.status(403).json({ error: "Not authorized" });
}
- const raw = await createCurrentSettingsRepository().get(
- NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
- );
+ const raw = await createCurrentSettingsRepository().get(settingKey);
res.json({ hosts: parseNotificationAllowlist(raw) });
} 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" });
}
- },
- );
+ });
- router.patch(
- "/notification-private-endpoints",
- authenticateJWT,
- async (req, res) => {
+ router.patch(path, authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const actor = await getAdminActor(userId);
@@ -1123,7 +1126,6 @@ export function registerUserSettingsRoutes(
.status(400)
.json({ error: "At most 50 hosts are allowed" });
}
-
const cleaned: string[] = [];
for (const entry of hosts) {
if (typeof entry !== "string") {
@@ -1142,14 +1144,14 @@ export function registerUserSettingsRoutes(
}
await createCurrentSettingsRepository().set(
- NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
+ settingKey,
JSON.stringify(cleaned),
);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor.username ?? userId,
- action: "update_notification_private_endpoints",
+ action: auditAction,
resourceType: "setting",
details: JSON.stringify({ hosts: cleaned }),
ipAddress,
@@ -1158,15 +1160,156 @@ export function registerUserSettingsRoutes(
});
res.json({ hosts: cleaned });
} catch (err) {
- authLogger.error(
- "Failed to update notification endpoint allowlist",
- err,
- );
+ authLogger.error(`Failed to update ${label} allowlist`, err);
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
* /users/host-defaults:
diff --git a/src/backend/hosts/docker/routes.ts b/src/backend/hosts/docker/routes.ts
index 78149f84..3c36ed6c 100644
--- a/src/backend/hosts/docker/routes.ts
+++ b/src/backend/hosts/docker/routes.ts
@@ -1,4 +1,5 @@
import { getErrorMessage } from "../../utils/error-message.js";
+import { usesIssuedCertificate } from "../issued-certificate-auth.js";
import express from "express";
import axios from "axios";
import { Client as SSHClient } from "ssh2";
@@ -300,7 +301,7 @@ export function registerDockerSshRoutes(app: express.Express): void {
if (resolvedCredentials.password) {
config.password = resolvedCredentials.password;
}
- } else if (resolvedCredentials.authType === "opkssh") {
+ } else if (usesIssuedCertificate(resolvedCredentials.authType)) {
try {
const { getOPKSSHToken } = await import("../opkssh-auth.js");
const token = await getOPKSSHToken(userId, hostId);
diff --git a/src/backend/hosts/file-manager/index.ts b/src/backend/hosts/file-manager/index.ts
index f38c011f..dcce4418 100644
--- a/src/backend/hosts/file-manager/index.ts
+++ b/src/backend/hosts/file-manager/index.ts
@@ -1,4 +1,5 @@
import { getErrorMessage } from "../../utils/error-message.js";
+import { usesIssuedCertificate } from "../issued-certificate-auth.js";
import express from "express";
import {
logAudit,
@@ -289,7 +290,7 @@ async function buildDedicatedTransferConnectConfig(
throw new Error("Password required for transfer connection");
}
config.password = host.password;
- } else if (authType === "opkssh") {
+ } else if (usesIssuedCertificate(authType)) {
const { getOPKSSHToken } = await import("../opkssh-auth.js");
const token = await getOPKSSHToken(userId, host.id);
if (!token) {
@@ -1120,7 +1121,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
connectionLogs.push(
createConnectionLog("info", "sftp_auth", "Using password authentication"),
);
- } else if (resolvedCredentials.authType === "opkssh") {
+ } else if (usesIssuedCertificate(resolvedCredentials.authType)) {
try {
const { getOPKSSHToken } = await import("../opkssh-auth.js");
const token = await getOPKSSHToken(userId, hostId);
diff --git a/src/backend/hosts/issued-certificate-auth.ts b/src/backend/hosts/issued-certificate-auth.ts
new file mode 100644
index 00000000..b5dccdc5
--- /dev/null
+++ b/src/backend/hosts/issued-certificate-auth.ts
@@ -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 ?? "",
+ );
+}
diff --git a/src/backend/hosts/metrics/helpers.ts b/src/backend/hosts/metrics/helpers.ts
index bd9eb4cb..d10b6282 100644
--- a/src/backend/hosts/metrics/helpers.ts
+++ b/src/backend/hosts/metrics/helpers.ts
@@ -1,4 +1,5 @@
import type { Client } from "ssh2";
+import { usesIssuedCertificate } from "../issued-certificate-auth.js";
export type StatsCapableHost = {
connectionType?: string;
@@ -13,7 +14,8 @@ export type TcpPingStatsConfig = {
export function supportsMetrics(host: StatsCapableHost): boolean {
const connectionType = host.connectionType || "ssh";
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;
}
diff --git a/src/backend/hosts/metrics/index.ts b/src/backend/hosts/metrics/index.ts
index be431cf1..9368c9ca 100644
--- a/src/backend/hosts/metrics/index.ts
+++ b/src/backend/hosts/metrics/index.ts
@@ -1,4 +1,5 @@
import { getErrorMessage } from "../../utils/error-message.js";
+import { usesIssuedCertificate } from "../issued-certificate-auth.js";
import express from "express";
import net from "net";
import { createCorsMiddleware } from "../../utils/cors-config.js";
@@ -1396,7 +1397,7 @@ async function buildSshConfig(
host.authType === "warpgate"
) {
// no credentials needed
- } else if (host.authType === "opkssh") {
+ } else if (usesIssuedCertificate(host.authType)) {
// cert auth setup happens in createSshFactory (needs client instance)
} else if (host.authType === "vault") {
// cert auth setup happens in createSshFactory (needs client instance)
@@ -1440,7 +1441,7 @@ function createSshFactory(host: SSHHostWithCredentials): () => Promise {
const client = new Client();
// 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 token = await getOPKSSHToken(host.userId, host.id);
if (!token) {
@@ -2359,7 +2360,7 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
const config = await buildSshConfig(host);
const client = new Client();
- if (host.authType === "opkssh" && host.userId) {
+ if (usesIssuedCertificate(host.authType) && host.userId) {
const { getOPKSSHToken } = await import("../opkssh-auth.js");
const token = await getOPKSSHToken(host.userId, host.id);
if (!token) {
diff --git a/src/backend/hosts/step-ca-auth.ts b/src/backend/hosts/step-ca-auth.ts
new file mode 100644
index 00000000..b3ce0bc8
--- /dev/null
+++ b/src/backend/hosts/step-ca-auth.ts
@@ -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 {
+ 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();
+
+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 {
+ 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 {
+ 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 {
+ 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 };
+ }
+}
diff --git a/src/backend/hosts/step-ca-runtime.ts b/src/backend/hosts/step-ca-runtime.ts
new file mode 100644
index 00000000..a5edf57f
--- /dev/null
+++ b/src/backend/hosts/step-ca-runtime.ts
@@ -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 | null = null;
+ private connecting: Promise | null = null;
+ private nextConnectAttempt = 0;
+
+ async register(state: string): Promise {
+ 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 {
+ 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 {
+ 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(
+ await decryptSystemSecret(encrypted.toString()),
+ );
+ } catch (error) {
+ this.logFailure("take_command", error);
+ return null;
+ }
+ }
+
+ async complete(state: string, result: StepCaCallbackResult): Promise {
+ 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 {
+ 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(
+ await decryptSystemSecret(encrypted.toString()),
+ );
+ } catch (error) {
+ this.logFailure("take_result", error);
+ return null;
+ }
+ }
+
+ async remove(state: string): Promise {
+ 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 {
+ if (this.client?.isOpen) await this.client.quit();
+ this.client = null;
+ }
+
+ private async ensureConnected(): Promise {
+ 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 {
+ 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(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();
diff --git a/src/backend/hosts/terminal/index.ts b/src/backend/hosts/terminal/index.ts
index 94a8a152..1292f6a4 100644
--- a/src/backend/hosts/terminal/index.ts
+++ b/src/backend/hosts/terminal/index.ts
@@ -1,5 +1,6 @@
import { getErrorMessage } from "../../utils/error-message.js";
import { getAuditUsername } from "../../utils/audit-logger.js";
+import { usesIssuedCertificate } from "../issued-certificate-auth.js";
import { collabRoomHub } from "../collab/room-hub.js";
import type { SessionShareRecord } from "../../database/repositories/session-share-repository.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 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(
userId,
opksshData.hostId,
@@ -1113,6 +1125,12 @@ wss.on("connection", async (ws: WebSocket, req) => {
}
case "opkssh_cancel": {
+ {
+ const { cancelStepCaAuth } = await import("../step-ca-auth.js");
+ cancelStepCaAuth(
+ String((data as { requestId?: string })?.requestId ?? ""),
+ );
+ }
const cancelData = data as { requestId: string };
try {
const { cancelAuthSession } = await import("../opkssh-auth.js");
@@ -2457,7 +2475,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
});
if (
- resolvedCredentials.authType === "opkssh" &&
+ usesIssuedCertificate(resolvedCredentials.authType) &&
err.message.includes("All configured authentication methods failed")
) {
sshLogger.warn("OPKSSH authentication failed - invalidating token", {
@@ -3064,8 +3082,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
}),
);
return;
- } else if (resolvedCredentials.authType === "opkssh") {
- sendLog("auth", "info", "Using OPKSSH certificate authentication");
+ } else if (usesIssuedCertificate(resolvedCredentials.authType)) {
+ sendLog("auth", "info", "Using issued SSH certificate authentication");
try {
const { getOPKSSHToken } = await import("../opkssh-auth.js");
const token = await getOPKSSHToken(userId, id);
@@ -3074,7 +3092,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
sendLog(
"auth",
"info",
- "No valid OPKSSH token found, requesting authentication",
+ "No valid certificate found, requesting sign-in",
);
ws.send(
JSON.stringify({
@@ -3085,7 +3103,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
return;
}
- sendLog("auth", "info", "Using cached OPKSSH certificate");
+ sendLog("auth", "info", "Using cached SSH certificate");
const { setupOPKSSHCertAuth } = await import("../opkssh-cert-auth.js");
await setupOPKSSHCertAuth(connectConfig, sshConn, token, username);
diff --git a/src/backend/tests/hosts/step-ca-auth.test.ts b/src/backend/tests/hosts/step-ca-auth.test.ts
new file mode 100644
index 00000000..d3ffc4fe
--- /dev/null
+++ b/src/backend/tests/hosts/step-ca-auth.test.ts
@@ -0,0 +1,188 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { WebSocket } from "ws";
+
+const state = vi.hoisted(() => ({
+ sent: [] as Array>,
+ 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 | 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) => {
+ 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);
+ });
+});
diff --git a/src/backend/tests/utils/step-ca-client.test.ts b/src/backend/tests/utils/step-ca-client.test.ts
new file mode 100644
index 00000000..ff4147de
--- /dev/null
+++ b/src/backend/tests/utils/step-ca-client.test.ts
@@ -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({});
+ });
+});
diff --git a/src/backend/utils/safe-outbound-fetch.ts b/src/backend/utils/safe-outbound-fetch.ts
index 0ebf4262..23d7cc38 100644
--- a/src/backend/utils/safe-outbound-fetch.ts
+++ b/src/backend/utils/safe-outbound-fetch.ts
@@ -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(
rawUrl: string,
options: RequestInit,
allowedPrivateHosts: readonly string[] = [],
+ tls: OutboundTlsOptions = {},
): Promise {
const url = new URL(rawUrl);
if (
@@ -172,6 +183,10 @@ export async function safeOutboundFetch(
const dispatcher = new Agent({
connect: {
lookup: createDnsLookupHook(lookup, allowPrivate),
+ ...(tls.ca ? { ca: tls.ca } : {}),
+ ...(tls.rejectUnauthorized === false
+ ? { rejectUnauthorized: false }
+ : {}),
},
});
diff --git a/src/backend/utils/step-ca-client.ts b/src/backend/utils/step-ca-client.ts
new file mode 100644
index 00000000..ce54d720
--- /dev/null
+++ b/src/backend/utils/step-ca-client.ts
@@ -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 {
+ 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(response: Response, what: string): Promise {
+ 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 {
+ 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 {
+ 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>;
+ 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 {
+ 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 {
+ 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 {
+ 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),
+ };
+}
diff --git a/src/backend/utils/step-ca-egress.ts b/src/backend/utils/step-ca-egress.ts
new file mode 100644
index 00000000..69e0bad5
--- /dev/null
+++ b/src/backend/utils/step-ca-egress.ts
@@ -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 {
+ const raw = await createCurrentSettingsRepository().get(
+ STEP_CA_PRIVATE_ALLOWLIST_KEY,
+ );
+ return parseNotificationAllowlist(raw);
+}
diff --git a/src/types/host-sidebar-preferences.ts b/src/types/host-sidebar-preferences.ts
index b9601e75..3013559b 100644
--- a/src/types/host-sidebar-preferences.ts
+++ b/src/types/host-sidebar-preferences.ts
@@ -34,7 +34,9 @@ export type HostTrayTrigger = "always" | "hover" | "click" | "actionsOnly";
export interface HostSidebarFilterState {
status: ("online" | "offline" | "pinned")[];
- authType: ("password" | "key" | "credential" | "none" | "opkssh")[];
+ authType: (
+ "password" | "key" | "credential" | "none" | "opkssh" | "stepca"
+ )[];
protocol: ("ssh" | "rdp" | "vnc" | "telnet")[];
features: ("terminal" | "fileManager" | "tunnel" | "docker")[];
tags: string[];
@@ -94,6 +96,7 @@ const FILTER_AUTH_TYPE: HostSidebarFilterState["authType"] = [
"credential",
"none",
"opkssh",
+ "stepca",
];
const FILTER_PROTOCOL: HostSidebarFilterState["protocol"] = [
"ssh",
diff --git a/src/types/index.ts b/src/types/index.ts
index 0ffa2b68..e40b5484 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -68,7 +68,13 @@ export interface LDAPProviderConfig {
export type ConnectionType = "ssh" | "rdp" | "vnc" | "telnet";
export type SSHAuthType =
- "password" | "key" | "credential" | "none" | "opkssh" | "tailscale";
+ | "password"
+ | "key"
+ | "credential"
+ | "none"
+ | "opkssh"
+ | "stepca"
+ | "tailscale";
export type GuacamoleAuthType = "password" | "credential";
@@ -133,6 +139,7 @@ export type Host = {
| "credential"
| "none"
| "opkssh"
+ | "stepca"
| "tailscale"
| "agent"
| "vault";
@@ -298,6 +305,7 @@ export interface HostData {
| "credential"
| "none"
| "opkssh"
+ | "stepca"
| "tailscale"
| "agent"
| "vault";
@@ -832,7 +840,13 @@ export type ErrorType =
// ============================================================================
export type AuthType =
- "password" | "key" | "credential" | "none" | "opkssh" | "tailscale";
+ | "password"
+ | "key"
+ | "credential"
+ | "none"
+ | "opkssh"
+ | "stepca"
+ | "tailscale";
export type KeyType = "rsa" | "ecdsa" | "ed25519";
diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts
index f5e86ae2..ae568324 100644
--- a/src/types/ui-types.ts
+++ b/src/types/ui-types.ts
@@ -31,6 +31,7 @@ export type Host = {
| "credential"
| "none"
| "opkssh"
+ | "stepca"
| "tailscale"
| "vault"
| "agent";
diff --git a/src/ui/api/ai-api.ts b/src/ui/api/ai-api.ts
index d28609fd..61629cbe 100644
--- a/src/ui/api/ai-api.ts
+++ b/src/ui/api/ai-api.ts
@@ -226,6 +226,25 @@ export async function getNotificationPrivateEndpoints(): Promise {
}
}
+export async function getStepCaPrivateEndpoints(): Promise {
+ 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 {
+ 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(
hosts: string[],
): Promise {
diff --git a/src/ui/api/settings-api.ts b/src/ui/api/settings-api.ts
index a11824c2..8c9659d8 100644
--- a/src/ui/api/settings-api.ts
+++ b/src/ui/api/settings-api.ts
@@ -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 {
+ 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 {
+ try {
+ await authApi.patch("/users/step-ca-settings", input);
+ } catch (error) {
+ handleApiError(error, "update Step CA settings");
+ }
+}
+
export async function updateSessionTimeout(
timeoutHours: number,
): Promise {
diff --git a/src/ui/components/proxmox/proxmox-import-auth.ts b/src/ui/components/proxmox/proxmox-import-auth.ts
index 4aaed4ec..77293ca1 100644
--- a/src/ui/components/proxmox/proxmox-import-auth.ts
+++ b/src/ui/components/proxmox/proxmox-import-auth.ts
@@ -3,6 +3,7 @@ const SECRETLESS_AUTH_TYPES = new Set([
"none",
"agent",
"opkssh",
+ "stepca",
"tailscale",
"vault",
]);
diff --git a/src/ui/dashboard/DashboardTab.tsx b/src/ui/dashboard/DashboardTab.tsx
index 047de057..f321fd0a 100644
--- a/src/ui/dashboard/DashboardTab.tsx
+++ b/src/ui/dashboard/DashboardTab.tsx
@@ -1357,7 +1357,12 @@ export function DashboardTab({
const hostId = Number(host.id);
const knownStatus = statuses?.[hostId]?.status;
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 {
const existing = newSessions.get(hostId);
diff --git a/src/ui/features/terminal/Terminal.tsx b/src/ui/features/terminal/Terminal.tsx
index 6a62f2e7..90e0e08f 100644
--- a/src/ui/features/terminal/Terminal.tsx
+++ b/src/ui/features/terminal/Terminal.tsx
@@ -270,6 +270,8 @@ const TerminalInner = forwardRef(
stage: "chooser" | "waiting" | "authenticating" | "completed" | "error";
error?: string;
providers?: Array<{ alias: string; issuer: string }>;
+ /** Which issuer is asking (OPKSSH by default, "Step CA", ...). */
+ label?: string;
} | null>(null);
const opksshTimeoutRef = useRef(null);
@@ -1823,6 +1825,7 @@ const TerminalInner = forwardRef(
requestId: msg.requestId || "",
stage: "chooser",
providers: msg.providers,
+ label: typeof msg.label === "string" ? msg.label : undefined,
});
if (opksshTimeoutRef.current) {
clearTimeout(opksshTimeoutRef.current);
@@ -3598,6 +3601,7 @@ const TerminalInner = forwardRef(
stage={opksshDialog.stage}
error={opksshDialog.error}
providers={opksshDialog.providers}
+ label={opksshDialog.label}
onCancel={() => {
if (webSocketRef.current) {
webSocketRef.current.send(
diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json
index 2a2f7bcc..17392441 100644
--- a/src/ui/locales/en.json
+++ b/src/ui/locales/en.json
@@ -920,6 +920,9 @@
"keyPassphraseSaved": "Passphrase saved, type to change",
"replaceKey": "Replace key",
"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",
"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",
@@ -1503,6 +1506,7 @@
"filterAuthCredential": "Credential",
"filterAuthNone": "None",
"filterAuthOpkssh": "OPKSSH",
+ "filterAuthStepca": "Step CA",
"filterProtocolGroup": "Protocol",
"filterProtocolSsh": "SSH",
"filterProtocolRdp": "RDP",
@@ -2068,6 +2072,7 @@
"warpgateAuthUrl": "Authentication URL",
"warpgateOpenBrowser": "Open in Browser",
"warpgateContinue": "I've Completed Authentication",
+ "certAuthRequired": "{{provider}} Sign-in Required",
"opksshAuthRequired": "OPKSSH Authentication Required",
"opksshAuthDescription": "Complete authentication in your browser to continue. This session will remain valid for 24 hours.",
"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.",
"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.",
+ "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",
"notificationPrivateEndpointsDesc": "Exact private hosts that notification channels may contact. Separate them with commas.",
"updateNotificationEndpointsFailed": "Failed to update notification endpoint allowlist",
diff --git a/src/ui/sidebar/AdminSettingsPanel.tsx b/src/ui/sidebar/AdminSettingsPanel.tsx
index 9ece3214..2ae81e28 100644
--- a/src/ui/sidebar/AdminSettingsPanel.tsx
+++ b/src/ui/sidebar/AdminSettingsPanel.tsx
@@ -5,6 +5,8 @@ import {
getAiGloballyEnabled,
getAiPrivateEndpoints,
getNotificationPrivateEndpoints,
+ getStepCaPrivateEndpoints,
+ setStepCaPrivateEndpoints as setStepCaPrivateEndpointsApi,
setAiGloballyEnabled as setAiGloballyEnabledApi,
setAiPrivateEndpoints as setAiPrivateEndpointsApi,
setNotificationPrivateEndpoints as setNotificationPrivateEndpointsApi,
@@ -97,6 +99,8 @@ import { toast } from "sonner";
import {
getTerminalSessionSettings,
updateTerminalSessionSettings,
+ getStepCaSettings,
+ updateStepCaSettings,
} from "@/api/settings-api";
import { getDatabaseTransferUrl } from "@/lib/database-transfer-url";
import {
@@ -181,6 +185,25 @@ export function AdminSettingsPanel({
useState(true);
const [aiGloballyEnabled, setAiGloballyEnabled] = useState(false);
const [aiPrivateEndpoints, setAiPrivateEndpoints] = useState([]);
+ 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] =
useState([]);
const [hostDefaults, setHostDefaults] = useState({});
@@ -380,6 +403,7 @@ export function AdminSettingsPanel({
aiEnabled,
aiEndpoints,
notificationEndpoints,
+ stepCaEndpoints,
imageStorage,
] = await Promise.allSettled([
getRegistrationAllowed(),
@@ -399,6 +423,7 @@ export function AdminSettingsPanel({
getAiGloballyEnabled(),
getAiPrivateEndpoints(),
getNotificationPrivateEndpoints(),
+ getStepCaPrivateEndpoints(),
getTerminalImageStorageSettings(),
]);
@@ -451,6 +476,9 @@ export function AdminSettingsPanel({
if (aiEndpoints.status === "fulfilled") {
setAiPrivateEndpoints(aiEndpoints.value);
}
+ if (stepCaEndpoints.status === "fulfilled") {
+ setStepCaPrivateEndpoints(stepCaEndpoints.value);
+ }
if (notificationEndpoints.status === "fulfilled") {
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[]) {
const previous = notificationPrivateEndpoints;
setNotificationPrivateEndpoints(hosts);
@@ -1166,6 +1216,11 @@ export function AdminSettingsPanel({
aiPrivateEndpoints={aiPrivateEndpoints}
onSaveAiPrivateEndpoints={handleSaveAiPrivateEndpoints}
notificationPrivateEndpoints={notificationPrivateEndpoints}
+ stepCaPrivateEndpoints={stepCaPrivateEndpoints}
+ onSaveStepCaPrivateEndpoints={handleSaveStepCaPrivateEndpoints}
+ stepCaSettings={stepCaSettings}
+ setStepCaSettings={setStepCaSettings}
+ handleSaveStepCaSettings={handleSaveStepCaSettings}
onSaveNotificationPrivateEndpoints={
handleSaveNotificationPrivateEndpoints
}
diff --git a/src/ui/sidebar/AdminSettingsSections.tsx b/src/ui/sidebar/AdminSettingsSections.tsx
index 38dab945..5de229cb 100644
--- a/src/ui/sidebar/AdminSettingsSections.tsx
+++ b/src/ui/sidebar/AdminSettingsSections.tsx
@@ -33,6 +33,13 @@ type GeneralSettingsSectionProps = {
onSaveAiPrivateEndpoints: (hosts: string[]) => void;
notificationPrivateEndpoints: string[];
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;
allowRegistration: boolean;
handleToggleRegistration: () => void;
@@ -87,6 +94,11 @@ export function AdminGeneralSettingsSection({
onSaveAiPrivateEndpoints,
notificationPrivateEndpoints,
onSaveNotificationPrivateEndpoints,
+ stepCaPrivateEndpoints,
+ onSaveStepCaPrivateEndpoints,
+ stepCaSettings,
+ setStepCaSettings,
+ handleSaveStepCaSettings,
handleToggleSessionSharingGloballyEnabled,
allowRegistration,
handleToggleRegistration,
@@ -215,6 +227,73 @@ export function AdminGeneralSettingsSection({
}
/>
+
+
+ {t("admin.stepCaPrivateEndpoints")}
+
+
+ {t("admin.stepCaPrivateEndpointsDesc")}
+
+
+ onSaveStepCaPrivateEndpoints(
+ event.target.value
+ .split(",")
+ .map((entry) => entry.trim())
+ .filter(Boolean),
+ )
+ }
+ />
+
+
+
(
@@ -647,6 +648,26 @@ export function HostEditor({
{t("hosts.oidcUsernameHint")}
)}
+ {authMethod === "stepca" && (
+
+
+
+ {t("hosts.stepcaDesc")}
+
+
+ )}
{authMethod === "tailscale" && (
{t("hosts.tailscaleUsernameHint")}
diff --git a/src/ui/sidebar/HostEditorFeatureTabs.tsx b/src/ui/sidebar/HostEditorFeatureTabs.tsx
index 98bd27e8..094a2a37 100644
--- a/src/ui/sidebar/HostEditorFeatureTabs.tsx
+++ b/src/ui/sidebar/HostEditorFeatureTabs.tsx
@@ -171,6 +171,7 @@ export function HostProxmoxTab({
{t("hosts.authTypeCredential")}
+
diff --git a/src/ui/sidebar/HostsPanel.tsx b/src/ui/sidebar/HostsPanel.tsx
index 2aaa5957..4ee1c175 100644
--- a/src/ui/sidebar/HostsPanel.tsx
+++ b/src/ui/sidebar/HostsPanel.tsx
@@ -766,7 +766,14 @@ export function HostsPanel({
{t("hosts.filterAuthGroup")}
{(
- ["password", "key", "credential", "none", "opkssh"] as const
+ [
+ "password",
+ "key",
+ "credential",
+ "none",
+ "opkssh",
+ "stepca",
+ ] as const
).map((val) => (
;
+ /** Issuer name shown in the title; defaults to OPKSSH. */
+ label?: string;
onCancel: () => void;
onOpenUrl: () => void;
onSelectProvider?: (alias: string) => void;
@@ -26,6 +28,7 @@ export function OPKSSHDialog({
onOpenUrl,
onSelectProvider,
backgroundColor,
+ label,
}: OPKSSHDialogProps) {
const { t } = useTranslation();
@@ -42,7 +45,9 @@ export function OPKSSHDialog({
- {t("terminal.opksshAuthRequired")}
+ {label
+ ? t("terminal.certAuthRequired", { provider: label })
+ : t("terminal.opksshAuthRequired")}
{stage === "chooser" && (