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({}); }); });