mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
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:
@@ -49,6 +49,8 @@ export const FORBIDDEN_DOMAINS = [
|
||||
"identity",
|
||||
"certificate",
|
||||
"opkssh",
|
||||
"stepca",
|
||||
"step_ca",
|
||||
"acme",
|
||||
"ssl",
|
||||
"audit",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(/</g, "<")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type { RequestHandler, Router } from "express";
|
||||
import { restartGuacServer } from "../../hosts/guacamole/guacamole-server.js";
|
||||
import {
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
} from "../../utils/audit-forwarder.js";
|
||||
import { getTelemetryEnvOverride } from "../../utils/analytics.js";
|
||||
import { AI_PRIVATE_ALLOWLIST_KEY, parseAllowlist } from "../../ai/egress.js";
|
||||
import { STEP_CA_PRIVATE_ALLOWLIST_KEY } from "../../utils/step-ca-egress.js";
|
||||
import {
|
||||
NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
|
||||
parseNotificationAllowlist,
|
||||
@@ -1083,30 +1085,31 @@ export function registerUserSettingsRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
router.get(
|
||||
"/notification-private-endpoints",
|
||||
authenticateJWT,
|
||||
async (req, res) => {
|
||||
/**
|
||||
* 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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ?? "",
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Client> {
|
||||
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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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<Response> {
|
||||
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 }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
+16
-2
@@ -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";
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ export type Host = {
|
||||
| "credential"
|
||||
| "none"
|
||||
| "opkssh"
|
||||
| "stepca"
|
||||
| "tailscale"
|
||||
| "vault"
|
||||
| "agent";
|
||||
|
||||
@@ -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(
|
||||
hosts: string[],
|
||||
): Promise<string[]> {
|
||||
|
||||
@@ -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(
|
||||
timeoutHours: number,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -3,6 +3,7 @@ const SECRETLESS_AUTH_TYPES = new Set([
|
||||
"none",
|
||||
"agent",
|
||||
"opkssh",
|
||||
"stepca",
|
||||
"tailscale",
|
||||
"vault",
|
||||
]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -270,6 +270,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
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<NodeJS.Timeout | null>(null);
|
||||
|
||||
@@ -1823,6 +1825,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
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<TerminalHandle, SSHTerminalProps>(
|
||||
stage={opksshDialog.stage}
|
||||
error={opksshDialog.error}
|
||||
providers={opksshDialog.providers}
|
||||
label={opksshDialog.label}
|
||||
onCancel={() => {
|
||||
if (webSocketRef.current) {
|
||||
webSocketRef.current.send(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<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] =
|
||||
useState<string[]>([]);
|
||||
const [hostDefaults, setHostDefaults] = useState<HostDefaults>({});
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
}
|
||||
/>
|
||||
</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
|
||||
label={t("admin.allowRegistration")}
|
||||
description={t("admin.allowRegistrationDesc")}
|
||||
|
||||
@@ -590,6 +590,7 @@ export function HostEditor({
|
||||
"vault",
|
||||
"none",
|
||||
"opkssh",
|
||||
"stepca",
|
||||
"tailscale",
|
||||
"agent",
|
||||
].map((m) => (
|
||||
@@ -647,6 +648,26 @@ export function HostEditor({
|
||||
{t("hosts.oidcUsernameHint")}
|
||||
</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" && (
|
||||
<p className="text-[10px] text-muted-foreground/60">
|
||||
{t("hosts.tailscaleUsernameHint")}
|
||||
|
||||
@@ -171,6 +171,7 @@ export function HostProxmoxTab({
|
||||
{t("hosts.authTypeCredential")}
|
||||
</option>
|
||||
<option value="opkssh">{t("hosts.authTypeOpkssh")}</option>
|
||||
<option value="stepca">{t("hosts.authTypeStepca")}</option>
|
||||
<option value="none">{t("hosts.authTypeNone")}</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
|
||||
@@ -766,7 +766,14 @@ export function HostsPanel({
|
||||
{t("hosts.filterAuthGroup")}
|
||||
</DropdownMenuLabel>
|
||||
{(
|
||||
["password", "key", "credential", "none", "opkssh"] as const
|
||||
[
|
||||
"password",
|
||||
"key",
|
||||
"credential",
|
||||
"none",
|
||||
"opkssh",
|
||||
"stepca",
|
||||
] as const
|
||||
).map((val) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={val}
|
||||
|
||||
@@ -10,6 +10,8 @@ interface OPKSSHDialogProps {
|
||||
stage: "chooser" | "waiting" | "authenticating" | "completed" | "error";
|
||||
error?: string;
|
||||
providers?: Array<{ alias: string; issuer: string }>;
|
||||
/** 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({
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="size-4 text-accent-brand" />
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest">
|
||||
{t("terminal.opksshAuthRequired")}
|
||||
{label
|
||||
? t("terminal.certAuthRequired", { provider: label })
|
||||
: t("terminal.opksshAuthRequired")}
|
||||
</h3>
|
||||
</div>
|
||||
{stage === "chooser" && (
|
||||
|
||||
Reference in New Issue
Block a user