From c063e9b300109beedb078a02994a54f6432da437 Mon Sep 17 00:00:00 2001 From: bordumb Date: Mon, 28 Sep 2026 20:14:16 +0100 Subject: [PATCH 1/2] fix(api): refuse to run without a strong JWT secret core/auth/jwt.py fell back to the key "dev-secret-change-in-production" when JWT_SECRET_KEY was unset. Every login token is HS256-signed with that key, so on any deployment that never set the variable, anyone who has read the source could mint an access token for any user, org and role. Nothing warned that the variable was missing. There is no fallback now. jwt_secret_key() returns JWT_SECRET_KEY, or raises JWTSecretKeyError when it is unset or shorter than 32 bytes (the HS256 minimum from RFC 7518 section 3.2, which pyjwt 2.15 also warns about). Issuing and verifying tokens both call it, including the SSE ?token= path. create_app() calls it first, so the CE and EE APIs refuse to start without a key. The Temporal worker signs no tokens and needs none. Local flows keep working: - 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 that run - just demo generates a key when neither the shell nor .env has a good one - docker-compose passes JWT_SECRET_KEY to the api service - .env.example, infra/check-env.sh, the quickstart and the deployment docs say to set it (openssl rand -hex 32) The CE and EE test conftests set a 32+ byte key, so pyjwt's InsecureKeyLengthWarning is gone. New tests check that: - no token is issued or verified with an unset, empty, 31-byte or old-default key - a token forged with the old default key is rejected - create_app() and create_ee_app() refuse to start without a key Operators: set JWT_SECRET_KEY before upgrading, or the API will not start. Tokens signed with the old default stop verifying, so users sign in again. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- .env.example | 8 ++- docker-compose.yml | 3 + docs/docs/development/deployment.md | 1 + docs/docs/quickstart.md | 12 ++++ docs/test-quickstart.sh | 6 ++ infra/check-env.sh | 9 +++ justfile | 17 ++++++ python-packages/dataing-ee/tests/conftest.py | 5 ++ .../tests/unit/entrypoints/api/test_app.py | 13 +++++ .../dataing/src/dataing/core/auth/__init__.py | 2 + .../dataing/src/dataing/core/auth/jwt.py | 38 +++++++++++-- .../src/dataing/entrypoints/api/factory.py | 8 +++ python-packages/dataing/tests/conftest.py | 5 ++ .../dataing/tests/unit/core/auth/test_jwt.py | 55 +++++++++++++++++++ .../unit/entrypoints/api/test_factory.py | 22 ++++++++ 15 files changed, 199 insertions(+), 5 deletions(-) create mode 100644 python-packages/dataing/tests/unit/entrypoints/api/test_factory.py diff --git a/.env.example b/.env.example index f3c00ccb7..5bcfa8639 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/docker-compose.yml b/docker-compose.yml index 593be7490..823af2f6c 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 2d765384e..0610c2ba8 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 78d7fc8eb..f851f23d3 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 c3f6f8f9b..0ab295666 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 e88027b47..e63e2d7d1 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 5f3d782d2..7dcea1270 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 6ad88e680..e04dae7f8 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 2e7b35dca..f6052a96b 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 bb25a4901..854b20456 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 bc9a58c81..a02f1e037 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 bbcaecc6b..10862df66 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 a080a6f0e..83769b3b1 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 f7408e4a2..d8263aa8d 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 000000000..b7739b02d --- /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() From c4b3edb14171ba93b0ee057f78f69ea9de1ac5cb Mon Sep 17 00:00:00 2001 From: bordumb Date: Mon, 28 Sep 2026 20:17:30 +0100 Subject: [PATCH 2/2] chore: record fn-68.7 as done Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- .flow/tasks/fn-68.7.json | 29 +++++++++++++++++++++++++++++ .flow/tasks/fn-68.7.md | 23 +++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 .flow/tasks/fn-68.7.json create mode 100644 .flow/tasks/fn-68.7.md diff --git a/.flow/tasks/fn-68.7.json b/.flow/tasks/fn-68.7.json new file mode 100644 index 000000000..a2440d00c --- /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 000000000..deea640b6 --- /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