feat: postMessage auth token relay for cross-origin federation login

- Backend: AuthMiddleware.dispatch() checks X-Muxplex-Token header
  after cookie auth, allowing cross-origin federation requests to
  authenticate using a session token sent as a header instead of a
  SameSite=Strict cookie.

- Backend: GET /api/auth/token endpoint returns the current session
  token for authenticated requests. Used by login popups to relay
  the token back to the opener window via postMessage.

- Frontend (app.js): api() injects X-Muxplex-Token header for
  cross-origin requests when a token is stored in localStorage under
  muxplex.federation_tokens keyed by origin.

- Frontend (app.js): storeFederationToken() helper stores tokens.
  window.addEventListener('message') handler catches muxplex-auth-token
  postMessages and stores the token, then retriggers a poll so the
  auth_required source transitions to authenticated immediately.

- Frontend (app.js): _saveRemoteInstances() prunes stale tokens for
  URLs no longer in the remote instances list.

- Frontend (index.html): Popup relay script fetches /api/auth/token
  and sends the token to window.opener via postMessage when loaded
  as a login popup (window.opener present).

Tests added:
  test_auth.py: X-Muxplex-Token header authenticates / invalid rejected
  test_api.py: GET /api/auth/token returns token / 401 when unauthed
  test_app.mjs: api() header injection, storeFederationToken storage,
                index.html popup script presence
This commit is contained in:
Brian Krabach
2026-04-01 08:17:28 -07:00
parent 062a815f83
commit cb264bbaaa
23 changed files with 237 additions and 1 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+6
View File
@@ -192,6 +192,12 @@ class AuthMiddleware(BaseHTTPMiddleware):
if cookie and verify_session_cookie(self.secret, cookie, self.ttl_seconds): if cookie and verify_session_cookie(self.secret, cookie, self.ttl_seconds):
return await call_next(request) 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 # 5. Authorization: Basic header
auth_header = request.headers.get("authorization", "") auth_header = request.headers.get("authorization", "")
if auth_header.lower().startswith("basic "): if auth_header.lower().startswith("basic "):
+64
View File
@@ -179,6 +179,20 @@ async function api(method, path, body, baseUrl) {
if (baseUrl) { if (baseUrl) {
url = baseUrl.replace(/\/+$/, '') + path; url = baseUrl.replace(/\/+$/, '') + path;
opts.credentials = 'include'; 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); const res = await fetch(url, opts);
if (!res.ok) { if (!res.ok) {
@@ -190,6 +204,37 @@ async function api(method, path, body, baseUrl) {
} }
// ─── Device ID ──────────────────────────────────────────────────────────────── // ─── 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() { function initDeviceId() {
const STORAGE_KEY = 'tmux-web-device-id'; const STORAGE_KEY = 'tmux-web-device-id';
try { try {
@@ -1489,6 +1534,23 @@ function _saveRemoteInstances() {
}); });
patchServerSetting('remote_instances', instances).then(function() { patchServerSetting('remote_instances', instances).then(function() {
_sources = buildSources(_serverSettings); _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, buildOfflineTileHTML,
openLoginPopup, openLoginPopup,
formatLastSeen, formatLastSeen,
// Federation auth token relay
storeFederationToken,
// Constants // Constants
NEW_SESSION_DEFAULT_TEMPLATE, NEW_SESSION_DEFAULT_TEMPLATE,
DELETE_SESSION_DEFAULT_TEMPLATE, DELETE_SESSION_DEFAULT_TEMPLATE,
+20
View File
@@ -15,6 +15,26 @@
<title>muxplex</title> <title>muxplex</title>
</head> </head>
<body> <body>
<script>
// Federation auth: if opened as a login popup by another muxplex instance,
// relay the session token back to the opener window via postMessage.
(function() {
if (!window.opener) return;
fetch('/api/auth/token')
.then(function(r) { return r.ok ? r.json() : null; })
.then(function(data) {
if (data && data.token) {
window.opener.postMessage({
type: 'muxplex-auth-token',
origin: window.location.origin,
token: data.token
}, '*');
setTimeout(function() { window.close(); }, 500);
}
})
.catch(function() {});
})();
</script>
<!-- ── Overview view ──────────────────────────────────────────────────── --> <!-- ── Overview view ──────────────────────────────────────────────────── -->
<div id="view-overview" class="view view--active"> <div id="view-overview" class="view view--active">
+85
View File
@@ -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)'); 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');
});
+14 -1
View File
@@ -28,7 +28,7 @@ from fastapi import FastAPI, Form, HTTPException, Request, WebSocket
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, field_validator from pydantic import BaseModel, field_validator
from starlette.responses import RedirectResponse from starlette.responses import JSONResponse, RedirectResponse
from starlette.middleware.cors import CORSMiddleware from starlette.middleware.cors import CORSMiddleware
from muxplex.auth import ( from muxplex.auth import (
@@ -733,6 +733,19 @@ async def logout() -> RedirectResponse:
return response 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") @app.get("/auth/mode")
async def auth_mode_endpoint(): async def auth_mode_endpoint():
"""Return the current auth mode and running username.""" """Return the current auth mode and running username."""
+24
View File
@@ -1363,3 +1363,27 @@ def test_delete_session_default_template_is_tmux_kill(client, monkeypatch, tmp_p
assert "kill-session" in executed_cmd, ( assert "kill-session" in executed_cmd, (
f"Default template must contain 'kill-session', got: {executed_cmd!r}" 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: <value>} 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
+24
View File
@@ -460,3 +460,27 @@ def test_middleware_instance_info_path_excluded():
client = TestClient(app, base_url="http://192.168.1.1") client = TestClient(app, base_url="http://192.168.1.1")
response = client.get("/api/instance-info") response = client.get("/api/instance-info")
assert response.status_code == 200 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)