Files
muxplex/docs/plans/2026-03-28-auth-phase1-infrastructure.md
2026-03-29 22:12:07 -07:00

36 KiB
Raw Permalink Blame History

Auth Phase 1: Core Infrastructure — Implementation Plan

Execution: Use the subagent-driven-development workflow to implement this plan.

Goal: Build the auth module (auth.py), middleware, and route stubs so all non-localhost HTTP/WS requests require authentication.

Architecture: A new muxplex/auth.py module provides password file management, signing secret management, session cookie signing/verification, PAM authentication, and a FastAPI middleware class. The middleware is mounted in main.py and gates every request. Localhost clients bypass auth entirely. A stub /login page and /auth/mode JSON endpoint are added for Phase 2 to build on.

Tech Stack: Python 3.11+, FastAPI, itsdangerous (TimestampSigner), python-pam, pytest

Phase: 1 of 2 — complete this phase before starting Phase 2 (2026-03-28-auth-phase2-ui-cli.md)

Design doc: docs/plans/2026-03-28-auth-design.md


Task 1: Add dependencies to pyproject.toml

Files:

  • Modify: pyproject.toml (line 1217, [project.dependencies])

Step 1: Add python-pam and itsdangerous to dependencies

In pyproject.toml, add two entries to the dependencies list. The existing list looks like:

dependencies = [
    "fastapi>=0.115.0",
    "uvicorn[standard]>=0.30.0",
    "aiofiles>=23.0",
    "websockets>=11.0",
]

Change it to:

dependencies = [
    "fastapi>=0.115.0",
    "uvicorn[standard]>=0.30.0",
    "aiofiles>=23.0",
    "websockets>=11.0",
    "python-pam>=1.8.4",
    "itsdangerous>=2.1.0",
]

Step 2: Install updated dependencies

Run: cd /home/bkrabach/dev/web-tmux/muxplex && uv pip install -e ".[dev]" Expected: installs without errors, both new packages appear in the output

Step 3: Verify imports work

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -c "import pam; import itsdangerous; print('OK')" Expected: prints OK

Step 4: Commit

cd /home/bkrabach/dev/web-tmux/muxplex && git add pyproject.toml && git commit -m "chore: add python-pam and itsdangerous dependencies"

Task 2: Password file management in auth.py

Files:

  • Create: muxplex/auth.py
  • Create: muxplex/tests/test_auth.py

Step 1: Write the failing tests

Create muxplex/tests/test_auth.py:

"""Tests for muxplex/auth.py — authentication module."""

import os
import stat
from pathlib import Path

import pytest


# ---------------------------------------------------------------------------
# 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

Step 2: Run tests to verify they fail

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_auth.py -v -k "password" 2>&1 | head -30 Expected: FAIL — ModuleNotFoundError: No module named 'muxplex.auth'

Step 3: Write the implementation

Create muxplex/auth.py:

"""
muxplex authentication — password management, secret management,
session cookies, PAM integration, and request middleware.
"""

import os
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

Step 4: Run tests to verify they pass

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_auth.py -v -k "password" Expected: all 5 tests PASS

Step 5: Commit

cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/auth.py muxplex/tests/test_auth.py && git commit -m "feat(auth): password file management"

Task 3: Secret file management in auth.py

Files:

  • Modify: muxplex/auth.py
  • Modify: muxplex/tests/test_auth.py

Step 1: Write the failing tests

Append to muxplex/tests/test_auth.py:

# ---------------------------------------------------------------------------
# Secret file management
# ---------------------------------------------------------------------------


def test_get_secret_path_returns_expected_path(monkeypatch, tmp_path):
    """get_secret_path() returns ~/.config/muxplex/secret."""
    monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path))
    from muxplex.auth import get_secret_path

    assert get_secret_path() == tmp_path / ".config" / "muxplex" / "secret"


def test_load_or_create_secret_creates_new_file(monkeypatch, tmp_path):
    """load_or_create_secret() creates a secret file when none exists."""
    monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path))
    from muxplex.auth import load_or_create_secret

    secret = load_or_create_secret()
    assert isinstance(secret, str)
    assert len(secret) > 20

    secret_path = tmp_path / ".config" / "muxplex" / "secret"
    assert secret_path.exists()


