From 1dd456d5e03ea4334ef2fe5096be0c6f927dbfdc Mon Sep 17 00:00:00 2001 From: Divyam Talwar Date: Sat, 4 Jul 2026 00:55:11 +0530 Subject: [PATCH] Prevent Docker auth from using development signing secrets Authenticated Docker deployments could start with missing or placeholder signing material, including a development session secret. This fails fast for weak auth secrets, generates session secrets in hosted installers, and keeps Docker secret delivery compatible with both optional .env files and shell-injected CI environments. Constraint: Preserve bypass-mode local development defaults, keep blank LOCAL_URI_PASSWORD as the disabled /local/generate_uri state, and avoid making .env mandatory for CI/secret-manager deployments. Rejected: Raw Compose env_file parsing | it raises the Compose requirement and conflicts with preserving shell-injected secret workflows. Rejected: Compose required-variable syntax | it would break bypass-mode local development before app-level config can apply defaults. Confidence: high Scope-risk: moderate Directive: Keep LOCAL_URI_PASSWORD optional at startup; validate it only when configured with a nonblank value. Tested: .venv/bin/python -m pytest core/tests/unit/test_config_auth_secrets.py core/tests/unit/test_installer_auth_env.py -q (58 passed, 1 skipped); bash -n install_docker.sh && bash -n install_and_start.sh; .venv/bin/python -m py_compile core/config.py core/api.py core/local_uri.py core/tests/unit/test_config_auth_secrets.py core/tests/unit/test_installer_auth_env.py; uv run ty check core/tests/unit/test_installer_auth_env.py core/tests/unit/test_config_auth_secrets.py core/local_uri.py; git diff --check Not-tested: PowerShell parser execution skipped locally because pwsh/powershell is unavailable; ruff unavailable in local project environment. --- DOCKER.md | 42 ++- core/api.py | 6 +- core/config.py | 94 +++++- core/local_uri.py | 11 + core/tests/unit/test_config_auth_secrets.py | 290 ++++++++++++++++++ core/tests/unit/test_installer_auth_env.py | 308 ++++++++++++++++++++ docker-compose.run.yml | 14 +- docker-compose.yml | 14 +- install_docker.ps1 | 111 ++++++- install_docker.sh | 111 ++++++- 10 files changed, 958 insertions(+), 43 deletions(-) create mode 100644 core/local_uri.py create mode 100644 core/tests/unit/test_config_auth_secrets.py create mode 100644 core/tests/unit/test_installer_auth_env.py diff --git a/DOCKER.md b/DOCKER.md index a9e02c75..5d81393b 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -4,7 +4,7 @@ Morphik Core provides a streamlined Docker-based setup that includes all necessa ## Prerequisites -- Docker and Docker Compose installed on your system +- Docker and Docker Compose 2.24.0 or newer installed on your system - At least 10GB of free disk space (for models and data) - 8GB+ RAM recommended @@ -16,7 +16,19 @@ git clone https://github.com/morphik-org/morphik-core.git cd morphik-core ``` -2. First-time setup: +2. Create a `.env` file for Docker secrets: +```bash +umask 077 +cat > .env < # Important: Change in production +SESSION_SECRET_KEY=<32+-character-random-hex-secret> # Important: Change in production +LOCAL_URI_PASSWORD=<32+-character-random-hex-secret> # Only needed for /local/generate_uri OPENAI_API_KEY=sk-... # Only if using OpenAI HOST=0.0.0.0 # Leave as is for Docker PORT=8000 # Change if needed ``` +When `bypass_auth_mode = false`, `JWT_SECRET_KEY` and `SESSION_SECRET_KEY` must be non-empty, non-placeholder values with at least 32 characters. If `LOCAL_URI_PASSWORD` is unset or blank, `/local/generate_uri` is disabled; if you set it, use a non-placeholder value with at least 32 characters. When writing secrets to `.env`, use hex values such as `openssl rand -hex 32` so Docker Compose does not treat characters like `$`, quotes, or `#` as env-file syntax. + +Upgrade note: existing authenticated Docker deployments must verify that `JWT_SECRET_KEY` and `SESSION_SECRET_KEY` are both non-placeholder random values with at least 32 characters before pulling an image with this validation. Use the same values for both the `morphik` API service and the `worker` service through `.env` or shell-exported environment variables. If `LOCAL_URI_PASSWORD` is set, replace weak or placeholder values with a non-placeholder 32+ character value, or clear it to disable `/local/generate_uri`. + ### 4. Custom Configuration To use your own configuration: @@ -135,12 +153,17 @@ services: - Check PostgreSQL is healthy: `docker compose ps` - Verify database connection: `docker compose exec postgres psql -U morphik -d morphik` -3. **Model Download Issues** +3. **Auth Secret Issues** + - If startup fails with `JWT_SECRET_KEY` or `SESSION_SECRET_KEY` validation errors, set both values in `.env` to non-placeholder random strings with at least 32 characters and restart + - If startup fails with `LOCAL_URI_PASSWORD` validation errors, replace it with a non-placeholder value with at least 32 characters, or clear it to disable `/local/generate_uri` + - If `/local/generate_uri` returns HTTP `503` with `LOCAL_URI_PASSWORD is not configured; /local/generate_uri is disabled`, set `LOCAL_URI_PASSWORD` in `.env` to a non-placeholder value with at least 32 characters before using that endpoint + +4. **Model Download Issues** - Check Ollama logs: `docker compose logs ollama` - Ensure enough disk space for models - Try restarting Ollama: `docker compose restart ollama` -4. **Performance Issues** +5. **Performance Issues** - Monitor resources: `docker stats` - Ensure sufficient RAM (8GB+ recommended) - Check disk space: `df -h` @@ -150,7 +173,8 @@ services: For production environments: 1. **Security**: - - Change the default `JWT_SECRET_KEY` + - Use randomly generated `JWT_SECRET_KEY` and `SESSION_SECRET_KEY` values of at least 32 characters; do not rely on example or development defaults + - Set a randomly generated `LOCAL_URI_PASSWORD` of at least 32 characters before using `/local/generate_uri` - Use proper network security groups - Enable HTTPS (recommended: use a reverse proxy) - Regularly update containers and dependencies diff --git a/core/api.py b/core/api.py index 1fd9bc5d..a5618ab7 100644 --- a/core/api.py +++ b/core/api.py @@ -31,6 +31,7 @@ from core.limits_utils import check_and_increment_limits from core.logging_config import setup_logging from core.middleware.profiling import ProfilingMiddleware +from core.local_uri import require_local_uri_password_configured from core.models.auth import AuthContext from core.models.chat import ChatMessage from core.models.completion import CompletionResponse @@ -970,14 +971,13 @@ async def get_available_models_for_selection(auth: AuthContext = Depends(verify_ async def generate_local_uri( name: str = Form("admin"), expiry_days: int = Form(5475), # 15 years - password_token: str = Form(...), + password_token: Optional[str] = Form(None), server_mode: bool = Form(False), ) -> Dict[str, str]: """Generate a development URI for running Morphik locally.""" try: # Authenticate with LOCAL_URI_PASSWORD - if not settings.LOCAL_URI_PASSWORD: - raise HTTPException(status_code=500, detail="LOCAL_URI_PASSWORD not configured") + require_local_uri_password_configured(settings.LOCAL_URI_PASSWORD) if password_token != settings.LOCAL_URI_PASSWORD: raise HTTPException(status_code=401, detail="Invalid authentication token") diff --git a/core/config.py b/core/config.py index 6b6388fe..cba7e2de 100644 --- a/core/config.py +++ b/core/config.py @@ -12,6 +12,8 @@ # injecting variables. load_local_env(override=True) +AUTH_SECRET_MIN_LENGTH = 32 + class ParserXMLSettings(BaseModel): max_tokens: int = 350 @@ -185,6 +187,72 @@ def get_settings() -> Settings: em = "'{missing_value}' needed if '{field}' is set to '{value}'" settings_dict = {} + def normalize_auth_secret(value: str) -> str: + normalized = value.strip() + if len(normalized) >= 2 and ( + (normalized[0] == '"' and normalized[-1] == '"') + or (normalized[0] == "'" and normalized[-1] == "'") + ): + normalized = normalized[1:-1].strip() + return normalized + + def env_or_default(name: str, default: str) -> str: + value = os.environ.get(name) + if value is None: + return default + normalized = normalize_auth_secret(value) + return normalized or default + + def validate_auth_secrets(secret_values: Dict[str, str], *, context: str) -> None: + insecure_values = { + "JWT_SECRET_KEY": { + "dev-secret-key", + "", + "your-secret-key-here", + "your-secure-jwt-key-here", + "your-super-secret-key-change-in-production", + }, + "SESSION_SECRET_KEY": { + "", + "super-secret-dev-session-key", + "your-secure-session-key-here", + "your-session-secret-key-change-in-production", + }, + "LOCAL_URI_PASSWORD": { + "", + "change-me-local-uri-password", + "local-uri-password", + "your-local-uri-password-here", + }, + } + missing = [name for name, value in secret_values.items() if not value] + if missing: + secret_names = ", ".join(missing) + verb = "is" if len(missing) == 1 else "are" + raise ValueError(f"{secret_names} {verb} required {context}") + + placeholders = [ + name + for name, value in secret_values.items() + if value in insecure_values[name] or (value.startswith("<") and value.endswith(">")) + ] + if placeholders: + secret_names = ", ".join(placeholders) + verb = "uses" if len(placeholders) == 1 else "use" + raise ValueError( + f"{secret_names} {verb} an example or development default value; " + f"set non-placeholder values {context}" + ) + + short = [name for name, value in secret_values.items() if len(value) < AUTH_SECRET_MIN_LENGTH] + if short: + secret_names = ", ".join(short) + verb = "is" if len(short) == 1 else "are" + raise ValueError( + f"{secret_names} {verb} too short; set values with at least " + f"{AUTH_SECRET_MIN_LENGTH} characters {context}" + ) + # Load API config settings_dict.update( { @@ -207,19 +275,32 @@ def get_settings() -> Settings: ) # Load auth config + local_uri_password = normalize_auth_secret(os.environ.get("LOCAL_URI_PASSWORD", "")) settings_dict.update( { "JWT_ALGORITHM": config["auth"]["jwt_algorithm"], - "JWT_SECRET_KEY": os.environ.get("JWT_SECRET_KEY", "dev-secret-key"), # Default for bypass mode - "SESSION_SECRET_KEY": os.environ.get("SESSION_SECRET_KEY", "super-secret-dev-session-key"), + "JWT_SECRET_KEY": env_or_default("JWT_SECRET_KEY", "dev-secret-key"), # Default for bypass mode + "SESSION_SECRET_KEY": env_or_default("SESSION_SECRET_KEY", "super-secret-dev-session-key"), + "LOCAL_URI_PASSWORD": local_uri_password or None, "bypass_auth_mode": config["auth"].get("bypass_auth_mode", config["auth"].get("dev_mode", False)), "dev_user_id": config["auth"].get("dev_user_id", config["auth"].get("dev_entity_id", "dev_user")), } ) - # Only require JWT_SECRET_KEY in non-dev mode - if not settings_dict["bypass_auth_mode"] and "JWT_SECRET_KEY" not in os.environ: - raise ValueError("JWT_SECRET_KEY is required when bypass_auth_mode is disabled") + # Authenticated mode must not start with missing, example, or weak signing secrets. + if not settings_dict["bypass_auth_mode"]: + signing_secret_values = { + "JWT_SECRET_KEY": normalize_auth_secret(os.environ.get("JWT_SECRET_KEY", "")), + "SESSION_SECRET_KEY": normalize_auth_secret(os.environ.get("SESSION_SECRET_KEY", "")), + } + validate_auth_secrets(signing_secret_values, context="when bypass_auth_mode is disabled") + settings_dict.update(signing_secret_values) + + if settings_dict["LOCAL_URI_PASSWORD"]: + validate_auth_secrets( + {"LOCAL_URI_PASSWORD": settings_dict["LOCAL_URI_PASSWORD"]}, + context="before using /local/generate_uri", + ) # Load registered models if available if "registered_models" in config: @@ -438,9 +519,6 @@ def get_settings() -> Settings: settings_dict["TELEMETRY_ENABLED"] = os.getenv("TELEMETRY", "").strip().lower() != "false" - # Load LOCAL_URI_PASSWORD from environment - settings_dict["LOCAL_URI_PASSWORD"] = os.environ.get("LOCAL_URI_PASSWORD") - # Load LiteLLM config (dummy API key for providers that don't need auth) settings_dict["LITELLM_DUMMY_API_KEY"] = os.environ.get("LITELLM_DUMMY_API_KEY", "ollama") diff --git a/core/local_uri.py b/core/local_uri.py new file mode 100644 index 00000000..04d1a836 --- /dev/null +++ b/core/local_uri.py @@ -0,0 +1,11 @@ +from typing import Optional + +from fastapi import HTTPException + + +LOCAL_URI_PASSWORD_DISABLED_DETAIL = "LOCAL_URI_PASSWORD is not configured; /local/generate_uri is disabled" + + +def require_local_uri_password_configured(local_uri_password: Optional[str]) -> None: + if not local_uri_password: + raise HTTPException(status_code=503, detail=LOCAL_URI_PASSWORD_DISABLED_DETAIL) diff --git a/core/tests/unit/test_config_auth_secrets.py b/core/tests/unit/test_config_auth_secrets.py new file mode 100644 index 00000000..432c675d --- /dev/null +++ b/core/tests/unit/test_config_auth_secrets.py @@ -0,0 +1,290 @@ +"""Unit tests for authentication secret settings.""" + +from pathlib import Path +import re + +import pytest + +from core.config import get_settings + + +ROOT_CONFIG = Path(__file__).resolve().parents[3] / "morphik.toml" +STRONG_JWT_SECRET = "jwt-secret-0123456789abcdef0123456789" +STRONG_SESSION_SECRET = "session-secret-0123456789abcdef0123456789" +STRONG_LOCAL_URI_PASSWORD = "local-uri-password-0123456789abcdef0123456789" + + +@pytest.fixture(autouse=True) +def clear_settings_cache(monkeypatch): + get_settings.cache_clear() + monkeypatch.delenv("JWT_SECRET_KEY", raising=False) + monkeypatch.delenv("SESSION_SECRET_KEY", raising=False) + monkeypatch.delenv("LOCAL_URI_PASSWORD", raising=False) + yield + get_settings.cache_clear() + + +def _write_config(tmp_path, *, bypass_auth_mode): + text = ROOT_CONFIG.read_text() + replacement = f"bypass_auth_mode = {'true' if bypass_auth_mode else 'false'}" + text, replacements = re.subn(r"bypass_auth_mode = (true|false)", replacement, text, count=1) + assert replacements == 1 + (tmp_path / "morphik.toml").write_text(text) + + +def test_requires_session_secret_when_auth_bypass_is_disabled(tmp_path, monkeypatch): + _write_config(tmp_path, bypass_auth_mode=False) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("POSTGRES_URI", "postgresql://user:pass@localhost:5432/test") + monkeypatch.setenv("JWT_SECRET_KEY", STRONG_JWT_SECRET) + monkeypatch.delenv("SESSION_SECRET_KEY", raising=False) + + with pytest.raises(ValueError, match="SESSION_SECRET_KEY is required when bypass_auth_mode is disabled"): + get_settings() + + +def test_requires_both_signing_secrets_when_auth_bypass_is_disabled(tmp_path, monkeypatch): + _write_config(tmp_path, bypass_auth_mode=False) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("POSTGRES_URI", "postgresql://user:pass@localhost:5432/test") + monkeypatch.delenv("JWT_SECRET_KEY", raising=False) + monkeypatch.delenv("SESSION_SECRET_KEY", raising=False) + + with pytest.raises( + ValueError, + match="JWT_SECRET_KEY, SESSION_SECRET_KEY are required when bypass_auth_mode is disabled", + ): + get_settings() + + +@pytest.mark.parametrize("secret_value", ["", " ", '""', '" "', "''", "' '"]) +def test_rejects_blank_session_secret_when_auth_bypass_is_disabled(tmp_path, monkeypatch, secret_value): + _write_config(tmp_path, bypass_auth_mode=False) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("POSTGRES_URI", "postgresql://user:pass@localhost:5432/test") + monkeypatch.setenv("JWT_SECRET_KEY", STRONG_JWT_SECRET) + monkeypatch.setenv("SESSION_SECRET_KEY", secret_value) + + with pytest.raises(ValueError, match="SESSION_SECRET_KEY is required when bypass_auth_mode is disabled"): + get_settings() + + +@pytest.mark.parametrize("secret_value", ["", " ", '""', '" "', "''", "' '"]) +def test_rejects_blank_jwt_secret_when_auth_bypass_is_disabled(tmp_path, monkeypatch, secret_value): + _write_config(tmp_path, bypass_auth_mode=False) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("POSTGRES_URI", "postgresql://user:pass@localhost:5432/test") + monkeypatch.setenv("JWT_SECRET_KEY", secret_value) + monkeypatch.setenv("SESSION_SECRET_KEY", STRONG_SESSION_SECRET) + + with pytest.raises(ValueError, match="JWT_SECRET_KEY is required when bypass_auth_mode is disabled"): + get_settings() + + +@pytest.mark.parametrize( + ("jwt_secret", "session_secret", "error_match"), + [ + ( + "your-super-secret-key-change-in-production", + STRONG_SESSION_SECRET, + "JWT_SECRET_KEY uses an example or development default value", + ), + ( + "dev-secret-key", + STRONG_SESSION_SECRET, + "JWT_SECRET_KEY uses an example or development default value", + ), + ( + STRONG_JWT_SECRET, + "your-session-secret-key-change-in-production", + "SESSION_SECRET_KEY uses an example or development default value", + ), + ( + STRONG_JWT_SECRET, + "super-secret-dev-session-key", + "SESSION_SECRET_KEY uses an example or development default value", + ), + ( + "your-secure-jwt-key-here", + "your-secure-session-key-here", + "JWT_SECRET_KEY, SESSION_SECRET_KEY use an example or development default value", + ), + ( + '"dev-secret-key"', + STRONG_SESSION_SECRET, + "JWT_SECRET_KEY uses an example or development default value", + ), + ( + STRONG_JWT_SECRET, + "'your-secure-session-key-here'", + "SESSION_SECRET_KEY uses an example or development default value", + ), + ], +) +def test_rejects_example_auth_secrets_when_auth_bypass_is_disabled( + tmp_path, monkeypatch, jwt_secret, session_secret, error_match +): + _write_config(tmp_path, bypass_auth_mode=False) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("POSTGRES_URI", "postgresql://user:pass@localhost:5432/test") + monkeypatch.setenv("JWT_SECRET_KEY", jwt_secret) + monkeypatch.setenv("SESSION_SECRET_KEY", session_secret) + + with pytest.raises(ValueError, match=error_match): + get_settings() + + +@pytest.mark.parametrize( + ("jwt_secret", "session_secret", "error_match"), + [ + ("short-jwt-secret", STRONG_SESSION_SECRET, "JWT_SECRET_KEY is too short"), + (STRONG_JWT_SECRET, "short-session-secret", "SESSION_SECRET_KEY is too short"), + ("short-jwt-secret", "short-session-secret", "JWT_SECRET_KEY, SESSION_SECRET_KEY are too short"), + ], +) +def test_rejects_short_auth_secrets_when_auth_bypass_is_disabled( + tmp_path, monkeypatch, jwt_secret, session_secret, error_match +): + _write_config(tmp_path, bypass_auth_mode=False) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("POSTGRES_URI", "postgresql://user:pass@localhost:5432/test") + monkeypatch.setenv("JWT_SECRET_KEY", jwt_secret) + monkeypatch.setenv("SESSION_SECRET_KEY", session_secret) + + with pytest.raises(ValueError, match=error_match): + get_settings() + + +def test_allows_missing_local_uri_password_when_auth_bypass_is_disabled(tmp_path, monkeypatch): + _write_config(tmp_path, bypass_auth_mode=False) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("POSTGRES_URI", "postgresql://user:pass@localhost:5432/test") + monkeypatch.setenv("JWT_SECRET_KEY", STRONG_JWT_SECRET) + monkeypatch.setenv("SESSION_SECRET_KEY", STRONG_SESSION_SECRET) + monkeypatch.delenv("LOCAL_URI_PASSWORD", raising=False) + + settings = get_settings() + + assert settings.LOCAL_URI_PASSWORD is None + assert settings.bypass_auth_mode is False + + +@pytest.mark.parametrize("local_uri_password", ["", " ", '""', '" "', "''", "' '"]) +def test_treats_blank_local_uri_password_as_disabled(tmp_path, monkeypatch, local_uri_password): + _write_config(tmp_path, bypass_auth_mode=False) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("POSTGRES_URI", "postgresql://user:pass@localhost:5432/test") + monkeypatch.setenv("JWT_SECRET_KEY", STRONG_JWT_SECRET) + monkeypatch.setenv("SESSION_SECRET_KEY", STRONG_SESSION_SECRET) + monkeypatch.setenv("LOCAL_URI_PASSWORD", local_uri_password) + + settings = get_settings() + + assert settings.LOCAL_URI_PASSWORD is None + assert settings.bypass_auth_mode is False + + +def test_loads_strong_local_uri_password_when_configured(tmp_path, monkeypatch): + _write_config(tmp_path, bypass_auth_mode=False) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("POSTGRES_URI", "postgresql://user:pass@localhost:5432/test") + monkeypatch.setenv("JWT_SECRET_KEY", STRONG_JWT_SECRET) + monkeypatch.setenv("SESSION_SECRET_KEY", STRONG_SESSION_SECRET) + monkeypatch.setenv("LOCAL_URI_PASSWORD", f'"{STRONG_LOCAL_URI_PASSWORD}"') + + settings = get_settings() + + assert settings.LOCAL_URI_PASSWORD == STRONG_LOCAL_URI_PASSWORD + + +@pytest.mark.parametrize( + ("local_uri_password", "error_match"), + [ + ("change-me-local-uri-password", "LOCAL_URI_PASSWORD uses an example or development default value"), + ("your-local-uri-password-here", "LOCAL_URI_PASSWORD uses an example or development default value"), + ("", "LOCAL_URI_PASSWORD uses an example or development default value"), + ("short-local-uri-password", "LOCAL_URI_PASSWORD is too short"), + ], +) +def test_rejects_weak_local_uri_password_when_configured( + tmp_path, monkeypatch, local_uri_password, error_match +): + _write_config(tmp_path, bypass_auth_mode=False) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("POSTGRES_URI", "postgresql://user:pass@localhost:5432/test") + monkeypatch.setenv("JWT_SECRET_KEY", STRONG_JWT_SECRET) + monkeypatch.setenv("SESSION_SECRET_KEY", STRONG_SESSION_SECRET) + monkeypatch.setenv("LOCAL_URI_PASSWORD", local_uri_password) + + with pytest.raises(ValueError, match=error_match): + get_settings() + + +def test_rejects_weak_local_uri_password_when_auth_bypass_is_enabled(tmp_path, monkeypatch): + _write_config(tmp_path, bypass_auth_mode=True) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("POSTGRES_URI", "postgresql://user:pass@localhost:5432/test") + monkeypatch.setenv("LOCAL_URI_PASSWORD", "short-local-uri-password") + + with pytest.raises(ValueError, match="LOCAL_URI_PASSWORD is too short"): + get_settings() + + +def test_accepts_auth_secrets_at_minimum_length_when_auth_bypass_is_disabled(tmp_path, monkeypatch): + jwt_secret = "j" * 32 + session_secret = "s" * 32 + _write_config(tmp_path, bypass_auth_mode=False) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("POSTGRES_URI", "postgresql://user:pass@localhost:5432/test") + monkeypatch.setenv("JWT_SECRET_KEY", jwt_secret) + monkeypatch.setenv("SESSION_SECRET_KEY", session_secret) + monkeypatch.setenv("LOCAL_URI_PASSWORD", "") + + settings = get_settings() + + assert settings.JWT_SECRET_KEY == jwt_secret + assert settings.SESSION_SECRET_KEY == session_secret + assert settings.LOCAL_URI_PASSWORD is None + assert settings.bypass_auth_mode is False + + +def test_allows_default_auth_secrets_when_auth_bypass_is_enabled(tmp_path, monkeypatch): + _write_config(tmp_path, bypass_auth_mode=True) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("POSTGRES_URI", "postgresql://user:pass@localhost:5432/test") + monkeypatch.delenv("JWT_SECRET_KEY", raising=False) + monkeypatch.delenv("SESSION_SECRET_KEY", raising=False) + + settings = get_settings() + + assert settings.JWT_SECRET_KEY == "dev-secret-key" + assert settings.SESSION_SECRET_KEY == "super-secret-dev-session-key" + assert settings.bypass_auth_mode is True + + +def test_uses_default_auth_secrets_for_blank_values_when_auth_bypass_is_enabled(tmp_path, monkeypatch): + _write_config(tmp_path, bypass_auth_mode=True) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("POSTGRES_URI", "postgresql://user:pass@localhost:5432/test") + monkeypatch.setenv("JWT_SECRET_KEY", "") + monkeypatch.setenv("SESSION_SECRET_KEY", " ") + + settings = get_settings() + + assert settings.JWT_SECRET_KEY == "dev-secret-key" + assert settings.SESSION_SECRET_KEY == "super-secret-dev-session-key" + assert settings.bypass_auth_mode is True + + +def test_uses_default_auth_secrets_for_quoted_blank_values_when_auth_bypass_is_enabled(tmp_path, monkeypatch): + _write_config(tmp_path, bypass_auth_mode=True) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("POSTGRES_URI", "postgresql://user:pass@localhost:5432/test") + monkeypatch.setenv("JWT_SECRET_KEY", '""') + monkeypatch.setenv("SESSION_SECRET_KEY", "' '") + + settings = get_settings() + + assert settings.JWT_SECRET_KEY == "dev-secret-key" + assert settings.SESSION_SECRET_KEY == "super-secret-dev-session-key" + assert settings.bypass_auth_mode is True diff --git a/core/tests/unit/test_installer_auth_env.py b/core/tests/unit/test_installer_auth_env.py new file mode 100644 index 00000000..e082a966 --- /dev/null +++ b/core/tests/unit/test_installer_auth_env.py @@ -0,0 +1,308 @@ +"""Regression tests for Docker installer auth-secret environment wiring.""" + +from pathlib import Path +import re +import shlex +import shutil +import subprocess + +import pytest +from fastapi import HTTPException + +from core.local_uri import LOCAL_URI_PASSWORD_DISABLED_DETAIL, require_local_uri_password_configured + + +ROOT = Path(__file__).resolve().parents[3] + + +def _bash_function(source: str, name: str) -> str: + match = re.search(rf"(?ms)^{re.escape(name)}\(\) \{{.*?^\}}", source) + assert match is not None + return match.group(0) + + +def _powershell_executable() -> str: + executable = shutil.which("pwsh") or shutil.which("powershell") + if executable is None: + pytest.skip("PowerShell runtime is required for PowerShell installer parser tests") + assert executable is not None + return executable + + +def test_hosted_docker_installers_generate_session_secret(): + bash_installer = (ROOT / "install_docker.sh").read_text() + powershell_installer = (ROOT / "install_docker.ps1").read_text() + + assert 'generate_auth_secret "morphik-jwt"' in bash_installer + assert 'generate_auth_secret "morphik-session"' in bash_installer + assert "openssl rand -hex 32" in bash_installer + assert "Could not generate a secure auth secret" in bash_installer + assert "$jwt = \"morphik-jwt-$(New-RandomHex 32)\"" in powershell_installer + assert "$session = \"morphik-session-$(New-RandomHex 32)\"" in powershell_installer + assert "\"SESSION_SECRET_KEY=$session\"" in powershell_installer + + +def test_compose_files_do_not_fallback_to_placeholder_auth_secrets(): + def service_block(compose_text: str, service: str) -> str: + match = re.search(rf"(?ms)^ {service}:\n(?P.*?)(?=^ [a-zA-Z0-9_-]+:\n|\Z)", compose_text) + assert match is not None + return match.group("body") + + for compose_file in ("docker-compose.yml", "docker-compose.run.yml"): + compose_text = (ROOT / compose_file).read_text() + assert "JWT_SECRET_KEY=${JWT_SECRET_KEY:-your-secret-key-here}" not in compose_text + assert "JWT_SECRET_KEY=${JWT_SECRET_KEY:-}" in compose_text + assert "SESSION_SECRET_KEY=${SESSION_SECRET_KEY:-}" in compose_text + assert "LOCAL_URI_PASSWORD=${LOCAL_URI_PASSWORD:-}" in compose_text + + morphik_block = service_block(compose_text, "morphik") + worker_block = service_block(compose_text, "worker") + + for block in (morphik_block, worker_block): + assert "env_file:" in block + assert " - path: .env" in block + assert " required: false" in block + assert " format: raw" not in block + assert " - JWT_SECRET_KEY=${JWT_SECRET_KEY:-}" in block + assert " - SESSION_SECRET_KEY=${SESSION_SECRET_KEY:-}" in block + assert " - LOCAL_URI_PASSWORD=${LOCAL_URI_PASSWORD:-}" in block + + +def test_installers_require_compose_version_that_supports_optional_env_file(): + bash_installer = (ROOT / "install_docker.sh").read_text() + powershell_installer = (ROOT / "install_docker.ps1").read_text() + docker_docs = (ROOT / "DOCKER.md").read_text() + + assert 'MIN_COMPOSE_VERSION="2.24.0"' in bash_installer + assert "compose_version_at_least" in bash_installer + assert "optional env_file support" in bash_installer + assert "$script:MinComposeVersion = [Version]'2.24.0'" in powershell_installer + assert "optional env_file support" in powershell_installer + assert "Docker and Docker Compose 2.24.0 or newer" in docker_docs + + +def test_bash_installer_compose_version_check_handles_boundaries(): + bash_installer = (ROOT / "install_docker.sh").read_text() + function_def = _bash_function(bash_installer, "compose_version_at_least") + command = f""" + set -e + {function_def} + compose_version_at_least 2.24.0 2.24.0 + compose_version_at_least v2.24.1 2.24.0 + compose_version_at_least 3.0.0 2.24.0 + ! compose_version_at_least 2.23.9 2.24.0 + ! compose_version_at_least invalid 2.24.0 + """ + + subprocess.run(["bash", "-c", command], check=True, text=True) + + +def test_bash_installer_stops_before_writing_env_when_secret_generation_fails(tmp_path): + bash_installer = (ROOT / "install_docker.sh").read_text() + function_defs = "\n\n".join( + _bash_function(bash_installer, name) + for name in ("print_error", "protect_env_file", "generate_auth_secret") + ) + command = f""" + set -e + PATH=/no-such-command + {function_defs} + jwt_secret="$(generate_auth_secret "morphik-jwt")" + cat > .env < .env < .env <", + '"123456789012345678901234567890"', + ], +) +def test_bash_installer_stops_before_writing_invalid_local_uri_password(tmp_path, local_uri_password): + bash_installer = (ROOT / "install_docker.sh").read_text() + function_defs = "\n\n".join( + _bash_function(bash_installer, name) + for name in ("print_error", "protect_env_file", "set_env_value", "normalize_auth_secret", "validate_local_uri_password") + ) + command = f""" + set -e + AUTH_SECRET_MIN_LENGTH=32 + {function_defs} + local_uri_password="$(normalize_auth_secret {shlex.quote(local_uri_password)})" + validate_local_uri_password "$local_uri_password" || exit 1 + set_env_value "LOCAL_URI_PASSWORD" "$local_uri_password" + """ + + result = subprocess.run(["bash", "-c", command], cwd=tmp_path, capture_output=True, text=True, check=False) + + assert result.returncode != 0 + assert "LOCAL_URI_PASSWORD" in result.stderr + assert not (tmp_path / ".env").exists() + + +def test_local_uri_endpoint_has_explicit_disabled_response(): + api_source = (ROOT / "core/api.py").read_text() + + assert "password_token: Optional[str] = Form(None)" in api_source + assert "require_local_uri_password_configured(settings.LOCAL_URI_PASSWORD)" in api_source + + with pytest.raises(HTTPException) as exc_info: + require_local_uri_password_configured(None) + + exception = exc_info.value + assert isinstance(exception, HTTPException) + assert exception.status_code == 503 + assert exception.detail == LOCAL_URI_PASSWORD_DISABLED_DETAIL + + +def test_docker_docs_describe_session_secret_requirement(): + docker_docs = (ROOT / "DOCKER.md").read_text() + + assert "Create a `.env` file for Docker secrets" in docker_docs + assert "docker compose up --build" in docker_docs + assert docker_docs.index("Create a `.env` file for Docker secrets") < docker_docs.index("docker compose up --build") + assert "umask 077" in docker_docs + assert "umask 077" in docker_docs[: docker_docs.index("cat > .env <" in docker_docs + assert "JWT_SECRET_KEY` and `SESSION_SECRET_KEY` must be non-empty" in docker_docs + assert "`LOCAL_URI_PASSWORD` is unset or blank, `/local/generate_uri` is disabled" in docker_docs + assert "authenticated Docker deployments must verify that `JWT_SECRET_KEY` and `SESSION_SECRET_KEY`" in docker_docs + assert "through `.env` or shell-exported environment variables" in docker_docs + assert "use hex values such as `openssl rand -hex 32`" in docker_docs + assert "If startup fails with `LOCAL_URI_PASSWORD` validation errors" in docker_docs + assert "returns HTTP `503` with `LOCAL_URI_PASSWORD is not configured; /local/generate_uri is disabled`" in docker_docs + assert "If startup fails with `JWT_SECRET_KEY` or `SESSION_SECRET_KEY` validation errors" in docker_docs + + +@pytest.mark.parametrize("script_name", ["install_docker.ps1"]) +def test_powershell_installer_parses_when_runtime_available(script_name): + powershell = _powershell_executable() + script_path = str(ROOT / script_name).replace("'", "''") + command = f""" + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile('{script_path}', [ref] $tokens, [ref] $errors) | Out-Null + if ($errors.Count -gt 0) {{ + $errors | ForEach-Object {{ Write-Error $_.Message }} + exit 1 + }} + """ + + subprocess.run([powershell, "-NoProfile", "-NonInteractive", "-Command", command], check=True, text=True) diff --git a/docker-compose.run.yml b/docker-compose.run.yml index eb73a99c..b3cdd4eb 100644 --- a/docker-compose.run.yml +++ b/docker-compose.run.yml @@ -11,7 +11,9 @@ services: ports: - "8000:8000" environment: - - JWT_SECRET_KEY=${JWT_SECRET_KEY:-your-secret-key-here} + - JWT_SECRET_KEY=${JWT_SECRET_KEY:-} + - SESSION_SECRET_KEY=${SESSION_SECRET_KEY:-} + - LOCAL_URI_PASSWORD=${LOCAL_URI_PASSWORD:-} - POSTGRES_URI=postgresql+asyncpg://morphik:morphik@postgres:5432/morphik - PGPASSWORD=morphik # Used by internal health checks in the container - REDIS_HOST=redis @@ -34,7 +36,8 @@ services: networks: - morphik-network env_file: - - .env + - path: .env + required: false worker: image: ghcr.io/morphik-org/morphik-core:${MORPHIK_VERSION:-latest} @@ -42,7 +45,9 @@ services: # The worker runs as a background job processor, so no ports are exposed. command: arq core.workers.ingestion_worker.WorkerSettings environment: - - JWT_SECRET_KEY=${JWT_SECRET_KEY:-your-secret-key-here} + - JWT_SECRET_KEY=${JWT_SECRET_KEY:-} + - SESSION_SECRET_KEY=${SESSION_SECRET_KEY:-} + - LOCAL_URI_PASSWORD=${LOCAL_URI_PASSWORD:-} - POSTGRES_URI=postgresql+asyncpg://morphik:morphik@postgres:5432/morphik - PGPASSWORD=morphik - REDIS_HOST=redis @@ -62,7 +67,8 @@ services: networks: - morphik-network env_file: - - .env + - path: .env + required: false redis: image: redis:7-alpine diff --git a/docker-compose.yml b/docker-compose.yml index a425a99f..9aec0d77 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,7 +24,9 @@ services: # Note: Update this port mapping to match the port in morphik.toml - "8000:8000" environment: - - JWT_SECRET_KEY=${JWT_SECRET_KEY:-your-secret-key-here} + - JWT_SECRET_KEY=${JWT_SECRET_KEY:-} + - SESSION_SECRET_KEY=${SESSION_SECRET_KEY:-} + - LOCAL_URI_PASSWORD=${LOCAL_URI_PASSWORD:-} - POSTGRES_URI=postgresql+asyncpg://morphik:morphik@postgres:5432/morphik - PGPASSWORD=morphik - LOG_LEVEL=DEBUG @@ -49,7 +51,8 @@ services: networks: - morphik-network env_file: - - .env + - path: .env + required: false worker: build: @@ -57,7 +60,9 @@ services: dockerfile: dockerfile command: arq core.workers.ingestion_worker.WorkerSettings environment: - - JWT_SECRET_KEY=${JWT_SECRET_KEY:-your-secret-key-here} + - JWT_SECRET_KEY=${JWT_SECRET_KEY:-} + - SESSION_SECRET_KEY=${SESSION_SECRET_KEY:-} + - LOCAL_URI_PASSWORD=${LOCAL_URI_PASSWORD:-} - POSTGRES_URI=postgresql+asyncpg://morphik:morphik@postgres:5432/morphik - PGPASSWORD=morphik - LOG_LEVEL=DEBUG @@ -82,7 +87,8 @@ services: networks: - morphik-network env_file: - - .env + - path: .env + required: false redis: image: redis:7-alpine diff --git a/install_docker.ps1 b/install_docker.ps1 index 1c708698..62d225dd 100644 --- a/install_docker.ps1 +++ b/install_docker.ps1 @@ -8,7 +8,7 @@ ./install_docker.ps1 Requirements: - - Docker Desktop running (Compose V2 included) + - Docker Desktop running with Docker Compose 2.24.0 or newer - Internet connectivity to pull the image or fetch config files #> @@ -16,6 +16,8 @@ Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $script:EmbeddingSelection = $null +$script:AuthSecretMinLength = 32 +$script:MinComposeVersion = [Version]'2.24.0' function Write-Info($msg) { Write-Host "[INFO] $msg" -ForegroundColor Cyan } function Write-Step($msg) { Write-Host "[STEP] $msg" -ForegroundColor Yellow } @@ -38,10 +40,24 @@ function Assert-Docker { Write-Err "Docker is installed but not running. Start Docker Desktop and retry." throw "Docker not running" } - try { docker compose version | Out-Null } catch { + $composeVersionOutput = docker compose version --short 2>$null + if ($LASTEXITCODE -ne 0 -or -not $composeVersionOutput) { + $composeVersionOutput = docker compose version 2>$null + } + if ($LASTEXITCODE -ne 0 -or -not $composeVersionOutput) { Write-Err "Docker Compose V2 is required. Please update Docker Desktop." throw "Compose V2 missing" } + $composeVersionText = ($composeVersionOutput | Out-String).Trim() + if ($composeVersionText -notmatch 'v?(\d+\.\d+\.\d+)') { + Write-Err "Could not determine Docker Compose version. Please update Docker Desktop." + throw "Compose version unknown" + } + $composeVersion = [Version]$Matches[1] + if ($composeVersion -lt $script:MinComposeVersion) { + Write-Err "Docker Compose $($script:MinComposeVersion) or newer is required because Morphik uses optional env_file support for .env." + throw "Compose too old" + } } function New-RandomHex($bytes) { @@ -65,6 +81,7 @@ function Set-EnvValue { if (-not (Test-Path '.env')) { Add-Content -Path .env -Value "$Key=$Value" + Protect-EnvFile return } @@ -77,6 +94,77 @@ function Set-EnvValue { } else { Add-Content -Path .env -Value "$Key=$Value" } + + Protect-EnvFile +} + +function Protect-EnvFile { + if (-not (Test-Path '.env')) { return } + + if ($env:OS -eq 'Windows_NT') { + $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name + $acl = Get-Acl '.env' + $acl.SetAccessRuleProtection($true, $false) + foreach ($accessRule in @($acl.Access)) { + [void]$acl.RemoveAccessRuleSpecific($accessRule) + } + $rule = New-Object System.Security.AccessControl.FileSystemAccessRule -ArgumentList $identity, 'FullControl', 'Allow' + $acl.SetAccessRule($rule) + Set-Acl -Path '.env' -AclObject $acl + } else { + chmod 600 .env + } +} + +function Normalize-AuthSecret { + param([Parameter(Mandatory)] [string] $Value) + + $normalized = $Value.Trim() + if ($normalized.Length -ge 2) { + $first = $normalized.Substring(0, 1) + $last = $normalized.Substring($normalized.Length - 1, 1) + if (($first -eq '"' -and $last -eq '"') -or ($first -eq "'" -and $last -eq "'")) { + $normalized = $normalized.Substring(1, $normalized.Length - 2).Trim() + } + } + + return $normalized +} + +function ConvertFrom-SecureInput { + param([Parameter(Mandatory)] [System.Security.SecureString] $Value) + + $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($Value) + try { + return [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) + } finally { + if ($bstr -ne [IntPtr]::Zero) { + [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) + } + } +} + +function Assert-LocalUriPassword { + param([Parameter(Mandatory)] [string] $Value) + + $normalized = Normalize-AuthSecret -Value $Value + $placeholders = @( + 'change-me-local-uri-password', + 'local-uri-password', + 'your-local-uri-password-here' + ) + + if ($placeholders -contains $normalized -or ($normalized.StartsWith('<') -and $normalized.EndsWith('>'))) { + Write-Err "LOCAL_URI_PASSWORD must not use an example or placeholder value." + throw "Invalid LOCAL_URI_PASSWORD" + } + + if ($normalized.Length -lt $script:AuthSecretMinLength) { + Write-Err "LOCAL_URI_PASSWORD must be at least $script:AuthSecretMinLength characters before using /local/generate_uri." + throw "Invalid LOCAL_URI_PASSWORD" + } + + return $normalized } function Add-ComposeProfile { @@ -84,6 +172,7 @@ function Add-ComposeProfile { if (-not (Test-Path '.env')) { Add-Content -Path .env -Value "COMPOSE_PROFILES=$Profile" + Protect-EnvFile return } @@ -101,9 +190,11 @@ function Add-ComposeProfile { } $lines[$index] = "COMPOSE_PROFILES=$value" Set-Content -Path .env -Value $lines + Protect-EnvFile } } else { Add-Content -Path .env -Value "COMPOSE_PROFILES=$Profile" + Protect-EnvFile } } @@ -166,7 +257,8 @@ function Ensure-ComposeFile { function Ensure-EnvFile { Write-Step "Creating '.env' file for secrets..." - $jwt = "your-super-secret-key-$(New-RandomHex 16)" + $jwt = "morphik-jwt-$(New-RandomHex 32)" + $session = "morphik-session-$(New-RandomHex 32)" $envContent = @( "# Your OpenAI API key (optional - you can configure other providers in morphik.toml)", "OPENAI_API_KEY=", @@ -174,10 +266,14 @@ function Ensure-EnvFile { "# A secret key for signing JWTs. A random one is generated for you.", "JWT_SECRET_KEY=$jwt", "", - "# Local URI password for secure URI generation (required for creating connection URIs)", + "# A secret key for signing server-side sessions. A random one is generated for you.", + "SESSION_SECRET_KEY=$session", + "", + "# Optional at startup; leave blank to disable /local/generate_uri, or set a 32+ character random value before using it", "LOCAL_URI_PASSWORD=" ) -join [Environment]::NewLine Set-Content -Path .env -Value $envContent + Protect-EnvFile $openai = Read-Host "Enter your OpenAI API Key (or press Enter to skip)" if ($openai) { @@ -317,16 +413,17 @@ function Update-AuthBypassOrPassword { Write-Host ""; Write-Info "Setting up authentication for your Morphik deployment:" Write-Info " • For external access, set a LOCAL_URI_PASSWORD." Write-Info " • For local-only access, press Enter to enable bypass_auth_mode." - $password = Read-Host "Enter a secure LOCAL_URI_PASSWORD (or press Enter to skip)" + $password = ConvertFrom-SecureInput -Value (Read-Host -AsSecureString "Enter a secure LOCAL_URI_PASSWORD (or press Enter to skip)") + $password = Normalize-AuthSecret -Value $password if ([string]::IsNullOrWhiteSpace($password)) { Write-Info "No password provided - enabling authentication bypass (bypass_auth_mode=true)." $content = Get-Content morphik.toml -Raw $content = $content -replace '(?m)^bypass_auth_mode\s*=\s*false', 'bypass_auth_mode = true' Set-Content morphik.toml -Value $content } else { + $password = Assert-LocalUriPassword -Value $password Write-Ok "LOCAL_URI_PASSWORD set - keeping production mode (bypass_auth_mode=false)." - (Get-Content .env -Raw) -replace 'LOCAL_URI_PASSWORD=', "LOCAL_URI_PASSWORD=$password" | - Set-Content .env + Set-EnvValue -Key "LOCAL_URI_PASSWORD" -Value $password } } diff --git a/install_docker.sh b/install_docker.sh index bdad5ab0..65a21b43 100755 --- a/install_docker.sh +++ b/install_docker.sh @@ -16,6 +16,8 @@ DIRECT_INSTALL_URL="https://www.morphik.ai/docs/getting-started#self-host-direct EMBEDDING_PROVIDER="" EMBEDDING_PROVIDER_LABEL="" MORPHIK_VERSION="latest" +AUTH_SECRET_MIN_LENGTH=32 +MIN_COMPOSE_VERSION="2.24.0" # --- Parse Arguments --- while [[ $# -gt 0 ]]; do @@ -58,6 +60,33 @@ check_command() { fi } +compose_version_at_least() { + local version="${1#v}" + local minimum="${2#v}" + + if [[ ! "$version" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then + return 1 + fi + local version_major="${BASH_REMATCH[1]}" + local version_minor="${BASH_REMATCH[2]}" + local version_patch="${BASH_REMATCH[3]}" + + if [[ ! "$minimum" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then + return 1 + fi + local minimum_major="${BASH_REMATCH[1]}" + local minimum_minor="${BASH_REMATCH[2]}" + local minimum_patch="${BASH_REMATCH[3]}" + + (( version_major > minimum_major )) || + (( version_major == minimum_major && version_minor > minimum_minor )) || + (( version_major == minimum_major && version_minor == minimum_minor && version_patch >= minimum_patch )) +} + +protect_env_file() { + chmod 600 .env || print_error "Failed to restrict '.env' permissions to the current user." +} + set_env_value() { local key="$1" local value="$2" @@ -80,6 +109,57 @@ set_env_value() { fi echo "${key}=${value}" >> "$env_file" + protect_env_file +} + +normalize_auth_secret() { + local value="$1" + value="$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + if [ "${#value}" -ge 2 ]; then + local first_char="${value:0:1}" + local last_char="${value:$((${#value} - 1)):1}" + if { [ "$first_char" = "\"" ] && [ "$last_char" = "\"" ]; } || \ + { [ "$first_char" = "'" ] && [ "$last_char" = "'" ]; }; then + local inner_length=$((${#value} - 2)) + value="${value:1:$inner_length}" + value="$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + fi + fi + printf '%s' "$value" +} + +validate_local_uri_password() { + local password="$1" + case "$password" in + "change-me-local-uri-password"|"local-uri-password"|"your-local-uri-password-here") + print_error "LOCAL_URI_PASSWORD must not use an example or placeholder value." + ;; + esac + + if [[ "$password" == \<*\> ]]; then + print_error "LOCAL_URI_PASSWORD must not use an example or placeholder value." + fi + + if [ "${#password}" -lt "$AUTH_SECRET_MIN_LENGTH" ]; then + print_error "LOCAL_URI_PASSWORD must be at least ${AUTH_SECRET_MIN_LENGTH} characters before using /local/generate_uri." + fi +} + +generate_auth_secret() { + local prefix="$1" + local random_hex="" + + if command -v openssl &> /dev/null; then + random_hex="$(openssl rand -hex 32 2>/dev/null || true)" + elif [ -r /dev/urandom ]; then + random_hex="$(LC_ALL=C tr -dc 'a-f0-9' < /dev/urandom | head -c 64 || true)" + fi + + if [ "${#random_hex}" -lt 64 ]; then + print_error "Could not generate a secure auth secret. Install openssl or ensure /dev/urandom is readable." + fi + + printf '%s-%s' "$prefix" "$random_hex" } ensure_compose_profile() { @@ -146,6 +226,11 @@ check_command "docker" if ! docker compose version &> /dev/null; then print_error "Docker Compose V2 is required. Please ensure it's installed and accessible." fi +compose_version_output=$(docker compose version --short 2>/dev/null || docker compose version 2>/dev/null || true) +compose_version=$(printf '%s' "$compose_version_output" | grep -Eo 'v?[0-9]+\.[0-9]+\.[0-9]+' | head -n1) +if [[ -z "$compose_version" ]] || ! compose_version_at_least "$compose_version" "$MIN_COMPOSE_VERSION"; then + print_error "Docker Compose ${MIN_COMPOSE_VERSION} or newer is required because Morphik uses optional env_file support for .env." +fi print_success "Prerequisites are satisfied." # 2. Apple Silicon Warning @@ -170,19 +255,28 @@ fi # 4. Create .env and get User Input for API Key print_info "Creating '.env' file for your secrets..." +jwt_secret="$(generate_auth_secret "morphik-jwt")" +session_secret="$(generate_auth_secret "morphik-session")" +previous_umask="$(umask)" +umask 077 cat > .env <