diff --git a/muxplex/auth.py b/muxplex/auth.py index 05d907f..d340eca 100644 --- a/muxplex/auth.py +++ b/muxplex/auth.py @@ -5,6 +5,8 @@ muxplex authentication — password and signing secret file management. import secrets from pathlib import Path +from itsdangerous import BadSignature, SignatureExpired, TimestampSigner + # --------------------------------------------------------------------------- # Config directory @@ -66,3 +68,28 @@ def load_or_create_secret() -> str: path.write_text(secret + "\n") path.chmod(0o600) return secret + + +# --------------------------------------------------------------------------- +# Session cookie signing / verification +# --------------------------------------------------------------------------- + + +def create_session_cookie(secret: str, ttl_seconds: int) -> str: + """Create a signed, timestamped session cookie value.""" + signer = TimestampSigner(secret) + return signer.sign("muxplex-session").decode() + + +def verify_session_cookie(secret: str, cookie: str, ttl_seconds: int) -> bool: + """Verify a session cookie's signature and expiry. Returns True/False. + + ttl_seconds=0 means session cookie — no server-side expiry check. + """ + signer = TimestampSigner(secret) + try: + max_age = ttl_seconds if ttl_seconds > 0 else None + signer.unsign(cookie, max_age=max_age) + return True + except (BadSignature, SignatureExpired): + return False diff --git a/muxplex/tests/test_auth.py b/muxplex/tests/test_auth.py index 7f690b3..70990c8 100644 --- a/muxplex/tests/test_auth.py +++ b/muxplex/tests/test_auth.py @@ -119,3 +119,54 @@ def test_load_or_create_secret_returns_same_value_on_second_call(monkeypatch, tm first = load_or_create_secret() second = load_or_create_secret() assert first == second + + +# --------------------------------------------------------------------------- +# Session cookie signing / verification +# --------------------------------------------------------------------------- + + +def test_create_session_cookie_returns_string(): + """create_session_cookie() returns a non-empty string.""" + from muxplex.auth import create_session_cookie + + cookie = create_session_cookie("test-secret", ttl_seconds=3600) + assert isinstance(cookie, str) + assert len(cookie) > 0 + + +def test_verify_session_cookie_valid_roundtrip(): + """A cookie created by create_session_cookie verifies successfully.""" + from muxplex.auth import create_session_cookie, verify_session_cookie + + cookie = create_session_cookie("test-secret", ttl_seconds=3600) + assert verify_session_cookie("test-secret", cookie, ttl_seconds=3600) is True + + +def test_verify_session_cookie_tampered(): + """A tampered cookie fails verification.""" + from muxplex.auth import create_session_cookie, verify_session_cookie + + cookie = create_session_cookie("test-secret", ttl_seconds=3600) + tampered = cookie + "X" + assert verify_session_cookie("test-secret", tampered, ttl_seconds=3600) is False + + +def test_verify_session_cookie_wrong_secret(): + """A cookie signed with a different secret fails verification.""" + from muxplex.auth import create_session_cookie, verify_session_cookie + + cookie = create_session_cookie("secret-A", ttl_seconds=3600) + assert verify_session_cookie("secret-B", cookie, ttl_seconds=3600) is False + + +def test_verify_session_cookie_expired(): + """A cookie verified with a very short TTL fails (simulates expiry).""" + import time + from muxplex.auth import create_session_cookie, verify_session_cookie + + cookie = create_session_cookie("test-secret", ttl_seconds=1) + # itsdangerous uses integer-second timestamps; sleep 2s to guarantee + # age = 2 > max_age = 1, ensuring reliable expiry detection + time.sleep(2) + assert verify_session_cookie("test-secret", cookie, ttl_seconds=1) is False