def test_load_or_create_secret_sets_0600_permissions(monkeypatch, tmp_path):
    """load_or_create_secret() sets the secret file to mode 0600."""
    monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path))
    from muxplex.auth import load_or_create_secret

    load_or_create_secret()
    secret_path = tmp_path / ".config" / "muxplex" / "secret"
    mode = stat.S_IMODE(secret_path.stat().st_mode)
    assert mode == 0o600


def test_load_or_create_secret_returns_same_value_on_second_call(monkeypatch, tmp_path):
    """load_or_create_secret() returns the same secret on subsequent calls."""
    monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path))
    from muxplex.auth import load_or_create_secret

    first = load_or_create_secret()
    second = load_or_create_secret()
    assert first == second

Step 2: Run tests to verify they fail

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_auth.py -v -k "secret" 2>&1 | head -20 Expected: FAIL — ImportError: cannot import name 'get_secret_path' from 'muxplex.auth'

Step 3: Write the implementation

Add to muxplex/auth.py, after the password management section:

# ---------------------------------------------------------------------------
# Secret (signing key) management
# ---------------------------------------------------------------------------


def get_secret_path() -> Path:
    """Return the path to the signing secret file: ~/.config/muxplex/secret."""
    return Path.home() / ".config" / "muxplex" / "secret"


def load_or_create_secret() -> str:
    """Load the signing secret from file, or create one if it doesn't exist."""
    path = get_secret_path()
    if path.exists():
        return path.read_text().strip()
    secret = secrets.token_urlsafe(32)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(secret + "\n")
    path.chmod(0o600)
    return secret

Step 4: Run tests to verify they pass

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_auth.py -v -k "secret" Expected: all 4 tests PASS

Step 5: Commit

cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/auth.py muxplex/tests/test_auth.py && git commit -m "feat(auth): signing secret file management"

Files:

  • Modify: muxplex/auth.py
  • Modify: muxplex/tests/test_auth.py

Step 1: Write the failing tests

Append to muxplex/tests/test_auth.py:

# ---------------------------------------------------------------------------
# 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():
    """An expired cookie (max_age=0) fails verification."""
    from muxplex.auth import create_session_cookie, verify_session_cookie

    cookie = create_session_cookie("test-secret", ttl_seconds=3600)
    # Verify with max_age=0 means it's immediately expired
    assert verify_session_cookie("test-secret", cookie, ttl_seconds=0) is False

Step 2: Run tests to verify they fail

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_auth.py -v -k "cookie" 2>&1 | head -20 Expected: FAIL — ImportError: cannot import name 'create_session_cookie' from 'muxplex.auth'

Step 3: Write the implementation

Add to muxplex/auth.py, adding the import at the top and the functions after the secret section:

Add from itsdangerous import TimestampSigner, BadSignature, SignatureExpired to the imports at the top of the file.

Then add after the secret management section:

# ---------------------------------------------------------------------------
# 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."""
    signer = TimestampSigner(secret)
    try:
        signer.unsign(cookie, max_age=ttl_seconds if ttl_seconds > 0 else None)
        return ttl_seconds > 0 or True  # ttl=0 with valid sig is still valid (session cookie)
    except (BadSignature, SignatureExpired):
        return False

Wait — re-reading the design: --session-ttl 0 means session cookie (clears on browser close). So ttl_seconds=0 should mean no server-side expiry check (the cookie is valid until the browser drops it). But the test says ttl_seconds=0 should fail. Let me reconsider.

Actually the test test_verify_session_cookie_expired needs a different approach. We need to test that a cookie signed in the past with a short TTL fails. Let's fix both the test and implementation:

Replace the expired test with:

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)
    time.sleep(1.1)  # Wait for it to expire
    assert verify_session_cookie("test-secret", cookie, ttl_seconds=1) is False

And the implementation:

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

Step 4: Run tests to verify they pass

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_auth.py -v -k "cookie" Expected: all 5 tests PASS (the expired test takes ~1.1s)

Step 5: Commit

cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/auth.py muxplex/tests/test_auth.py && git commit -m "feat(auth): session cookie signing and verification"

Task 5: PAM authentication with running-user check

Files:

  • Modify: muxplex/auth.py
  • Modify: muxplex/tests/test_auth.py

Step 1: Write the failing tests

Append to muxplex/tests/test_auth.py:

# ---------------------------------------------------------------------------
# PAM authentication
# ---------------------------------------------------------------------------


def test_pam_available_returns_true_when_pam_importable():
    """pam_available() returns True when python-pam is installed."""
    from muxplex.auth import pam_available

    # python-pam is in our deps, so it should be importable
    assert pam_available() is True


