diff --git a/apps/login/Dockerfile b/apps/login/Dockerfile index 081ce95e6..c737bef05 100644 --- a/apps/login/Dockerfile +++ b/apps/login/Dockerfile @@ -11,9 +11,8 @@ USER nextjs ENV HOSTNAME="::" \ PORT="3000" \ NODE_ENV="production" \ - NODE_OPTIONS="--use-openssl-ca" \ + NODE_OPTIONS="--use-openssl-ca --require /app/load-ssl-cert-dir.cjs" \ SSL_CERT_FILE="/etc/ssl/certs/ca-certificates.crt" \ - SSL_CERT_DIR="/etc/ssl/certs" \ ZITADEL_TLS_ENABLED="false" \ OTEL_SERVICE_NAME="zitadel-login" \ OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf" diff --git a/apps/login/dockerized/ca/ca.integration.test.ts b/apps/login/dockerized/ca/ca.integration.test.ts index fa3f0f733..9c857fb11 100644 --- a/apps/login/dockerized/ca/ca.integration.test.ts +++ b/apps/login/dockerized/ca/ca.integration.test.ts @@ -21,12 +21,20 @@ const SMOCKER_ADMIN_PORT = 8081; * 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. + * custom Certificate Authorities via SSL_CERT_FILE, SSL_CERT_DIR, and the + * load-ssl-cert-dir.cjs preload script. * - * We use Smocker as a TLS server and test CA loading by making curl requests - * from inside the container - this verifies TLS at the transport layer without - * needing gRPC/HTTP2 support. + * OpenSSL (used by Node with --use-openssl-ca) only reads certs from + * SSL_CERT_DIR when they have hashed filenames (e.g. "9d66eef0.0"). Go's + * crypto/x509 reads any file. The preload script bridges this gap so that + * operators get the same behaviour with both the Go backend and the login + * container. + * + * We use Smocker as a TLS server whose certificate is signed by a test CA + * that is NOT in the system trust store. Each test block configures the + * login container differently and asserts whether the TLS handshake to + * Smocker succeeds or fails — verifying actual trust-store behaviour, not + * just environment variable values. */ const LOGIN_IMAGE_TAG = `zitadel-login-test:${process.env.GITHUB_SHA || "local"}`; @@ -84,7 +92,7 @@ describe("Custom CA Certificate Integration", () => { await network?.stop().catch(() => {}); }); - describe("when custom CA certificate is provided", () => { + describe("when custom CA is provided via SSL_CERT_FILE", () => { let container: StartedTestContainer; beforeAll(async () => { @@ -113,24 +121,40 @@ describe("Custom CA Certificate Integration", () => { const result = await testTlsConnection(container, "https://mock-zitadel/"); expect(result.exitCode).toBe(0); }); + }); - 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"); + describe("when custom CA is provided via SSL_CERT_DIR (non-hashed filename)", () => { + let container: StartedTestContainer; + let customCaDir: string; + + beforeAll(async () => { + customCaDir = fs.mkdtempSync(path.join(os.tmpdir(), "ca-dir-test-")); + fs.writeFileSync(path.join(customCaDir, "corp-root.crt"), fs.readFileSync(path.join(certsDir, "ca.crt"))); + + container = await new GenericContainer(LOGIN_IMAGE_TAG) + .withNetwork(network) + .withExposedPorts(3000) + .withEnvironment({ + SSL_CERT_DIR: "/custom-certs", + }) + .withCopyFilesToContainer([ + { + source: path.join(customCaDir, "corp-root.crt"), + target: "/custom-certs/corp-root.crt", + }, + ]) + .withStartupTimeout(180_000) + .withWaitStrategy(Wait.forHttp("/ui/v2/login/healthy", 3000)) + .start(); + }, 180_000); + + afterAll(async () => { + await container?.stop().catch(() => {}); }); - 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"); + it("can establish TLS connection with non-hashed cert in SSL_CERT_DIR", async () => { + const result = await testTlsConnection(container, "https://mock-zitadel/"); + expect(result.exitCode).toBe(0); }); }); @@ -154,19 +178,5 @@ describe("Custom CA Certificate Integration", () => { const result = await testTlsConnection(container, "https://mock-zitadel/"); expect(result.exitCode).not.toBe(0); }); - - 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"); - }); }); }); diff --git a/apps/login/scripts/load-ssl-cert-dir.cjs b/apps/login/scripts/load-ssl-cert-dir.cjs new file mode 100644 index 000000000..386e5a1cd --- /dev/null +++ b/apps/login/scripts/load-ssl-cert-dir.cjs @@ -0,0 +1,96 @@ +// Preload script that makes Node.js read CA certificates from SSL_CERT_DIR +// the same way Go's crypto/x509 does: every regular file in the directory is +// read regardless of filename. OpenSSL (used by Node when --use-openssl-ca +// is set) only reads files named with a subject-hash (e.g. "9d66eef0.0"), +// silently ignoring plain names like "corp-ca.crt". This script closes that +// gap so operators get identical behaviour across the Go backend and the +// Node.js login container. +// +// Usage: node --require /app/load-ssl-cert-dir.cjs ... +// +// The script is a no-op when SSL_CERT_DIR is not set. + +"use strict"; + +const tls = require("tls"); +const fs = require("fs"); +const path = require("path"); + +const sslCertDir = process.env.SSL_CERT_DIR; +if (!sslCertDir) { + return; // nothing to do — system trust via SSL_CERT_FILE is sufficient +} + +// Go splits on ":" and walks every directory in order. +const dirs = sslCertDir.split(":").filter(Boolean); +const certs = []; + +for (const dir of dirs) { + let entries; + try { + entries = fs.readdirSync(dir); + } catch { + // Directory doesn't exist or isn't readable — skip, like Go does. + continue; + } + + for (const entry of entries) { + const filePath = path.join(dir, entry); + + try { + const stat = fs.lstatSync(filePath); + + // Go's readUniqueDirectoryEntries skips symlinks whose target is a + // bare filename (no slash), because those are the c_rehash hash links + // pointing at files in the same directory. This avoids loading the + // same certificate twice. + if (stat.isSymbolicLink()) { + const target = fs.readlinkSync(filePath); + if (!target.includes("/")) { + continue; + } + } + + if (!stat.isFile() && !stat.isSymbolicLink()) { + continue; + } + + const content = fs.readFileSync(filePath, "utf8"); + if (content.includes("-----BEGIN CERTIFICATE-----")) { + certs.push(content); + } + } catch { + // Unreadable file — skip silently, matching Go behaviour. + continue; + } + } +} + +if (certs.length === 0) { + return; +} + +const origCreateSecureContext = tls.createSecureContext; + +tls.createSecureContext = function (options) { + const ctx = origCreateSecureContext.call(this, options); + + // Only inject directory CAs when the caller did not pass an explicit "ca" + // option. This matches Go's SystemCertPool() semantics: the extra certs + // are part of the system pool, not a forced override. + if (!options || !options.ca) { + for (let i = 0; i < certs.length; i++) { + try { + ctx.context.addCACert(certs[i]); + } catch (err) { + // Log once per bad cert, then remove it so subsequent contexts + // don't pay the cost of retrying a known-bad certificate. + console.error("load-ssl-cert-dir: skipping malformed certificate at index " + i + ": " + err.message); + certs.splice(i, 1); + i--; + } + } + } + + return ctx; +};