From 3b248e2a6371fecc8b4b0c672851f52a51d6c539 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Sat, 28 Mar 2026 20:49:04 -0700 Subject: [PATCH] feat(auth): password file management Add muxplex/auth.py with password file management functions: - get_password_path(): returns ~/.config/muxplex/password - load_password(): reads password file, returns None if not found - generate_and_save_password(): generates random password, writes with 0600 permissions Add muxplex/tests/test_auth.py with 5 tests covering all functions. Co-authored-by: Amplifier --- muxplex/auth.py | 47 ++++++++++++++++++++++++++++ muxplex/tests/test_auth.py | 63 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 muxplex/auth.py create mode 100644 muxplex/tests/test_auth.py diff --git a/muxplex/auth.py b/muxplex/auth.py new file mode 100644 index 0000000..88aa38d --- /dev/null +++ b/muxplex/auth.py @@ -0,0 +1,47 @@ +""" +muxplex authentication — password management, secret management, +session cookies, PAM integration, and request middleware. +""" + +import secrets +from pathlib import Path + + +# --------------------------------------------------------------------------- +# Config directory +# --------------------------------------------------------------------------- + + +def _config_dir() -> Path: + """Return ~/.config/muxplex, creating it (mode 0700) if needed.""" + d = Path.home() / ".config" / "muxplex" + d.mkdir(parents=True, exist_ok=True) + return d + + +# --------------------------------------------------------------------------- +# Password file management +# --------------------------------------------------------------------------- + + +def get_password_path() -> Path: + """Return the path to the password file: ~/.config/muxplex/password.""" + return Path.home() / ".config" / "muxplex" / "password" + + +def load_password() -> str | None: + """Read the password file if it exists, return None otherwise.""" + path = get_password_path() + if not path.exists(): + return None + return path.read_text().strip() + + +def generate_and_save_password() -> str: + """Generate a random password, write it to the password file (0600), return it.""" + pw = secrets.token_urlsafe(20) + path = get_password_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(pw + "\n") + path.chmod(0o600) + return pw diff --git a/muxplex/tests/test_auth.py b/muxplex/tests/test_auth.py new file mode 100644 index 0000000..8861664 --- /dev/null +++ b/muxplex/tests/test_auth.py @@ -0,0 +1,63 @@ +"""Tests for muxplex/auth.py — authentication module.""" + +import stat +from pathlib import Path + + +# --------------------------------------------------------------------------- +# Password file management +# --------------------------------------------------------------------------- + + +def test_get_password_path_returns_expected_path(monkeypatch, tmp_path): + """get_password_path() returns ~/.config/muxplex/password.""" + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + from muxplex.auth import get_password_path + + assert get_password_path() == tmp_path / ".config" / "muxplex" / "password" + + +def test_load_password_returns_none_when_no_file(monkeypatch, tmp_path): + """load_password() returns None when password file does not exist.""" + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + from muxplex.auth import load_password + + assert load_password() is None + + +def test_load_password_reads_existing_file(monkeypatch, tmp_path): + """load_password() reads and strips the password file contents.""" + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + pw_path = tmp_path / ".config" / "muxplex" / "password" + pw_path.parent.mkdir(parents=True, exist_ok=True) + pw_path.write_text("my-secret-password\n") + pw_path.chmod(0o600) + + from muxplex.auth import load_password + + assert load_password() == "my-secret-password" + + +def test_generate_and_save_password_creates_file(monkeypatch, tmp_path): + """generate_and_save_password() creates the file and returns a non-empty string.""" + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + from muxplex.auth import generate_and_save_password + + pw = generate_and_save_password() + assert isinstance(pw, str) + assert len(pw) > 10 + + pw_path = tmp_path / ".config" / "muxplex" / "password" + assert pw_path.exists() + assert pw_path.read_text().strip() == pw + + +def test_generate_and_save_password_sets_0600_permissions(monkeypatch, tmp_path): + """generate_and_save_password() sets the file to mode 0600.""" + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + from muxplex.auth import generate_and_save_password + + generate_and_save_password() + pw_path = tmp_path / ".config" / "muxplex" / "password" + mode = stat.S_IMODE(pw_path.stat().st_mode) + assert mode == 0o600