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
+64
View File
@@ -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,
+20
View File
@@ -15,6 +15,26 @@
<title>muxplex</title>
</head>
<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 ──────────────────────────────────────────────────── -->
<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)');
});
// ---------------------------------------------------------------------------
// 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');
});