diff --git a/.gitignore b/.gitignore index 982be8b22b..9cb2c93362 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,7 @@ outputs/* e2e/mock-api-v2/html/* e2e/**/.playwright/* +e2e/certs/ # bundle analysis @@ -82,4 +83,4 @@ test-output # Gemini local knowledge base files GEMINI.md -**/GEMINI.md \ No newline at end of file +**/GEMINI.md diff --git a/contributing_docs/README.md b/contributing_docs/README.md index 98d92fee82..f7acbe55d5 100644 --- a/contributing_docs/README.md +++ b/contributing_docs/README.md @@ -36,6 +36,10 @@ - [Private vs. Public Packages](./releases.md#adding-a-package-to-the-repository) - [Ignoring Packages from Releases](./releases.md#adding-a-package-to-the-repository) +### Local HTTPS + +- [E2E HTTPS Bootstrap](./local-https.md#local-https-for-e2e-apps) + ## 🚀 Quick Links - [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0-beta.4/#summary) diff --git a/contributing_docs/local-https.md b/contributing_docs/local-https.md new file mode 100644 index 0000000000..c67fd8910b --- /dev/null +++ b/contributing_docs/local-https.md @@ -0,0 +1,68 @@ +# Local HTTPS for E2E apps + +This guide explains how to generate and refresh the TLS certificates used by the shared HTTPS reverse proxy in the `e2e` stack. + +## Why this exists + +Some capabilities (for example WebAuthn) require that the browser sees a fully trusted HTTPS origin. Instead of teaching every test app to serve HTTPS, we terminate TLS once at a lightweight proxy container and keep the individual apps on HTTP. The proxy reads a single certificate/key pair from `e2e/certs` and routes traffic (e.g., `/davinci`, `/ping-am`) to the existing services. + +## One-time prerequisites + +1. Install [`mkcert`](https://github.com/FiloSottile/mkcert): + - macOS: `brew install mkcert nss` + - Windows (Powershell): `choco install mkcert` or `scoop install mkcert` + - Linux: use your package manager or download the binary +2. Trust the local root into the OS/browser store. Run `mkcert -install` (the script below will do this automatically if it has not been run before). Administrator/root approval may be needed. + +If your device already trusts the Ping internal CA that issues the certificates, you can skip `mkcert` and instead place the relevant certificate/key in `e2e/certs`. For the default workflow we ship, we rely on `mkcert`. + +## Bootstrap the certificate + +From the repository root run: + +```bash +pnpm run setup:https +``` + +`pnpm run setup:https` is a thin wrapper around `scripts/bootstrap-https.sh`. The script: + +- Ensures `mkcert` is installed +- Installs the mkcert root CA into the system trust store if it is not present +- Creates (or refreshes) `e2e/certs/proxy-cert.pem` and `e2e/certs/proxy-key.pem` with SANs for `localhost`, `127.0.0.1`, and `::1` + +> The files can safely be committed to your local clone—they should **not** be committed to git. + +## Regenerating or customizing + +- Re-run `pnpm run setup:https` at any time; it overwrites the pem files in place. +- To add additional hostnames (for example `dev.ping.local`), edit `DOMAIN_LIST` inside `scripts/bootstrap-https.sh` before re-running the script. Make sure the new hostnames resolve to your proxy (via `/etc/hosts`, corporate DNS, etc.). + +## Docker integration + +The `e2e/docker-compose.yml` proxy service mounts the mkcert outputs directly (`./certs/proxy-cert.pem` and `./certs/proxy-key.pem`) into `/etc/nginx/tls/`, matching the defaults baked into the Docker image. + +When you run `pnpm https-proxy:up` (or directly `docker compose -f e2e/docker-compose.yml up`), the proxy serves `https://localhost:8443/...` using the freshly generated certificate. Because the root CA is trusted, browsers treat the origin as fully secure and WebAuthn flows work without security errors. + +If you are using a corporate-managed device, your IT team can distribute the mkcert root CA (or an equivalent internal CA) via MDM so that new developers do not need to run `mkcert -install` manually. + +## Troubleshooting + +- **Browser warning persists**: Confirm the mkcert root CA is installed in the OS trust store (run `mkcert -CAROOT` to locate it). Remove any stale certificates and rerun the bootstrap script. +- **mkcert not found**: Ensure it is on your `PATH`. Open a new shell after installation. +- **Permission issues on install**: `mkcert -install` modifies OS certificate stores and may require elevated privileges. Run the script again with the necessary rights. + +With the certificate in place, the HTTPS proxy is ready and the E2E apps can rely on secure origins without per-app TLS configuration. + +## Running Applications Behind the Proxy + +When running an application that needs to be accessed by the HTTPS proxy, you must ensure that its development server is accessible from within the Docker network. + +For Vite-based applications (like `oidc-app` or `davinci-app`), you need to start the dev server with the `--host=0.0.0.0` flag. This tells the server to listen on all available network interfaces, not just `localhost`. This is essential for the `nginx` proxy container to be able to connect to your application's dev server. + +Here is an example command: + +```bash +pnpm nx run @forgerock/davinci-app:nxServe --port=5173 --host=0.0.0.0 +``` + +If you forget to add `--host=0.0.0.0`, the proxy will not be able to reach your application, and you will see a "502 Bad Gateway" error in your browser. diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml new file mode 100644 index 0000000000..2942fdfa4a --- /dev/null +++ b/e2e/docker-compose.yml @@ -0,0 +1,20 @@ +services: + https-proxy: + build: + context: ./https-proxy + image: ping-local-https + ports: + - '8443:8443' + environment: + LISTEN_PORT: 8443 + SSL_CERT_PATH: /etc/nginx/tls/proxy-cert.pem + SSL_CERT_KEY_PATH: /etc/nginx/tls/proxy-key.pem + DAVINCI_UPSTREAM: host.docker.internal:5829 + OIDC_UPSTREAM: host.docker.internal:5173 + PROTECT_UPSTREAM: host.docker.internal:4300 + DEVICE_UPSTREAM: host.docker.internal:4301 + MOCK_API_UPSTREAM: host.docker.internal:9443 + volumes: + - ./certs:/etc/nginx/tls:ro + extra_hosts: + - 'host.docker.internal:host-gateway' diff --git a/e2e/https-proxy/Dockerfile b/e2e/https-proxy/Dockerfile new file mode 100644 index 0000000000..949991f9f4 --- /dev/null +++ b/e2e/https-proxy/Dockerfile @@ -0,0 +1,18 @@ +FROM nginx:1.27-alpine + +RUN apk add --no-cache gettext + +ENV LISTEN_PORT=8443 \ + SSL_CERT_PATH=/etc/nginx/tls/proxy-cert.pem \ + SSL_CERT_KEY_PATH=/etc/nginx/tls/proxy-key.pem \ + DAVINCI_UPSTREAM=host.docker.internal:5829 \ + OIDC_UPSTREAM=host.docker.internal:5173 \ + PROTECT_UPSTREAM=host.docker.internal:4300 \ + DEVICE_UPSTREAM=host.docker.internal:4301 \ + MOCK_API_UPSTREAM=host.docker.internal:9443 + +COPY default.conf.template /etc/nginx/templates/default.conf.template +COPY docker-entrypoint.d/ /docker-entrypoint.d/ + +EXPOSE 8443 + diff --git a/e2e/https-proxy/default.conf.template b/e2e/https-proxy/default.conf.template new file mode 100644 index 0000000000..8f9b17f7cc --- /dev/null +++ b/e2e/https-proxy/default.conf.template @@ -0,0 +1,73 @@ +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen ${LISTEN_PORT} ssl http2; + listen [::]:${LISTEN_PORT} ssl http2; + server_name _; + + ssl_certificate ${SSL_CERT_PATH}; + ssl_certificate_key ${SSL_CERT_KEY_PATH}; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers on; + + keepalive_timeout 65; + + if ($scheme = http) { + return 301 https://$host$request_uri; + } + + add_header Strict-Transport-Security "max-age=31536000" always; + add_header X-Content-Type-Options nosniff; + add_header X-Frame-Options SAMEORIGIN; + add_header X-XSS-Protection "1; mode=block"; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_cache_bypass $http_upgrade; + proxy_redirect off; + + client_max_body_size 20m; + + location /davinci/ { + rewrite ^/davinci/(.*)$ /$1 break; + proxy_pass http://${DAVINCI_UPSTREAM}; + } + + location /ping-am/ { + proxy_pass http://${OIDC_UPSTREAM}; + } + + location /ping-one/ { + proxy_pass http://${OIDC_UPSTREAM}; + } + + location /protect/ { + rewrite ^/protect/(.*)$ /$1 break; + proxy_pass http://${PROTECT_UPSTREAM}; + } + + location /device-client/ { + rewrite ^/device-client/(.*)$ /$1 break; + proxy_pass http://${DEVICE_UPSTREAM}; + } + + location /mock-api/ { + rewrite ^/mock-api/(.*)$ /$1 break; + proxy_pass http://${MOCK_API_UPSTREAM}; + } + + # Fallback to oidc app for everything else + location / { + proxy_pass http://${OIDC_UPSTREAM}; + } +} \ No newline at end of file diff --git a/e2e/https-proxy/docker-entrypoint.d/10-generate-config.sh b/e2e/https-proxy/docker-entrypoint.d/10-generate-config.sh new file mode 100644 index 0000000000..eb4f92a1d1 --- /dev/null +++ b/e2e/https-proxy/docker-entrypoint.d/10-generate-config.sh @@ -0,0 +1,17 @@ +#!/bin/sh +set -eu + +template="/etc/nginx/templates/default.conf.template" +output="/etc/nginx/conf.d/default.conf" + +echo "[https-proxy] Rendering nginx config..." +envsubst '\ +${LISTEN_PORT} \ +${SSL_CERT_PATH} \ +${SSL_CERT_KEY_PATH} \ +${DAVINCI_UPSTREAM} \ +${OIDC_UPSTREAM} \ +${PROTECT_UPSTREAM} \ +${DEVICE_UPSTREAM} \ +${MOCK_API_UPSTREAM} \ +' < "$template" > "$output" diff --git a/e2e/oidc-app/src/ping-am/main.ts b/e2e/oidc-app/src/ping-am/main.ts index ed4dcf7f91..d4953ff63c 100644 --- a/e2e/oidc-app/src/ping-am/main.ts +++ b/e2e/oidc-app/src/ping-am/main.ts @@ -14,7 +14,7 @@ const wellknown = urlParams.get('wellknown'); const config = { clientId: clientId || 'WebOAuthClient', - redirectUri: 'http://localhost:8443/ping-am/', + redirectUri: 'https://localhost:8443/ping-am', scope: 'openid profile email', serverConfig: { wellknown: diff --git a/e2e/oidc-app/src/ping-one/main.ts b/e2e/oidc-app/src/ping-one/main.ts index d0c5de5569..7be7aed82e 100644 --- a/e2e/oidc-app/src/ping-one/main.ts +++ b/e2e/oidc-app/src/ping-one/main.ts @@ -14,7 +14,7 @@ const wellknown = urlParams.get('wellknown'); const config = { clientId: clientId || '654b14e2-7cc5-4977-8104-c4113e43c537', - redirectUri: 'http://localhost:8443/ping-one/', + redirectUri: 'https://localhost:8443/ping-one', scope: 'openid revoke profile email', serverConfig: { wellknown: diff --git a/e2e/oidc-app/vite.config.ts b/e2e/oidc-app/vite.config.ts index d2a956b1a9..5c2c86aad4 100644 --- a/e2e/oidc-app/vite.config.ts +++ b/e2e/oidc-app/vite.config.ts @@ -10,12 +10,12 @@ export default defineConfig(() => ({ cacheDir: '../../node_modules/.vite/e2e/oidc-app', publicDir: __dirname + '/public', server: { - port: 8443, - host: 'localhost', + port: 5173, + host: '0.0.0.0', }, preview: { - port: 8443, - host: 'localhost', + port: 5173, + host: '0.0.0.0', }, plugins: [], // Uncomment this if you are using workers. diff --git a/package.json b/package.json index a1363ebaa3..c6ba2c7e85 100644 --- a/package.json +++ b/package.json @@ -27,9 +27,11 @@ "lint": "nx affected --target=lint", "local-release": "pnpm ts-node tools/release/release.ts", "nx": "nx", + "https-proxy:up": "docker compose -f e2e/docker-compose.yml up --build", "postinstall": "ts-patch install", "preinstall": "npx only-allow pnpm", "prepare": "node .husky/install.mjs", + "setup:https": "scripts/bootstrap-https.sh", "serve": "nx serve", "test": "CI=true nx affected:test", "test:e2e": "CI=true nx affected:e2e", diff --git a/scripts/bootstrap-https.sh b/scripts/bootstrap-https.sh new file mode 100755 index 0000000000..9a1b3ae566 --- /dev/null +++ b/scripts/bootstrap-https.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +CERT_DIR="$(git rev-parse --show-toplevel)/e2e/certs" +DOMAIN_LIST=("localhost" "127.0.0.1" "::1") + +mkdir -p "${CERT_DIR}" + +if ! command -v mkcert >/dev/null 2>&1; then + echo "mkcert not found; install it first." >&2 + exit 1 +fi + +pushd "${CERT_DIR}" >/dev/null + +if [ ! -f "$(mkcert -CAROOT)/rootCA.pem" ]; then + echo "Installing mkcert root CA..." + mkcert -install +fi + +echo "Generating proxy certificate..." +mkcert -cert-file proxy-cert.pem -key-file proxy-key.pem "${DOMAIN_LIST[@]}" + +popd >/dev/null + +echo "Certificate material written to ${CERT_DIR}"