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

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

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

* fix: harden Step CA callback flow

* style: format Step CA changes
This commit is contained in:
ZacharyZcR
2026-08-25 02:56:44 +08:00
committed by GitHub
parent 0ab7cf2ab8
commit 32d77fc6d0
38 changed files with 1791 additions and 48 deletions
+19
View File
@@ -226,6 +226,25 @@ export async function getNotificationPrivateEndpoints(): Promise<string[]> {
}
}
export async function getStepCaPrivateEndpoints(): Promise<string[]> {
try {
return (await authApi.get("/users/step-ca-private-endpoints")).data.hosts;
} catch (error) {
throw handleApiError(error, "get Step CA endpoint allowlist");
}
}
export async function setStepCaPrivateEndpoints(
hosts: string[],
): Promise<string[]> {
try {
return (await authApi.patch("/users/step-ca-private-endpoints", { hosts }))
.data.hosts;
} catch (error) {
throw handleApiError(error, "update Step CA endpoint allowlist");
}
}
export async function setNotificationPrivateEndpoints(
hosts: string[],
): Promise<string[]> {
+33
View File
@@ -85,6 +85,39 @@ export async function updateTerminalSessionSettings(input: {
}
}
export interface StepCaSettings {
configured: boolean;
caUrl: string;
fingerprint: string;
provisioner: string;
}
export async function getStepCaSettings(): Promise<StepCaSettings> {
try {
const response = await authApi.get("/users/step-ca-settings");
return {
configured: !!response.data.configured,
caUrl: response.data.caUrl ?? "",
fingerprint: response.data.fingerprint ?? "",
provisioner: response.data.provisioner ?? "",
};
} catch (error) {
handleApiError(error, "fetch Step CA settings");
}
}
export async function updateStepCaSettings(input: {
caUrl: string;
fingerprint: string;
provisioner: string;
}): Promise<void> {
try {
await authApi.patch("/users/step-ca-settings", input);
} catch (error) {
handleApiError(error, "update Step CA settings");
}
}
export async function updateSessionTimeout(
timeoutHours: number,
): Promise<void> {
@@ -3,6 +3,7 @@ const SECRETLESS_AUTH_TYPES = new Set([
"none",
"agent",
"opkssh",
"stepca",
"tailscale",
"vault",
]);
+6 -1
View File
@@ -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);
+4
View File
@@ -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(
+14
View File
@@ -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",
+55
View File
@@ -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
}
+79
View File
@@ -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")}
+21
View File
@@ -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")}
+1
View File
@@ -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>
+8 -1
View File
@@ -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}
+6 -1
View File
@@ -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" && (