mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: move step-ca into a plugin (C4)
This commit is contained in:
1 parent
98d367d7e2
commit
b027717c46
128 files changed
+3805
-1781
No files matched your search
@@ -15,80 +15,11 @@ import type {
|
||||
SshConnectHost,
|
||||
} from "../hosts/connect/types.js";
|
||||
|
||||
const STEPCA_REQUIRED_MESSAGE =
|
||||
"Step CA authentication required. Please open a Terminal connection to this host first to complete browser-based authentication.";
|
||||
|
||||
const VAULT_REQUIRED_MESSAGE =
|
||||
"Vault SSH signer authentication required. Please open a Terminal connection first.";
|
||||
|
||||
const AUTH_FAILED_PATTERN = /All configured authentication methods failed/i;
|
||||
|
||||
const stepCaProvider: SshAuthProvider = {
|
||||
type: "stepca",
|
||||
pluginId: "core",
|
||||
labelKey: "hosts.filterAuthStepca",
|
||||
needsUserInteraction: true,
|
||||
supportsBackground: false,
|
||||
interaction: "stepca",
|
||||
prepare: async (config, host, env) => {
|
||||
const { getStepCaCert } = await import("../hosts/step-ca-auth.js");
|
||||
const cert = getStepCaCert(env.userId, env.hostId);
|
||||
if (!cert) {
|
||||
env.log("info", "No valid certificate found, requesting sign-in");
|
||||
return {
|
||||
status: "interaction-required",
|
||||
interaction: "stepca",
|
||||
message: STEPCA_REQUIRED_MESSAGE,
|
||||
};
|
||||
}
|
||||
try {
|
||||
await applyCertificateAuth(
|
||||
config,
|
||||
env.client,
|
||||
{ privateKey: cert.privateKey, certificate: cert.sshCert },
|
||||
host.username,
|
||||
);
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "error",
|
||||
code: "failed",
|
||||
message: "Step CA authentication failed: " + getErrorMessage(error),
|
||||
};
|
||||
}
|
||||
env.log("info", "Using cached SSH certificate");
|
||||
return { status: "ready" };
|
||||
},
|
||||
onAuthFailed: (_host, env, context) => {
|
||||
if (!AUTH_FAILED_PATTERN.test(context.error.message)) return;
|
||||
void import("../hosts/step-ca-auth.js")
|
||||
.then(({ invalidateStepCaCert }) =>
|
||||
invalidateStepCaCert(env.userId, env.hostId),
|
||||
)
|
||||
.catch(() => {});
|
||||
return {
|
||||
status: "interaction-required",
|
||||
interaction: "stepca",
|
||||
message:
|
||||
"Step CA authentication failed or expired. Please authenticate again.",
|
||||
};
|
||||
},
|
||||
startInteraction: async (request) => {
|
||||
const { startStepCaAuth } = await import("../hosts/step-ca-auth.js");
|
||||
await startStepCaAuth(
|
||||
request.userId,
|
||||
request.hostId,
|
||||
request.host.username,
|
||||
request.socket,
|
||||
request.requestOrigin,
|
||||
);
|
||||
},
|
||||
cancelInteraction: async (request) => {
|
||||
if (!request.requestId) return;
|
||||
const { cancelStepCaAuth } = await import("../hosts/step-ca-auth.js");
|
||||
cancelStepCaAuth(request.requestId);
|
||||
},
|
||||
};
|
||||
|
||||
function vaultProfileId(host: SshConnectHost): number | undefined {
|
||||
return host.vaultProfile?.id ?? undefined;
|
||||
}
|
||||
@@ -192,7 +123,6 @@ export function registerLegacySshAuthProviders(): void {
|
||||
if (sshRegistered) return;
|
||||
sshRegistered = true;
|
||||
|
||||
registerSshAuthProvider(stepCaProvider);
|
||||
registerSshAuthProvider(vaultProvider);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* The 2.8 URLs under /host that something outside Termix still depends on:
|
||||
* the OPKSSH redirect URI registered with identity providers. It forwards to
|
||||
* the plugin route that replaced it, so with the plugin off it signs nobody
|
||||
* in.
|
||||
* the OPKSSH and Step CA redirect URIs registered with identity providers.
|
||||
* Each forwards to the plugin route that replaced it, so with the plugin off
|
||||
* it signs nobody in.
|
||||
*/
|
||||
|
||||
import type { Request, Router } from "express";
|
||||
@@ -33,4 +33,20 @@ export function registerHostAuthCompatRoutes(router: Router): void {
|
||||
const rest = req.path.slice("/opkssh-callback".length);
|
||||
res.redirect(307, localUrl(req, `/plugin-api/opkssh/callback${rest}`));
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/step-ca-callback:
|
||||
* get:
|
||||
* summary: Step CA callback (2.8 URL)
|
||||
* description: The redirect URI Step CA identity providers were set up with before 2.9. Redirects to /plugin-api/step-ca/callback with the same query, so existing provider configurations keep working.
|
||||
* tags:
|
||||
* - Auth
|
||||
* responses:
|
||||
* 307:
|
||||
* description: Redirect to the step-ca plugin's callback.
|
||||
*/
|
||||
router.get("/step-ca-callback", (req, res) => {
|
||||
res.redirect(307, localUrl(req, "/plugin-api/step-ca/callback"));
|
||||
});
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { Request, Response, Router } from "express";
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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));
|
||||
});
|
||||
}
|
||||
@@ -46,7 +46,6 @@ import {
|
||||
} from "./host-plugin-settings.js";
|
||||
import { validateParentHostId } from "./host-parent-validation.js";
|
||||
import { registerHostAuthCompatRoutes } from "./host-compat-routes.js";
|
||||
import { registerHostStepCaRoutes } from "./host-step-ca-routes.js";
|
||||
import { registerHostFolderRoutes } from "./host-folder-routes.js";
|
||||
import { registerHostAutostartRoutes } from "./host-autostart-routes.js";
|
||||
import { registerHostInternalRoutes } from "./host-internal-routes.js";
|
||||
@@ -2661,7 +2660,6 @@ registerHostAutostartRoutes(router, {
|
||||
});
|
||||
|
||||
registerHostAuthCompatRoutes(router);
|
||||
registerHostStepCaRoutes(router);
|
||||
|
||||
registerHostNetworkRoutes(router, {
|
||||
authenticateJWT,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type { RequestHandler, Router } from "express";
|
||||
import {
|
||||
authLogger,
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
AUDIT_FORWARD_URL_SETTING,
|
||||
} from "../../utils/audit-forwarder.js";
|
||||
import { getTelemetryEnvOverride } from "../../utils/analytics.js";
|
||||
import { STEP_CA_PRIVATE_ALLOWLIST_KEY } from "../../utils/step-ca-egress.js";
|
||||
import {
|
||||
NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
|
||||
parseNotificationAllowlist,
|
||||
@@ -464,7 +462,7 @@ export function registerUserSettingsRoutes(
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* One route pair per private endpoint allowlist, each with its own setting key.
|
||||
*/
|
||||
const registerPrivateEndpointAllowlist = (
|
||||
path: string,
|
||||
@@ -562,131 +560,6 @@ export function registerUserSettingsRoutes(
|
||||
"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,376 +0,0 @@
|
||||
import { randomBytes } from "crypto";
|
||||
import type { WebSocket } from "ws";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
import { getErrorMessage } from "../utils/error-message.js";
|
||||
import { 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.
|
||||
*
|
||||
* The CA's HTTP API plus one OIDC redirect back to Termix. Issued
|
||||
* certificates are kept in memory until they expire.
|
||||
*/
|
||||
|
||||
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>();
|
||||
|
||||
interface IssuedCertificate {
|
||||
sshCert: string;
|
||||
privateKey: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const issued = new Map<string, IssuedCertificate>();
|
||||
|
||||
function certKey(userId: string, hostId: number): string {
|
||||
return `${userId}:${hostId}`;
|
||||
}
|
||||
|
||||
/** The cached certificate for this user and host, while it is valid. */
|
||||
export function getStepCaCert(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): { sshCert: string; privateKey: string } | null {
|
||||
const key = certKey(userId, hostId);
|
||||
const cert = issued.get(key);
|
||||
if (!cert) return null;
|
||||
if (cert.expiresAt <= Date.now()) {
|
||||
issued.delete(key);
|
||||
return null;
|
||||
}
|
||||
return { sshCert: cert.sshCert, privateKey: cert.privateKey };
|
||||
}
|
||||
|
||||
export function invalidateStepCaCert(userId: string, hostId: number): void {
|
||||
issued.delete(certKey(userId, hostId));
|
||||
}
|
||||
|
||||
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: "stepca_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: "stepca_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: "stepca_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: "stepca_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, caches the certificate 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: "stepca_error",
|
||||
requestId: session.state,
|
||||
error: `Step CA: ${message}`,
|
||||
});
|
||||
endSession(session, removeRuntime);
|
||||
return { ok: false, message };
|
||||
}
|
||||
|
||||
try {
|
||||
send(session.ws, {
|
||||
type: "stepca_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;
|
||||
|
||||
issued.set(certKey(session.userId, session.hostId), {
|
||||
sshCert: certificate,
|
||||
privateKey: session.keyPair.privateKeyPem,
|
||||
expiresAt: expiresAt.getTime(),
|
||||
});
|
||||
|
||||
session.completed = true;
|
||||
send(session.ws, {
|
||||
type: "stepca_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: "stepca_error",
|
||||
requestId: session.state,
|
||||
error: `Step CA: ${message}`,
|
||||
});
|
||||
endSession(session, removeRuntime);
|
||||
return { ok: false, message };
|
||||
}
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
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();
|
||||
@@ -147,6 +147,12 @@ export function createPluginFetch(deps: Deps): PluginFetch {
|
||||
signal: controller.signal,
|
||||
},
|
||||
(init.allowPrivateHosts ?? []).map((host) => host.toLowerCase()),
|
||||
{
|
||||
...(init.tls?.ca ? { ca: init.tls.ca } : {}),
|
||||
...(init.tls?.rejectUnauthorized === false
|
||||
? { rejectUnauthorized: false }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
|
||||
@@ -377,6 +377,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
await import("./utils/crypto-migration/warpgate-settings-migration.js");
|
||||
await runWarpgateSettingsMigration();
|
||||
|
||||
const { runStepCaSettingsMigration } =
|
||||
await import("./utils/crypto-migration/step-ca-settings-migration.js");
|
||||
await runStepCaSettingsMigration();
|
||||
|
||||
const { runSecretSourcesTokenMigration } =
|
||||
await import("./utils/crypto-migration/secret-sources-token-migration.js");
|
||||
await runSecretSourcesTokenMigration();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The OPKSSH redirect URI identity providers were registered with before
|
||||
* The OPKSSH and Step CA redirect URIs identity providers were registered with before
|
||||
* 2.9 must keep reaching the plugin's callback, query intact.
|
||||
*/
|
||||
|
||||
@@ -49,3 +49,16 @@ describe("the old OPKSSH callback", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the old Step CA callback", () => {
|
||||
it("forwards to the plugin's callback with the query", async () => {
|
||||
const response = await fetch(
|
||||
`${base}/host/step-ca-callback?code=abc&state=xyz`,
|
||||
{ redirect: "manual" },
|
||||
);
|
||||
expect(response.status).toBe(307);
|
||||
expect(response.headers.get("location")).toBe(
|
||||
"/plugin-api/step-ca/callback?code=abc&state=xyz",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -18,8 +18,6 @@ vi.mock("ssh2", async (importOriginal) => {
|
||||
const mocks = vi.hoisted(() => ({
|
||||
verifier: () => {},
|
||||
createHostVerifier: vi.fn(),
|
||||
getStepCaCert: vi.fn(),
|
||||
invalidateStepCaCert: vi.fn(),
|
||||
applyCertificateAuth: vi.fn(),
|
||||
getVaultCert: vi.fn(),
|
||||
deleteVaultCert: vi.fn(),
|
||||
@@ -40,10 +38,6 @@ vi.mock("../../../hosts/host-key-verifier.js", () => ({
|
||||
createHostVerifier: mocks.createHostVerifier,
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../hosts/step-ca-auth.js", () => ({
|
||||
getStepCaCert: mocks.getStepCaCert,
|
||||
invalidateStepCaCert: mocks.invalidateStepCaCert,
|
||||
}));
|
||||
vi.mock("@termix/plugin-sdk/ssh-certs", () => ({
|
||||
applyCertificateAuth: mocks.applyCertificateAuth,
|
||||
}));
|
||||
@@ -276,25 +270,20 @@ describe("buildConnectConfig per auth type", () => {
|
||||
// OPKSSH is a plugin-registered SSH auth provider now: see
|
||||
// plugins/opkssh/tests/backend/provider.test.ts.
|
||||
|
||||
it("stepca without a cached certificate asks for a sign-in", async () => {
|
||||
mocks.getStepCaCert.mockReturnValueOnce(null);
|
||||
const { outcome } = await build(host({ authType: "stepca" }));
|
||||
expect(outcome).toMatchObject({
|
||||
status: "interaction-required",
|
||||
interaction: "stepca",
|
||||
});
|
||||
expect(mocks.getStepCaCert).toHaveBeenCalledWith("user-1", 7);
|
||||
});
|
||||
// Step CA is the step-ca plugin's provider: see
|
||||
// plugins/step-ca/tests/backend/step-ca.test.ts.
|
||||
|
||||
it("stepca uses the cached certificate", async () => {
|
||||
mocks.getStepCaCert.mockReturnValueOnce({
|
||||
privateKey: "k",
|
||||
sshCert: "c",
|
||||
it("a stepca host without the step-ca plugin names it", async () => {
|
||||
setSshAuthTypeOwnerSource(() => [
|
||||
{ type: "stepca", pluginId: "step-ca", pluginName: "Step CA" },
|
||||
]);
|
||||
const { outcome } = await build(host({ authType: "stepca" }));
|
||||
expect(outcome).toEqual({
|
||||
status: "error",
|
||||
code: "provider-missing",
|
||||
message: "This host uses stepca, which needs the Step CA plugin",
|
||||
});
|
||||
const { config, outcome } = await build(host({ authType: "stepca" }));
|
||||
expect(outcome.status).toBe("ready");
|
||||
expect(config.authHandler).toBe("cert-handler");
|
||||
expect(mocks.applyCertificateAuth).toHaveBeenCalledOnce();
|
||||
setSshAuthTypeOwnerSource(() => []);
|
||||
});
|
||||
|
||||
it("vault needs a profile, then a cached certificate", async () => {
|
||||
@@ -465,18 +454,20 @@ describe("connectHost", () => {
|
||||
});
|
||||
|
||||
it("throws the auth outcome instead of connecting", async () => {
|
||||
mocks.getStepCaCert.mockReturnValue(null);
|
||||
const attempt = connectHost(host({ authType: "stepca" }), {
|
||||
mocks.getVaultCert.mockResolvedValue(null);
|
||||
const vaultHost = () =>
|
||||
host({ authType: "vault", vaultProfile: { id: 3 } });
|
||||
const attempt = connectHost(vaultHost(), {
|
||||
userId: "user-1",
|
||||
purpose: "tmux",
|
||||
});
|
||||
await expect(attempt).rejects.toBeInstanceOf(SshConnectError);
|
||||
await expect(
|
||||
connectHost(host({ authType: "stepca" }), {
|
||||
connectHost(vaultHost(), {
|
||||
userId: "user-1",
|
||||
purpose: "tmux",
|
||||
}).catch((error) => error.code),
|
||||
).resolves.toBe("stepca-required");
|
||||
).resolves.toBe("vault-required");
|
||||
});
|
||||
|
||||
it("goes through the jump chain and forwards to the target", async () => {
|
||||
@@ -628,18 +619,18 @@ describe("connectHost", () => {
|
||||
|
||||
it("lets the provider react to an auth failure", async () => {
|
||||
FakeSshClient.nextBehaviour = ["auth-fail"];
|
||||
mocks.getStepCaCert.mockReturnValueOnce({
|
||||
mocks.getVaultCert.mockResolvedValueOnce({
|
||||
privateKey: "k",
|
||||
sshCert: "c",
|
||||
});
|
||||
await expect(
|
||||
connectHost(host({ authType: "stepca" }), {
|
||||
connectHost(host({ authType: "vault", vaultProfile: { id: 3 } }), {
|
||||
userId: "user-1",
|
||||
purpose: "fleet",
|
||||
}),
|
||||
).rejects.toThrow(/All configured authentication methods failed/);
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.invalidateStepCaCert).toHaveBeenCalledWith("user-1", 7),
|
||||
expect(mocks.deleteVaultCert).toHaveBeenCalledWith("user-1", 3),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WebSocket } from "ws";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
sent: [] as Array<Record<string, unknown>>,
|
||||
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("../../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],
|
||||
}),
|
||||
}));
|
||||
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,
|
||||
getStepCaCert,
|
||||
invalidateStepCaCert,
|
||||
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;
|
||||
invalidateStepCaCert("user-1", 7);
|
||||
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(getStepCaCert("user-1", 7)).toEqual({
|
||||
sshCert: "CERT",
|
||||
privateKey: "PRIVATE",
|
||||
});
|
||||
expect(state.sent.at(-1)).toMatchObject({ type: "stepca_completed" });
|
||||
});
|
||||
|
||||
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(getStepCaCert("user-1", 7)).toBeNull();
|
||||
});
|
||||
|
||||
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(getStepCaCert("user-1", 7)).not.toBeNull();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ const state = vi.hoisted(() => ({
|
||||
delivered: [] as Array<{ channelId: number; title: string }>,
|
||||
failChannel: null as number | null,
|
||||
fetchCalls: [] as Array<{ url: string; allowlist: readonly string[] }>,
|
||||
lastTls: null as Record<string, unknown> | null,
|
||||
lastSignal: null as AbortSignal | null,
|
||||
}));
|
||||
|
||||
@@ -61,7 +62,9 @@ vi.mock("../../utils/safe-outbound-fetch.js", () => ({
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
allowlist: readonly string[],
|
||||
tls: Record<string, unknown> = {},
|
||||
) => {
|
||||
state.lastTls = tls;
|
||||
state.lastSignal = init.signal ?? null;
|
||||
state.fetchCalls.push({ url, allowlist });
|
||||
return new Response("ok", { status: 200 });
|
||||
@@ -184,6 +187,18 @@ describe("ctx.fetch", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes a pinned CA and the fingerprint bootstrap flag to the guard", async () => {
|
||||
const ctx = contextFor(["network:outbound"]);
|
||||
await ctx.fetch("https://ca.lan/root/abc", {
|
||||
tls: { rejectUnauthorized: false },
|
||||
});
|
||||
expect(state.lastTls).toEqual({ rejectUnauthorized: false });
|
||||
await ctx.fetch("https://ca.lan/provisioners", { tls: { ca: "PEM" } });
|
||||
expect(state.lastTls).toEqual({ ca: "PEM" });
|
||||
await ctx.fetch("https://example.com");
|
||||
expect(state.lastTls).toEqual({});
|
||||
});
|
||||
|
||||
it("aborts the request, and a streamed body, when the caller's signal fires", async () => {
|
||||
const ctx = contextFor(["network:outbound"]);
|
||||
const controller = new AbortController();
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
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({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* The Step CA settings migration: a 2.8 CA configuration and its private
|
||||
* endpoint allowlist come through into the step-ca plugin's admin settings,
|
||||
* the old redirect URI stays in use, and a second run changes nothing.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
legacy: {} as Record<string, string>,
|
||||
pluginRows: [] as Array<{ key: string; value: string | null }>,
|
||||
pluginInstalled: true,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
state.pluginInstalled && id === "step-ca" ? { id } : null,
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.legacy[key] ?? null,
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
_pluginId: string,
|
||||
_scope: string,
|
||||
_scopeId: string | null,
|
||||
key: string,
|
||||
) => state.pluginRows.find((row) => row.key === key) ?? null,
|
||||
set: async (
|
||||
_pluginId: string,
|
||||
_scope: string,
|
||||
_scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
state.pluginRows = state.pluginRows.filter((row) => row.key !== key);
|
||||
state.pluginRows.push({ key, value });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
import { runStepCaSettingsMigration } from "../../utils/crypto-migration/step-ca-settings-migration.js";
|
||||
|
||||
const value = (key: string) => {
|
||||
const row = state.pluginRows.find((candidate) => candidate.key === key);
|
||||
return row?.value == null ? undefined : JSON.parse(row.value);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
state.legacy = {
|
||||
step_ca_url: "https://ca.internal:9000",
|
||||
step_ca_fingerprint: "a".repeat(64),
|
||||
step_ca_provisioner: "oidc",
|
||||
step_ca_private_endpoint_allowlist: JSON.stringify([
|
||||
"ca.internal",
|
||||
"SSO.internal",
|
||||
]),
|
||||
};
|
||||
state.pluginRows = [];
|
||||
state.pluginInstalled = true;
|
||||
});
|
||||
|
||||
describe("runStepCaSettingsMigration", () => {
|
||||
it("moves the 2.8 settings and keeps the old redirect URI", async () => {
|
||||
const result = await runStepCaSettingsMigration();
|
||||
expect(result.moved).toEqual([
|
||||
"step_ca_url",
|
||||
"step_ca_fingerprint",
|
||||
"step_ca_provisioner",
|
||||
"step_ca_private_endpoint_allowlist",
|
||||
]);
|
||||
expect(result.legacyCallback).toBe(true);
|
||||
expect(value("caUrl")).toBe("https://ca.internal:9000");
|
||||
expect(value("fingerprint")).toBe("a".repeat(64));
|
||||
expect(value("provisioner")).toBe("oidc");
|
||||
expect(value("privateEndpoints")).toBe("ca.internal, sso.internal");
|
||||
expect(value("legacyCallback")).toBe(true);
|
||||
});
|
||||
|
||||
it("changes nothing the second time", async () => {
|
||||
await runStepCaSettingsMigration();
|
||||
const before = JSON.stringify(state.pluginRows);
|
||||
const again = await runStepCaSettingsMigration();
|
||||
expect(again).toEqual({ moved: [], legacyCallback: false });
|
||||
expect(JSON.stringify(state.pluginRows)).toBe(before);
|
||||
});
|
||||
|
||||
it("never overwrites a value set in the plugin", async () => {
|
||||
state.pluginRows = [
|
||||
{ key: "caUrl", value: JSON.stringify("https://new.ca") },
|
||||
{ key: "legacyCallback", value: JSON.stringify(false) },
|
||||
];
|
||||
await runStepCaSettingsMigration();
|
||||
expect(value("caUrl")).toBe("https://new.ca");
|
||||
expect(value("legacyCallback")).toBe(false);
|
||||
expect(value("provisioner")).toBe("oidc");
|
||||
});
|
||||
|
||||
it("leaves an install that never used Step CA alone", async () => {
|
||||
state.legacy = {};
|
||||
const result = await runStepCaSettingsMigration();
|
||||
expect(result).toEqual({ moved: [], legacyCallback: false });
|
||||
expect(state.pluginRows).toEqual([]);
|
||||
});
|
||||
|
||||
it("does nothing until the plugin row exists", async () => {
|
||||
state.pluginInstalled = false;
|
||||
await runStepCaSettingsMigration();
|
||||
expect(state.pluginRows).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Moves the 2.8 Step CA configuration into the step-ca plugin.
|
||||
*
|
||||
* The CA URL, root fingerprint, OIDC provisioner and private endpoint
|
||||
* allowlist lived in core `settings` rows. They become the plugin's admin
|
||||
* settings. An install that had a CA configured keeps sending the pre-2.9
|
||||
* redirect URI (/host/step-ca-callback), because that is the one registered
|
||||
* with its identity provider: the plugin's legacyCallback setting is turned
|
||||
* on.
|
||||
*
|
||||
* Idempotent: a value already present in plugin_settings is never
|
||||
* overwritten. The old rows stay for one release so a downgrade still works.
|
||||
*/
|
||||
|
||||
import { databaseLogger } from "../logger.js";
|
||||
import {
|
||||
createCurrentPluginRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import { parseNotificationAllowlist } from "../notification-egress.js";
|
||||
|
||||
const PLUGIN_ID = "step-ca";
|
||||
|
||||
const MOVES: { legacyKey: string; field: string }[] = [
|
||||
{ legacyKey: "step_ca_url", field: "caUrl" },
|
||||
{ legacyKey: "step_ca_fingerprint", field: "fingerprint" },
|
||||
{ legacyKey: "step_ca_provisioner", field: "provisioner" },
|
||||
];
|
||||
|
||||
const LEGACY_ALLOWLIST_KEY = "step_ca_private_endpoint_allowlist";
|
||||
|
||||
export interface StepCaSettingsMigrationResult {
|
||||
moved: string[];
|
||||
legacyCallback: boolean;
|
||||
}
|
||||
|
||||
export async function runStepCaSettingsMigration(): Promise<StepCaSettingsMigrationResult> {
|
||||
const result: StepCaSettingsMigrationResult = {
|
||||
moved: [],
|
||||
legacyCallback: false,
|
||||
};
|
||||
|
||||
try {
|
||||
// plugin_settings has a foreign key to plugins: nothing to move into yet.
|
||||
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
|
||||
if (!plugin) return result;
|
||||
|
||||
const settings = createCurrentSettingsRepository();
|
||||
const pluginSettings = createCurrentPluginSettingsRepository();
|
||||
|
||||
const writeIfUnset = async (field: string, value: unknown) => {
|
||||
const existing = await pluginSettings.get(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
field,
|
||||
);
|
||||
if (existing && existing.value !== null) return false;
|
||||
await pluginSettings.set(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
field,
|
||||
JSON.stringify(value),
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
for (const move of MOVES) {
|
||||
const value = (await settings.get(move.legacyKey))?.trim();
|
||||
if (!value) continue;
|
||||
if (await writeIfUnset(move.field, value)) {
|
||||
result.moved.push(move.legacyKey);
|
||||
}
|
||||
}
|
||||
|
||||
const hosts = parseNotificationAllowlist(
|
||||
await settings.get(LEGACY_ALLOWLIST_KEY),
|
||||
);
|
||||
if (
|
||||
hosts.length > 0 &&
|
||||
(await writeIfUnset("privateEndpoints", hosts.join(", ")))
|
||||
) {
|
||||
result.moved.push(LEGACY_ALLOWLIST_KEY);
|
||||
}
|
||||
|
||||
if ((await settings.get("step_ca_url"))?.trim()) {
|
||||
result.legacyCallback = await writeIfUnset("legacyCallback", true);
|
||||
}
|
||||
|
||||
if (result.moved.length > 0) {
|
||||
databaseLogger.info(
|
||||
`Moved ${result.moved.length} Step CA setting(s) into plugin settings`,
|
||||
{
|
||||
operation: "step_ca_settings_migration",
|
||||
moved: result.moved.join(", "),
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// Must not stop the backend: the plugin then reports "not configured".
|
||||
databaseLogger.warn("Step CA settings migration failed", {
|
||||
operation: "step_ca_settings_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,394 +0,0 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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);
|
||||
}
|
||||
Reference in new issue
Block a user