diff --git a/.env.example b/.env.example index f3c00ccb..5bcfa863 100644 --- a/.env.example +++ b/.env.example @@ -6,7 +6,7 @@ # Quick Start: # 1. cp .env.example .env # 2. Add your ANTHROPIC_API_KEY -# 3. Generate DATADR_ENCRYPTION_KEY (see below) +# 3. Generate DATADR_ENCRYPTION_KEY and JWT_SECRET_KEY (see below) # 4. docker compose up -d # # ============================================================================= @@ -23,6 +23,12 @@ ANTHROPIC_API_KEY= # Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" DATADR_ENCRYPTION_KEY= +# Key that signs and verifies login tokens (required, at least 32 bytes). +# The API refuses to start without it. Anyone who knows it can mint tokens for any +# user and role, so keep it secret, and rotate it to sign everyone out. +# Generate with: openssl rand -hex 32 +JWT_SECRET_KEY= + # ----------------------------------------------------------------------------- # Database Configuration # ----------------------------------------------------------------------------- diff --git a/.flow/tasks/fn-68.7.json b/.flow/tasks/fn-68.7.json new file mode 100644 index 00000000..a2440d00 --- /dev/null +++ b/.flow/tasks/fn-68.7.json @@ -0,0 +1,29 @@ +{ + "assignee": "bordumbb@gmail.com", + "claim_note": "", + "claimed_at": "2026-09-28T18:42:30.667003Z", + "created_at": "2026-09-28T18:42:30.394050Z", + "depends_on": [], + "epic": "fn-68", + "evidence": { + "commits": [ + "c063e9b3" + ], + "prs": [ + "https://github.com/bordumb/dataing/pull/230" + ], + "tests": [ + "uv run pytest python-packages/dataing/tests --no-cov", + "uv run pytest python-packages/dataing-ee/tests --no-cov", + "DATABASE_URL=postgresql://test:test@localhost:55473/test uv run pytest python-packages/dataing/tests/integration -m integration --no-cov", + "DATABASE_URL=postgresql://test:test@localhost:55473/test uv run pytest python-packages/dataing-ee/tests/integration -m integration --no-cov", + "uv run mypy python-packages/dataing/src python-packages/dataing-ee/src" + ] + }, + "id": "fn-68.7", + "priority": 7, + "spec_path": ".flow/tasks/fn-68.7.md", + "status": "done", + "title": "Refuse to run with the default JWT secret", + "updated_at": "2026-09-28T19:16:48.877864Z" +} diff --git a/.flow/tasks/fn-68.7.md b/.flow/tasks/fn-68.7.md new file mode 100644 index 00000000..deea640b --- /dev/null +++ b/.flow/tasks/fn-68.7.md @@ -0,0 +1,23 @@ +# fn-68.7 Refuse to run with the default JWT secret + +## Description +core/auth/jwt.py fell back to SECRET_KEY "dev-secret-change-in-production" when JWT_SECRET_KEY was unset, so any deployment without the variable accepted HS256 tokens that anyone who has read the source could forge for any user, org and role. Fail closed instead, and wire a real key into the local dev, demo and compose flows. + +## Acceptance +- No token is signed or verified unless JWT_SECRET_KEY is set to at least 32 bytes; no built-in fallback +- The CE and EE APIs refuse to start without it +- just dev/dev-backend/dev-backend-ce/demo, docker-compose api, .env.example, check-env.sh, quickstart docs provide or document the key +- Tests run with a 32+ byte key (no pyjwt InsecureKeyLengthWarning); new tests cover refusal and forged old-default tokens +- CE/EE unit and integration tests pass + + +## Done summary +- core/auth/jwt.py: SECRET_KEY constant and fallback removed. New jwt_secret_key() returns JWT_SECRET_KEY or raises JWTSecretKeyError (a RuntimeError, exported from core.auth) when it is unset or under 32 bytes; the message says to run `openssl rand -hex 32`. Issuing, verifying, refresh and the SSE ?token= path all call it. +- create_app() calls jwt_secret_key() first, so the CE and EE APIs (create_ee_app builds on create_app) refuse to start. The Temporal worker signs no tokens and still starts without a key. +- Local flows: just dev, dev-backend and dev-backend-ce keep a 32+ byte key from .env or the shell and otherwise use a random key for the run. just demo generates one when neither the shell nor .env has a valid key. docker-compose passes JWT_SECRET_KEY to the api service. .env.example, check-env.sh, test-quickstart.sh, the quickstart and deployment docs require it. The CLI, SDK and notebook only carry API-issued tokens, so they needed no change. +- Tests: the CE and EE conftests setdefault a 32+ byte key, and InsecureKeyLengthWarning dropped to 0 on pyjwt 2.15. New TestSecretKey covers refusal for unset, empty, 31-byte and old-default keys, a 32-byte round trip, and rejection of a token forged with the old key. test_factory.py and the EE test_app cover startup refusal. Red check: with the fallback restored, 6 tests fail. +- Verified: CE 3167 passed, EE 715 passed. On a pgvector:pg16 container on port 55473, CE integration 130 passed and EE integration 16 passed. mypy is clean after syncing the worktree venv, which still had trino 0.336 and pyjwt 2.10 from before #228 and #226. +## Evidence +- Commits: c063e9b3 +- Tests: uv run pytest python-packages/dataing/tests --no-cov, uv run pytest python-packages/dataing-ee/tests --no-cov, DATABASE_URL=postgresql://test:test@localhost:55473/test uv run pytest python-packages/dataing/tests/integration -m integration --no-cov, DATABASE_URL=postgresql://test:test@localhost:55473/test uv run pytest python-packages/dataing-ee/tests/integration -m integration --no-cov, uv run mypy python-packages/dataing/src python-packages/dataing-ee/src +- PRs: https://github.com/bordumb/dataing/pull/230 diff --git a/docker-compose.yml b/docker-compose.yml index 593be749..823af2f6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,8 @@ # Required environment variables: # ANTHROPIC_API_KEY - Anthropic API key for LLM features # DATADR_ENCRYPTION_KEY - Fernet key for encrypting datasource credentials +# JWT_SECRET_KEY - 32+ random bytes that sign login tokens (openssl rand -hex 32); +# the api refuses to start without it # # Access points: # Frontend: http://localhost:3000 @@ -133,6 +135,7 @@ services: INVESTIGATION_ENGINE: temporal DATADR_ENCRYPTION_KEY: ${DATADR_ENCRYPTION_KEY:-} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + JWT_SECRET_KEY: ${JWT_SECRET_KEY:-} REDIS_HOST: redis REDIS_PORT: 6379 LLM_MODEL: ${LLM_MODEL:-claude-sonnet-4-20250514} diff --git a/docs/docs/development/deployment.md b/docs/docs/development/deployment.md index 2d765384..0610c2ba 100644 --- a/docs/docs/development/deployment.md +++ b/docs/docs/development/deployment.md @@ -69,6 +69,7 @@ this script already have that login. | Variable | Value | |----------|-------| | `ANTHROPIC_API_KEY` | Your Anthropic API key | +| `JWT_SECRET_KEY` | 32+ random bytes that sign login tokens (`openssl rand -hex 32`); the API refuses to start without it | ### 6. Enable Public URL diff --git a/docs/docs/quickstart.md b/docs/docs/quickstart.md index 78d7fc8e..f851f23d 100644 --- a/docs/docs/quickstart.md +++ b/docs/docs/quickstart.md @@ -50,6 +50,18 @@ DATADR_ENCRYPTION_KEY= ``` + Generate the key that signs login tokens (required; the API refuses to start without it): + + ```bash + openssl rand -hex 32 + ``` + + Add it to `.env`: + + ```bash + JWT_SECRET_KEY= + ``` + Start the stack: ```bash diff --git a/docs/test-quickstart.sh b/docs/test-quickstart.sh index c3f6f8f9..0ab29566 100755 --- a/docs/test-quickstart.sh +++ b/docs/test-quickstart.sh @@ -54,6 +54,12 @@ if ! grep -q "ANTHROPIC_API_KEY=" .env 2>/dev/null; then echo "ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}" >> .env fi +# Generate a JWT signing key unless .env already has one of 32+ bytes +if ! grep -qE '^JWT_SECRET_KEY=.{32,}' .env 2>/dev/null; then + grep -v '^JWT_SECRET_KEY=' .env > .env.tmp && mv .env.tmp .env + echo "JWT_SECRET_KEY=$(openssl rand -hex 32)" >> .env +fi + # Generate encryption key if not present if ! grep -q "DATADR_ENCRYPTION_KEY=" .env 2>/dev/null; then KEY=$(python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())") diff --git a/infra/check-env.sh b/infra/check-env.sh index e88027b4..e63e2d7d 100755 --- a/infra/check-env.sh +++ b/infra/check-env.sh @@ -24,6 +24,15 @@ if [ -z "$ANTHROPIC_API_KEY" ]; then errors=$((errors + 1)) fi +jwt_key="${JWT_SECRET_KEY:-}" +if [ "${#jwt_key}" -lt 32 ]; then + echo -e "${RED}ERROR: JWT_SECRET_KEY is not set or shorter than 32 bytes${NC}" + echo " The API refuses to start without it: it signs every login token." + echo " Generate with: openssl rand -hex 32" + echo "" + errors=$((errors + 1)) +fi + if [ -z "$DATADR_ENCRYPTION_KEY" ]; then echo -e "${RED}ERROR: DATADR_ENCRYPTION_KEY is not set${NC}" echo " Datasource credentials cannot be stored without an encryption key." diff --git a/justfile b/justfile index 5f3d782d..7dcea127 100644 --- a/justfile +++ b/justfile @@ -94,6 +94,10 @@ dev: if [ -f .env ]; then export $(grep -v '^#' .env | xargs) fi + # The API refuses to start without a JWT signing key of 32+ bytes. Keep the one from + # .env or the shell; otherwise use a fresh one for this run (sign in again after a restart). + jwt_key="${JWT_SECRET_KEY:-}" + if [ "${#jwt_key}" -lt 32 ]; then export JWT_SECRET_KEY="$(openssl rand -hex 32)"; fi trap 'kill 0' EXIT @@ -120,6 +124,10 @@ dev-backend: export REDIS_PORT=6379 export ENCRYPTION_KEY=ZnxhCyx4-ZjziPWtUguwGOFMMiLNioSwso5-qNPAGZI= if [ -f .env ]; then export $(grep -v '^#' .env | xargs); fi + # The API refuses to start without a JWT signing key of 32+ bytes. Keep the one from + # .env or the shell; otherwise use a fresh one for this run (sign in again after a restart). + jwt_key="${JWT_SECRET_KEY:-}" + if [ "${#jwt_key}" -lt 32 ]; then export JWT_SECRET_KEY="$(openssl rand -hex 32)"; fi uv run fastapi dev python-packages/dataing-ee/src/dataing_ee/entrypoints/api/app.py --host 0.0.0.0 --port 8000 # Run CE backend only (no enterprise features). Requires infrastructure. @@ -136,6 +144,10 @@ dev-backend-ce: export REDIS_PORT=6379 export ENCRYPTION_KEY=ZnxhCyx4-ZjziPWtUguwGOFMMiLNioSwso5-qNPAGZI= if [ -f .env ]; then export $(grep -v '^#' .env | xargs); fi + # The API refuses to start without a JWT signing key of 32+ bytes. Keep the one from + # .env or the shell; otherwise use a fresh one for this run (sign in again after a restart). + jwt_key="${JWT_SECRET_KEY:-}" + if [ "${#jwt_key}" -lt 32 ]; then export JWT_SECRET_KEY="$(openssl rand -hex 32)"; fi uv run fastapi dev python-packages/dataing/src/dataing/entrypoints/api/app.py --host 0.0.0.0 --port 8000 # Stop dev servers @@ -334,6 +346,11 @@ demo: demo-fixtures #!/usr/bin/env bash set -euo pipefail echo "Starting demo stack..." + # The API refuses to start without a JWT signing key of 32+ bytes. docker compose + # takes it from the shell or .env; if neither has one, use a fresh key for this run. + if [ -z "${JWT_SECRET_KEY:-}" ] && ! grep -qsE '^JWT_SECRET_KEY=.{32,}' .env; then + export JWT_SECRET_KEY="$(openssl rand -hex 32)" + fi # Force recreate db-migrate to ensure seeds run docker compose -f docker-compose.yml -f demo/docker-compose.demo.yml up -d --build --force-recreate db-migrate docker compose -f docker-compose.yml -f demo/docker-compose.demo.yml up -d --build diff --git a/python-packages/dataing-ee/tests/conftest.py b/python-packages/dataing-ee/tests/conftest.py index 6ad88e68..e04dae7f 100644 --- a/python-packages/dataing-ee/tests/conftest.py +++ b/python-packages/dataing-ee/tests/conftest.py @@ -1,10 +1,15 @@ """Pytest configuration for dataing-ee tests.""" +import os import sys from pathlib import Path import pytest +# The API refuses to sign or verify JWTs without a 32+ byte key (core/auth/jwt.py). +# Set one before any test imports the app. +os.environ.setdefault("JWT_SECRET_KEY", "test-jwt-secret-key-that-is-at-least-32-bytes") + # Add EE package to path for imports ee_src = Path(__file__).parent.parent / "src" if str(ee_src) not in sys.path: diff --git a/python-packages/dataing-ee/tests/unit/entrypoints/api/test_app.py b/python-packages/dataing-ee/tests/unit/entrypoints/api/test_app.py index 2e7b35dc..f6052a96 100644 --- a/python-packages/dataing-ee/tests/unit/entrypoints/api/test_app.py +++ b/python-packages/dataing-ee/tests/unit/entrypoints/api/test_app.py @@ -4,6 +4,7 @@ import subprocess import sys +import pytest from dataing_ee.entrypoints.api.app import app from fastapi.routing import APIRoute from fastapi.testclient import TestClient @@ -60,3 +61,15 @@ def test_app_has_one_health_route() -> None: health_routes = [r for r in app.routes if isinstance(r, APIRoute) and r.path == "/health"] assert len(health_routes) == 1 + + +def test_ee_app_refuses_to_start_without_a_jwt_secret(monkeypatch: pytest.MonkeyPatch) -> None: + """The EE app, which SSO logins sign tokens for, needs the JWT key as well.""" + from dataing_ee.entrypoints.api.app import create_ee_app + + from dataing.core.auth.jwt import JWTSecretKeyError + + monkeypatch.delenv("JWT_SECRET_KEY", raising=False) + + with pytest.raises(JWTSecretKeyError): + create_ee_app() diff --git a/python-packages/dataing/src/dataing/core/auth/__init__.py b/python-packages/dataing/src/dataing/core/auth/__init__.py index bb25a490..854b2045 100644 --- a/python-packages/dataing/src/dataing/core/auth/__init__.py +++ b/python-packages/dataing/src/dataing/core/auth/__init__.py @@ -1,6 +1,7 @@ """Auth domain types and utilities.""" from dataing.core.auth.jwt import ( + JWTSecretKeyError, TokenError, create_access_token, create_refresh_token, @@ -29,6 +30,7 @@ "TokenPayload", "hash_password", "verify_password", + "JWTSecretKeyError", "create_access_token", "create_refresh_token", "decode_token", diff --git a/python-packages/dataing/src/dataing/core/auth/jwt.py b/python-packages/dataing/src/dataing/core/auth/jwt.py index bc9a58c8..a02f1e03 100644 --- a/python-packages/dataing/src/dataing/core/auth/jwt.py +++ b/python-packages/dataing/src/dataing/core/auth/jwt.py @@ -14,13 +14,42 @@ class TokenError(Exception): pass +class JWTSecretKeyError(RuntimeError): + """Raised when JWT_SECRET_KEY is missing or too short to sign tokens safely.""" + + # Configuration -SECRET_KEY = os.environ.get("JWT_SECRET_KEY", "dev-secret-change-in-production") +JWT_SECRET_KEY_ENV = "JWT_SECRET_KEY" # pragma: allowlist secret +# HS256 wants a key at least as long as its 32-byte hash output (RFC 7518, section 3.2) +MIN_SECRET_KEY_BYTES = 32 ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 hours REFRESH_TOKEN_EXPIRE_DAYS = 7 +def jwt_secret_key() -> str: + """Return the key that signs and verifies JWTs. + + There is deliberately no built-in fallback: a key that ships in the source lets + anyone who reads it forge tokens for any user, org and role. + + Returns: + The JWT_SECRET_KEY environment variable. + + Raises: + JWTSecretKeyError: If JWT_SECRET_KEY is unset or shorter than 32 bytes. + """ + key = os.environ.get(JWT_SECRET_KEY_ENV, "") + size = len(key.encode()) + if size < MIN_SECRET_KEY_BYTES: + problem = "is not set" if not key else f"is only {size} bytes" + raise JWTSecretKeyError( + f"{JWT_SECRET_KEY_ENV} {problem}; it must be at least {MIN_SECRET_KEY_BYTES} " + "random bytes. Generate one with: openssl rand -hex 32" + ) + return key + + def create_access_token( user_id: str, org_id: str, @@ -50,7 +79,7 @@ def create_access_token( "iat": int(now.timestamp()), } - return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) + return jwt.encode(payload, jwt_secret_key(), algorithm=ALGORITHM) def create_refresh_token(user_id: str) -> str: @@ -75,7 +104,7 @@ def create_refresh_token(user_id: str) -> str: "type": "refresh", } - return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) + return jwt.encode(payload, jwt_secret_key(), algorithm=ALGORITHM) def decode_token(token: str) -> TokenPayload: @@ -89,9 +118,10 @@ def decode_token(token: str) -> TokenPayload: Raises: TokenError: If token is invalid or expired + JWTSecretKeyError: If JWT_SECRET_KEY is missing or too short """ try: - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + payload = jwt.decode(token, jwt_secret_key(), algorithms=[ALGORITHM]) return TokenPayload( sub=payload["sub"], org_id=payload["org_id"], diff --git a/python-packages/dataing/src/dataing/entrypoints/api/factory.py b/python-packages/dataing/src/dataing/entrypoints/api/factory.py index bbcaecc6..10862df6 100644 --- a/python-packages/dataing/src/dataing/entrypoints/api/factory.py +++ b/python-packages/dataing/src/dataing/entrypoints/api/factory.py @@ -13,6 +13,7 @@ from fastapi.middleware.cors import CORSMiddleware from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor +from dataing.core.auth.jwt import jwt_secret_key from dataing.telemetry import CorrelationMiddleware, configure_logging, init_telemetry from .deps import lifespan @@ -24,7 +25,14 @@ def create_app() -> FastAPI: Returns: Configured FastAPI application instance. + + Raises: + JWTSecretKeyError: If JWT_SECRET_KEY is missing or too short. The API + signs and verifies every login token with it, so it does not start + without one. """ + jwt_secret_key() + # Initialize OpenTelemetry SDK (idempotent, safe to call multiple times) init_telemetry() diff --git a/python-packages/dataing/tests/conftest.py b/python-packages/dataing/tests/conftest.py index a080a6f0..83769b3b 100644 --- a/python-packages/dataing/tests/conftest.py +++ b/python-packages/dataing/tests/conftest.py @@ -3,12 +3,17 @@ from __future__ import annotations import logging +import os import sys from collections.abc import Iterator from pathlib import Path import pytest +# The API refuses to sign or verify JWTs without a 32+ byte key (core/auth/jwt.py). +# Set one before any test imports the app. +os.environ.setdefault("JWT_SECRET_KEY", "test-jwt-secret-key-that-is-at-least-32-bytes") + # Add CE package to path for imports ce_src = Path(__file__).parent.parent / "src" if str(ce_src) not in sys.path: diff --git a/python-packages/dataing/tests/unit/core/auth/test_jwt.py b/python-packages/dataing/tests/unit/core/auth/test_jwt.py index f7408e4a..d8263aa8 100644 --- a/python-packages/dataing/tests/unit/core/auth/test_jwt.py +++ b/python-packages/dataing/tests/unit/core/auth/test_jwt.py @@ -1,10 +1,14 @@ """Tests for JWT token service.""" +import warnings +from datetime import UTC, datetime, timedelta from uuid import uuid4 +import jwt import pytest from dataing.core.auth.jwt import ( + JWTSecretKeyError, TokenError, create_access_token, create_refresh_token, @@ -92,3 +96,54 @@ def test_refresh_token_longer_expiry(self) -> None: # Refresh should expire later than access assert refresh_payload.exp > access_payload.exp + + +class TestSecretKey: + """Tokens are only signed and verified with a configured key of 32+ bytes.""" + + @pytest.mark.parametrize( + "key", + [None, "", "k" * 31, "dev-secret-change-in-production"], + ids=["unset", "empty", "31-bytes", "old-default"], + ) + def test_refuses_missing_or_short_key( + self, monkeypatch: pytest.MonkeyPatch, key: str | None + ) -> None: + """No token is issued or verified without a proper key.""" + if key is None: + monkeypatch.delenv("JWT_SECRET_KEY", raising=False) + else: + monkeypatch.setenv("JWT_SECRET_KEY", key) + + with pytest.raises(JWTSecretKeyError, match="openssl rand -hex 32"): + create_access_token(user_id="u", org_id="o", role="admin", teams=[]) + with pytest.raises(JWTSecretKeyError): + create_refresh_token(user_id="u") + with pytest.raises(JWTSecretKeyError): + decode_token("header.payload.signature") + + def test_accepts_a_32_byte_key(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A key of exactly 32 bytes signs and verifies tokens.""" + monkeypatch.setenv("JWT_SECRET_KEY", "k" * 32) + + token = create_access_token(user_id="u", org_id="o", role="admin", teams=[]) + + assert decode_token(token).sub == "u" + + def test_rejects_a_token_forged_with_the_old_default_key(self) -> None: + """A token signed with the key that used to ship in the source is refused.""" + now = datetime.now(UTC) + claims = { + "sub": "attacker", + "org_id": str(uuid4()), + "role": "owner", + "teams": [], + "exp": int((now + timedelta(hours=1)).timestamp()), + "iat": int(now.timestamp()), + } + with warnings.catch_warnings(): # pyjwt warns that this old key is too short + warnings.simplefilter("ignore") + forged = jwt.encode(claims, "dev-secret-change-in-production", algorithm="HS256") + + with pytest.raises(TokenError, match="Signature verification failed"): + decode_token(forged) diff --git a/python-packages/dataing/tests/unit/entrypoints/api/test_factory.py b/python-packages/dataing/tests/unit/entrypoints/api/test_factory.py new file mode 100644 index 00000000..b7739b02 --- /dev/null +++ b/python-packages/dataing/tests/unit/entrypoints/api/test_factory.py @@ -0,0 +1,22 @@ +"""Tests for the CE app factory.""" + +import pytest + +from dataing.core.auth.jwt import JWTSecretKeyError +from dataing.entrypoints.api.factory import create_app + + +def test_refuses_to_build_the_app_without_a_jwt_secret(monkeypatch: pytest.MonkeyPatch) -> None: + """The API does not start when JWTs could only be signed with a missing key.""" + monkeypatch.delenv("JWT_SECRET_KEY", raising=False) + + with pytest.raises(JWTSecretKeyError): + create_app() + + +def test_refuses_to_build_the_app_with_a_short_jwt_secret(monkeypatch: pytest.MonkeyPatch) -> None: + """A key shorter than 32 bytes is refused at startup too.""" + monkeypatch.setenv("JWT_SECRET_KEY", "dev-secret-change-in-production") + + with pytest.raises(JWTSecretKeyError): + create_app()