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:
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user