Files
muxplex/docs/plans/2026-03-28-auth-phase2-ui-cli.md
T
2026-03-29 22:12:07 -07:00

1066 lines
36 KiB
Markdown

# 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 <form> 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 <img> 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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0D1117" />
<title>muxplex — login</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<style>
/* Inline styles — login page must render before any auth */
*,*::before,*::after{box-sizing:border-box}
:root {
--bg: #0D1117;
--bg-surface: #1A1F2B;
--text: #F0F6FF;
--text-muted: #8E95A3;
--border: #2A3040;
--border-subtle: #1E2430;
--accent: #00D9F5;
--accent-hover: #00b8d1;
--err: #f85149;
--font-ui: system-ui, -apple-system, 'Segoe UI', sans-serif;
}
html,body {
height: 100%; margin: 0; padding: 0;
background: var(--bg); color: var(--text);
font-family: var(--font-ui); font-size: 14px;
}
.login-wrapper {
min-height: 100vh; display: flex;
align-items: center; justify-content: center;
padding: 24px;
}
.login-card {
width: 100%; max-width: 380px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 40px 32px 32px;
}
.login-wordmark {
display: block; margin: 0 auto 32px;
height: 28px;
}
.login-field { margin-bottom: 16px; }
.login-label {
display: block; font-size: 13px;
color: var(--text-muted); margin-bottom: 6px;
}
.login-input {
width: 100%; padding: 10px 12px;
background: var(--bg); color: var(--text);
border: 1px solid var(--border-subtle);
border-radius: 6px; font-size: 14px;
font-family: var(--font-ui);
outline: none; transition: border-color 150ms ease;
}
.login-input:focus {
border-color: var(--accent);
}
.login-input[readonly] {
opacity: 0.6; cursor: not-allowed;
}
.login-btn {
width: 100%; padding: 10px 0; margin-top: 8px;
background: var(--accent); color: var(--bg);
border: none; border-radius: 6px;
font-size: 14px; font-weight: 600;
font-family: var(--font-ui);
cursor: pointer; transition: background 150ms ease;
}
.login-btn:hover { background: var(--accent-hover); }
.login-error {
background: rgba(248,81,73,0.1);
border: 1px solid var(--err);
color: var(--err); border-radius: 6px;
padding: 10px 12px; margin-bottom: 16px;
font-size: 13px; display: none;
}
.login-error.visible { display: block; }
#username-field { display: none; }
</style>
</head>
<body>
<div class="login-wrapper">
<div class="login-card">
<img src="/wordmark-on-dark.svg" alt="muxplex" class="login-wordmark" />
<div id="login-error" class="login-error">
Invalid credentials. Please try again.
</div>
<form method="post" action="/login">
<div id="username-field" class="login-field">
<label class="login-label" for="username">Username</label>
<input id="username" name="username" type="text"
class="login-input" autocomplete="username" readonly />
</div>
<div class="login-field">
<label class="login-label" for="password">Password</label>
<input id="password" name="password" type="password"
class="login-input" autocomplete="current-password"
placeholder="Enter password" autofocus />
</div>
<button type="submit" class="login-btn">Sign in</button>
</form>
</div>
</div>
<script>
// Auth mode is injected by the server as window.MUXPLEX_AUTH = {mode, user}
(function() {
var auth = window.MUXPLEX_AUTH || {mode: 'password', user: ''};
// Show username field in PAM mode
if (auth.mode === 'pam' && auth.user) {
var field = document.getElementById('username-field');
var input = document.getElementById('username');
field.style.display = 'block';
input.value = auth.user;
}
// Show error if redirected back with ?error=1
if (window.location.search.indexOf('error=1') !== -1) {
document.getElementById('login-error').classList.add('visible');
}
})();
</script>
</body>
</html>
```
**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 </head> so the inline script can read it
html = html.replace(
"</head>",
f"<script>window.MUXPLEX_AUTH = {mode_data};</script>\n</head>",
)
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)