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
@@ -0,0 +1,378 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import type { CertStore } from "./cert-store.js";
|
||||
import type { CallbackQuery, CallbackResult, Runtime } from "./runtime.js";
|
||||
import {
|
||||
buildAuthorizationUrl,
|
||||
createPkce,
|
||||
decodeJwtClaims,
|
||||
discoverOidcEndpoints,
|
||||
exchangeCodeForIdToken,
|
||||
fetchRootCertificate,
|
||||
findOidcProvisioner,
|
||||
generateSshKeyPair,
|
||||
parseSshCertificate,
|
||||
signSshCertificate,
|
||||
type StepCaTarget,
|
||||
} from "./client.js";
|
||||
|
||||
/** The redirect URI 2.8 registered with identity providers. */
|
||||
export const LEGACY_CALLBACK_PATH = "/host/step-ca-callback";
|
||||
export const CALLBACK_PATH = "/plugin-api/step-ca/callback";
|
||||
|
||||
const AUTH_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const REMOTE_CALLBACK_WAIT_MS = 30_000;
|
||||
const REMOTE_POLL_MS = 250;
|
||||
|
||||
/** The terminal socket, as far as a sign-in needs it. */
|
||||
export interface SignInSocket {
|
||||
send: (data: string) => void;
|
||||
on: (event: "close", listener: () => void) => void;
|
||||
}
|
||||
|
||||
export interface StartRequest {
|
||||
userId: string;
|
||||
hostId: number;
|
||||
username: string;
|
||||
socket: SignInSocket;
|
||||
requestOrigin: string;
|
||||
}
|
||||
|
||||
interface Session {
|
||||
state: string;
|
||||
userId: string;
|
||||
hostId: number;
|
||||
username: string;
|
||||
socket: SignInSocket;
|
||||
target: StepCaTarget;
|
||||
rootPem: string;
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
tokenEndpoint: string;
|
||||
redirectUri: string;
|
||||
codeVerifier: string;
|
||||
nonce: string;
|
||||
keyPair: { publicKeyLine: string; privateKeyPem: string };
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
commandPoll: ReturnType<typeof setInterval> | null;
|
||||
processing: boolean;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
export interface StepCaSettings {
|
||||
caUrl: string;
|
||||
fingerprint: string;
|
||||
provisioner: string;
|
||||
privateEndpoints: string[];
|
||||
}
|
||||
|
||||
/** Comma or newline separated hosts, lower-cased. */
|
||||
export function parseHostList(raw: unknown): string[] {
|
||||
if (typeof raw !== "string") return [];
|
||||
return raw
|
||||
.split(/[\s,]+/)
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function send(socket: SignInSocket, message: Record<string, unknown>): void {
|
||||
try {
|
||||
socket.send(JSON.stringify(message));
|
||||
} catch {
|
||||
// socket already gone
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export type AuthSessions = ReturnType<typeof createAuthSessions>;
|
||||
|
||||
/**
|
||||
* Step CA sign-ins in flight: the OIDC authorization-code flow against the
|
||||
* CA's provisioner, then the CA signs a fresh key. Created per activation.
|
||||
*/
|
||||
export function createAuthSessions(
|
||||
ctx: PluginContext,
|
||||
deps: { certs: CertStore; runtime: Runtime },
|
||||
) {
|
||||
const sessions = new Map<string, Session>();
|
||||
|
||||
async function readSettings(): Promise<StepCaSettings | null> {
|
||||
const [caUrl, fingerprint, provisioner, privateEndpoints] =
|
||||
await Promise.all([
|
||||
ctx.settings.get<string>("caUrl"),
|
||||
ctx.settings.get<string>("fingerprint"),
|
||||
ctx.settings.get<string>("provisioner"),
|
||||
ctx.settings.get<string>("privateEndpoints"),
|
||||
]);
|
||||
if (!caUrl?.trim() || !fingerprint?.trim() || !provisioner?.trim()) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
caUrl: caUrl.trim(),
|
||||
fingerprint: fingerprint.trim(),
|
||||
provisioner: provisioner.trim(),
|
||||
privateEndpoints: parseHostList(privateEndpoints),
|
||||
};
|
||||
}
|
||||
|
||||
async function callbackPath(): Promise<string> {
|
||||
const legacy = await ctx.settings.get<boolean>("legacyCallback");
|
||||
return legacy ? LEGACY_CALLBACK_PATH : CALLBACK_PATH;
|
||||
}
|
||||
|
||||
function end(session: Session, removeRuntime = true): void {
|
||||
clearTimeout(session.timeout);
|
||||
if (session.commandPoll) clearInterval(session.commandPoll);
|
||||
sessions.delete(session.state);
|
||||
if (removeRuntime) void deps.runtime.remove(session.state);
|
||||
}
|
||||
|
||||
async function finish(
|
||||
session: Session,
|
||||
query: CallbackQuery,
|
||||
removeRuntime = true,
|
||||
): Promise<CallbackResult> {
|
||||
if (query.error || !query.code) {
|
||||
const message =
|
||||
query.error_description || query.error || "Sign-in failed";
|
||||
send(session.socket, {
|
||||
type: "stepca_error",
|
||||
requestId: session.state,
|
||||
error: `Step CA: ${message}`,
|
||||
});
|
||||
end(session, removeRuntime);
|
||||
return { ok: false, message };
|
||||
}
|
||||
|
||||
try {
|
||||
send(session.socket, {
|
||||
type: "stepca_status",
|
||||
requestId: session.state,
|
||||
stage: "authenticating",
|
||||
});
|
||||
const idToken = await exchangeCodeForIdToken({
|
||||
fetch: ctx.fetch,
|
||||
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 info = parseSshCertificate(certificate);
|
||||
if (info.publicKeyLine !== session.keyPair.publicKeyLine) {
|
||||
throw new Error("The CA returned a certificate for a different key");
|
||||
}
|
||||
if (!info.principals.includes(session.username)) {
|
||||
throw new Error(
|
||||
"The CA certificate does not include the host username",
|
||||
);
|
||||
}
|
||||
const now = Date.now();
|
||||
if (
|
||||
info.validBefore.getTime() <= now ||
|
||||
info.validAfter.getTime() > now + 60_000
|
||||
) {
|
||||
throw new Error(
|
||||
"The CA returned a certificate outside its validity window",
|
||||
);
|
||||
}
|
||||
|
||||
await deps.certs.save(
|
||||
session.userId,
|
||||
session.hostId,
|
||||
{ sshCert: certificate, privateKey: session.keyPair.privateKeyPem },
|
||||
info.validBefore,
|
||||
email,
|
||||
);
|
||||
|
||||
session.completed = true;
|
||||
send(session.socket, {
|
||||
type: "stepca_completed",
|
||||
requestId: session.state,
|
||||
expiresAt: info.validBefore.toISOString(),
|
||||
});
|
||||
end(session, removeRuntime);
|
||||
return { ok: true, message: "Signed in. You can close this window." };
|
||||
} catch (error) {
|
||||
const message = errorMessage(error);
|
||||
ctx.log.error(`Step CA certificate issuance failed: ${message}`);
|
||||
send(session.socket, {
|
||||
type: "stepca_error",
|
||||
requestId: session.state,
|
||||
error: `Step CA: ${message}`,
|
||||
});
|
||||
end(session, removeRuntime);
|
||||
return { ok: false, message };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async start(request: StartRequest): Promise<void> {
|
||||
const settings = await readSettings();
|
||||
if (!settings) {
|
||||
send(request.socket, {
|
||||
type: "stepca_config_error",
|
||||
requestId: "",
|
||||
error:
|
||||
"Step CA is not configured. An administrator must set the CA URL, root fingerprint and OIDC provisioner in the Step CA plugin settings.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const state = randomBytes(24).toString("base64url");
|
||||
try {
|
||||
const target: StepCaTarget = {
|
||||
fetch: ctx.fetch,
|
||||
caUrl: settings.caUrl,
|
||||
fingerprint: settings.fingerprint,
|
||||
allowedPrivateHosts: settings.privateEndpoints,
|
||||
};
|
||||
const rootPem = await fetchRootCertificate(target);
|
||||
const provisioner = await findOidcProvisioner(
|
||||
target,
|
||||
rootPem,
|
||||
settings.provisioner,
|
||||
);
|
||||
const endpoints = await discoverOidcEndpoints(
|
||||
ctx.fetch,
|
||||
provisioner.configurationEndpoint,
|
||||
target.allowedPrivateHosts,
|
||||
);
|
||||
const pkce = createPkce();
|
||||
const nonce = randomBytes(16).toString("base64url");
|
||||
const redirectUri = `${request.requestOrigin}${await callbackPath()}`;
|
||||
|
||||
const session: Session = {
|
||||
state,
|
||||
userId: request.userId,
|
||||
hostId: request.hostId,
|
||||
username: request.username,
|
||||
socket: request.socket,
|
||||
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(request.socket, { type: "stepca_timeout", requestId: state });
|
||||
end(current);
|
||||
}, AUTH_TIMEOUT_MS),
|
||||
};
|
||||
sessions.set(state, session);
|
||||
await deps.runtime.register(state);
|
||||
// Another instance may receive the callback and queue it for us.
|
||||
session.commandPoll = setInterval(() => {
|
||||
if (session.processing || session.completed) return;
|
||||
void deps.runtime.takeCommand(state).then(async (query) => {
|
||||
if (!query || session.processing || session.completed) return;
|
||||
session.processing = true;
|
||||
const result = await finish(session, query, false);
|
||||
await deps.runtime.complete(state, result);
|
||||
});
|
||||
}, REMOTE_POLL_MS);
|
||||
session.commandPoll.unref?.();
|
||||
request.socket.on("close", () => {
|
||||
const current = sessions.get(state);
|
||||
if (current && !current.completed) end(current);
|
||||
});
|
||||
|
||||
send(request.socket, {
|
||||
type: "stepca_status",
|
||||
requestId: state,
|
||||
stage: "chooser",
|
||||
url: buildAuthorizationUrl({
|
||||
authorizationEndpoint: endpoints.authorizationEndpoint,
|
||||
clientId: provisioner.clientID,
|
||||
redirectUri,
|
||||
state,
|
||||
nonce,
|
||||
codeChallenge: pkce.challenge,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.log.error(
|
||||
`Failed to start Step CA authentication: ${errorMessage(error)}`,
|
||||
);
|
||||
send(request.socket, {
|
||||
type: "stepca_error",
|
||||
requestId: state,
|
||||
error: `Step CA: ${errorMessage(error)}`,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
cancel(requestId: string): boolean {
|
||||
const session = sessions.get(requestId);
|
||||
if (!session) return false;
|
||||
end(session);
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* The identity provider's redirect: exchanges the code, has the CA sign
|
||||
* the key and stores the certificate. A sign-in another instance started
|
||||
* is handed to it through the runtime.
|
||||
*/
|
||||
async complete(query: CallbackQuery): Promise<CallbackResult> {
|
||||
const session = query.state ? sessions.get(query.state) : undefined;
|
||||
if (!session) {
|
||||
if (!query.state || !(await deps.runtime.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 deps.runtime.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 finish(session, query);
|
||||
},
|
||||
|
||||
closeAll(): void {
|
||||
for (const session of [...sessions.values()]) end(session);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// ctx.db.define hands the table back untyped and the drizzle handle is the
|
||||
// server's own, so both are typed at this module's edge.
|
||||
type Table = any;
|
||||
type Drizzle = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export interface IssuedCertificate {
|
||||
sshCert: string;
|
||||
privateKey: string;
|
||||
}
|
||||
|
||||
export type CertStore = ReturnType<typeof createCertStore>;
|
||||
|
||||
export function createCertStore(ctx: PluginContext, table: Table) {
|
||||
const client = () => ctx.db.client<Drizzle>();
|
||||
const byUserAndHost = (userId: string, hostId: number) =>
|
||||
and(eq(table.userId, userId), eq(table.hostId, hostId));
|
||||
|
||||
async function find(userId: string, hostId: number) {
|
||||
const rows = await (
|
||||
await client()
|
||||
)
|
||||
.select()
|
||||
.from(table)
|
||||
.where(byUserAndHost(userId, hostId))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function remove(userId: string, hostId: number): Promise<boolean> {
|
||||
if (!(await find(userId, hostId))) return false;
|
||||
await (await client()).delete(table).where(byUserAndHost(userId, hostId));
|
||||
await ctx.db.persist();
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
async save(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
certificate: IssuedCertificate,
|
||||
expiresAt: Date,
|
||||
email?: string,
|
||||
): Promise<void> {
|
||||
const values = {
|
||||
sshCert: await ctx.secrets.seal(certificate.sshCert),
|
||||
privateKey: await ctx.secrets.seal(certificate.privateKey),
|
||||
email: email ?? null,
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
};
|
||||
if (await find(userId, hostId)) {
|
||||
await (
|
||||
await client()
|
||||
)
|
||||
.update(table)
|
||||
.set(values)
|
||||
.where(byUserAndHost(userId, hostId));
|
||||
} else {
|
||||
await (
|
||||
await client()
|
||||
)
|
||||
.insert(table)
|
||||
.values({ userId, hostId, ...values });
|
||||
}
|
||||
await ctx.db.persist();
|
||||
},
|
||||
|
||||
/** The usable certificate, or null. Expired or unreadable rows are deleted. */
|
||||
async get(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<IssuedCertificate | null> {
|
||||
const row = await find(userId, hostId);
|
||||
if (!row) return null;
|
||||
const expiresAt = new Date(row.expiresAt).getTime();
|
||||
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) {
|
||||
await remove(userId, hostId);
|
||||
return null;
|
||||
}
|
||||
const sshCert = await ctx.secrets.unseal(row.sshCert);
|
||||
const privateKey = await ctx.secrets.unseal(row.privateKey);
|
||||
if (!sshCert || !privateKey) {
|
||||
await remove(userId, hostId);
|
||||
return null;
|
||||
}
|
||||
return { sshCert, privateKey };
|
||||
},
|
||||
|
||||
remove,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { PluginFetch } from "@termix/plugin-sdk/backend";
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
/** ctx.fetch: core's SSRF guard. */
|
||||
fetch: PluginFetch;
|
||||
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 target.fetch(
|
||||
`${normalizeCaUrl(target.caUrl)}/root/${fingerprint}`,
|
||||
{
|
||||
timeoutMs: FETCH_TIMEOUT_MS,
|
||||
allowPrivateHosts: target.allowedPrivateHosts,
|
||||
tls: { 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 target.fetch(
|
||||
`${base}/provisioners?limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`,
|
||||
{
|
||||
timeoutMs: FETCH_TIMEOUT_MS,
|
||||
allowPrivateHosts: target.allowedPrivateHosts,
|
||||
tls: { 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(
|
||||
fetch: PluginFetch,
|
||||
configurationEndpoint: string,
|
||||
allowedPrivateHosts: readonly string[],
|
||||
): Promise<OidcEndpoints> {
|
||||
const response = await fetch(configurationEndpoint, {
|
||||
timeoutMs: FETCH_TIMEOUT_MS,
|
||||
allowPrivateHosts: 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: {
|
||||
fetch: PluginFetch;
|
||||
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 input.fetch(input.tokenEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: form.toString(),
|
||||
timeoutMs: FETCH_TIMEOUT_MS,
|
||||
allowPrivateHosts: 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 target.fetch(
|
||||
`${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,
|
||||
}),
|
||||
timeoutMs: FETCH_TIMEOUT_MS,
|
||||
allowPrivateHosts: target.allowedPrivateHosts,
|
||||
tls: { 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,28 @@
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { certs as certsTable } from "./tables.js";
|
||||
import { createCertStore } from "./cert-store.js";
|
||||
import { createRuntime } from "./runtime.js";
|
||||
import { createAuthSessions } from "./auth-session.js";
|
||||
import { createStepCaProvider } from "./provider.js";
|
||||
import { PUBLIC_PATHS, registerRoutes } from "./routes.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
const table = await ctx.db.define(certsTable);
|
||||
const certs = createCertStore(ctx, table);
|
||||
|
||||
const runtime = createRuntime(ctx);
|
||||
const sessions = createAuthSessions(ctx, { certs, runtime });
|
||||
ctx.disposables.add(async () => {
|
||||
sessions.closeAll();
|
||||
await runtime.close();
|
||||
});
|
||||
|
||||
ctx.auth.registerSshAuthProvider(createStepCaProvider(ctx, certs, sessions));
|
||||
|
||||
registerRoutes(ctx.http.router<Router>({ public: PUBLIC_PATHS }), sessions);
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
// Everything above was registered through ctx and is disposed by core.
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type {
|
||||
PluginContext,
|
||||
PluginSshAuthProvider,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import { applyCertificateAuth } from "@termix/plugin-sdk/ssh-certs";
|
||||
import type { AuthSessions, SignInSocket } from "./auth-session.js";
|
||||
import type { CertStore } from "./cert-store.js";
|
||||
|
||||
export const AUTH_TYPE = "stepca";
|
||||
|
||||
const REQUIRED_MESSAGE =
|
||||
"Step CA authentication required. Please open a Terminal connection to this host first to complete browser-based authentication.";
|
||||
|
||||
const AUTH_FAILED_PATTERN = /All configured authentication methods failed/i;
|
||||
|
||||
/**
|
||||
* Hosts with auth type "stepca" connect with the certificate the CA issued.
|
||||
* Without one, the terminal is asked for a browser sign-in
|
||||
* ("stepca_auth_required"), which startInteraction runs.
|
||||
*/
|
||||
export function createStepCaProvider(
|
||||
ctx: PluginContext,
|
||||
certs: CertStore,
|
||||
sessions: AuthSessions,
|
||||
): PluginSshAuthProvider {
|
||||
return {
|
||||
type: AUTH_TYPE,
|
||||
labelKey: "hosts.filterAuthStepca",
|
||||
needsUserInteraction: true,
|
||||
supportsBackground: false,
|
||||
interaction: AUTH_TYPE,
|
||||
prepare: async (config, host, env) => {
|
||||
const certificate = await certs.get(env.userId, env.hostId);
|
||||
if (!certificate) {
|
||||
env.log("info", "No valid certificate found, requesting sign-in");
|
||||
return {
|
||||
status: "interaction-required",
|
||||
interaction: AUTH_TYPE,
|
||||
message: REQUIRED_MESSAGE,
|
||||
};
|
||||
}
|
||||
try {
|
||||
await applyCertificateAuth(
|
||||
config as never,
|
||||
env.client as never,
|
||||
{
|
||||
privateKey: certificate.privateKey,
|
||||
certificate: certificate.sshCert,
|
||||
},
|
||||
host.username,
|
||||
);
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "error",
|
||||
code: "failed",
|
||||
message:
|
||||
"Step CA authentication failed: " +
|
||||
(error instanceof Error ? error.message : String(error)),
|
||||
};
|
||||
}
|
||||
env.log("info", "Using cached SSH certificate");
|
||||
return { status: "ready" };
|
||||
},
|
||||
onAuthFailed: (_host, env, context) => {
|
||||
if (!AUTH_FAILED_PATTERN.test(context.error.message)) return undefined;
|
||||
ctx.log.warn("Step CA authentication failed, forgetting the certificate");
|
||||
void certs.remove(env.userId, env.hostId).catch(() => undefined);
|
||||
return {
|
||||
status: "interaction-required",
|
||||
interaction: AUTH_TYPE,
|
||||
message:
|
||||
"Step CA authentication failed or expired. Please authenticate again.",
|
||||
};
|
||||
},
|
||||
startInteraction: async (request) => {
|
||||
await sessions.start({
|
||||
userId: request.userId,
|
||||
hostId: request.hostId,
|
||||
username: request.host.username,
|
||||
socket: request.socket as SignInSocket,
|
||||
requestOrigin: request.requestOrigin,
|
||||
});
|
||||
},
|
||||
cancelInteraction: async (request) => {
|
||||
if (request.requestId) sessions.cancel(request.requestId);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Request, Response, Router } from "express";
|
||||
import type { AuthSessions } from "./auth-session.js";
|
||||
|
||||
/**
|
||||
* The identity provider redirects here without a Termix session: the sign-in
|
||||
* may finish in another browser, so it is matched by the OAuth state.
|
||||
*/
|
||||
export const PUBLIC_PATHS = ["/callback"];
|
||||
|
||||
export function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export 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>`;
|
||||
}
|
||||
|
||||
export function registerRoutes(router: Router, sessions: AuthSessions): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/step-ca/callback:
|
||||
* get:
|
||||
* summary: OIDC callback for a Step CA sign-in
|
||||
* description: Public. The redirect URI registered with the identity provider behind the CA's OIDC provisioner. Exchanges the code, has the CA sign the key and tells the terminal to reconnect. The 2.8 URI /host/step-ca-callback redirects here.
|
||||
* tags: [Step CA]
|
||||
* parameters:
|
||||
* - name: state
|
||||
* in: query
|
||||
* schema:
|
||||
* type: string
|
||||
* - name: code
|
||||
* in: query
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Signed in; an HTML page the browser can close.
|
||||
* 400:
|
||||
* description: The sign-in failed or is no longer active.
|
||||
*/
|
||||
router.get("/callback", async (req: Request, res: Response) => {
|
||||
const query = (name: string): string | undefined => {
|
||||
const value = req.query[name];
|
||||
return typeof value === "string" ? value : undefined;
|
||||
};
|
||||
const result = await sessions.complete({
|
||||
state: query("state"),
|
||||
code: query("code"),
|
||||
error: query("error"),
|
||||
error_description: query("error_description"),
|
||||
});
|
||||
res
|
||||
.status(result.ok ? 200 : 400)
|
||||
.type("html")
|
||||
.send(resultPage(result.ok, result.message));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { createClient } from "redis";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export interface CallbackQuery {
|
||||
state?: string;
|
||||
code?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
}
|
||||
|
||||
export interface CallbackResult {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const SESSION_TTL_SECONDS = 5 * 60;
|
||||
const RESULT_TTL_SECONDS = 60;
|
||||
const CONNECT_RETRY_MS = 15_000;
|
||||
|
||||
export type Runtime = ReturnType<typeof createRuntime>;
|
||||
|
||||
/**
|
||||
* Hands an identity provider callback to the Termix instance that started
|
||||
* the sign-in, through Redis when REDIS_URL is set. With one instance, or no
|
||||
* Redis, every call is a no-op and the callback is handled locally.
|
||||
*/
|
||||
export function createRuntime(ctx: PluginContext) {
|
||||
const prefix =
|
||||
process.env.TERMIX_STEP_CA_REDIS_PREFIX?.trim() || "termix:step-ca";
|
||||
let client: ReturnType<typeof createClient> | null = null;
|
||||
let connecting: Promise<boolean> | null = null;
|
||||
let nextConnectAttempt = 0;
|
||||
|
||||
const routeKey = (state: string) => `${prefix}:route:${state}`;
|
||||
const commandKey = (state: string) => `${prefix}:command:${state}`;
|
||||
const resultKey = (state: string) => `${prefix}:result:${state}`;
|
||||
|
||||
function logFailure(operation: string, error: unknown): void {
|
||||
ctx.log.warn(
|
||||
`Step CA Redis runtime unavailable (${operation}): ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function seal(value: unknown): Promise<string> {
|
||||
return ctx.secrets.seal(JSON.stringify(value));
|
||||
}
|
||||
|
||||
async function unseal<T>(raw: string): Promise<T | null> {
|
||||
const plain = await ctx.secrets.unseal(raw);
|
||||
if (!plain) return null;
|
||||
try {
|
||||
return JSON.parse(plain) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function connect(url: string): Promise<boolean> {
|
||||
try {
|
||||
client = createClient({
|
||||
url,
|
||||
socket: { connectTimeout: 1500, reconnectStrategy: false },
|
||||
});
|
||||
client.on("error", (error) => logFailure("client", error));
|
||||
await client.connect();
|
||||
nextConnectAttempt = 0;
|
||||
return true;
|
||||
} catch (error) {
|
||||
nextConnectAttempt = Date.now() + CONNECT_RETRY_MS;
|
||||
logFailure("connect", error);
|
||||
await client?.disconnect().catch(() => {});
|
||||
client = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ready(): Promise<boolean> {
|
||||
const url = process.env.REDIS_URL?.trim();
|
||||
if (!url) return false;
|
||||
if (client?.isReady) return true;
|
||||
if (Date.now() < nextConnectAttempt) return false;
|
||||
if (connecting) return connecting;
|
||||
connecting = connect(url).finally(() => {
|
||||
connecting = null;
|
||||
});
|
||||
return connecting;
|
||||
}
|
||||
|
||||
return {
|
||||
async register(state: string): Promise<void> {
|
||||
if (!(await ready()) || !client) return;
|
||||
await client
|
||||
.set(routeKey(state), "active", { EX: SESSION_TTL_SECONDS })
|
||||
.catch((error) => logFailure("register", error));
|
||||
},
|
||||
|
||||
/** Queues a callback for the instance that owns the sign-in. */
|
||||
async submit(state: string, query: CallbackQuery): Promise<boolean> {
|
||||
if (!(await ready()) || !client) return false;
|
||||
try {
|
||||
if (!(await client.exists(routeKey(state)))) return false;
|
||||
const stored = await client.set(commandKey(state), await seal(query), {
|
||||
EX: SESSION_TTL_SECONDS,
|
||||
NX: true,
|
||||
});
|
||||
return stored === "OK";
|
||||
} catch (error) {
|
||||
logFailure("submit", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async takeCommand(state: string): Promise<CallbackQuery | null> {
|
||||
if (!(await ready()) || !client) return null;
|
||||
try {
|
||||
const raw = await client.getDel(commandKey(state));
|
||||
return raw ? unseal<CallbackQuery>(raw.toString()) : null;
|
||||
} catch (error) {
|
||||
logFailure("take_command", error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async complete(state: string, result: CallbackResult): Promise<void> {
|
||||
if (!(await ready()) || !client) return;
|
||||
try {
|
||||
await client
|
||||
.multi()
|
||||
.set(resultKey(state), await seal(result), {
|
||||
EX: RESULT_TTL_SECONDS,
|
||||
})
|
||||
.del(routeKey(state))
|
||||
.del(commandKey(state))
|
||||
.exec();
|
||||
} catch (error) {
|
||||
logFailure("complete", error);
|
||||
}
|
||||
},
|
||||
|
||||
async takeResult(state: string): Promise<CallbackResult | null> {
|
||||
if (!(await ready()) || !client) return null;
|
||||
try {
|
||||
const raw = await client.getDel(resultKey(state));
|
||||
return raw ? unseal<CallbackResult>(raw.toString()) : null;
|
||||
} catch (error) {
|
||||
logFailure("take_result", error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async remove(state: string): Promise<void> {
|
||||
if (!(await ready()) || !client) return;
|
||||
await client
|
||||
.del([routeKey(state), commandKey(state), resultKey(state)])
|
||||
.catch((error) => logFailure("remove", error));
|
||||
},
|
||||
|
||||
async close(): Promise<void> {
|
||||
const current = client;
|
||||
client = null;
|
||||
if (current?.isOpen) await current.quit().catch(() => {});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
defineTable,
|
||||
id,
|
||||
refHost,
|
||||
refUser,
|
||||
text,
|
||||
varchar,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
|
||||
/**
|
||||
* One issued certificate per user and host. sshCert and privateKey are
|
||||
* sealed with ctx.secrets.seal. 2.8 kept Step CA certificates in
|
||||
* opkssh_tokens; those are left to expire.
|
||||
*/
|
||||
export const certs = defineTable(
|
||||
"certs",
|
||||
{
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
hostId: refHost(),
|
||||
sshCert: text().notNull(),
|
||||
privateKey: text().notNull(),
|
||||
email: text(),
|
||||
createdAt: text().notNull().defaultNow(),
|
||||
expiresAt: varchar().notNull(),
|
||||
},
|
||||
{
|
||||
uniques: [
|
||||
{ name: "idx_step_ca_certs_user_host", columns: ["userId", "hostId"] },
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
export const tables = [certs];
|
||||
@@ -0,0 +1,54 @@
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
useTranslation,
|
||||
type SettingsComponentProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { Button, Input, copyToClipboard } from "@termix/plugin-sdk/ui";
|
||||
|
||||
const CALLBACK_PATH = "plugin-api/step-ca/callback";
|
||||
const LEGACY_CALLBACK_PATH = "host/step-ca-callback";
|
||||
|
||||
/** The callback URI to register with the provisioner's identity provider. */
|
||||
export function redirectUri(
|
||||
legacy: boolean,
|
||||
baseUri = document.baseURI,
|
||||
): string {
|
||||
return new URL(
|
||||
legacy ? LEGACY_CALLBACK_PATH : CALLBACK_PATH,
|
||||
baseUri,
|
||||
).toString();
|
||||
}
|
||||
|
||||
export function RedirectUriSetting({ values }: SettingsComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
const uri = redirectUri(values.legacyCallback === true);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium">
|
||||
{t("settings.redirectUri.label")}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={uri}
|
||||
readOnly
|
||||
className="rounded-none font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rounded-none"
|
||||
onClick={async () => {
|
||||
if (await copyToClipboard(uri)) toast.success(t("common.copied"));
|
||||
else toast.error(t("common.copyFailed"));
|
||||
}}
|
||||
>
|
||||
{t("common.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("settings.redirectUri.description")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
const DOCS_URL =
|
||||
"https://smallstep.com/docs/step-ca/provisioners/#oauthoidc-single-sign-on";
|
||||
|
||||
/** The host editor's "stepca" auth method: nothing to fill in, just a pointer. */
|
||||
export function StepCaAuthEditor() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<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("editor.label")}
|
||||
</span>
|
||||
<a
|
||||
href={DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[10px] text-accent-brand hover:underline"
|
||||
>
|
||||
{t("editor.docs")}
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("editor.description")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Shield, ExternalLink, Loader2, AlertCircle } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
|
||||
export type SignInStage = "chooser" | "waiting" | "authenticating" | "error";
|
||||
|
||||
interface StepCaDialogProps {
|
||||
stage: SignInStage;
|
||||
error?: string;
|
||||
onCancel: () => void;
|
||||
onOpenUrl: () => void;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
const actionButton =
|
||||
"border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest w-full flex items-center gap-2";
|
||||
const footerButton =
|
||||
"rounded-none text-[10px] font-bold uppercase tracking-widest";
|
||||
|
||||
/** The browser sign-in the terminal shows while Step CA waits. */
|
||||
export function StepCaDialog({
|
||||
stage,
|
||||
error,
|
||||
onCancel,
|
||||
onOpenUrl,
|
||||
backgroundColor,
|
||||
}: StepCaDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
|
||||
<div
|
||||
className="absolute inset-0 bg-canvas"
|
||||
style={{ backgroundColor: backgroundColor || undefined }}
|
||||
/>
|
||||
<div className="bg-card border border-border w-full max-w-md mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="p-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="size-4 text-accent-brand" />
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest">
|
||||
{t("dialog.title")}
|
||||
</h3>
|
||||
</div>
|
||||
{stage === "chooser" && (
|
||||
<p className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground mt-1">
|
||||
{t("dialog.description")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 flex flex-col gap-4">
|
||||
{stage === "chooser" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onOpenUrl}
|
||||
className={actionButton}
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
{t("dialog.openBrowser")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{(stage === "waiting" || stage === "authenticating") && (
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<Loader2 className="size-4 animate-spin text-accent-brand shrink-0" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stage === "waiting"
|
||||
? t("dialog.waiting")
|
||||
: t("dialog.authenticating")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stage === "error" && error && (
|
||||
<div className="flex items-start gap-3 p-3 border border-destructive/20 bg-destructive/10">
|
||||
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-destructive">
|
||||
{t("common.error")}
|
||||
</p>
|
||||
<p className="text-xs text-destructive/90 mt-1 whitespace-pre-wrap break-words">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onCancel}
|
||||
className={footerButton}
|
||||
>
|
||||
{stage === "error" ? t("common.close") : t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { StepCaDialog, type SignInStage } from "./StepCaDialog";
|
||||
|
||||
/** What the ssh-terminal plugin hands a "terminal.overlay" component. */
|
||||
export interface TerminalOverlayProps {
|
||||
host: { id: number | string; [key: string]: unknown } | undefined;
|
||||
backgroundColor?: string;
|
||||
subscribe: (
|
||||
listener: (message: { type: string; [key: string]: unknown }) => void,
|
||||
) => () => void;
|
||||
holdConnectTimeout: (held: boolean) => void;
|
||||
fail: (message: string) => void;
|
||||
disconnect: () => void;
|
||||
send: (type: string, data?: unknown) => void;
|
||||
connectPayload: () => Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface DialogState {
|
||||
authUrl: string;
|
||||
requestId: string;
|
||||
stage: SignInStage;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** How long the sign-in dialog waits before giving up on the connection. */
|
||||
const DIALOG_TIMEOUT_MS = 300_000;
|
||||
|
||||
/**
|
||||
* Runs the Step CA browser sign-in for a terminal: asks the server to start
|
||||
* it, opens the identity provider, and reconnects once the CA has issued the
|
||||
* certificate.
|
||||
*/
|
||||
export function StepCaOverlay({
|
||||
subscribe,
|
||||
holdConnectTimeout,
|
||||
fail,
|
||||
disconnect,
|
||||
send,
|
||||
connectPayload,
|
||||
backgroundColor,
|
||||
}: TerminalOverlayProps) {
|
||||
const { t } = useTranslation();
|
||||
const [dialog, setDialog] = useState<DialogState | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const heldRef = useRef(false);
|
||||
// A second "auth required" after a sign-in means the certificate did not work.
|
||||
const failedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const clearTimer = () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
};
|
||||
const hold = (held: boolean) => {
|
||||
if (heldRef.current === held) return;
|
||||
heldRef.current = held;
|
||||
holdConnectTimeout(held);
|
||||
};
|
||||
const showError = (requestId: unknown, error: string) => {
|
||||
clearTimer();
|
||||
setDialog((current) =>
|
||||
current
|
||||
? { ...current, stage: "error", error }
|
||||
: {
|
||||
authUrl: "",
|
||||
requestId: typeof requestId === "string" ? requestId : "",
|
||||
stage: "error",
|
||||
error,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const unsubscribe = subscribe((message) => {
|
||||
switch (message.type) {
|
||||
case "stepca_auth_required":
|
||||
hold(true);
|
||||
if (failedRef.current) {
|
||||
clearTimer();
|
||||
setDialog(null);
|
||||
hold(false);
|
||||
fail(t("errors.authFailed"));
|
||||
} else {
|
||||
failedRef.current = true;
|
||||
send("stepca_start_auth", { hostId: message.hostId });
|
||||
}
|
||||
break;
|
||||
case "stepca_status":
|
||||
if (message.stage === "chooser") {
|
||||
setDialog({
|
||||
authUrl: typeof message.url === "string" ? message.url : "",
|
||||
requestId:
|
||||
typeof message.requestId === "string" ? message.requestId : "",
|
||||
stage: "chooser",
|
||||
});
|
||||
clearTimer();
|
||||
timerRef.current = setTimeout(() => {
|
||||
setDialog(null);
|
||||
hold(false);
|
||||
disconnect();
|
||||
}, DIALOG_TIMEOUT_MS);
|
||||
} else {
|
||||
setDialog((current) =>
|
||||
current
|
||||
? { ...current, stage: message.stage as SignInStage }
|
||||
: null,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "stepca_completed":
|
||||
clearTimer();
|
||||
setDialog(null);
|
||||
send("stepca_auth_completed", connectPayload());
|
||||
break;
|
||||
case "stepca_error":
|
||||
failedRef.current = true;
|
||||
showError(message.requestId, String(message.error ?? ""));
|
||||
break;
|
||||
case "stepca_timeout":
|
||||
failedRef.current = true;
|
||||
showError(message.requestId, t("errors.timeout"));
|
||||
break;
|
||||
case "stepca_config_error":
|
||||
showError(message.requestId, String(message.error ?? ""));
|
||||
break;
|
||||
case "connected":
|
||||
case "sessionAttached":
|
||||
failedRef.current = false;
|
||||
hold(false);
|
||||
break;
|
||||
case "session_closed":
|
||||
clearTimer();
|
||||
setDialog(null);
|
||||
hold(false);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
clearTimer();
|
||||
if (heldRef.current) {
|
||||
heldRef.current = false;
|
||||
holdConnectTimeout(false);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
subscribe,
|
||||
holdConnectTimeout,
|
||||
fail,
|
||||
disconnect,
|
||||
send,
|
||||
connectPayload,
|
||||
t,
|
||||
]);
|
||||
|
||||
if (!dialog) return null;
|
||||
|
||||
const close = () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
if (dialog.requestId) {
|
||||
send("stepca_cancel", { requestId: dialog.requestId });
|
||||
}
|
||||
setDialog(null);
|
||||
if (heldRef.current) {
|
||||
heldRef.current = false;
|
||||
holdConnectTimeout(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StepCaDialog
|
||||
stage={dialog.stage}
|
||||
error={dialog.error}
|
||||
onCancel={close}
|
||||
onOpenUrl={() => {
|
||||
if (!dialog.authUrl) return;
|
||||
window.open(dialog.authUrl, "_blank");
|
||||
setDialog((current) =>
|
||||
current ? { ...current, stage: "waiting" } : null,
|
||||
);
|
||||
}}
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import { StepCaAuthEditor } from "./StepCaAuthEditor";
|
||||
import { StepCaOverlay } from "./StepCaOverlay";
|
||||
import { RedirectUriSetting } from "./RedirectUriSetting";
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
app.registerSshAuthEditor({
|
||||
authType: "stepca",
|
||||
titleKey: "hosts.filterAuthStepca",
|
||||
component: StepCaAuthEditor,
|
||||
});
|
||||
|
||||
// The browser sign-in while a terminal connects to a Step CA host.
|
||||
app.registerSlotContribution("terminal.overlay", {
|
||||
actionId: "step-ca.signIn",
|
||||
titleKey: "dialog.title",
|
||||
kind: "component",
|
||||
component: StepCaOverlay as unknown as ComponentType<
|
||||
Record<string, unknown>
|
||||
>,
|
||||
});
|
||||
|
||||
app.registerSettingsComponent("redirectUri", RedirectUriSetting);
|
||||
}
|
||||
|
||||
export function deactivate(): void {
|
||||
// Registrations through app are disposed by core.
|
||||
}
|
||||
Reference in new issue
Block a user