def test_pam_available_returns_false_on_import_error(monkeypatch):
    """pam_available() returns False when pam cannot be imported."""
    import builtins

    real_import = builtins.__import__

    def mock_import(name, *args, **kwargs):
        if name == "pam":
            raise ImportError("mock: no pam")
        return real_import(name, *args, **kwargs)

    monkeypatch.setattr(builtins, "__import__", mock_import)
    from muxplex.auth import pam_available

    assert pam_available() is False


def test_authenticate_pam_success(monkeypatch):
    """authenticate_pam() returns True when PAM succeeds for the running user."""
    import pwd

    from muxplex.auth import authenticate_pam

    running_user = pwd.getpwuid(os.getuid()).pw_name
    monkeypatch.setattr("pam.authenticate", lambda u, p, service="login": True)
    assert authenticate_pam(running_user, "correct-password") is True


def test_authenticate_pam_wrong_password(monkeypatch):
    """authenticate_pam() returns False when PAM rejects credentials."""
    import pwd

    from muxplex.auth import authenticate_pam

    running_user = pwd.getpwuid(os.getuid()).pw_name
    monkeypatch.setattr("pam.authenticate", lambda u, p, service="login": False)
    assert authenticate_pam(running_user, "wrong-password") is False


def test_authenticate_pam_wrong_user_rejected(monkeypatch):
    """authenticate_pam() rejects a different username even if PAM would accept it."""
    from muxplex.auth import authenticate_pam

    # Mock PAM to always return True — but wrong username should still fail
    monkeypatch.setattr("pam.authenticate", lambda u, p, service="login": True)
    assert authenticate_pam("root", "any-password") is False

Step 2: Run tests to verify they fail

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_auth.py -v -k "pam" 2>&1 | head -20 Expected: FAIL — ImportError: cannot import name 'pam_available' from 'muxplex.auth'

Step 3: Write the implementation

Add to muxplex/auth.py, after the cookie section:

# ---------------------------------------------------------------------------
# PAM authentication
# ---------------------------------------------------------------------------


def pam_available() -> bool:
    """Check whether the python-pam module is importable."""
    try:
        import pam  # noqa: F811

        return True
    except ImportError:
        return False


def authenticate_pam(username: str, password: str) -> bool:
    """Authenticate via PAM. Username must match the running process owner."""
    import os as _os
    import pwd

    import pam

    running_user = pwd.getpwuid(_os.getuid()).pw_name
    if username != running_user:
        return False
    return pam.authenticate(username, password, service="login")

Step 4: Run tests to verify they pass

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_auth.py -v -k "pam" Expected: all 5 PAM tests PASS

Step 5: Commit

cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/auth.py muxplex/tests/test_auth.py && git commit -m "feat(auth): PAM authentication with running-user check"

Task 6: Auth middleware in auth.py

Files:

  • Modify: muxplex/auth.py
  • Modify: muxplex/tests/test_auth.py

Step 1: Write the failing tests

Append to muxplex/tests/test_auth.py:

# ---------------------------------------------------------------------------
# Auth middleware
# ---------------------------------------------------------------------------

import base64

from fastapi import FastAPI
from fastapi.testclient import TestClient
from starlette.responses import PlainTextResponse

from muxplex.auth import AuthMiddleware, create_session_cookie


def _make_test_app(auth_mode: str = "password", password: str = "test-pw") -> FastAPI:
    """Create a minimal FastAPI app with AuthMiddleware for testing."""
    test_app = FastAPI()

    test_app.add_middleware(
        AuthMiddleware,
        auth_mode=auth_mode,
        secret="test-secret",
        ttl_seconds=3600,
        password=password,
    )

    @test_app.get("/protected")
    async def protected():
        return PlainTextResponse("OK")

    return test_app


def test_middleware_localhost_bypasses_auth():
    """Requests from 127.0.0.1 pass through without auth."""
    app = _make_test_app()
    client = TestClient(app, base_url="http://127.0.0.1")
    response = client.get("/protected")
    assert response.status_code == 200
    assert response.text == "OK"


def test_middleware_valid_session_cookie_passes():
    """Non-localhost request with a valid session cookie passes through."""
    app = _make_test_app()
    cookie = create_session_cookie("test-secret", ttl_seconds=3600)
    client = TestClient(app, base_url="http://192.168.1.1")
    response = client.get("/protected", cookies={"muxplex_session": cookie})
    assert response.status_code == 200
    assert response.text == "OK"


