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)