mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35: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({});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user