def test_middleware_tampered_cookie_redirects():
    """Non-localhost request with a tampered cookie redirects to /login."""
    app = _make_test_app()
    client = TestClient(app, base_url="http://192.168.1.1", follow_redirects=False)
    response = client.get("/protected", cookies={"muxplex_session": "bad.cookie.value"})
    assert response.status_code == 307
    assert "/login" in response.headers["location"]


def test_middleware_no_cookie_non_localhost_redirects():
    """Non-localhost request with no cookie redirects to /login."""
    app = _make_test_app()
    client = TestClient(app, base_url="http://192.168.1.1", follow_redirects=False)
    response = client.get("/protected")
    assert response.status_code == 307
    assert "/login" in response.headers["location"]


def test_middleware_basic_auth_valid_password():
    """Non-localhost request with valid Basic auth header passes through."""
    app = _make_test_app(auth_mode="password", password="test-pw")
    client = TestClient(app, base_url="http://192.168.1.1")
    creds = base64.b64encode(b":test-pw").decode()
    response = client.get("/protected", headers={"Authorization": f"Basic {creds}"})
    assert response.status_code == 200
    assert response.text == "OK"


def test_middleware_basic_auth_invalid_password():
    """Non-localhost request with wrong Basic auth header returns 401."""
    app = _make_test_app(auth_mode="password", password="test-pw")
    client = TestClient(app, base_url="http://192.168.1.1")
    creds = base64.b64encode(b":wrong-pw").decode()
    response = client.get("/protected", headers={"Authorization": f"Basic {creds}"})
    assert response.status_code == 401


def test_middleware_json_request_gets_401_not_redirect():
    """Non-localhost API request (Accept: application/json) gets 401, not redirect."""
    app = _make_test_app()
    client = TestClient(app, base_url="http://192.168.1.1", follow_redirects=False)
    response = client.get("/protected", headers={"Accept": "application/json"})
    assert response.status_code == 401


def test_middleware_login_path_excluded():
    """/login path is excluded from auth to avoid redirect loops."""
    app = _make_test_app()

    @app.get("/login")
    async def login():
        return PlainTextResponse("login page")

    client = TestClient(app, base_url="http://192.168.1.1")
    response = client.get("/login")
    assert response.status_code == 200

Step 2: Run tests to verify they fail

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_auth.py -v -k "middleware" 2>&1 | head -20 Expected: FAIL — ImportError: cannot import name 'AuthMiddleware' from 'muxplex.auth'

Step 3: Write the implementation

Add these imports to the top of muxplex/auth.py:

import base64

from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse, RedirectResponse, Response

Then add after the PAM section:

# ---------------------------------------------------------------------------
# Auth middleware
# ---------------------------------------------------------------------------

# Paths that bypass auth (login page itself, static assets it needs)
_AUTH_EXEMPT_PATHS = {"/login", "/auth/mode", "/auth/logout"}


class AuthMiddleware(BaseHTTPMiddleware):
    """FastAPI middleware that enforces authentication on non-localhost requests."""

    def __init__(
        self,
        app,
        auth_mode: str,
        secret: str,
        ttl_seconds: int,
        password: str = "",
    ):
        super().__init__(app)
        self.auth_mode = auth_mode
        self.secret = secret
        self.ttl_seconds = ttl_seconds
        self.password = password

    async def dispatch(self, request: Request, call_next) -> Response:
        # 1. Localhost bypass
        client_host = request.client.host if request.client else "unknown"
        if client_host in ("127.0.0.1", "::1"):
            return await call_next(request)

        # 2. Exempt paths (login page, auth endpoints)
        if request.url.path in _AUTH_EXEMPT_PATHS:
            return await call_next(request)

        # 3. Valid session cookie
        cookie = request.cookies.get("muxplex_session")
        if cookie and verify_session_cookie(self.secret, cookie, self.ttl_seconds):
            return await call_next(request)

        # 4. Authorization: Basic header
        auth_header = request.headers.get("authorization", "")
        if auth_header.lower().startswith("basic "):
            try:
                decoded = base64.b64decode(auth_header[6:]).decode()
                username, _, pw = decoded.partition(":")
                if self._check_credentials(username, pw):
                    return await call_next(request)
            except Exception:
                pass
            return JSONResponse({"detail": "Invalid credentials"}, status_code=401)

        # 5. No auth — redirect browsers, 401 for API clients
        accept = request.headers.get("accept", "")
        if "application/json" in accept:
            return JSONResponse({"detail": "Authentication required"}, status_code=401)
        return RedirectResponse(url="/login", status_code=307)

    def _check_credentials(self, username: str, password: str) -> bool:
        """Validate credentials against the configured auth mode."""
        if self.auth_mode == "pam":
            return authenticate_pam(username, password)
        return password == self.password

