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:
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.
@@ -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 "):
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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."""
|
||||||
|
|||||||
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.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user