diff --git a/muxplex/__pycache__/__main__.cpython-313.pyc b/muxplex/__pycache__/__main__.cpython-313.pyc
new file mode 100644
index 0000000..7797b03
Binary files /dev/null and b/muxplex/__pycache__/__main__.cpython-313.pyc differ
diff --git a/muxplex/__pycache__/auth.cpython-313.pyc b/muxplex/__pycache__/auth.cpython-313.pyc
index 2634239..0055d92 100644
Binary files a/muxplex/__pycache__/auth.cpython-313.pyc and b/muxplex/__pycache__/auth.cpython-313.pyc differ
diff --git a/muxplex/__pycache__/cli.cpython-313.pyc b/muxplex/__pycache__/cli.cpython-313.pyc
index 47da95b..dadcb0b 100644
Binary files a/muxplex/__pycache__/cli.cpython-313.pyc and b/muxplex/__pycache__/cli.cpython-313.pyc differ
diff --git a/muxplex/__pycache__/main.cpython-313.pyc b/muxplex/__pycache__/main.cpython-313.pyc
index 36cab23..5d3d638 100644
Binary files a/muxplex/__pycache__/main.cpython-313.pyc and b/muxplex/__pycache__/main.cpython-313.pyc differ
diff --git a/muxplex/__pycache__/service.cpython-313.pyc b/muxplex/__pycache__/service.cpython-313.pyc
new file mode 100644
index 0000000..554ead6
Binary files /dev/null and b/muxplex/__pycache__/service.cpython-313.pyc differ
diff --git a/muxplex/__pycache__/settings.cpython-313.pyc b/muxplex/__pycache__/settings.cpython-313.pyc
index 492914b..172565f 100644
Binary files a/muxplex/__pycache__/settings.cpython-313.pyc and b/muxplex/__pycache__/settings.cpython-313.pyc differ
diff --git a/muxplex/__pycache__/ttyd.cpython-313.pyc b/muxplex/__pycache__/ttyd.cpython-313.pyc
index eb1eaf1..3b8e870 100644
Binary files a/muxplex/__pycache__/ttyd.cpython-313.pyc and b/muxplex/__pycache__/ttyd.cpython-313.pyc differ
diff --git a/muxplex/auth.py b/muxplex/auth.py
index 06ea5bd..a6d0e22 100644
--- a/muxplex/auth.py
+++ b/muxplex/auth.py
@@ -192,6 +192,12 @@ class AuthMiddleware(BaseHTTPMiddleware):
if cookie and verify_session_cookie(self.secret, cookie, self.ttl_seconds):
return await call_next(request)
+ # 4b. X-Muxplex-Token header (for cross-origin federation)
+ token_header = request.headers.get("x-muxplex-token")
+ if token_header:
+ if verify_session_cookie(self.secret, token_header, self.ttl_seconds):
+ return await call_next(request)
+
# 5. Authorization: Basic header
auth_header = request.headers.get("authorization", "")
if auth_header.lower().startswith("basic "):
diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js
index 1dbb9b8..2a8cf55 100644
--- a/muxplex/frontend/app.js
+++ b/muxplex/frontend/app.js
@@ -179,6 +179,20 @@ async function api(method, path, body, baseUrl) {
if (baseUrl) {
url = baseUrl.replace(/\/+$/, '') + path;
opts.credentials = 'include';
+ // Tell the remote auth middleware this is a JSON API client, not a browser
+ // navigation. Without this header the middleware returns a 307 redirect to
+ // /login (HTML) instead of a 401 JSON response, causing res.json() to throw
+ // a SyntaxError that is misclassified as "unreachable" instead of
+ // "auth_required".
+ opts.headers['Accept'] = 'application/json';
+ // Check for stored federation token (X-Muxplex-Token for cross-origin auth)
+ try {
+ var _origin = new URL(url).origin;
+ var _tokens = JSON.parse(localStorage.getItem('muxplex.federation_tokens') || '{}');
+ if (_tokens[_origin]) {
+ opts.headers['X-Muxplex-Token'] = _tokens[_origin];
+ }
+ } catch (_) { /* ignore — URL parse or localStorage errors */ }
}
const res = await fetch(url, opts);
if (!res.ok) {
@@ -190,6 +204,37 @@ async function api(method, path, body, baseUrl) {
}
// ─── Device ID ────────────────────────────────────────────────────────────────
+// ─── Federation token relay ──────────────────────────────────────────────────
+
+/**
+ * Store a federation auth token for a remote origin in localStorage.
+ * Keyed by origin URL in muxplex.federation_tokens.
+ * Called by the postMessage listener when a login popup relays a token back.
+ * @param {string} origin - The remote muxplex origin URL (e.g. 'https://host:8088')
+ * @param {string} token - The session token to store
+ */
+function storeFederationToken(origin, token) {
+ try {
+ var _ftokens = JSON.parse(localStorage.getItem('muxplex.federation_tokens') || '{}');
+ _ftokens[origin] = token;
+ localStorage.setItem('muxplex.federation_tokens', JSON.stringify(_ftokens));
+ } catch (_) { /* blocked — ok */ }
+}
+
+// Listen for federation auth tokens relayed from login popups via postMessage.
+// When the user logs in via a popup, the popup fetches /api/auth/token and sends
+// it here, letting subsequent cross-origin API calls use X-Muxplex-Token header.
+window.addEventListener('message', function(event) {
+ if (event.data && event.data.type === 'muxplex-auth-token') {
+ storeFederationToken(event.data.origin, event.data.token);
+ // Immediately trigger a poll so the source transitions from auth_required
+ // to authenticated on the next cycle (uses the newly stored token).
+ if (_pollingTimer) {
+ pollSessions();
+ }
+ }
+});
+
function initDeviceId() {
const STORAGE_KEY = 'tmux-web-device-id';
try {
@@ -1489,6 +1534,23 @@ function _saveRemoteInstances() {
});
patchServerSetting('remote_instances', instances).then(function() {
_sources = buildSources(_serverSettings);
+ // Prune federation tokens for URLs no longer in the remote instances list
+ try {
+ var activeOrigins = instances.map(function(r) {
+ try { return new URL(r.url).origin; } catch (_) { return null; }
+ }).filter(Boolean);
+ var ftokens = JSON.parse(localStorage.getItem('muxplex.federation_tokens') || '{}');
+ var pruned = false;
+ Object.keys(ftokens).forEach(function(origin) {
+ if (!activeOrigins.includes(origin)) {
+ delete ftokens[origin];
+ pruned = true;
+ }
+ });
+ if (pruned) {
+ localStorage.setItem('muxplex.federation_tokens', JSON.stringify(ftokens));
+ }
+ } catch (_) { /* blocked — ok */ }
});
}
@@ -2626,6 +2688,8 @@ if (typeof module !== 'undefined' && module.exports) {
buildOfflineTileHTML,
openLoginPopup,
formatLastSeen,
+ // Federation auth token relay
+ storeFederationToken,
// Constants
NEW_SESSION_DEFAULT_TEMPLATE,
DELETE_SESSION_DEFAULT_TEMPLATE,
diff --git a/muxplex/frontend/index.html b/muxplex/frontend/index.html
index 6ed566c..f155d96 100644
--- a/muxplex/frontend/index.html
+++ b/muxplex/frontend/index.html
@@ -15,6 +15,26 @@
muxplex
+
diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs
index 809f5d4..396cd74 100644
--- a/muxplex/frontend/tests/test_app.mjs
+++ b/muxplex/frontend/tests/test_app.mjs
@@ -4068,3 +4068,88 @@ test('HTML Sessions panel hidden sessions field appears after bell sound', () =>
assert.ok(hiddenIdx > bellSoundIdx, 'hidden sessions must appear after bell sound (i.e., near the end)');
});
+// ---------------------------------------------------------------------------
+// Federation auth token relay tests (postMessage / X-Muxplex-Token)
+// ---------------------------------------------------------------------------
+
+test('api() includes X-Muxplex-Token header when token exists in localStorage for that origin', async () => {
+ // Store a fake token for https://remote.example.com
+ const tokens = { 'https://remote.example.com': 'fake-token-abc123' };
+ localStorage.setItem('muxplex.federation_tokens', JSON.stringify(tokens));
+
+ const calls = [];
+ globalThis.fetch = (url, opts) => {
+ calls.push({ url, opts });
+ return Promise.resolve({ ok: true, status: 200, json: async () => ([]) });
+ };
+
+ await app.api('GET', '/api/sessions', undefined, 'https://remote.example.com');
+
+ assert.strictEqual(calls.length, 1);
+ assert.ok(calls[0].opts.headers['X-Muxplex-Token'] === 'fake-token-abc123',
+ 'X-Muxplex-Token header must be set to the stored token');
+
+ // Cleanup
+ localStorage.removeItem('muxplex.federation_tokens');
+});
+
+test('api() does not include X-Muxplex-Token header when no token for that origin', async () => {
+ // Ensure no tokens stored
+ localStorage.removeItem('muxplex.federation_tokens');
+
+ const calls = [];
+ globalThis.fetch = (url, opts) => {
+ calls.push({ url, opts });
+ return Promise.resolve({ ok: true, status: 200, json: async () => ([]) });
+ };
+
+ await app.api('GET', '/api/sessions', undefined, 'https://remote.example.com');
+
+ assert.strictEqual(calls.length, 1);
+ assert.ok(!calls[0].opts.headers['X-Muxplex-Token'],
+ 'X-Muxplex-Token header must NOT be present when no token stored');
+});
+
+test('api() does not include X-Muxplex-Token header for local (no baseUrl) requests', async () => {
+ // Store a token for some origin, make sure it does not bleed into local requests
+ const tokens = { 'https://remote.example.com': 'fake-token-abc123' };
+ localStorage.setItem('muxplex.federation_tokens', JSON.stringify(tokens));
+
+ const calls = [];
+ globalThis.fetch = (url, opts) => {
+ calls.push({ url, opts });
+ return Promise.resolve({ ok: true, status: 200, json: async () => ([]) });
+ };
+
+ await app.api('GET', '/api/sessions');
+
+ assert.strictEqual(calls.length, 1);
+ assert.ok(!calls[0].opts.headers['X-Muxplex-Token'],
+ 'X-Muxplex-Token header must NOT be present for local requests');
+
+ // Cleanup
+ localStorage.removeItem('muxplex.federation_tokens');
+});
+
+test('postMessage muxplex-auth-token event stores token in localStorage', () => {
+ // Simulate receiving a postMessage event
+ localStorage.removeItem('muxplex.federation_tokens');
+
+ // Find and invoke the message event listener registered by app.js
+ // The app registers on window.addEventListener('message', ...) in DOMContentLoaded
+ // We need to directly call storeFederationToken helper or simulate via the listener.
+ // Since bindStaticEventListeners or DOMContentLoaded registered a 'message' listener on window,
+ // we call storeFederationToken directly if exported, or test via the exported API.
+ const result = app.storeFederationToken('https://remote.host.com', 'relay-token-xyz');
+
+ const stored = JSON.parse(localStorage.getItem('muxplex.federation_tokens') || '{}');
+ assert.strictEqual(stored['https://remote.host.com'], 'relay-token-xyz',
+ 'storeFederationToken must store token keyed by origin in muxplex.federation_tokens');
+});
+
+test('index.html contains popup federation auth relay script', () => {
+ const source = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8');
+ assert.ok(source.includes('window.opener'), 'index.html must contain window.opener check for federation popup relay');
+ assert.ok(source.includes('muxplex-auth-token'), 'index.html must post muxplex-auth-token message type');
+ assert.ok(source.includes('/api/auth/token'), 'index.html popup script must fetch /api/auth/token');
+});
diff --git a/muxplex/main.py b/muxplex/main.py
index 23b32a0..a5b9c7c 100644
--- a/muxplex/main.py
+++ b/muxplex/main.py
@@ -28,7 +28,7 @@ from fastapi import FastAPI, Form, HTTPException, Request, WebSocket
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, field_validator
-from starlette.responses import RedirectResponse
+from starlette.responses import JSONResponse, RedirectResponse
from starlette.middleware.cors import CORSMiddleware
from muxplex.auth import (
@@ -733,6 +733,19 @@ async def logout() -> RedirectResponse:
return response
+@app.get("/api/auth/token")
+async def get_auth_token(request: Request):
+ """Return the current session token for federation relay.
+
+ Only accessible when already authenticated (via cookie or localhost bypass).
+ Used by the login popup to relay the token back to the opener window via postMessage.
+ """
+ cookie = request.cookies.get("muxplex_session")
+ if cookie and verify_session_cookie(_auth_secret, cookie, _auth_ttl):
+ return {"token": cookie}
+ return JSONResponse({"error": "not authenticated"}, status_code=401)
+
+
@app.get("/auth/mode")
async def auth_mode_endpoint():
"""Return the current auth mode and running username."""
diff --git a/muxplex/tests/__pycache__/test_api.cpython-313-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_api.cpython-313-pytest-9.0.2.pyc
index 5e91309..581c791 100644
Binary files a/muxplex/tests/__pycache__/test_api.cpython-313-pytest-9.0.2.pyc and b/muxplex/tests/__pycache__/test_api.cpython-313-pytest-9.0.2.pyc differ
diff --git a/muxplex/tests/__pycache__/test_auth.cpython-313-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_auth.cpython-313-pytest-9.0.2.pyc
index ed7d12a..a648077 100644
Binary files a/muxplex/tests/__pycache__/test_auth.cpython-313-pytest-9.0.2.pyc and b/muxplex/tests/__pycache__/test_auth.cpython-313-pytest-9.0.2.pyc differ
diff --git a/muxplex/tests/__pycache__/test_cli.cpython-313-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_cli.cpython-313-pytest-9.0.2.pyc
index c757d31..d6e0b66 100644
Binary files a/muxplex/tests/__pycache__/test_cli.cpython-313-pytest-9.0.2.pyc and b/muxplex/tests/__pycache__/test_cli.cpython-313-pytest-9.0.2.pyc differ
diff --git a/muxplex/tests/__pycache__/test_frontend_css.cpython-313-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_frontend_css.cpython-313-pytest-9.0.2.pyc
index 8dd2c10..92a04b2 100644
Binary files a/muxplex/tests/__pycache__/test_frontend_css.cpython-313-pytest-9.0.2.pyc and b/muxplex/tests/__pycache__/test_frontend_css.cpython-313-pytest-9.0.2.pyc differ
diff --git a/muxplex/tests/__pycache__/test_readme.cpython-313-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_readme.cpython-313-pytest-9.0.2.pyc
new file mode 100644
index 0000000..79b90b0
Binary files /dev/null and b/muxplex/tests/__pycache__/test_readme.cpython-313-pytest-9.0.2.pyc differ
diff --git a/muxplex/tests/__pycache__/test_service.cpython-313-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_service.cpython-313-pytest-9.0.2.pyc
new file mode 100644
index 0000000..f2dd8d2
Binary files /dev/null and b/muxplex/tests/__pycache__/test_service.cpython-313-pytest-9.0.2.pyc differ
diff --git a/muxplex/tests/__pycache__/test_settings.cpython-313-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_settings.cpython-313-pytest-9.0.2.pyc
index 7f1374a..99c8249 100644
Binary files a/muxplex/tests/__pycache__/test_settings.cpython-313-pytest-9.0.2.pyc and b/muxplex/tests/__pycache__/test_settings.cpython-313-pytest-9.0.2.pyc differ
diff --git a/muxplex/tests/__pycache__/test_ttyd.cpython-313-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_ttyd.cpython-313-pytest-9.0.2.pyc
index e398294..2a00acc 100644
Binary files a/muxplex/tests/__pycache__/test_ttyd.cpython-313-pytest-9.0.2.pyc and b/muxplex/tests/__pycache__/test_ttyd.cpython-313-pytest-9.0.2.pyc differ
diff --git a/muxplex/tests/__pycache__/test_ws_proxy.cpython-313-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_ws_proxy.cpython-313-pytest-9.0.2.pyc
index adeed34..3fbc3f3 100644
Binary files a/muxplex/tests/__pycache__/test_ws_proxy.cpython-313-pytest-9.0.2.pyc and b/muxplex/tests/__pycache__/test_ws_proxy.cpython-313-pytest-9.0.2.pyc differ
diff --git a/muxplex/tests/test_api.py b/muxplex/tests/test_api.py
index f247555..46b898e 100644
--- a/muxplex/tests/test_api.py
+++ b/muxplex/tests/test_api.py
@@ -1363,3 +1363,27 @@ def test_delete_session_default_template_is_tmux_kill(client, monkeypatch, tmp_p
assert "kill-session" in executed_cmd, (
f"Default template must contain 'kill-session', got: {executed_cmd!r}"
)
+
+
+# ---------------------------------------------------------------------------
+# GET /api/auth/token (cross-origin federation token relay)
+# ---------------------------------------------------------------------------
+
+
+def test_get_auth_token_returns_token_when_authenticated(client):
+ """GET /api/auth/token returns {token: } when request has a valid session cookie."""
+ response = client.get("/api/auth/token")
+ assert response.status_code == 200
+ data = response.json()
+ assert "token" in data
+ assert isinstance(data["token"], str)
+ assert len(data["token"]) > 0
+
+
+def test_get_auth_token_returns_401_when_not_authenticated(monkeypatch):
+ """GET /api/auth/token returns 401 when request has no valid session cookie."""
+ monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
+ with TestClient(app, base_url="http://192.168.1.1") as c:
+ # No cookie set — endpoint must return 401 with application/json accept
+ response = c.get("/api/auth/token", headers={"Accept": "application/json"})
+ assert response.status_code == 401
diff --git a/muxplex/tests/test_auth.py b/muxplex/tests/test_auth.py
index 2a9f591..a000854 100644
--- a/muxplex/tests/test_auth.py
+++ b/muxplex/tests/test_auth.py
@@ -460,3 +460,27 @@ def test_middleware_instance_info_path_excluded():
client = TestClient(app, base_url="http://192.168.1.1")
response = client.get("/api/instance-info")
assert response.status_code == 200
+
+
+# ---------------------------------------------------------------------------
+# X-Muxplex-Token header auth (cross-origin federation)
+# ---------------------------------------------------------------------------
+
+
+def test_middleware_valid_token_header_passes():
+ """Non-localhost request with a valid X-Muxplex-Token header passes through."""
+ app = _make_test_app()
+ token = create_session_cookie("test-secret", ttl_seconds=3600)
+ client = TestClient(app, base_url="http://192.168.1.1")
+ response = client.get("/protected", headers={"X-Muxplex-Token": token})
+ assert response.status_code == 200
+ assert response.text == "OK"
+
+
+def test_middleware_invalid_token_header_falls_through_to_redirect():
+ """Non-localhost request with an invalid X-Muxplex-Token header falls through to redirect."""
+ app = _make_test_app()
+ client = TestClient(app, base_url="http://192.168.1.1", follow_redirects=False)
+ response = client.get("/protected", headers={"X-Muxplex-Token": "bad.token.value"})
+ # Invalid token: should NOT pass auth, falls through to redirect or 401
+ assert response.status_code in (307, 401)