Step 4: Run tests to verify they pass

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_auth.py -v -k "middleware" Expected: all 8 middleware tests PASS

Step 5: Run all auth tests

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_auth.py -v Expected: all tests PASS (password + secret + cookie + PAM + middleware)

Step 6: Commit

cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/auth.py muxplex/tests/test_auth.py && git commit -m "feat(auth): request middleware with localhost bypass and session cookie check"

Task 7: Wire middleware into main.py

Files:

  • Modify: muxplex/main.py

Step 1: Write the failing test

Append to muxplex/tests/test_api.py, at the very end:

# ---------------------------------------------------------------------------
# Auth middleware integration
# ---------------------------------------------------------------------------


def test_non_localhost_without_auth_gets_redirected(monkeypatch):
    """A non-localhost request without credentials is redirected to /login."""
    from fastapi.testclient import TestClient

    from muxplex.main import app

    # Ensure auth is active — set a known password via env
    monkeypatch.setenv("MUXPLEX_PASSWORD", "test-pw-for-api")

    with TestClient(app, base_url="http://192.168.1.1") as c:
        response = c.get("/health", follow_redirects=False)
        # Should be redirected to /login or get 307/401
        assert response.status_code in (307, 401)

Step 2: Run to verify it fails

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_api.py::test_non_localhost_without_auth_gets_redirected -v Expected: FAIL — currently returns 200 (no middleware yet)

Step 3: Wire middleware into main.py

In muxplex/main.py, add auth setup logic. Add these imports near the top (after the existing imports):

import pwd
import sys

from muxplex.auth import (
    AuthMiddleware,
    generate_and_save_password,
    load_or_create_secret,
    load_password,
    pam_available,
)

Then, after the app = FastAPI(...) line (line 173) and before the request/response models section, add:

# ---------------------------------------------------------------------------
# Auth setup
# ---------------------------------------------------------------------------

def _resolve_auth() -> tuple[str, str]:
    """Determine auth mode and resolve password. Returns (auth_mode, password).

    Fallback chain for non-localhost:
      1. PAM available → ("pam", "")
      2. MUXPLEX_PASSWORD env → ("password", <env value>)
      3. ~/.config/muxplex/password file → ("password", <file value>)
      4. Auto-generate → ("password", <generated>)
    """
    # Explicit override: MUXPLEX_AUTH=password forces password mode
    force_password = os.environ.get("MUXPLEX_AUTH", "").lower() == "password"

    if not force_password and pam_available():
        running_user = pwd.getpwuid(os.getuid()).pw_name
        print(f"  muxplex auth: PAM (user: {running_user})", file=sys.stderr)
        return "pam", ""

    if not force_password:
        print("  muxplex auth: PAM unavailable, using password mode", file=sys.stderr)

    # Password mode — resolve password
    env_pw = os.environ.get("MUXPLEX_PASSWORD")
    if env_pw:
        print("  muxplex auth: password (env)", file=sys.stderr)
        return "password", env_pw

    file_pw = load_password()
    if file_pw:
        print(f"  muxplex auth: password (file: {load_password.__module__})", file=sys.stderr)
        return "password", file_pw

    # Last resort: auto-generate
    generated = generate_and_save_password()
    from muxplex.auth import get_password_path

    print(
        f"  muxplex auth: password generated — {generated} — saved to {get_password_path()}",
        file=sys.stderr,
    )
    return "password", generated


_auth_mode, _auth_password = _resolve_auth()
_auth_secret = load_or_create_secret()
_auth_ttl = int(os.environ.get("MUXPLEX_SESSION_TTL", "604800"))

app.add_middleware(
    AuthMiddleware,
    auth_mode=_auth_mode,
    secret=_auth_secret,
    ttl_seconds=_auth_ttl,
    password=_auth_password,
)

Step 4: Run to verify test passes

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_api.py::test_non_localhost_without_auth_gets_redirected -v Expected: PASS

