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,136 @@
|
||||
import crypto from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildAuthorizationUrl,
|
||||
certificateFingerprint,
|
||||
createPkce,
|
||||
decodeJwtClaims,
|
||||
fetchRootCertificate,
|
||||
findOidcProvisioner,
|
||||
generateSshKeyPair,
|
||||
normalizeCaUrl,
|
||||
normalizeFingerprint,
|
||||
parseSshCertificate,
|
||||
} from "../../src/backend/client.js";
|
||||
import { parseHostList } from "../../src/backend/auth-session.js";
|
||||
import { CA_URL, createFakeCa, createSshCa, fakeRoot } from "./fake-ca.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 root = fakeRoot();
|
||||
expect(certificateFingerprint(root.pem)).toBe(root.fingerprint);
|
||||
});
|
||||
|
||||
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 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 cert = createSshCa().sign({
|
||||
publicKeyBlob: publicKeyLine.split(" ")[1],
|
||||
keyId: "alice@example",
|
||||
principals: ["alice", "ops"],
|
||||
validAfter: 1_700_000_000,
|
||||
validBefore: 1_700_057_600,
|
||||
});
|
||||
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/);
|
||||
});
|
||||
|
||||
it("reads the private endpoint list with commas or new lines", () => {
|
||||
expect(parseHostList("ca.internal, SSO.internal\nidp.lan")).toEqual([
|
||||
"ca.internal",
|
||||
"sso.internal",
|
||||
"idp.lan",
|
||||
]);
|
||||
expect(parseHostList(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("talking to the CA", () => {
|
||||
it("refuses a root whose fingerprint does not match", async () => {
|
||||
const ca = createFakeCa({ nonce: () => "" });
|
||||
await expect(
|
||||
fetchRootCertificate({
|
||||
fetch: ca.fetch,
|
||||
caUrl: CA_URL,
|
||||
fingerprint: "b".repeat(64),
|
||||
allowedPrivateHosts: [],
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("finds the OIDC provisioner by name and passes the private hosts", async () => {
|
||||
const ca = createFakeCa({ nonce: () => "" });
|
||||
const target = {
|
||||
fetch: ca.fetch,
|
||||
caUrl: CA_URL,
|
||||
fingerprint: ca.root.fingerprint,
|
||||
allowedPrivateHosts: ["ca.test"],
|
||||
};
|
||||
const root = await fetchRootCertificate(target);
|
||||
const provisioner = await findOidcProvisioner(target, root, "oidc");
|
||||
expect(provisioner.clientID).toBe("termix");
|
||||
expect(ca.calls.every((call) => call.init?.allowPrivateHosts)).toBe(true);
|
||||
await expect(findOidcProvisioner(target, root, "jwk")).rejects.toThrow(
|
||||
/not found/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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,160 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { PluginFetchInit } from "@termix/plugin-sdk/backend";
|
||||
|
||||
const CERT_TYPE = "ssh-ed25519-cert-v01@openssh.com";
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
function uint64(value: bigint): Buffer {
|
||||
const out = Buffer.alloc(8);
|
||||
out.writeBigUInt64BE(value, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
function uint32(value: number): Buffer {
|
||||
const out = Buffer.alloc(4);
|
||||
out.writeUInt32BE(value, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** An ed25519 CA that signs OpenSSH user certificates, like step-ca does. */
|
||||
export function createSshCa() {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||
const raw = Buffer.from(
|
||||
(publicKey.export({ format: "jwk" }) as { x: string }).x,
|
||||
"base64url",
|
||||
);
|
||||
const caBlob = Buffer.concat([sshString("ssh-ed25519"), sshString(raw)]);
|
||||
|
||||
return {
|
||||
sign(input: {
|
||||
publicKeyBlob: string;
|
||||
keyId: string;
|
||||
principals: string[];
|
||||
validAfter: number;
|
||||
validBefore: number;
|
||||
}): string {
|
||||
const userBlob = Buffer.from(input.publicKeyBlob, "base64");
|
||||
// ssh-ed25519 blob: string type, string pk
|
||||
const pk = userBlob.subarray(4 + 11 + 4);
|
||||
const body = Buffer.concat([
|
||||
sshString(CERT_TYPE),
|
||||
sshString(crypto.randomBytes(32)),
|
||||
sshString(pk),
|
||||
uint64(1n),
|
||||
uint32(1),
|
||||
sshString(input.keyId),
|
||||
sshString(Buffer.concat(input.principals.map((p) => sshString(p)))),
|
||||
uint64(BigInt(input.validAfter)),
|
||||
uint64(BigInt(input.validBefore)),
|
||||
sshString(Buffer.alloc(0)),
|
||||
sshString(Buffer.alloc(0)),
|
||||
sshString(Buffer.alloc(0)),
|
||||
sshString(caBlob),
|
||||
]);
|
||||
const signature = Buffer.concat([
|
||||
sshString("ssh-ed25519"),
|
||||
sshString(crypto.sign(null, body, privateKey)),
|
||||
]);
|
||||
return `${CERT_TYPE} ${Buffer.concat([body, sshString(signature)]).toString("base64")}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A root "certificate" the client fingerprints by the sha256 of its DER. */
|
||||
export function fakeRoot() {
|
||||
const der = crypto.randomBytes(128);
|
||||
return {
|
||||
pem: `-----BEGIN CERTIFICATE-----\n${der.toString("base64")}\n-----END CERTIFICATE-----\n`,
|
||||
fingerprint: crypto.createHash("sha256").update(der).digest("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
export function jwt(claims: Record<string, unknown>): string {
|
||||
const part = (value: object) =>
|
||||
Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
return `${part({ alg: "none" })}.${part(claims)}.sig`;
|
||||
}
|
||||
|
||||
export const CA_URL = "https://ca.test";
|
||||
export const DISCOVERY_URL =
|
||||
"https://idp.test/.well-known/openid-configuration";
|
||||
|
||||
export interface FakeCaOptions {
|
||||
/** Principals the CA puts on the certificate; defaults to what was asked. */
|
||||
principals?: string[];
|
||||
/** Nonce the identity provider puts in the id token. */
|
||||
nonce: () => string;
|
||||
/** Status for /1.0/ssh/sign. */
|
||||
signStatus?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers ctx.fetch as a step-ca server plus its identity provider would.
|
||||
* Records every call so a test can check the TLS options.
|
||||
*/
|
||||
export function createFakeCa(options: FakeCaOptions) {
|
||||
const root = fakeRoot();
|
||||
const ca = createSshCa();
|
||||
const calls: Array<{ url: string; init?: PluginFetchInit }> = [];
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
|
||||
const fetch = async (url: string, init?: PluginFetchInit) => {
|
||||
calls.push({ url, init });
|
||||
if (url === `${CA_URL}/root/${root.fingerprint}`) {
|
||||
return json({ ca: root.pem });
|
||||
}
|
||||
if (url.startsWith(`${CA_URL}/provisioners`)) {
|
||||
return json({
|
||||
provisioners: [
|
||||
{ name: "jwk", type: "JWK" },
|
||||
{
|
||||
name: "oidc",
|
||||
type: "OIDC",
|
||||
clientID: "termix",
|
||||
configurationEndpoint: DISCOVERY_URL,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (url === DISCOVERY_URL) {
|
||||
return json({
|
||||
authorization_endpoint: "https://idp.test/authorize",
|
||||
token_endpoint: "https://idp.test/token",
|
||||
});
|
||||
}
|
||||
if (url === "https://idp.test/token") {
|
||||
return json({
|
||||
id_token: jwt({ email: "alice@example.com", nonce: options.nonce() }),
|
||||
});
|
||||
}
|
||||
if (url === `${CA_URL}/1.0/ssh/sign`) {
|
||||
if (options.signStatus && options.signStatus !== 200) {
|
||||
return json({ message: "not allowed" }, options.signStatus);
|
||||
}
|
||||
const body = JSON.parse(init?.body ?? "{}");
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return json({
|
||||
crt: ca.sign({
|
||||
publicKeyBlob: body.publicKey,
|
||||
keyId: body.keyID,
|
||||
principals: options.principals ?? body.principals,
|
||||
validAfter: now - 60,
|
||||
validBefore: now + 16 * 3600,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
};
|
||||
|
||||
return { fetch, calls, root, ca };
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import express, { type Router } from "express";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type MockPluginContext,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type {
|
||||
PluginFetchInit,
|
||||
PluginSshAuthProvider,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import manifestJson from "../../manifest.json";
|
||||
|
||||
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
|
||||
export const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
/** A fake terminal socket that records what it was sent. */
|
||||
export function fakeSocket() {
|
||||
const sent: Array<Record<string, unknown>> = [];
|
||||
const closers: Array<() => void> = [];
|
||||
return {
|
||||
sent,
|
||||
socket: {
|
||||
send: (data: string) => sent.push(JSON.parse(data)),
|
||||
on: (_event: "close", listener: () => void) => closers.push(listener),
|
||||
},
|
||||
close: () => closers.forEach((listener) => listener()),
|
||||
};
|
||||
}
|
||||
|
||||
export interface TestServer {
|
||||
db: TestDb;
|
||||
mock: MockPluginContext;
|
||||
provider: () => PluginSshAuthProvider;
|
||||
get: (path: string) => Promise<{ status: number; body: string }>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
export async function startServer(
|
||||
options: {
|
||||
settings?: Record<string, unknown>;
|
||||
fetch?: (url: string, init?: PluginFetchInit) => Promise<Response>;
|
||||
} = {},
|
||||
): Promise<TestServer> {
|
||||
const db = await createTestDb(pluginDir);
|
||||
db.sqlite
|
||||
.prepare("INSERT OR IGNORE INTO users (id) VALUES (?)")
|
||||
.run("user-1");
|
||||
db.sqlite.prepare("INSERT OR IGNORE INTO ssh_data (id) VALUES (?)").run(7);
|
||||
|
||||
let router: Router | null = null;
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities,
|
||||
db: db.database,
|
||||
router: () => (router = express.Router()),
|
||||
settings: options.settings,
|
||||
fetch: options.fetch,
|
||||
baseUrl: "https://termix.test",
|
||||
});
|
||||
|
||||
const { activate } = await import("../../src/backend/index.js");
|
||||
await activate(mock.ctx);
|
||||
|
||||
const app = express();
|
||||
app.use((req, res, next) => router!(req, res, next));
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
db,
|
||||
mock,
|
||||
provider: () =>
|
||||
mock.auth.sshAuthProviders.find(
|
||||
(provider) => provider.type === "stepca",
|
||||
)!,
|
||||
async get(path) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
redirect: "manual",
|
||||
});
|
||||
return { status: response.status, body: await response.text() };
|
||||
},
|
||||
async close() {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const env = (hostId = 7) => ({
|
||||
client: {},
|
||||
userId: "user-1",
|
||||
hostId,
|
||||
purpose: "terminal",
|
||||
interactive: true,
|
||||
log: () => {},
|
||||
});
|
||||
|
||||
export const HOST = { id: 7, ip: "10.0.0.7", port: 22, username: "root" };
|
||||
@@ -0,0 +1,295 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
|
||||
import { createMockCtx, createTestDb } from "@termix/plugin-sdk/testing";
|
||||
import { CA_URL, createFakeCa } from "./fake-ca.js";
|
||||
import {
|
||||
HOST,
|
||||
env,
|
||||
fakeSocket,
|
||||
manifest,
|
||||
pluginDir,
|
||||
startServer,
|
||||
type TestServer,
|
||||
} from "./helpers.js";
|
||||
|
||||
const certs = vi.hoisted(() => ({ applyCertificateAuth: vi.fn() }));
|
||||
vi.mock("@termix/plugin-sdk/ssh-certs", () => certs);
|
||||
|
||||
let server: TestServer | null = null;
|
||||
let nonce = "";
|
||||
|
||||
beforeEach(() => {
|
||||
certs.applyCertificateAuth.mockReset();
|
||||
nonce = "";
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
async function startWithCa(
|
||||
options: {
|
||||
principals?: string[];
|
||||
signStatus?: number;
|
||||
wrongNonce?: boolean;
|
||||
settings?: Record<string, unknown>;
|
||||
} = {},
|
||||
) {
|
||||
const ca = createFakeCa({
|
||||
principals: options.principals,
|
||||
signStatus: options.signStatus,
|
||||
nonce: () => (options.wrongNonce ? "other" : nonce),
|
||||
});
|
||||
server = await startServer({
|
||||
fetch: ca.fetch,
|
||||
settings: {
|
||||
caUrl: CA_URL,
|
||||
fingerprint: ca.root.fingerprint,
|
||||
provisioner: "oidc",
|
||||
...options.settings,
|
||||
},
|
||||
});
|
||||
return ca;
|
||||
}
|
||||
|
||||
/** Starts a sign-in and returns the socket and the authorization URL. */
|
||||
async function startSignIn() {
|
||||
const socket = fakeSocket();
|
||||
await server!.provider().startInteraction!({
|
||||
userId: "user-1",
|
||||
hostId: 7,
|
||||
host: { name: "box", ip: "10.0.0.7", username: "root" },
|
||||
socket: socket.socket,
|
||||
requestOrigin: "https://termix.test",
|
||||
payload: {},
|
||||
});
|
||||
const chooser = socket.sent.find((m) => m.stage === "chooser");
|
||||
const url = chooser ? new URL(String(chooser.url)) : null;
|
||||
nonce = url?.searchParams.get("nonce") ?? "";
|
||||
return { socket, url, state: url?.searchParams.get("state") ?? "" };
|
||||
}
|
||||
|
||||
describe("the certs table", () => {
|
||||
it("is created on a fresh install", async () => {
|
||||
const db = await createTestDb(pluginDir);
|
||||
expect(
|
||||
db.sqlite
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE name = 'p_step_ca_certs'",
|
||||
)
|
||||
.get(),
|
||||
).toBeTruthy();
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("activate", () => {
|
||||
it("registers the stepca auth type and its callback route", async () => {
|
||||
server = await startServer();
|
||||
expect(server.provider()).toMatchObject({
|
||||
type: "stepca",
|
||||
interaction: "stepca",
|
||||
supportsBackground: false,
|
||||
});
|
||||
expect(server.mock.httpRouters).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each(["db:own", "auth:provide", "network:serve"])(
|
||||
"fails closed without %s",
|
||||
async (capability) => {
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities.filter((c) => c !== capability),
|
||||
db: (await createTestDb(pluginDir)).database,
|
||||
});
|
||||
const { activate } = await import("../../src/backend/index.js");
|
||||
await expect(activate(mock.ctx)).rejects.toThrow(PluginCapabilityError);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("issuing a certificate", () => {
|
||||
it("runs the OIDC flow against the CA and stores the certificate sealed", async () => {
|
||||
const ca = await startWithCa();
|
||||
const { socket, url, state } = await startSignIn();
|
||||
|
||||
expect(url?.origin + url?.pathname).toBe("https://idp.test/authorize");
|
||||
expect(url?.searchParams.get("client_id")).toBe("termix");
|
||||
expect(url?.searchParams.get("redirect_uri")).toBe(
|
||||
"https://termix.test/plugin-api/step-ca/callback",
|
||||
);
|
||||
expect(url?.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
|
||||
// The root is fetched unverified and checked by fingerprint, then pinned.
|
||||
expect(ca.calls[0].init?.tls).toEqual({ rejectUnauthorized: false });
|
||||
expect(ca.calls[1].init?.tls).toEqual({ ca: ca.root.pem });
|
||||
|
||||
const response = await server!.get(`/callback?state=${state}&code=abc`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toContain("Signed in");
|
||||
expect(socket.sent.at(-1)).toMatchObject({
|
||||
type: "stepca_completed",
|
||||
requestId: state,
|
||||
});
|
||||
|
||||
const sign = ca.calls.find((call) => call.url.endsWith("/1.0/ssh/sign"));
|
||||
expect(JSON.parse(sign!.init!.body!)).toMatchObject({
|
||||
certType: "user",
|
||||
principals: ["root"],
|
||||
keyID: "alice@example.com",
|
||||
});
|
||||
expect(sign!.init!.tls).toEqual({ ca: ca.root.pem });
|
||||
|
||||
const row = server!.db.sqlite
|
||||
.prepare("SELECT * FROM p_step_ca_certs")
|
||||
.get() as Record<string, string>;
|
||||
expect(row.user_id).toBe("user-1");
|
||||
expect(row.email).toBe("alice@example.com");
|
||||
expect(row.ssh_cert).toMatch(/^sealed:/);
|
||||
expect(row.private_key).toMatch(/^sealed:/);
|
||||
});
|
||||
|
||||
it("answers a second use of the same callback with an error", async () => {
|
||||
await startWithCa();
|
||||
const { state } = await startSignIn();
|
||||
await server!.get(`/callback?state=${state}&code=abc`);
|
||||
const again = await server!.get(`/callback?state=${state}&code=abc`);
|
||||
expect(again.status).toBe(400);
|
||||
expect(again.body).toContain("no longer active");
|
||||
});
|
||||
|
||||
it("refuses an id token issued for another sign-in", async () => {
|
||||
await startWithCa({ wrongNonce: true });
|
||||
const { socket, state } = await startSignIn();
|
||||
const response = await server!.get(`/callback?state=${state}&code=abc`);
|
||||
expect(response.status).toBe(400);
|
||||
expect(socket.sent.at(-1)).toMatchObject({
|
||||
type: "stepca_error",
|
||||
error: expect.stringContaining("does not match"),
|
||||
});
|
||||
expect(
|
||||
server!.db.sqlite.prepare("SELECT * FROM p_step_ca_certs").all(),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reports the CA refusing to sign", async () => {
|
||||
await startWithCa({ signStatus: 403 });
|
||||
const { socket, state } = await startSignIn();
|
||||
await server!.get(`/callback?state=${state}&code=abc`);
|
||||
expect(socket.sent.at(-1)).toMatchObject({
|
||||
type: "stepca_error",
|
||||
error: expect.stringContaining("HTTP 403"),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a certificate without the host username", async () => {
|
||||
await startWithCa({ principals: ["someone-else"] });
|
||||
const { socket, state } = await startSignIn();
|
||||
await server!.get(`/callback?state=${state}&code=abc`);
|
||||
expect(socket.sent.at(-1)).toMatchObject({
|
||||
type: "stepca_error",
|
||||
error: expect.stringContaining("host username"),
|
||||
});
|
||||
});
|
||||
|
||||
it("passes an identity provider error through", async () => {
|
||||
await startWithCa();
|
||||
const { socket, state } = await startSignIn();
|
||||
const response = await server!.get(
|
||||
`/callback?state=${state}&error=access_denied&error_description=Denied`,
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
expect(socket.sent.at(-1)).toMatchObject({
|
||||
type: "stepca_error",
|
||||
error: "Step CA: Denied",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends the 2.8 redirect URI while legacyCallback is on", async () => {
|
||||
await startWithCa({ settings: { legacyCallback: true } });
|
||||
const { url } = await startSignIn();
|
||||
expect(url?.searchParams.get("redirect_uri")).toBe(
|
||||
"https://termix.test/host/step-ca-callback",
|
||||
);
|
||||
});
|
||||
|
||||
it("tells the terminal when Step CA is not configured", async () => {
|
||||
server = await startServer();
|
||||
const { socket } = await startSignIn();
|
||||
expect(socket.sent).toEqual([
|
||||
expect.objectContaining({ type: "stepca_config_error" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("lets a pending sign-in be cancelled", async () => {
|
||||
await startWithCa();
|
||||
const { state } = await startSignIn();
|
||||
await server!.provider().cancelInteraction!({
|
||||
userId: "user-1",
|
||||
requestId: state,
|
||||
});
|
||||
const response = await server!.get(`/callback?state=${state}&code=abc`);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the stepca provider", () => {
|
||||
it("asks for a sign-in without a certificate", async () => {
|
||||
server = await startServer();
|
||||
const outcome = await server.provider().prepare({}, HOST, env());
|
||||
expect(outcome).toMatchObject({
|
||||
status: "interaction-required",
|
||||
interaction: "stepca",
|
||||
});
|
||||
expect(certs.applyCertificateAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("connects with the issued certificate", async () => {
|
||||
await startWithCa();
|
||||
const { state } = await startSignIn();
|
||||
await server!.get(`/callback?state=${state}&code=abc`);
|
||||
|
||||
const config: Record<string, unknown> = {};
|
||||
const outcome = await server!.provider().prepare(config, HOST, env());
|
||||
expect(outcome).toEqual({ status: "ready" });
|
||||
const [passedConfig, , credentials, username] =
|
||||
certs.applyCertificateAuth.mock.calls[0];
|
||||
expect(passedConfig).toBe(config);
|
||||
expect(username).toBe("root");
|
||||
expect(credentials.certificate).toMatch(
|
||||
/^ssh-ed25519-cert-v01@openssh\.com /,
|
||||
);
|
||||
expect(credentials.privateKey).toContain("BEGIN PRIVATE KEY");
|
||||
});
|
||||
|
||||
it("forgets an expired certificate", async () => {
|
||||
await startWithCa();
|
||||
const { state } = await startSignIn();
|
||||
await server!.get(`/callback?state=${state}&code=abc`);
|
||||
server!.db.sqlite
|
||||
.prepare("UPDATE p_step_ca_certs SET expires_at = ?")
|
||||
.run("2000-01-01T00:00:00.000Z");
|
||||
const outcome = await server!.provider().prepare({}, HOST, env());
|
||||
expect(outcome.status).toBe("interaction-required");
|
||||
expect(
|
||||
server!.db.sqlite.prepare("SELECT * FROM p_step_ca_certs").all(),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("drops the certificate when the host rejects it", async () => {
|
||||
await startWithCa();
|
||||
const { state } = await startSignIn();
|
||||
await server!.get(`/callback?state=${state}&code=abc`);
|
||||
const decision = server!.provider().onAuthFailed!(HOST, env(), {
|
||||
error: new Error("All configured authentication methods failed"),
|
||||
} as never);
|
||||
expect(decision).toMatchObject({ status: "interaction-required" });
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
server!.db.sqlite.prepare("SELECT * FROM p_step_ca_certs").all(),
|
||||
).toHaveLength(0),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import * as plugin from "../../src/frontend/index";
|
||||
import { StepCaOverlay } from "../../src/frontend/StepCaOverlay";
|
||||
import { redirectUri } from "../../src/frontend/RedirectUriSetting";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import locales from "../../locales/en.json";
|
||||
|
||||
vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@termix/plugin-sdk/frontend")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
let rendered: RenderedPluginApp | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await rendered?.deactivate();
|
||||
rendered = null;
|
||||
});
|
||||
|
||||
describe("step-ca activate", () => {
|
||||
it("registers the auth editor, the terminal overlay and the settings field", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
expect(rendered.registered.settingsComponents()).toEqual(["redirectUri"]);
|
||||
expect(rendered.registered.slot("terminal.overlay")).toEqual([
|
||||
"step-ca.signIn",
|
||||
]);
|
||||
await rendered.deactivate();
|
||||
expect(rendered.registered.settingsComponents()).toEqual([]);
|
||||
expect(rendered.registered.slot("terminal.overlay")).toEqual([]);
|
||||
rendered = null;
|
||||
});
|
||||
});
|
||||
|
||||
describe("redirectUri", () => {
|
||||
it("names the plugin callback, or the 2.8 one", () => {
|
||||
expect(redirectUri(false, "https://t.example/app/")).toBe(
|
||||
"https://t.example/app/plugin-api/step-ca/callback",
|
||||
);
|
||||
expect(redirectUri(true, "https://t.example/")).toBe(
|
||||
"https://t.example/host/step-ca-callback",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("StepCaOverlay", () => {
|
||||
function setup() {
|
||||
let listener: (message: {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}) => void = () => {};
|
||||
const props = {
|
||||
host: { id: 7 },
|
||||
subscribe: vi.fn((next: typeof listener) => {
|
||||
listener = next;
|
||||
return () => {};
|
||||
}),
|
||||
holdConnectTimeout: vi.fn(),
|
||||
fail: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
send: vi.fn(),
|
||||
connectPayload: vi.fn(() => ({ hostId: 7, cols: 80, rows: 24 })),
|
||||
};
|
||||
render(<StepCaOverlay {...props} />);
|
||||
const emit = (message: { type: string; [key: string]: unknown }) =>
|
||||
act(() => listener(message));
|
||||
return { props, emit };
|
||||
}
|
||||
|
||||
it("starts the sign-in once, then reports a second request as a failure", () => {
|
||||
const { props, emit } = setup();
|
||||
emit({ type: "stepca_auth_required", hostId: 7 });
|
||||
expect(props.send).toHaveBeenCalledWith("stepca_start_auth", { hostId: 7 });
|
||||
expect(props.holdConnectTimeout).toHaveBeenCalledWith(true);
|
||||
|
||||
emit({ type: "stepca_auth_required", hostId: 7 });
|
||||
expect(props.fail).toHaveBeenCalledWith("errors.authFailed");
|
||||
});
|
||||
|
||||
it("opens the identity provider, waits, and reconnects once signed in", () => {
|
||||
const open = vi.spyOn(window, "open").mockReturnValue(null);
|
||||
const { props, emit } = setup();
|
||||
emit({ type: "stepca_auth_required", hostId: 7 });
|
||||
emit({
|
||||
type: "stepca_status",
|
||||
stage: "chooser",
|
||||
requestId: "req-1",
|
||||
url: "https://idp.test/authorize?state=req-1",
|
||||
});
|
||||
fireEvent.click(screen.getByText("dialog.openBrowser"));
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
"https://idp.test/authorize?state=req-1",
|
||||
"_blank",
|
||||
);
|
||||
expect(screen.getByText("dialog.waiting")).toBeTruthy();
|
||||
|
||||
emit({ type: "stepca_completed", requestId: "req-1" });
|
||||
expect(props.send).toHaveBeenCalledWith("stepca_auth_completed", {
|
||||
hostId: 7,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
expect(screen.queryByText("dialog.title")).toBeNull();
|
||||
open.mockRestore();
|
||||
});
|
||||
|
||||
it("cancels a pending sign-in", () => {
|
||||
const { props, emit } = setup();
|
||||
emit({
|
||||
type: "stepca_status",
|
||||
stage: "chooser",
|
||||
requestId: "req-2",
|
||||
url: "https://idp.test/authorize",
|
||||
});
|
||||
fireEvent.click(screen.getByText("common.cancel"));
|
||||
expect(props.send).toHaveBeenCalledWith("stepca_cancel", {
|
||||
requestId: "req-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a config error", () => {
|
||||
const { emit } = setup();
|
||||
emit({
|
||||
type: "stepca_config_error",
|
||||
requestId: "",
|
||||
error: "Step CA is not configured.",
|
||||
});
|
||||
expect(screen.getByText("Step CA is not configured.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user