feat(login): use OpenSSL CA store for TLS certificate validation (#11562)

This commit is contained in:
Mridang Agarwalla
2026-02-16 23:25:50 +00:00
committed by GitHub
parent d9df30df20
commit 61756af7f7
10 changed files with 1217 additions and 2 deletions
+1
View File
@@ -13,3 +13,4 @@ dist-ssr
*.local
.vscode
/blob-report/
dockerized/*/output/
+4 -1
View File
@@ -10,7 +10,10 @@ COPY --chown=nextjs:nodejs .next/standalone ./
USER nextjs
ENV HOSTNAME="::" \
PORT="3000" \
NODE_ENV="production"
NODE_ENV="production" \
NODE_OPTIONS="--use-openssl-ca" \
SSL_CERT_FILE="/etc/ssl/certs/ca-certificates.crt" \
SSL_CERT_DIR="/etc/ssl/certs"
# TODO: Check healthy, not ready
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
@@ -0,0 +1,235 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { GenericContainer, type StartedTestContainer, Network, Wait } from "testcontainers";
import type { StartedNetwork } from "testcontainers";
import * as fs from "fs";
import * as path from "path";
import * as esbuild from "esbuild";
import { generateCertificates } from "./utils/tls.ts";
const TEST_DIR = path.dirname(new URL(import.meta.url).pathname);
const OUTPUT_DIR = path.join(TEST_DIR, "output");
const CERTS_DIR = path.join(OUTPUT_DIR, "certs");
const LOGIN_APP_DIR = path.join(TEST_DIR, "../..");
async function bundleMockServer(): Promise<string> {
const bundleDir = path.join(OUTPUT_DIR, "bundle");
fs.mkdirSync(bundleDir, { recursive: true });
const outfile = path.join(bundleDir, "mock-server.js");
await esbuild.build({
entryPoints: [path.join(TEST_DIR, "mock-server.ts")],
bundle: true,
platform: "node",
target: "node22",
outfile,
format: "cjs",
});
return outfile;
}
function readCapturedRequests(): Array<{ method: string; url: string; tlsConnected: boolean }> {
const filePath = path.join(OUTPUT_DIR, "requests.json");
if (!fs.existsSync(filePath)) {
return [];
}
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
}
/**
* Integration tests for custom CA certificate support in the login application.
*
* These tests verify that the Dockerfile correctly configures Node.js to trust custom
* Certificate Authorities via the SSL_CERT_FILE environment variable and the
* NODE_OPTIONS=--use-openssl-ca flag. This is essential for deployments where the
* ZITADEL API is served behind a TLS-terminating proxy using internal or self-signed
* certificates.
*
* The test suite spins up a mock TLS server with a self-signed certificate inside a
* Docker network. The login application container connects to this mock server, and
* the tests verify that TLS connections succeed when the custom CA is mounted and
* fail when it is not. The mock server records all incoming requests to a shared
* volume, allowing the tests to verify that TLS handshakes completed successfully.
*
* The Docker network ensures container-to-container communication uses internal DNS
* resolution, avoiding port conflicts on the host machine and ensuring reliable
* execution in CI environments.
*/
describe("Custom CA Certificate Integration", () => {
let certs: ReturnType<typeof generateCertificates>;
let network: StartedNetwork;
let mockServer: StartedTestContainer;
let mockServerBundle: string;
let loginImage: GenericContainer;
beforeAll(async () => {
if (fs.existsSync(OUTPUT_DIR)) {
fs.rmSync(OUTPUT_DIR, { recursive: true });
}
fs.mkdirSync(CERTS_DIR, { recursive: true });
certs = generateCertificates();
fs.writeFileSync(path.join(CERTS_DIR, "ca.crt"), certs.ca.cert);
fs.writeFileSync(path.join(CERTS_DIR, "server.key"), certs.server.key);
fs.writeFileSync(path.join(CERTS_DIR, "server.crt"), certs.server.cert);
mockServerBundle = await bundleMockServer();
network = await new Network().start();
mockServer = await new GenericContainer("node:22-alpine")
.withNetwork(network)
.withNetworkAliases("mock-zitadel")
.withCopyFilesToContainer([
{ source: mockServerBundle, target: "/app/server.js" },
{ source: path.join(CERTS_DIR, "server.key"), target: "/certs/server.key" },
{ source: path.join(CERTS_DIR, "server.crt"), target: "/certs/server.crt" },
])
.withBindMounts([{ source: OUTPUT_DIR, target: "/output" }])
.withCommand(["node", "/app/server.js"])
.withExposedPorts(443)
.withWaitStrategy(Wait.forLogMessage("Mock TLS server listening"))
.start();
loginImage = await GenericContainer.fromDockerfile(LOGIN_APP_DIR).build();
});
afterAll(async () => {
await mockServer?.stop();
await network?.stop();
});
describe("when custom CA certificate is provided", () => {
let container: StartedTestContainer;
let appUrl: string;
beforeAll(async () => {
if (fs.existsSync(path.join(OUTPUT_DIR, "requests.json"))) {
fs.unlinkSync(path.join(OUTPUT_DIR, "requests.json"));
}
container = await loginImage
.withNetwork(network)
.withExposedPorts(3000)
.withEnvironment({
ZITADEL_API_URL: "https://mock-zitadel",
ZITADEL_SERVICE_USER_TOKEN: "test-token",
SSL_CERT_FILE: "/etc/ssl/certs/custom-ca.crt",
})
.withCopyFilesToContainer([
{
source: path.join(CERTS_DIR, "ca.crt"),
target: "/etc/ssl/certs/custom-ca.crt",
},
])
.withWaitStrategy(Wait.forHttp("/ui/v2/login/healthy", 3000))
.start();
appUrl = `http://${container.getHost()}:${container.getMappedPort(3000)}`;
await fetch(`${appUrl}/ui/v2/login/loginname`, { redirect: "manual" });
for (let i = 0; i < 30; i++) {
const requests = readCapturedRequests();
if (requests.length > 0) break;
await new Promise((r) => setTimeout(r, 1000));
}
});
afterAll(async () => {
await container?.stop();
});
it("connects to mock server over TLS", () => {
const requests = readCapturedRequests();
expect(requests.length).toBeGreaterThan(0);
});
it("marks requests as TLS connected", () => {
const requests = readCapturedRequests();
const tlsRequests = requests.filter((r) => r.tlsConnected === true);
expect(tlsRequests.length).toBeGreaterThan(0);
});
it("returns 200 from healthy endpoint", async () => {
const response = await fetch(`${appUrl}/ui/v2/login/healthy`);
expect(response.status).toBe(200);
});
it("serves the login page", async () => {
const response = await fetch(`${appUrl}/ui/v2/login/loginname`, { redirect: "manual" });
expect([200, 302, 303, 307, 308]).toContain(response.status);
});
it("has NODE_OPTIONS set with --use-openssl-ca", async () => {
const result = await container.exec(["printenv", "NODE_OPTIONS"]);
expect(result.output.trim()).toContain("--use-openssl-ca");
});
it("has SSL_CERT_FILE pointing to custom CA", async () => {
const result = await container.exec(["printenv", "SSL_CERT_FILE"]);
expect(result.output.trim()).toBe("/etc/ssl/certs/custom-ca.crt");
});
it("has CA certificate accessible in container", async () => {
const result = await container.exec([
"sh",
"-c",
"test -f /etc/ssl/certs/custom-ca.crt && echo exists",
]);
expect(result.output.trim()).toContain("exists");
});
});
describe("when custom CA certificate is not provided", () => {
let container: StartedTestContainer;
let appUrl: string;
let requestsBefore: number;
beforeAll(async () => {
requestsBefore = readCapturedRequests().length;
container = await loginImage
.withNetwork(network)
.withExposedPorts(3000)
.withEnvironment({
ZITADEL_API_URL: "https://mock-zitadel",
ZITADEL_SERVICE_USER_TOKEN: "test-token",
})
.withWaitStrategy(Wait.forHttp("/ui/v2/login/healthy", 3000))
.start();
appUrl = `http://${container.getHost()}:${container.getMappedPort(3000)}`;
await fetch(`${appUrl}/ui/v2/login/loginname`, { redirect: "manual" });
for (let i = 0; i < 10; i++) {
await new Promise((r) => setTimeout(r, 500));
}
});
afterAll(async () => {
await container?.stop();
});
it("uses system CA store by default", async () => {
const result = await container.exec(["printenv", "SSL_CERT_FILE"]);
expect(result.output.trim()).toBe("/etc/ssl/certs/ca-certificates.crt");
});
it("does not have custom CA certificate mounted", async () => {
const result = await container.exec([
"sh",
"-c",
"test -f /etc/ssl/certs/custom-ca.crt && echo exists || echo missing",
]);
expect(result.output.trim()).toContain("missing");
});
it("cannot establish TLS connection to mock server", () => {
const requestsAfter = readCapturedRequests().length;
expect(requestsAfter).toBe(requestsBefore);
});
});
});
+38
View File
@@ -0,0 +1,38 @@
import * as http2 from "node:http2";
import * as fs from "node:fs";
const OUTPUT_DIR = process.env.OUTPUT_DIR || "/output";
const server = http2.createSecureServer({
key: fs.readFileSync("/certs/server.key"),
cert: fs.readFileSync("/certs/server.crt"),
allowHTTP1: true,
});
interface CapturedRequest {
method: string;
url: string;
tlsConnected: boolean;
}
const requests: CapturedRequest[] = [];
server.on("stream", (stream, headers) => {
requests.push({
method: (headers[":method"] as string) || "UNKNOWN",
url: (headers[":path"] as string) || "/",
tlsConnected: true,
});
fs.writeFileSync(`${OUTPUT_DIR}/requests.json`, JSON.stringify(requests, null, 2));
stream.respond({
":status": 200,
"content-type": "application/json",
"grpc-status": "0",
});
stream.end(JSON.stringify({ status: "ok" }));
});
server.listen(443, "0.0.0.0", () => {
console.log("Mock TLS server listening on port 443");
});
+87
View File
@@ -0,0 +1,87 @@
import forge from "node-forge";
/**
* Generates a self-signed CA certificate and a server certificate signed by
* that CA, returning both as PEM-encoded strings alongside their private keys.
*
* This is intended for test and development environments where a trusted
* certificate chain is needed without relying on the openssl binary. The
* server certificate includes Subject Alternative Names so that TLS clients
* can verify the connection against the expected hostnames and IP addresses.
*/
export function generateCertificates({
caCN = "Test CA",
serverCN = "mock-zitadel",
dns = ["mock-zitadel", "localhost"],
ips = ["127.0.0.1"],
days = 1,
} = {}) {
function createCert({
subject,
issuer,
publicKey,
signingKey,
extensions,
serial = "01",
}: {
subject: forge.pki.CertificateField[];
issuer: forge.pki.CertificateField[];
publicKey: forge.pki.rsa.PublicKey;
signingKey: forge.pki.rsa.PrivateKey;
extensions?: forge.pki.CertificateField[];
serial?: string;
}) {
const cert = forge.pki.createCertificate();
cert.publicKey = publicKey;
cert.serialNumber = serial;
cert.validity.notBefore = new Date();
cert.validity.notAfter = new Date(Date.now() + days * 86400000);
cert.setSubject(subject);
cert.setIssuer(issuer);
if (extensions) {
cert.setExtensions(extensions);
}
cert.sign(signingKey, forge.md.sha256.create());
return forge.pki.certificateToPem(cert);
}
const caKeys = forge.pki.rsa.generateKeyPair(2048);
const serverKeys = forge.pki.rsa.generateKeyPair(2048);
const caSubject = [{ name: "commonName", value: caCN }];
return {
ca: {
cert: createCert({
subject: caSubject,
issuer: caSubject,
publicKey: caKeys.publicKey,
signingKey: caKeys.privateKey,
extensions: [{ name: "basicConstraints", cA: true }],
}),
key: forge.pki.privateKeyToPem(caKeys.privateKey),
},
server: {
cert: createCert({
subject: [{ name: "commonName", value: serverCN }],
issuer: caSubject,
publicKey: serverKeys.publicKey,
signingKey: caKeys.privateKey,
serial: "02",
extensions: [
{
name: "subjectAltName",
altNames: [
...dns.map(function (value) {
return { type: 2, value };
}),
...ips.map(function (ip) {
return { type: 7, ip };
}),
],
},
],
}),
key: forge.pki.privateKeyToPem(serverKeys.privateKey),
},
};
}
+4
View File
@@ -54,6 +54,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^22.19.7",
"@types/node-forge": "^1.3.14",
"@types/escape-html": "^1.0.4",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
@@ -70,6 +71,7 @@
"cypress": "^14.5.4",
"dotenv-cli": "^8.0.0",
"env-cmd": "^10.1.0",
"esbuild": "^0.27.3",
"eslint": "^8.57.1",
"eslint-config-next": "15.5.9",
"eslint-config-prettier": "^9.1.2",
@@ -77,6 +79,7 @@
"grpc-tools": "^1.13.1",
"jsdom": "^26.1.0",
"lint-staged": "15.5.1",
"node-forge": "^1.3.3",
"nodemon": "^3.1.11",
"postcss": "^8.5.6",
"prettier": "^3.8.1",
@@ -85,6 +88,7 @@
"sass": "^1.97.3",
"start-server-and-test": "^2.1.3",
"tailwindcss": "3.4.14",
"testcontainers": "^11.11.0",
"ts-proto": "^2.11.1",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^5.1.4",
+22
View File
@@ -33,6 +33,7 @@
"!{projectRoot}/.local.env",
"!{projectRoot}/integration/**/*",
"!{projectRoot}/acceptance/**/*",
"!{projectRoot}/dockerized/**/*",
"!{projectRoot}/cypress.config.ts"
],
"outputs": [
@@ -52,6 +53,7 @@
"!{projectRoot}/.local.env",
"!{projectRoot}/integration/**/*",
"!{projectRoot}/acceptance/**/*",
"!{projectRoot}/dockerized/**/*",
"!{projectRoot}/cypress.config.ts"
],
"outputs": [
@@ -93,6 +95,26 @@
"^build"
]
},
"test-ca": {
"description": "Runs custom CA certificate integration tests using testcontainers",
"dependsOn": [
"build"
],
"executor": "nx:run-commands",
"cache": true,
"inputs": [
"default",
"!{projectRoot}/integration/**/*",
"!{projectRoot}/acceptance/**/*",
"!{projectRoot}/dockerized/**/output/**/*"
],
"options": {
"cwd": "{projectRoot}",
"commands": [
"pnpm vitest --run --config vitest.config.ca.mts"
]
}
},
"test-integration-run-login": {
"description": "Runs the Login application under test. It has its own target, separate from test-integration, because it's a continuous task.",
"dependsOn": [
+2 -1
View File
@@ -38,6 +38,7 @@
"exclude": [
"node_modules",
"integration",
"acceptance"
"acceptance",
"dockerized"
]
}
+11
View File
@@ -0,0 +1,11 @@
import tsconfigPaths from "vite-tsconfig-paths";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
include: ["dockerized/ca/**/*.test.ts"],
testTimeout: 30000,
hookTimeout: 180000,
},
});
+813
View File
File diff suppressed because it is too large Load Diff