Step 5: Run the full existing test suite to make sure nothing broke

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_api.py -v Expected: all existing tests still PASS (they use TestClient with default base_url="http://testserver" which resolves to localhost via TestClient internals — but verify this. If existing tests break because they're not coming from localhost, the patch_startup_and_state fixture needs to also set MUXPLEX_PASSWORD or the middleware needs to treat testserver as localhost.)

Important: If existing tests fail with 307/401, add this line to the patch_startup_and_state fixture in test_api.py:

monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")

This ensures auth resolves without side effects. The TestClient's default host (testserver) won't match localhost, so the fixture needs to provide auth context.

Also, if tests fail because TestClient doesn't present as localhost, you may need to add the muxplex_session cookie to the client fixture. However, the simpler fix is: TestClient by default uses base_url="http://testserver" and request.client.host will be testclient — which is NOT 127.0.0.1. Two solutions:

Option A (recommended): Update the client fixture to pass a valid session cookie:

@pytest.fixture
def client(monkeypatch):
    monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
    with TestClient(app) as c:
        # Authenticate for non-localhost TestClient
        from muxplex.auth import create_session_cookie
        from muxplex.main import _auth_secret, _auth_ttl
        cookie = create_session_cookie(_auth_secret, _auth_ttl)
        c.cookies.set("muxplex_session", cookie)
        yield c

Option B: Use base_url="http://127.0.0.1" on the existing client fixture. But this changes request.client.host behavior.

Verify which approach is needed by running the existing tests first. Adapt accordingly.

Step 6: Commit

cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: wire auth middleware into FastAPI app"

Task 8: GET /login stub and /auth/mode endpoint

Files:

  • Modify: muxplex/main.py
  • Modify: muxplex/tests/test_api.py

Step 1: Write the failing tests

Append to muxplex/tests/test_api.py:

# ---------------------------------------------------------------------------
# Login stub and auth mode endpoint
# ---------------------------------------------------------------------------


def test_get_login_returns_200_html(client):
    """GET /login returns 200 with HTML content."""
    response = client.get("/login")
    assert response.status_code == 200
    assert "text/html" in response.headers["content-type"]
    assert "<form" in response.text


def test_get_auth_mode_returns_json(client):
    """GET /auth/mode returns JSON with mode field."""
    response = client.get("/auth/mode")
    assert response.status_code == 200
    data = response.json()
    assert "mode" in data
    assert data["mode"] in ("pam", "password")

Step 2: Run to verify they fail

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_api.py -v -k "login or auth_mode" 2>&1 | head -20 Expected: FAIL — 404 (routes don't exist yet)

Step 3: Add the routes to main.py

Add these imports to the top of muxplex/main.py if not already present:

from fastapi.responses import HTMLResponse, JSONResponse as FastJSONResponse

Then add these routes before the static file mount (before the _FRONTEND_DIR line, which must remain the last mount):

# ---------------------------------------------------------------------------
# Auth routes
# ---------------------------------------------------------------------------


@app.get("/login", response_class=HTMLResponse)
async def login_page():
    """Stub login page — replaced in Phase 2 with branded login.html."""
    return HTMLResponse(
        "<html><body>"
        "<h1>muxplex login</h1>"
        "<form method='POST' action='/login'>"
        "<input name='password' type='password' placeholder='Password' autocomplete='current-password'>"
        "<button type='submit'>Login</button>"
        "</form>"
        "</body></html>"
    )


@app.get("/auth/mode")
async def auth_mode_endpoint():
    """Return the current auth mode and running username."""
    username = ""
    if _auth_mode == "pam":
        username = pwd.getpwuid(os.getuid()).pw_name
    return {"mode": _auth_mode, "user": username}

Step 4: Run to verify they pass

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_api.py -v -k "login or auth_mode" Expected: both tests PASS

Step 5: Run the full test suite

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/ -v Expected: all tests PASS

Step 6: Commit

cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: add /login stub and /auth/mode endpoint"

Phase 1 Complete Checklist

After all 8 tasks:

  • pyproject.toml has python-pam and itsdangerous in [project.dependencies]
  • muxplex/auth.py exists with: password mgmt, secret mgmt, cookie signing, PAM auth, AuthMiddleware
  • muxplex/tests/test_auth.py exists with tests for all of the above
  • muxplex/main.py mounts AuthMiddleware, has /login stub and /auth/mode endpoint
  • All tests in muxplex/tests/ pass: python -m pytest muxplex/tests/ -v
  • 8 clean commits with conventional commit messages

Proceed to Phase 2: docs/plans/2026-03-28-auth-phase2-ui-cli.md