mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-16 08:39:19 +00:00
137 lines
4.6 KiB
TypeScript
137 lines
4.6 KiB
TypeScript
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({});
|
|
});
|
|
});
|