mirror of
https://github.com/zitadel/zitadel.git
synced 2026-08-17 16:35:14 -05:00
feat(login): use OpenSSL CA store for TLS certificate validation (#11562)
This commit is contained in:
@@ -13,3 +13,4 @@ dist-ssr
|
||||
*.local
|
||||
.vscode
|
||||
/blob-report/
|
||||
dockerized/*/output/
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"integration",
|
||||
"acceptance"
|
||||
"acceptance",
|
||||
"dockerized"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Generated
+813
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user