# Auth Phase 2: Login UI + CLI — Implementation Plan > **Execution:** Use the subagent-driven-development workflow to implement this plan. **Goal:** Build the branded login page, complete login/logout routes, and add all CLI auth commands (`--auth`, `--session-ttl`, `show-password`, `reset-secret`, startup logging). **Architecture:** Phase 1 established `auth.py` (middleware, password/secret/cookie/PAM functions) and a stub `/login` route. Phase 2 replaces the stub with a fully branded `login.html` that auto-detects PAM vs password mode, adds the POST `/login` and GET `/auth/logout` handlers, and wires the CLI flags and subcommands that control auth behavior at startup. **Tech Stack:** Python 3.11+, FastAPI, HTML/CSS/JS (no framework), argparse, pytest **Phase:** 2 of 2 — complete Phase 1 (`2026-03-28-auth-phase1-infrastructure.md`) before starting this phase. **Design doc:** `docs/plans/2026-03-28-auth-design.md` **Prerequisite:** Phase 1 must be complete. Verify: `python -m pytest muxplex/tests/ -v` — all tests pass, `muxplex/auth.py` exists with `AuthMiddleware`, `/login` stub and `/auth/mode` endpoint exist in `main.py`. --- ### Task 1: Create branded login.html **Files:** - Create: `muxplex/frontend/login.html` - Modify: `muxplex/tests/test_frontend_html.py` (add login.html tests) **Step 1: Write the failing tests** Append to `muxplex/tests/test_frontend_html.py`: ```python # --------------------------------------------------------------------------- # login.html tests # --------------------------------------------------------------------------- _LOGIN_HTML_PATH = pathlib.Path(__file__).parent.parent / "frontend" / "login.html" def _login_soup() -> BeautifulSoup: """Parse login.html — separate from index.html soup.""" return BeautifulSoup(_LOGIN_HTML_PATH.read_text(), "html.parser") def test_login_html_exists() -> None: """login.html must exist in the frontend directory.""" assert _LOGIN_HTML_PATH.exists(), f"Missing {_LOGIN_HTML_PATH}" def test_login_html_has_form() -> None: """login.html must contain a POST form targeting /login.""" soup = _login_soup() form = soup.find("form") assert form is not None, "Missing
element" assert form.get("method", "").lower() == "post", "Form method should be POST" assert form.get("action") == "/login", "Form action should be /login" def test_login_html_has_password_autocomplete() -> None: """login.html password field must have autocomplete='current-password'.""" soup = _login_soup() pw_input = soup.find("input", attrs={"autocomplete": "current-password"}) assert pw_input is not None, "Missing password input with autocomplete='current-password'" def test_login_html_has_wordmark() -> None: """login.html must include the muxplex wordmark SVG.""" soup = _login_soup() # Check for either an with wordmark or inline SVG img = soup.find("img", attrs={"src": lambda s: s and "wordmark" in s}) assert img is not None, "Missing muxplex wordmark image" def test_login_html_references_muxplex_auth() -> None: """login.html must reference window.MUXPLEX_AUTH for mode detection.""" text = _LOGIN_HTML_PATH.read_text() assert "MUXPLEX_AUTH" in text, "login.html must reference MUXPLEX_AUTH for mode detection" def test_login_html_has_error_display() -> None: """login.html must have an element for displaying auth errors.""" soup = _login_soup() # Look for an element that handles error state text = _LOGIN_HTML_PATH.read_text() assert "error" in text.lower(), "login.html must handle error display (query param ?error=1)" ``` **Step 2: Run tests to verify they fail** Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_frontend_html.py -v -k "login" 2>&1 | head -20` Expected: FAIL — `login.html` doesn't exist yet **Step 3: Create the branded login.html** Create `muxplex/frontend/login.html`: ```html muxplex — login
``` **Step 4: Run tests to verify they pass** Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_frontend_html.py -v -k "login"` Expected: all 6 login tests PASS **Step 5: Commit** ```bash cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/frontend/login.html muxplex/tests/test_frontend_html.py && git commit -m "feat: branded login.html with PAM/password mode detection" ``` --- ### Task 2: POST /login handler **Files:** - Modify: `muxplex/main.py` - Modify: `muxplex/tests/test_api.py` **Step 1: Write the failing tests** Append to `muxplex/tests/test_api.py`: ```python # --------------------------------------------------------------------------- # POST /login # --------------------------------------------------------------------------- def test_post_login_correct_password_redirects_to_root(client, monkeypatch): """POST /login with correct password returns 303 redirect to / with session cookie.""" monkeypatch.setattr("muxplex.main._auth_mode", "password") monkeypatch.setattr("muxplex.main._auth_password", "test-pw") response = client.post( "/login", data={"password": "test-pw"}, follow_redirects=False, ) assert response.status_code == 303 assert response.headers["location"] == "/" assert "muxplex_session" in response.cookies def test_post_login_wrong_password_redirects_to_login_error(client, monkeypatch): """POST /login with wrong password returns 303 redirect to /login?error=1.""" monkeypatch.setattr("muxplex.main._auth_mode", "password") monkeypatch.setattr("muxplex.main._auth_password", "test-pw") response = client.post( "/login", data={"password": "wrong-pw"}, follow_redirects=False, ) assert response.status_code == 303 assert "/login" in response.headers["location"] assert "error=1" in response.headers["location"] def test_post_login_pam_mode_correct_creds(client, monkeypatch): """POST /login in PAM mode with correct creds sets cookie and redirects.""" monkeypatch.setattr("muxplex.main._auth_mode", "pam") monkeypatch.setattr( "muxplex.auth.authenticate_pam", lambda u, p: True, ) response = client.post( "/login", data={"username": "testuser", "password": "correct"}, follow_redirects=False, ) assert response.status_code == 303 assert response.headers["location"] == "/" assert "muxplex_session" in response.cookies def test_post_login_pam_mode_wrong_creds(client, monkeypatch): """POST /login in PAM mode with wrong creds redirects to /login?error=1.""" monkeypatch.setattr("muxplex.main._auth_mode", "pam") monkeypatch.setattr( "muxplex.auth.authenticate_pam", lambda u, p: False, ) response = client.post( "/login", data={"username": "testuser", "password": "wrong"}, follow_redirects=False, ) assert response.status_code == 303 assert "error=1" in response.headers["location"] ``` **Step 2: Run tests to verify they fail** Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_api.py -v -k "post_login" 2>&1 | head -20` Expected: FAIL — 405 Method Not Allowed (no POST handler for `/login` yet) **Step 3: Add the POST /login handler** In `muxplex/main.py`, add `from fastapi import Request` to the existing FastAPI imports if not already present. Then add this route right after the existing `GET /login` route: ```python @app.post("/login") async def login_submit(request: Request): """Handle login form submission.""" from muxplex.auth import authenticate_pam, create_session_cookie form = await request.form() username = form.get("username", "") password = form.get("password", "") # Validate credentials if _auth_mode == "pam": ok = authenticate_pam(str(username), str(password)) else: ok = str(password) == _auth_password if not ok: from starlette.responses import RedirectResponse return RedirectResponse("/login?error=1", status_code=303) # Success — set session cookie and redirect to / cookie = create_session_cookie(_auth_secret, _auth_ttl) from starlette.responses import RedirectResponse response = RedirectResponse("/", status_code=303) response.set_cookie( "muxplex_session", cookie, httponly=True, samesite="strict", max_age=_auth_ttl if _auth_ttl > 0 else None, ) return response ``` Note: The `RedirectResponse` import may already be available from `starlette.responses` (used in `auth.py`). Use whatever import pattern is cleanest — either add to the top-level imports or keep the local imports. Prefer adding `from starlette.responses import RedirectResponse` to the module-level imports at the top. **Step 4: Run tests to verify they pass** Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_api.py -v -k "post_login"` Expected: all 4 tests PASS **Step 5: Commit** ```bash cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: POST /login handler for PAM and password modes" ``` --- ### Task 3: GET /auth/logout **Files:** - Modify: `muxplex/main.py` - Modify: `muxplex/tests/test_api.py` **Step 1: Write the failing tests** Append to `muxplex/tests/test_api.py`: ```python # --------------------------------------------------------------------------- # GET /auth/logout # --------------------------------------------------------------------------- def test_logout_redirects_to_login(client): """GET /auth/logout returns 303 redirect to /login.""" response = client.get("/auth/logout", follow_redirects=False) assert response.status_code == 303 assert "/login" in response.headers["location"] def test_logout_clears_session_cookie(client): """GET /auth/logout deletes the muxplex_session cookie (max-age=0).""" response = client.get("/auth/logout", follow_redirects=False) # Check Set-Cookie header clears the cookie set_cookie = response.headers.get("set-cookie", "") assert "muxplex_session" in set_cookie # Cookie should be expired (max-age=0 or empty value) assert 'max-age=0' in set_cookie.lower() or '=""' in set_cookie or "=''" in set_cookie ``` **Step 2: Run tests to verify they fail** Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_api.py -v -k "logout" 2>&1 | head -20` Expected: FAIL — 404 or 307 (no `/auth/logout` route yet) **Step 3: Add the logout route** In `muxplex/main.py`, add this route after the POST `/login` handler (and before the static file mount): ```python @app.get("/auth/logout") async def logout(): """Clear the session cookie and redirect to login.""" from starlette.responses import RedirectResponse response = RedirectResponse("/login", status_code=303) response.delete_cookie("muxplex_session") return response ``` **Step 4: Run tests to verify they pass** Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_api.py -v -k "logout"` Expected: both tests PASS **Step 5: Commit** ```bash cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: GET /auth/logout clears session cookie" ``` --- ### Task 4: Replace /login stub with branded login.html serving **Files:** - Modify: `muxplex/main.py` - Modify: `muxplex/tests/test_api.py` **Step 1: Write the failing test** Append to `muxplex/tests/test_api.py`: ```python # --------------------------------------------------------------------------- # GET /login serves branded page with injected auth mode # --------------------------------------------------------------------------- def test_get_login_injects_muxplex_auth(client): """GET /login HTML must contain window.MUXPLEX_AUTH with the auth mode.""" response = client.get("/login") assert response.status_code == 200 assert "MUXPLEX_AUTH" in response.text assert '"mode"' in response.text ``` **Step 2: Run to verify it fails** Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_api.py::test_get_login_injects_muxplex_auth -v` Expected: FAIL — current stub doesn't have `MUXPLEX_AUTH` **Step 3: Replace the GET /login handler** In `muxplex/main.py`, replace the existing `login_page()` function with: ```python @app.get("/login", response_class=HTMLResponse) async def login_page(): """Serve the branded login page with auth mode injected.""" import json html = (_FRONTEND_DIR / "login.html").read_text() username = "" if _auth_mode == "pam": username = pwd.getpwuid(os.getuid()).pw_name mode_data = json.dumps({"mode": _auth_mode, "user": username}) # Inject auth mode before so the inline script can read it html = html.replace( "", f"\n", ) return HTMLResponse(html) ``` Note: `_FRONTEND_DIR` is already defined at the bottom of `main.py` as `pathlib.Path(__file__).parent / "frontend"`. It's used for the StaticFiles mount. You need to move this variable definition **above** the routes section so `login_page()` can reference it, or define it separately near the top. The simplest change: move the `_FRONTEND_DIR = pathlib.Path(__file__).parent / "frontend"` line to just after the imports/config section (around line 50), keeping the `app.mount(...)` line at the bottom. **Step 4: Run to verify it passes** Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_api.py -v -k "login"` Expected: all login tests PASS (including the old `test_get_login_returns_200_html`) **Step 5: Optionally remove the /auth/mode endpoint** Since `login.html` now reads `window.MUXPLEX_AUTH` instead of fetching `/auth/mode`, the endpoint is redundant. However, it's harmless and could be useful for API clients. **Keep it** but it's no longer required for the login flow. **Step 6: Commit** ```bash cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: /login GET serves branded login.html with injected auth mode" ``` --- ### Task 5: CLI: update host default and add auth flags **Files:** - Modify: `muxplex/cli.py` - Modify: `muxplex/tests/test_cli.py` **Step 1: Write the failing tests** Append to `muxplex/tests/test_cli.py`: ```python # --------------------------------------------------------------------------- # Auth CLI flags # --------------------------------------------------------------------------- def test_main_default_host_is_localhost(): """Default --host must be 127.0.0.1 (changed from 0.0.0.0).""" from muxplex.cli import main with patch("muxplex.cli.serve") as mock_serve: with patch("sys.argv", ["muxplex"]): main() call_kwargs = mock_serve.call_args assert call_kwargs[1]["host"] == "127.0.0.1" or call_kwargs[0][0] == "127.0.0.1" def test_main_passes_auth_flag(): """main() with --auth password must forward auth='password' to serve().""" from muxplex.cli import main with patch("muxplex.cli.serve") as mock_serve: with patch("sys.argv", ["muxplex", "--auth", "password"]): main() _, kwargs = mock_serve.call_args assert kwargs.get("auth") == "password" def test_main_passes_session_ttl_flag(): """main() with --session-ttl 3600 must forward session_ttl=3600 to serve().""" from muxplex.cli import main with patch("muxplex.cli.serve") as mock_serve: with patch("sys.argv", ["muxplex", "--session-ttl", "3600"]): main() _, kwargs = mock_serve.call_args assert kwargs.get("session_ttl") == 3600 ``` **Step 2: Run tests to verify they fail** Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_cli.py -v -k "default_host or auth_flag or session_ttl" 2>&1 | head -20` Expected: FAIL — default host is still `0.0.0.0`, no `--auth` or `--session-ttl` flags **Step 3: Update cli.py** In `muxplex/cli.py`, make these changes: 1. Change the `serve()` signature to accept auth params: ```python def serve(host: str = "127.0.0.1", port: int = 8088, auth: str = "pam", session_ttl: int = 604800) -> None: """Start the muxplex server.""" import uvicorn # noqa: PLC0415 os.environ.setdefault("MUXPLEX_PORT", str(port)) if auth: os.environ.setdefault("MUXPLEX_AUTH", auth) os.environ.setdefault("MUXPLEX_SESSION_TTL", str(session_ttl)) from muxplex.main import app # noqa: PLC0415 print(f" muxplex → http://{host}:{port}") uvicorn.run(app, host=host, port=port, log_level="warning") ``` 2. Change the `--host` default: ```python parser.add_argument( "--host", default="127.0.0.1", help="Bind host (default: 127.0.0.1)" ) ``` 3. Add new arguments after the `--port` argument: ```python parser.add_argument( "--auth", choices=["pam", "password"], default="pam", help="Auth mode: pam (default) or password", ) parser.add_argument( "--session-ttl", type=int, default=604800, help="Session cookie TTL in seconds (default: 604800 = 7 days, 0 = browser session)", ) ``` 4. Update the `serve()` call in `main()` to pass the new args: ```python if args.command == "install-service": install_service(system=args.system) else: serve(host=args.host, port=args.port, auth=args.auth, session_ttl=args.session_ttl) ``` **Step 4: Update the existing test that checks the old default** The existing test `test_main_calls_serve_by_default` asserts `host="0.0.0.0"`. Update it: In `muxplex/tests/test_cli.py`, change: ```python mock_serve.assert_called_once_with(host="0.0.0.0", port=8088) ``` to: ```python mock_serve.assert_called_once_with(host="127.0.0.1", port=8088, auth="pam", session_ttl=604800) ``` **Step 5: Run tests to verify they pass** Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_cli.py -v` Expected: all tests PASS (including updated existing tests) **Step 6: Commit** ```bash cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/cli.py muxplex/tests/test_cli.py && git commit -m "feat(cli): add --auth and --session-ttl flags, change --host default to 127.0.0.1" ``` --- ### Task 6: CLI: show-password subcommand **Files:** - Modify: `muxplex/cli.py` - Modify: `muxplex/tests/test_cli.py` **Step 1: Write the failing tests** Append to `muxplex/tests/test_cli.py`: ```python # --------------------------------------------------------------------------- # show-password subcommand # --------------------------------------------------------------------------- def test_show_password_prints_password_from_file(tmp_path, monkeypatch, capsys): """show-password prints the password when the file exists.""" from muxplex.cli import main # Set up a fake password file fake_home = tmp_path / "home" fake_home.mkdir() monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) pw_path = fake_home / ".config" / "muxplex" / "password" pw_path.parent.mkdir(parents=True, exist_ok=True) pw_path.write_text("my-test-password\n") pw_path.chmod(0o600) # Force password mode monkeypatch.setenv("MUXPLEX_AUTH", "password") with patch("sys.argv", ["muxplex", "show-password"]): main() captured = capsys.readouterr() assert "my-test-password" in captured.out def test_show_password_no_file(tmp_path, monkeypatch, capsys): """show-password prints a helpful message when no password file exists.""" from muxplex.cli import main fake_home = tmp_path / "home" fake_home.mkdir() monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) monkeypatch.setenv("MUXPLEX_AUTH", "password") with patch("sys.argv", ["muxplex", "show-password"]): main() captured = capsys.readouterr() assert "no password" in captured.out.lower() or "not found" in captured.out.lower() def test_show_password_pam_mode(monkeypatch, capsys): """show-password in PAM mode prints that PAM is active.""" from muxplex.cli import main monkeypatch.delenv("MUXPLEX_AUTH", raising=False) monkeypatch.setattr("muxplex.auth.pam_available", lambda: True) with patch("sys.argv", ["muxplex", "show-password"]): main() captured = capsys.readouterr() assert "pam" in captured.out.lower() ``` **Step 2: Run tests to verify they fail** Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_cli.py -v -k "show_password" 2>&1 | head -20` Expected: FAIL — `show-password` subcommand doesn't exist **Step 3: Add the show-password subcommand** In `muxplex/cli.py`, add the function: ```python def show_password() -> None: """Show the current muxplex password.""" from muxplex.auth import load_password, pam_available auth_mode = os.environ.get("MUXPLEX_AUTH", "").lower() if auth_mode != "password" and pam_available(): print("Auth mode: PAM — no password file used") return pw = load_password() if pw: print(f"Password: {pw}") else: print("No password file found. Start muxplex to auto-generate one.") ``` Then register it as a subcommand in `main()`. Add after the `install-service` subparser: ```python sub.add_parser("show-password", help="Show the current muxplex password") ``` And in the command dispatch: ```python if args.command == "install-service": install_service(system=args.system) elif args.command == "show-password": show_password() else: serve(host=args.host, port=args.port, auth=args.auth, session_ttl=args.session_ttl) ``` **Step 4: Run tests to verify they pass** Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_cli.py -v -k "show_password"` Expected: all 3 tests PASS **Step 5: Commit** ```bash cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/cli.py muxplex/tests/test_cli.py && git commit -m "feat(cli): add show-password subcommand" ``` --- ### Task 7: CLI: reset-secret subcommand **Files:** - Modify: `muxplex/cli.py` - Modify: `muxplex/tests/test_cli.py` **Step 1: Write the failing tests** Append to `muxplex/tests/test_cli.py`: ```python # --------------------------------------------------------------------------- # reset-secret subcommand # --------------------------------------------------------------------------- def test_reset_secret_writes_new_secret(tmp_path, monkeypatch, capsys): """reset-secret writes a new secret file.""" from muxplex.cli import main fake_home = tmp_path / "home" fake_home.mkdir() monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) with patch("sys.argv", ["muxplex", "reset-secret"]): main() secret_path = fake_home / ".config" / "muxplex" / "secret" assert secret_path.exists() content = secret_path.read_text().strip() assert len(content) > 20 def test_reset_secret_sets_0600_permissions(tmp_path, monkeypatch, capsys): """reset-secret sets the secret file to mode 0600.""" from muxplex.cli import main fake_home = tmp_path / "home" fake_home.mkdir() monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) with patch("sys.argv", ["muxplex", "reset-secret"]): main() secret_path = fake_home / ".config" / "muxplex" / "secret" mode = stat.S_IMODE(secret_path.stat().st_mode) assert mode == 0o600 def test_reset_secret_prints_warning(tmp_path, monkeypatch, capsys): """reset-secret prints a warning about invalidated sessions.""" from muxplex.cli import main fake_home = tmp_path / "home" fake_home.mkdir() monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) with patch("sys.argv", ["muxplex", "reset-secret"]): main() captured = capsys.readouterr() assert "invalid" in captured.out.lower() or "warning" in captured.out.lower() ``` **Step 2: Run tests to verify they fail** Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_cli.py -v -k "reset_secret" 2>&1 | head -20` Expected: FAIL — `reset-secret` subcommand doesn't exist **Step 3: Add the reset-secret subcommand** In `muxplex/cli.py`, add the function: ```python def reset_secret() -> None: """Regenerate the signing secret, invalidating all active sessions.""" import secrets as _secrets from muxplex.auth import get_secret_path path = get_secret_path() path.parent.mkdir(parents=True, exist_ok=True) new_secret = _secrets.token_urlsafe(32) path.write_text(new_secret + "\n") path.chmod(0o600) print(f"New signing secret written to {path}") print("Warning: all active sessions are now invalid.") ``` Register it as a subcommand. Add after the `show-password` subparser: ```python sub.add_parser("reset-secret", help="Regenerate signing secret (invalidates sessions)") ``` And in the command dispatch: ```python if args.command == "install-service": install_service(system=args.system) elif args.command == "show-password": show_password() elif args.command == "reset-secret": reset_secret() else: serve(host=args.host, port=args.port, auth=args.auth, session_ttl=args.session_ttl) ``` Also add `import stat` to the test file imports if not already present. **Step 4: Run tests to verify they pass** Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_cli.py -v -k "reset_secret"` Expected: all 3 tests PASS **Step 5: Commit** ```bash cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/cli.py muxplex/tests/test_cli.py && git commit -m "feat(cli): add reset-secret subcommand" ``` --- ### Task 8: Startup auth logging **Files:** - Modify: `muxplex/main.py` (refine `_resolve_auth` logging) - Modify: `muxplex/tests/test_auth.py` **Step 1: Write the failing tests** Append to `muxplex/tests/test_auth.py`: ```python # --------------------------------------------------------------------------- # Startup auth logging (via _resolve_auth) # --------------------------------------------------------------------------- def test_resolve_auth_pam_mode_logs_pam(monkeypatch, capsys, tmp_path): """_resolve_auth() prints PAM auth line when PAM is available.""" monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) monkeypatch.delenv("MUXPLEX_AUTH", raising=False) monkeypatch.delenv("MUXPLEX_PASSWORD", raising=False) monkeypatch.setattr("muxplex.auth.pam_available", lambda: True) # Import after patching from muxplex.main import _resolve_auth mode, pw = _resolve_auth() assert mode == "pam" captured = capsys.readouterr() assert "PAM" in captured.err def test_resolve_auth_env_password_logs_env(monkeypatch, capsys, tmp_path): """_resolve_auth() prints env password line when MUXPLEX_PASSWORD is set.""" monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) monkeypatch.setenv("MUXPLEX_AUTH", "password") monkeypatch.setenv("MUXPLEX_PASSWORD", "from-env") from muxplex.main import _resolve_auth mode, pw = _resolve_auth() assert mode == "password" assert pw == "from-env" captured = capsys.readouterr() assert "env" in captured.err.lower() def test_resolve_auth_file_password_logs_file(monkeypatch, capsys, tmp_path): """_resolve_auth() prints file password line when password file exists.""" monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) monkeypatch.setenv("MUXPLEX_AUTH", "password") monkeypatch.delenv("MUXPLEX_PASSWORD", raising=False) pw_path = tmp_path / ".config" / "muxplex" / "password" pw_path.parent.mkdir(parents=True, exist_ok=True) pw_path.write_text("file-password\n") pw_path.chmod(0o600) from muxplex.main import _resolve_auth mode, pw = _resolve_auth() assert mode == "password" assert pw == "file-password" captured = capsys.readouterr() assert "file" in captured.err.lower() or "password" in captured.err.lower() def test_resolve_auth_generates_password_as_last_resort(monkeypatch, capsys, tmp_path): """_resolve_auth() auto-generates a password when nothing else is available.""" monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) monkeypatch.setenv("MUXPLEX_AUTH", "password") monkeypatch.delenv("MUXPLEX_PASSWORD", raising=False) from muxplex.main import _resolve_auth mode, pw = _resolve_auth() assert mode == "password" assert len(pw) > 10 captured = capsys.readouterr() assert "generated" in captured.err.lower() # The generated password should be printed so the user can see it assert pw in captured.err ``` **Step 2: Run tests to verify they fail or pass** Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_auth.py -v -k "resolve_auth" 2>&1 | head -30` Expected: These tests verify the `_resolve_auth` function added in Phase 1 Task 7. They may pass already if the logging was correctly implemented. If they fail, fix the `_resolve_auth` function. **Step 3: Refine _resolve_auth logging if needed** Verify the `_resolve_auth()` function in `muxplex/main.py` prints exactly these lines to stderr: - PAM available: `muxplex auth: PAM (user: {username})` - Env password: `muxplex auth: password (env)` - File password: `muxplex auth: password (file: ~/.config/muxplex/password)` - Auto-generated: `muxplex auth: password generated — {password} — saved to ~/.config/muxplex/password` Update the function if the format doesn't match. Fix the `file_pw` logging line — the Phase 1 plan had a bug (it printed `load_password.__module__` instead of the file path). It should be: ```python file_pw = load_password() if file_pw: from muxplex.auth import get_password_path print(f" muxplex auth: password (file: {get_password_path()})", file=sys.stderr) return "password", file_pw ``` **Step 4: Run all tests** Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_auth.py -v -k "resolve_auth"` Expected: all 4 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 across all test files **Step 6: Commit** ```bash cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/main.py muxplex/tests/test_auth.py && git commit -m "feat: startup auth mode logging with auto-generated password display" ``` --- ## Phase 2 Complete Checklist After all 8 tasks: - [ ] `muxplex/frontend/login.html` exists — branded dark theme, wordmark, PAM/password mode detection - [ ] POST `/login` works — correct creds → cookie + redirect to `/`, wrong creds → redirect to `/login?error=1` - [ ] GET `/auth/logout` works — clears cookie, redirects to `/login` - [ ] GET `/login` serves `login.html` with `window.MUXPLEX_AUTH` injected - [ ] `--host` defaults to `127.0.0.1` - [ ] `--auth` and `--session-ttl` flags work - [ ] `muxplex show-password` prints the password or PAM message - [ ] `muxplex reset-secret` regenerates the signing key with warning - [ ] Startup prints one clear auth mode line to stderr - [ ] All tests pass: `python -m pytest muxplex/tests/ -v` - [ ] 8 clean commits with conventional commit messages ## End-to-End Smoke Test After both phases are complete, manually verify: 1. `cd /home/bkrabach/dev/web-tmux/muxplex && python -m muxplex --host 0.0.0.0` — should print auth mode line 2. Open `http://localhost:8088` — should load the dashboard (localhost bypass) 3. Open from another device on the LAN — should redirect to `/login` 4. Log in with the displayed password — should redirect to dashboard 5. `muxplex show-password` — prints the password 6. `muxplex reset-secret` — prints warning, old browser session should fail ## Deferred - HTTPS/TLS support - Rate limiting on login endpoint - Remember-me longer TTL - Admin reset flow - `install-service` auth-aware unit files (systemd EnvironmentFile, launchd plist)