diff --git a/decnet/env.py b/decnet/env.py index 8473a570..426e916d 100644 --- a/decnet/env.py +++ b/decnet/env.py @@ -28,6 +28,17 @@ def _port(name: str, default: int) -> int: return value +def _pos_int(name: str, default: int) -> int: + raw = os.environ.get(name, str(default)) + try: + value = int(raw) + except ValueError: + raise ValueError(f"Environment variable '{name}' must be an integer, got '{raw}'.") + if value < 1: + raise ValueError(f"Environment variable '{name}' must be a positive integer, got {value}.") + return value + + def _require_env(name: str) -> str: """Return the env var value or raise at startup if it is unset or a known-bad default.""" _KNOWN_BAD = {"fallback-secret-key-change-me", "admin", "secret", "password", "changeme"} @@ -138,6 +149,11 @@ DECNET_BATCH_MAX_WAIT_MS: int = int(os.environ.get("DECNET_BATCH_MAX_WAIT_MS", " DECNET_WEB_HOST: str = os.environ.get("DECNET_WEB_HOST", "127.0.0.1") DECNET_WEB_PORT: int = _port("DECNET_WEB_PORT", 8080) DECNET_ADMIN_USER: str = os.environ.get("DECNET_ADMIN_USER", "admin") +# Access-token lifetime in minutes. Default 4h — short enough to bound the +# passive window of a stolen token (active revocation is immediate→≤10s), long +# enough that the operator is not re-authenticating constantly. Shortening this +# meaningfully (e.g. 15m) requires a refresh-token mechanism; deferred to v1. +DECNET_JWT_EXP_MINUTES: int = _pos_int("DECNET_JWT_EXP_MINUTES", 240) # DECNET_ADMIN_PASSWORD is resolved lazily via __getattr__ (like DECNET_JWT_SECRET) # so it is validated only on the master processes that seed the admin user, and # never silently defaults to "admin". See _require_env + __getattr__ below. diff --git a/decnet/web/auth.py b/decnet/web/auth.py index a67f13b6..6468cb58 100644 --- a/decnet/web/auth.py +++ b/decnet/web/auth.py @@ -5,11 +5,11 @@ from typing import Optional, Any import jwt import bcrypt -from decnet.env import DECNET_JWT_SECRET +from decnet.env import DECNET_JWT_SECRET, DECNET_JWT_EXP_MINUTES SECRET_KEY: str = DECNET_JWT_SECRET ALGORITHM: str = "HS256" -ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440 +ACCESS_TOKEN_EXPIRE_MINUTES: int = DECNET_JWT_EXP_MINUTES def verify_password(plain_password: str, hashed_password: str) -> bool: diff --git a/env.config.example b/env.config.example index a6be174c..c029cfc5 100644 --- a/env.config.example +++ b/env.config.example @@ -40,6 +40,11 @@ DECNET_API_PORT=8000 # is true. Known-bad values (admin, secret, password, changeme, # fallback-secret-key-change-me) are rejected at startup. DECNET_JWT_SECRET= +# Access-token lifetime in minutes (positive integer). Default 240 (4h). +# Bounds how long a stolen token stays valid passively; active revocation +# (logout, password/role change) is immediate→≤10s regardless. Going much +# lower (e.g. 15) needs a refresh-token flow — not yet implemented. +DECNET_JWT_EXP_MINUTES=240 # File the ingester tails for honeypot events. DECNET_INGEST_LOG_FILE=/var/log/decnet/decnet.log diff --git a/tests/web/test_jwt_exp_config.py b/tests/web/test_jwt_exp_config.py new file mode 100644 index 00000000..a2a29799 --- /dev/null +++ b/tests/web/test_jwt_exp_config.py @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Access-token lifetime is configurable via DECNET_JWT_EXP_MINUTES, defaults +to 4h, and rejects non-positive / non-integer values at import time.""" +from __future__ import annotations + +import importlib +import sys + +import pytest + + +def _reimport_env(monkeypatch, value: str | None): + if value is None: + monkeypatch.delenv("DECNET_JWT_EXP_MINUTES", raising=False) + else: + monkeypatch.setenv("DECNET_JWT_EXP_MINUTES", value) + for mod in list(sys.modules): + if mod == "decnet.env" or mod.startswith("decnet.env."): + sys.modules.pop(mod) + return importlib.import_module("decnet.env") + + +def test_default_is_four_hours(monkeypatch): + env = _reimport_env(monkeypatch, None) + assert env.DECNET_JWT_EXP_MINUTES == 240 + + +def test_override_is_honored(monkeypatch): + env = _reimport_env(monkeypatch, "30") + assert env.DECNET_JWT_EXP_MINUTES == 30 + + +def test_non_integer_rejected(monkeypatch): + with pytest.raises(ValueError, match="must be an integer"): + _reimport_env(monkeypatch, "soon") + + +@pytest.mark.parametrize("bad", ["0", "-5"]) +def test_non_positive_rejected(monkeypatch, bad): + with pytest.raises(ValueError, match="positive integer"): + _reimport_env(monkeypatch, bad) + + +def test_auth_module_tracks_env(monkeypatch): + """decnet.web.auth.ACCESS_TOKEN_EXPIRE_MINUTES reflects the env var.""" + def _drop(): + for mod in ("decnet.env", "decnet.web.auth"): + sys.modules.pop(mod, None) + + monkeypatch.setenv("DECNET_JWT_EXP_MINUTES", "45") + monkeypatch.setenv("DECNET_JWT_SECRET", "x" * 32) + _drop() + try: + auth = importlib.import_module("decnet.web.auth") + assert auth.ACCESS_TOKEN_EXPIRE_MINUTES == 45 + finally: + # Don't leak a 45-minute auth module into the rest of the suite — force + # a clean rebuild from the (monkeypatch-restored) environment. + _drop()