Covers PAM + password auth modes, localhost bypass, session cookies, service installation (systemd/launchd), and testing strategy.
11 KiB
Authentication Design
Goal
Add authentication to muxplex so that localhost access always bypasses auth (no friction for local use), non-localhost network access requires auth (prevents exposure on LAN/VPN), the default bind address changes from 0.0.0.0 to 127.0.0.1, and each user runs their own muxplex instance with PAM naturally restricting to that user.
Background
muxplex currently binds to 0.0.0.0 with no authentication. Anyone on the same network can access and control terminal sessions. This is fine for purely local use but dangerous on shared networks, VPNs, or cloud hosts. Authentication needs to be zero-friction for the common local case while making network exposure safe by default.
Approach
PAM as the default auth backend (folded into main dependencies, not optional). Password mode as a fallback when PAM is unavailable. A custom login form with signed session cookies handles browser-based auth. The hard guard: binding to a non-localhost address without auth is impossible — PAM or an auto-generated password is always in place.
Architecture
Request Flow
All requests — including WebSocket upgrades — pass through a single FastAPI middleware:
Incoming request
├── client is 127.0.0.1 or ::1 → pass through (no auth check)
├── has valid session cookie → pass through
├── has Authorization: Basic header → validate (PAM or password), pass or 401
└── none of the above → redirect to /login (or 401 for non-browser)
Auth Mode Resolution (startup)
1. --auth=password or MUXPLEX_AUTH=password → password mode
2. try: import pam → success → PAM mode (default)
3. ImportError or PAM init failure → password mode + startup log line:
"PAM unavailable, using password auth"
PAM Mode
- Calls
pam.authenticate(username, password, service='login') - Plus a running-user check:
username == pwd.getpwuid(os.getuid()).pw_name - Both checks must pass — valid credentials for a different user are rejected
- Single-user isolation: the tmux socket is already per-user; the PAM check reinforces it
Password Mode
Priority order for password resolution:
MUXPLEX_PASSWORDenvironment variable~/.config/muxplex/passwordfile (mode0600)- Neither set → auto-generate with
secrets.token_urlsafe(20), write to file, print at startup
Login Form (/login)
- Standalone HTML page, no external JS dependencies (loads before auth)
- Styled with muxplex branding (dark theme, Urbanist wordmark, brand color tokens)
- PAM mode: username field (read-only, pre-filled with running user's name) + password field
- Password mode: single password field only
autocomplete="current-password"(andautocomplete="username"in PAM mode)- Password managers (1Password, Bitwarden, Safari Keychain) detect these attributes correctly
Session Cookies
- Signed with
itsdangerous.TimestampSignerusing the server secret - Server secret:
~/.config/muxplex/secret, auto-generated on first run, mode0600 - Stateless — no server-side session store; verification is signature + expiry check
- Default expiry: 7 days (configurable via
--session-ttl/MUXPLEX_SESSION_TTL) - Attributes:
HttpOnly,SameSite=Strict /auth/logoutendpoint: clears cookie (GET, no auth required)
Service Installation
Platform Detection in muxplex install-service
Linux (systemd user service)
- Writes to
~/.config/systemd/user/muxplex.service - PAM mode (default): no credentials in unit file
- Password mode:
EnvironmentFile=~/.config/muxplex/env(mode0600) — password never in unit file - Default service binding:
--host 0.0.0.0(network access is the point of a service) - Prints activation commands but does not run them:
systemctl --user daemon-reload systemctl --user enable --now muxplex # For always-on without login session: loginctl enable-linger $(whoami) --lingerflag oninstall-serviceexecutes theloginctlcall automatically
macOS (launchd agent)
- Writes to
~/Library/LaunchAgents/io.github.bkrabach.muxplex.plist RunAtLoad=true,KeepAlive=true— starts at login, restarts on crash- Prints:
launchctl load ~/Library/LaunchAgents/io.github.bkrabach.muxplex.plist
Both Platforms
--port PORTflag (default8088) for multi-user installations on same host- Design note:
EnvironmentFilefor secrets is the correct systemd pattern — unit files can go in version control; env files never do
Configuration Reference
CLI Flags
| Flag | Default | Notes |
|---|---|---|
--host |
127.0.0.1 |
Changed from previous 0.0.0.0 default |
--port |
8088 |
Pick per-user on shared machines |
--auth |
pam |
pam or password |
--session-ttl |
7d |
0 = session cookie (clears on browser close) |
Environment Variables
| Variable | Maps to |
|---|---|
MUXPLEX_AUTH |
--auth |
MUXPLEX_PASSWORD |
Password for password mode (highest priority) |
MUXPLEX_SESSION_TTL |
--session-ttl |
Config Files
All stored in ~/.config/muxplex/, all mode 0600:
| File | Purpose |
|---|---|
password |
Auto-generated password (password mode only) |
secret |
Server signing key for session cookies |
env |
Sourced by systemd/launchd service unit |
New Subcommands
muxplex show-password— reads and prints~/.config/muxplex/password(or "using PAM" if PAM mode active)muxplex reset-secret— regenerates signing secret, invalidates all active sessions
Data Flow
Login (browser)
Browser → GET /login → login.html (with form)
Browser → POST /login (username?, password)
Server → PAM: pam.authenticate(user, pass) + running-user check
or Password: compare against resolved password
Success → Set-Cookie (signed, HttpOnly, SameSite=Strict) → 302 /
Failure → 401 → re-render login form with error
Authenticated Request (browser)
Browser → GET /any-path (Cookie: session=<signed-token>)
Middleware → verify signature + check expiry
Valid → pass to route handler
Invalid → redirect to /login
Authenticated Request (API / CLI)
Client → GET /any-path (Authorization: Basic base64(user:pass))
Middleware → validate credentials (PAM or password)
Valid → pass to route handler
Invalid → 401 Unauthorized
Logout
Browser → GET /auth/logout
Server → Set-Cookie: session=; Max-Age=0 → 302 /login
Error Handling
- PAM import failure at startup: fall back to password mode, log one line:
"PAM unavailable, using password auth" - PAM authentication failure: 401 with generic message (never reveal whether username or password was wrong)
- Tampered cookie: treated as no cookie — redirect to
/loginfor browsers, 401 for API clients - Expired cookie: same as tampered — redirect or 401
- Missing
~/.config/muxplex/directory: auto-create with mode0700 - Secret file missing: auto-generate on first run
- Password file unreadable (permissions): startup error with clear message about expected file permissions
Testing Strategy
Middleware Unit Tests (all auth mocked)
Seven request shapes covering every middleware branch:
- Localhost (
127.0.0.1/::1) → passes through, no auth check - Valid session cookie + non-localhost → passes through
- Expired or tampered cookie → redirects to
/login - No cookie, non-localhost → redirects to
/login Authorization: Basicheader, valid credentials → passes throughAuthorization: Basicheader, invalid credentials → 401- WebSocket upgrade with valid cookie → passes (same middleware path)
Login Form Tests
GET /login→ correct HTML: PAM mode has read-only username field pre-filled with running user; password mode has single password fieldPOST /login, PAM mode, correct user + password → cookie set, redirect to/POST /login, PAM mode, wrong password → 401POST /login, PAM mode, different username (valid creds for another user) → 401POST /login, password mode, correct password → cookie set, redirectPOST /login, password mode, wrong password → 401
PAM Mocking Strategy
- Mock target:
pam.authenticate()frompython-pam— notlibpam.so, not the route handler - ImportError path: PAM unavailable → falls back to password mode, emits one startup log line
- Principle: don't test the PAM library — test your code that calls it. One mock, at the right layer.
Password and Secret Management
- Auto-generate: creates
~/.config/muxplex/passwordwith mode0600 show-password: reads and prints correct file (mocked path in tests)reset-secret: writes a new signing key; cookies signed with old key fail verificationMUXPLEX_PASSWORDenv var takes precedence over file in all tests
Session Cookie Tests
- Sign → verify round trip works
- Tampered cookie (modified payload) → rejected
- Timestamp-expired cookie → rejected
- Correct
HttpOnlyandSameSite=Strictattributes set
Service File Tests
- Extend existing
test_cli.pypatterns - Linux: unit file content correct,
EnvironmentFile=present when password mode, absent when PAM - macOS: plist content correct,
RunAtLoadandKeepAliveset
Implementation Notes
Key files to create or modify:
| File | Changes |
|---|---|
muxplex/auth.py |
New module. Auth mode resolution, PAM wrapper, password resolution, cookie signing/verification, middleware class |
muxplex/main.py |
Add auth middleware to FastAPI app, /login route (GET + POST), /auth/logout route |
muxplex/cli.py |
New --auth and --session-ttl flags, show-password and reset-secret subcommands, update install-service for auth-aware unit files |
muxplex/frontend/login.html |
New file. Standalone login page with dark theme, muxplex branding, conditional username/password fields |
Dependencies Added
| Package | Version | Purpose |
|---|---|---|
python-pam |
>=1.8.4 |
PAM authentication (folded into main deps) |
itsdangerous |
>=2.1.0 |
TimestampSigner for session cookies |
Open Questions
- macOS PAM quirks.
python-pamon older macOS has known issues. Document the PAM fallback behavior clearly in the README so users know what to expect if PAM fails silently. reset-secretconfirmation. Shouldmuxplex reset-secretwarn the user that all active sessions will be invalidated? Recommendation: yes, print a warning before writing the new secret.--session-ttl 0semantics.0means session cookie (clears on browser close). Consider whether this needs a separate--session-cookieflag for clarity, or if0is intuitive enough.