diff --git a/apps/login/.prettierignore b/apps/login/.prettierignore index 413c4b52e..22dde8a90 100644 --- a/apps/login/.prettierignore +++ b/apps/login/.prettierignore @@ -2,4 +2,5 @@ !constants !src !locales -!scripts/healthcheck.js \ No newline at end of file +!scripts/healthcheck.mjs +!scripts/server.mjs \ No newline at end of file diff --git a/apps/login/Dockerfile b/apps/login/Dockerfile index 6dd7d5184..a76fabee0 100644 --- a/apps/login/Dockerfile +++ b/apps/login/Dockerfile @@ -13,9 +13,10 @@ ENV HOSTNAME="::" \ NODE_ENV="production" \ NODE_OPTIONS="--use-openssl-ca" \ SSL_CERT_FILE="/etc/ssl/certs/ca-certificates.crt" \ - SSL_CERT_DIR="/etc/ssl/certs" + SSL_CERT_DIR="/etc/ssl/certs" \ + ZITADEL_TLS_ENABLED="false" # TODO: Check healthy, not ready HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD ["/bin/sh", "-c", "node /app/healthcheck.js http://localhost:${PORT}/ui/v2/login/healthy"] + CMD ["/usr/local/bin/node", "/app/healthcheck.mjs"] ENTRYPOINT ["/app/entrypoint.sh", "node", "apps/login/server.js" ] diff --git a/apps/login/dockerized/ssl/tls.integration.test.ts b/apps/login/dockerized/ssl/tls.integration.test.ts new file mode 100644 index 000000000..dafec9e92 --- /dev/null +++ b/apps/login/dockerized/ssl/tls.integration.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { GenericContainer, type StartedTestContainer, Wait } from "testcontainers"; +import * as https from "node:https"; +import * as fs from "fs"; +import * as path from "path"; +import { fileURLToPath } from "node:url"; +import { generateCertificates } from "../ca/utils/tls.ts"; + +const TEST_DIR = path.dirname(fileURLToPath(import.meta.url)); +const OUTPUT_DIR = path.join(TEST_DIR, "output"); +const CERTS_DIR = path.join(OUTPUT_DIR, "certs"); +const LOGIN_APP_DIR = path.join(TEST_DIR, "../.."); + +/** + * Integration tests for TLS support in the login application. + * + * These tests verify that the login container can serve traffic over HTTPS + * when configured with ZITADEL_TLS_ENABLED, ZITADEL_TLS_CERTPATH, and + * ZITADEL_TLS_KEYPATH environment variables. This is essential for deployments + * where the login application must terminate TLS itself rather than relying on + * a reverse proxy. + * + * The test suite builds the login container image and runs it in two modes: + * HTTP (default) and HTTPS (with TLS enabled). For HTTPS mode, a self-signed + * certificate is generated and mounted into the container. The tests verify + * that the application starts correctly and responds to health checks in both + * modes. + */ +describe("Login Container TLS Support", () => { + let loginImage: GenericContainer; + + beforeAll(async () => { + if (fs.existsSync(OUTPUT_DIR)) { + fs.rmSync(OUTPUT_DIR, { recursive: true }); + } + fs.mkdirSync(CERTS_DIR, { recursive: true }); + + const certs = generateCertificates({ + serverCN: "localhost", + dns: ["localhost"], + ips: ["127.0.0.1"], + }); + + fs.writeFileSync(path.join(CERTS_DIR, "server.crt"), certs.server.cert); + fs.writeFileSync(path.join(CERTS_DIR, "server.key"), certs.server.key); + + loginImage = await GenericContainer.fromDockerfile(LOGIN_APP_DIR).build(); + }); + + describe("when TLS is not enabled", () => { + let container: StartedTestContainer; + let appUrl: string; + + beforeAll(async () => { + container = await loginImage + .withExposedPorts(3000) + .withWaitStrategy(Wait.forHttp("/ui/v2/login/healthy", 3000)) + .start(); + + appUrl = `http://${container.getHost()}:${container.getMappedPort(3000)}`; + }); + + afterAll(async () => { + await container?.stop(); + }); + + it("returns 200 from healthy endpoint", async () => { + const response = await fetch(`${appUrl}/ui/v2/login/healthy`); + expect(response.status).toBe(200); + }); + }); + + describe("when TLS is enabled", () => { + let container: StartedTestContainer; + let appUrl: string; + + beforeAll(async () => { + container = await loginImage + .withExposedPorts(3000) + .withEnvironment({ + ZITADEL_TLS_ENABLED: "true", + ZITADEL_TLS_CERTPATH: "/certs/server.crt", + ZITADEL_TLS_KEYPATH: "/certs/server.key", + }) + .withCopyFilesToContainer([ + { + source: path.join(CERTS_DIR, "server.crt"), + target: "/certs/server.crt", + }, + { + source: path.join(CERTS_DIR, "server.key"), + target: "/certs/server.key", + }, + ]) + .withWaitStrategy(Wait.forLogMessage(/Ready on https/)) + .start(); + + appUrl = `https://${container.getHost()}:${container.getMappedPort(3000)}`; + }); + + afterAll(async () => { + await container?.stop(); + }); + + it("returns 200 from healthy endpoint over HTTPS", async () => { + const response = await new Promise((resolve, reject) => { + https + .get(`${appUrl}/ui/v2/login/healthy`, { rejectUnauthorized: false }, (res) => { + res.resume(); + resolve(res); + }) + .on("error", reject); + }); + expect(response.statusCode).toBe(200); + }); + }); +}); diff --git a/apps/login/package.json b/apps/login/package.json index eec09a2ab..454c66192 100644 --- a/apps/login/package.json +++ b/apps/login/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "build": "NEXT_OUTPUT_MODE=standalone next build && cp -r public scripts/* .next/standalone/ && cp -r .next/static .next/standalone/apps/login/.next/ && rm .next/standalone/apps/login/.env", + "build": "NEXT_OUTPUT_MODE=standalone next build && cp -r public scripts/* .next/standalone/ && mv .next/standalone/apps/login/server.js .next/standalone/apps/login/next-server.js && cp .next/standalone/server.mjs .next/standalone/apps/login/server.js && cp -r .next/static .next/standalone/apps/login/.next/ && rm .next/standalone/apps/login/.env", "build-vercel": "next build", "dev": "HOSTNAME=127.0.0.1 ./scripts/entrypoint.sh next dev", "prod": "cd ./.next/standalone && HOSTNAME=127.0.0.1 ./entrypoint.sh node apps/login/server.js", diff --git a/apps/login/project.json b/apps/login/project.json index cca178b50..814bcd3cc 100644 --- a/apps/login/project.json +++ b/apps/login/project.json @@ -131,6 +131,26 @@ ] } }, + "test-ssl": { + "description": "Runs TLS 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.dockerized.mts dockerized/ssl" + ] + } + }, "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": [ diff --git a/apps/login/scripts/healthcheck.js b/apps/login/scripts/healthcheck.js deleted file mode 100644 index 652524154..000000000 --- a/apps/login/scripts/healthcheck.js +++ /dev/null @@ -1,14 +0,0 @@ -const url = process.argv[2]; - -if (!url) { - console.error("❌ No URL provided as command line argument."); - process.exit(1); -} - -try { - const res = await fetch(url); - if (!res.ok) process.exit(1); - process.exit(0); -} catch (e) { - process.exit(1); -} diff --git a/apps/login/scripts/healthcheck.mjs b/apps/login/scripts/healthcheck.mjs new file mode 100644 index 000000000..d5f7e41b1 --- /dev/null +++ b/apps/login/scripts/healthcheck.mjs @@ -0,0 +1,22 @@ +import * as https from "node:https"; +import * as http from "node:http"; + +const scheme = process.env.ZITADEL_TLS_ENABLED === "true" ? "https" : "http"; +const port = process.env.PORT || "3000"; +const url = new URL(process.argv[2] || `/ui/v2/login/healthy`, `${scheme}://localhost:${port}`); + +const get = scheme === "https" ? https.get : http.get; + +try { + const res = await new Promise((resolve, reject) => { + // Safe: localhost-only probe, self-signed certs expected + get(url, { rejectUnauthorized: false }, (res) => { + res.resume(); + resolve(res); + }).on("error", reject); + }); + process.exit(res.statusCode >= 200 && res.statusCode < 400 ? 0 : 1); +} catch (e) { + console.error("Healthcheck failed:", e); + process.exit(1); +} diff --git a/apps/login/scripts/server.mjs b/apps/login/scripts/server.mjs new file mode 100644 index 000000000..2dfb7125a --- /dev/null +++ b/apps/login/scripts/server.mjs @@ -0,0 +1,160 @@ +import { readFileSync, accessSync, constants } from "node:fs"; +import { createServer } from "node:https"; +import { createRequire } from "node:module"; + +if (process.env.ZITADEL_TLS_ENABLED !== "true") { + // The build script must rename the Next.js-generated server.js to next-server.js. + // Validate that the file exists before importing to provide a clearer error if the build step fails. + try { + accessSync(new URL("./next-server.js", import.meta.url), constants.R_OK); + } catch (err) { + console.error( + 'Unable to find or read "./next-server.js". Ensure the build script has renamed Next.js\'s server.js to next-server.js.', + err, + ); + process.exit(1); + } + await import("./next-server.js"); +} else { + const certPath = process.env.ZITADEL_TLS_CERTPATH; + const keyPath = process.env.ZITADEL_TLS_KEYPATH; + + if (!certPath || !keyPath) { + console.error( + "ZITADEL_TLS_ENABLED is set to true but ZITADEL_TLS_CERTPATH or ZITADEL_TLS_KEYPATH are missing. Both must be set when TLS is enabled.", + ); + process.exit(1); + } + + try { + accessSync(certPath, constants.R_OK); + } catch (err) { + console.error(`Certificate file is not readable: ${certPath}`, err); + process.exit(1); + } + + try { + accessSync(keyPath, constants.R_OK); + } catch (err) { + console.error(`Key file is not readable: ${keyPath}`, err); + process.exit(1); + } + + const cert = readFileSync(certPath); + const key = readFileSync(keyPath); + + // Requires Node.js >= 20.11.0 (or >= 21.2.0) for import.meta.dirname support. + const appDir = import.meta.dirname; + + const requiredServerFilesPath = new URL(".next/required-server-files.json", import.meta.url); + + let requiredServerFiles; + try { + accessSync(requiredServerFilesPath, constants.R_OK); + const requiredServerFilesContent = readFileSync(requiredServerFilesPath, "utf8"); + requiredServerFiles = JSON.parse(requiredServerFilesContent); + } catch (err) { + console.error( + "Failed to load .next/required-server-files.json. The Next.js build may be incomplete or the file may contain invalid JSON.", + err, + ); + process.exit(1); + } + process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(requiredServerFiles.config); + + process.env.NODE_ENV = "production"; + process.chdir(appDir); + + const currentPort = parseInt(process.env.PORT, 10) || 3000; + const hostname = process.env.HOSTNAME || "0.0.0.0"; + + // KEEP_ALIVE_TIMEOUT is expected to be in milliseconds. + // Reject values that are negative or unreasonably large to avoid resource issues. + const MAX_KEEP_ALIVE_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes + + let keepAliveTimeout = parseInt(process.env.KEEP_ALIVE_TIMEOUT, 10); + if ( + Number.isNaN(keepAliveTimeout) || + !Number.isFinite(keepAliveTimeout) || + keepAliveTimeout < 0 || + keepAliveTimeout > MAX_KEEP_ALIVE_TIMEOUT_MS + ) { + keepAliveTimeout = undefined; + } + + const require = createRequire(import.meta.url); + require("next"); + // NOTE: This imports an internal Next.js API that is not part of the public, semver-stable surface. + // - Verified with Next.js version 15.5.10 (see package.json). + // - This may break on Next.js upgrades; if Next.js is updated, re-check that + // `next/dist/server/lib/start-server` still exists and that `getRequestHandlers` behaves as expected. + // - This is currently required to support HTTPS with self-signed certificates in production mode, + // as there is no equivalent public API in Next.js at this time. + const { getRequestHandlers } = require("next/dist/server/lib/start-server"); + + const httpsServer = createServer({ key, cert }); + + if (keepAliveTimeout !== undefined) { + httpsServer.keepAliveTimeout = keepAliveTimeout; + } + + const { requestHandler, upgradeHandler } = await getRequestHandlers({ + dir: appDir, + port: currentPort, + isDev: false, + server: httpsServer, + hostname, + keepAliveTimeout, + }); + + httpsServer.on("request", async (req, res) => { + try { + await requestHandler(req, res); + } catch (err) { + res.statusCode = 500; + res.end("Internal Server Error"); + console.error(`Failed to handle request for ${req.url}`); + console.error(err); + } + }); + + httpsServer.on("upgrade", async (req, socket, head) => { + try { + await upgradeHandler(req, socket, head); + } catch (err) { + socket.destroy(); + console.error(`Failed to handle upgrade for ${req.url}`); + console.error(err); + } + }); + + httpsServer.on("error", (err) => { + console.error("Failed to start HTTPS server"); + console.error(err); + process.exit(1); + }); + + let cleanupStarted = false; + const cleanup = () => { + if (cleanupStarted) return; + cleanupStarted = true; + httpsServer.close((err) => { + if (err) { + console.error(err); + process.exit(1); + } else { + process.exit(0); + } + }); + }; + + if (!process.env.NEXT_MANUAL_SIG_HANDLE) { + process.on("SIGINT", cleanup); + process.on("SIGTERM", cleanup); + } + + httpsServer.listen(currentPort, hostname, () => { + const readyUrl = new URL(`https://${hostname}:${currentPort}`); + console.log(`> Ready on ${readyUrl}`); + }); +} diff --git a/apps/login/vitest.config.dockerized.mts b/apps/login/vitest.config.dockerized.mts new file mode 100644 index 000000000..544cb048f --- /dev/null +++ b/apps/login/vitest.config.dockerized.mts @@ -0,0 +1,14 @@ +import tsconfigPaths from "vite-tsconfig-paths"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + include: ["dockerized/**/*.test.ts"], + // Increased timeouts are intentional: Docker image build and container startup + // can be slow in CI and local environments, so we allow up to 3 minutes. + testTimeout: 180000, + hookTimeout: 180000, + fileParallelism: false, + }, +});