refactor: remove all cross-origin browser-direct federation code
- Delete storeFederationToken function from app.js
- Delete window.addEventListener('message',...) handler from app.js
- Delete buildAuthTileHTML function from app.js
- Delete formatLastSeen function from app.js
- Delete buildOfflineTileHTML function from app.js
- Delete openLoginPopup function from app.js
- Simplify api() to same-origin only (no baseUrl/credentials/X-Muxplex-Token)
- Remove federation auth relay <script> block from index.html
- Remove /api/auth/token endpoint from main.py
- Remove CORSMiddleware from main.py
- Remove X-Muxplex-Token header check from auth.py
- Delete corresponding tests and add absence verification tests
This commit is contained in:
@@ -206,12 +206,6 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
_log.warning("federation: rejected Bearer from %s", client_host)
|
_log.warning("federation: rejected Bearer from %s", client_host)
|
||||||
|
|
||||||
# 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 "):
|
||||||
|
|||||||
+2
-123
@@ -168,32 +168,13 @@ function isMobile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Fetch wrapper ────────────────────────────────────────────────────────────
|
// ─── Fetch wrapper ────────────────────────────────────────────────────────────
|
||||||
async function api(method, path, body, baseUrl) {
|
async function api(method, path, body) {
|
||||||
const opts = { method, headers: {} };
|
const opts = { method, headers: {} };
|
||||||
if (body !== undefined) {
|
if (body !== undefined) {
|
||||||
opts.headers['Content-Type'] = 'application/json';
|
opts.headers['Content-Type'] = 'application/json';
|
||||||
opts.body = JSON.stringify(body);
|
opts.body = JSON.stringify(body);
|
||||||
}
|
}
|
||||||
let url = path;
|
const res = await fetch(path, opts);
|
||||||
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) {
|
if (!res.ok) {
|
||||||
const err = new Error(`HTTP ${res.status}: ${res.statusText}`);
|
const err = new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||||
err.status = res.status;
|
err.status = res.status;
|
||||||
@@ -203,36 +184,6 @@ 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';
|
||||||
@@ -549,54 +500,6 @@ function buildSidebarHTML(session, currentSession) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the HTML string for an auth-required source tile.
|
|
||||||
* @param {{ name: string, url: string }} source
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
function buildAuthTileHTML(source) {
|
|
||||||
const escapedName = escapeHtml(source.name || '');
|
|
||||||
const escapedUrl = escapeHtml(source.url || '');
|
|
||||||
return (
|
|
||||||
'<article class="source-tile source-tile--auth">' +
|
|
||||||
'<span class="source-tile__name">' + escapedName + '</span>' +
|
|
||||||
'<button class="source-tile__login-btn" data-url="' + escapedUrl + '">Log in</button>' +
|
|
||||||
'<span class="source-tile__hint">Authenticate to see sessions</span>' +
|
|
||||||
'</article>'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format a millisecond timestamp into a relative 'last seen' string.
|
|
||||||
* @param {number|null} ms - Millisecond timestamp
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
function formatLastSeen(ms) {
|
|
||||||
if (ms == null) return 'Never';
|
|
||||||
var diff = Math.floor((Date.now() - ms) / 1000);
|
|
||||||
if (diff < 60) return diff + 's ago';
|
|
||||||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
|
||||||
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
|
||||||
return Math.floor(diff / 86400) + 'd ago';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the HTML string for an offline (unreachable) source tile.
|
|
||||||
* @param {{ name: string, url: string, lastSeenAt: number|null }} source
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
function buildOfflineTileHTML(source) {
|
|
||||||
var escapedName = escapeHtml(source.name || '');
|
|
||||||
var lastSeen = formatLastSeen(source.lastSeenAt);
|
|
||||||
return (
|
|
||||||
'<article class="source-tile source-tile--offline">' +
|
|
||||||
'<span class="source-tile__name">' + escapedName + '</span>' +
|
|
||||||
'<span class="source-tile__badge">Offline</span>' +
|
|
||||||
'<span class="source-tile__last-seen">Last seen ' + escapeHtml(lastSeen) + '</span>' +
|
|
||||||
'</article>'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the HTML string for a generic status tile (auth_failed or unreachable).
|
* Build the HTML string for a generic status tile (auth_failed or unreachable).
|
||||||
* @param {string} deviceName
|
* @param {string} deviceName
|
||||||
@@ -613,16 +516,6 @@ function buildStatusTileHTML(deviceName, statusText, statusClass) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Open a login popup window for a remote muxplex instance.
|
|
||||||
* Strips trailing slashes from remoteUrl before appending /login.
|
|
||||||
* @param {string} remoteUrl - The base URL of the remote instance
|
|
||||||
*/
|
|
||||||
function openLoginPopup(remoteUrl) {
|
|
||||||
var baseUrl = remoteUrl.replace(/\/+$/, '');
|
|
||||||
window.open(baseUrl + '/login', '_blank', 'width=500,height=600');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns sessions with hidden session names removed.
|
* Returns sessions with hidden session names removed.
|
||||||
* Only hides LOCAL sessions (those with no remoteId) matching the
|
* Only hides LOCAL sessions (those with no remoteId) matching the
|
||||||
@@ -2159,14 +2052,6 @@ function bindStaticEventListeners() {
|
|||||||
if (name) killSession(name);
|
if (name) killSession(name);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('click', function(e) {
|
|
||||||
var loginBtn = e.target.closest && e.target.closest('.source-tile__login-btn');
|
|
||||||
if (!loginBtn) return;
|
|
||||||
e.stopPropagation();
|
|
||||||
var url = loginBtn.dataset.url;
|
|
||||||
if (url) openLoginPopup(url);
|
|
||||||
});
|
|
||||||
|
|
||||||
on($('back-btn'), 'click', closeSession);
|
on($('back-btn'), 'click', closeSession);
|
||||||
var newSessionBtn = $('new-session-btn');
|
var newSessionBtn = $('new-session-btn');
|
||||||
if (newSessionBtn) on(newSessionBtn, 'click', function() { showNewSessionInput(newSessionBtn); });
|
if (newSessionBtn) on(newSessionBtn, 'click', function() { showNewSessionInput(newSessionBtn); });
|
||||||
@@ -2566,13 +2451,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
|||||||
// Filter bar
|
// Filter bar
|
||||||
renderFilterBar,
|
renderFilterBar,
|
||||||
// Federation tiles
|
// Federation tiles
|
||||||
buildAuthTileHTML,
|
|
||||||
buildOfflineTileHTML,
|
|
||||||
buildStatusTileHTML,
|
buildStatusTileHTML,
|
||||||
openLoginPopup,
|
|
||||||
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,27 +15,6 @@
|
|||||||
<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">
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
|
|||||||
@@ -2298,38 +2298,7 @@ test('api with no baseUrl uses relative path', async () => {
|
|||||||
globalThis.fetch = origFetch;
|
globalThis.fetch = origFetch;
|
||||||
});
|
});
|
||||||
|
|
||||||
test('api with baseUrl prepends it to path and sets credentials include', async () => {
|
|
||||||
const calls = [];
|
|
||||||
const origFetch = globalThis.fetch;
|
|
||||||
globalThis.fetch = async (url, opts) => {
|
|
||||||
calls.push({ url, opts });
|
|
||||||
return { ok: true };
|
|
||||||
};
|
|
||||||
|
|
||||||
await app.api('GET', '/api/sessions', undefined, 'https://remote.example.com');
|
|
||||||
|
|
||||||
assert.strictEqual(calls.length, 1, 'should call fetch once');
|
|
||||||
assert.strictEqual(calls[0].url, 'https://remote.example.com/api/sessions', 'url should prepend baseUrl');
|
|
||||||
assert.strictEqual(calls[0].opts.credentials, 'include', 'credentials should be include for cross-origin');
|
|
||||||
|
|
||||||
globalThis.fetch = origFetch;
|
|
||||||
});
|
|
||||||
|
|
||||||
test('api with baseUrl and trailing slash does not double-slash', async () => {
|
|
||||||
const calls = [];
|
|
||||||
const origFetch = globalThis.fetch;
|
|
||||||
globalThis.fetch = async (url, opts) => {
|
|
||||||
calls.push({ url, opts });
|
|
||||||
return { ok: true };
|
|
||||||
};
|
|
||||||
|
|
||||||
await app.api('GET', '/api/sessions', undefined, 'https://remote.example.com/');
|
|
||||||
|
|
||||||
assert.strictEqual(calls.length, 1, 'should call fetch once');
|
|
||||||
assert.strictEqual(calls[0].url, 'https://remote.example.com/api/sessions', 'trailing slash on baseUrl should not create double-slash');
|
|
||||||
|
|
||||||
globalThis.fetch = origFetch;
|
|
||||||
});
|
|
||||||
|
|
||||||
test('createNewSession polls for session before auto-opening (not immediate setTimeout openSession)', () => {
|
test('createNewSession polls for session before auto-opening (not immediate setTimeout openSession)', () => {
|
||||||
// The old behavior was: setTimeout(() => openSession(...), 500) immediately after POST.
|
// The old behavior was: setTimeout(() => openSession(...), 500) immediately after POST.
|
||||||
@@ -2807,75 +2776,6 @@ test('app.js exports all Phase 2 federation functions', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- buildAuthTileHTML ---
|
|
||||||
|
|
||||||
test('buildAuthTileHTML is exported as a function', () => {
|
|
||||||
assert.strictEqual(typeof app.buildAuthTileHTML, 'function');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildAuthTileHTML returns article with source-tile--auth class', () => {
|
|
||||||
const html = app.buildAuthTileHTML({ name: 'Dev Server', url: 'http://dev:8088' });
|
|
||||||
assert.ok(html.startsWith('<article'), 'html should start with <article');
|
|
||||||
assert.ok(html.includes('source-tile--auth'), 'html should include source-tile--auth class');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildAuthTileHTML includes device name', () => {
|
|
||||||
const html = app.buildAuthTileHTML({ name: 'Dev Server', url: 'http://dev:8088' });
|
|
||||||
assert.ok(html.includes('Dev Server'), 'html should include the device name');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildAuthTileHTML includes login button with data-url attribute', () => {
|
|
||||||
const html = app.buildAuthTileHTML({ name: 'Dev Server', url: 'http://dev:8088' });
|
|
||||||
assert.ok(html.includes('source-tile__login-btn'), 'html should include source-tile__login-btn class');
|
|
||||||
assert.ok(html.includes('data-url="http://dev:8088"'), 'html should include data-url attribute with correct value');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildAuthTileHTML escapes HTML in device name', () => {
|
|
||||||
const html = app.buildAuthTileHTML({ name: '<script>alert(1)</script>', url: '' });
|
|
||||||
assert.ok(!html.includes('<script>alert(1)</script>'), 'raw script tag should not appear in html');
|
|
||||||
assert.ok(html.includes('<script>'), 'escaped script tag should appear in html');
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- buildOfflineTileHTML ---
|
|
||||||
|
|
||||||
test('buildOfflineTileHTML is exported as a function', () => {
|
|
||||||
assert.strictEqual(typeof app.buildOfflineTileHTML, 'function');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildOfflineTileHTML returns article with source-tile--offline class', () => {
|
|
||||||
const html = app.buildOfflineTileHTML({ name: 'Dev Server', url: 'http://dev:8088', lastSeenAt: null });
|
|
||||||
assert.ok(html.startsWith('<article'), 'html should start with <article');
|
|
||||||
assert.ok(html.includes('source-tile--offline'), 'html should include source-tile--offline class');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildOfflineTileHTML includes device name', () => {
|
|
||||||
const html = app.buildOfflineTileHTML({ name: 'Dev Server', url: 'http://dev:8088', lastSeenAt: null });
|
|
||||||
assert.ok(html.includes('Dev Server'), 'html should include the device name');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildOfflineTileHTML includes Offline badge', () => {
|
|
||||||
const html = app.buildOfflineTileHTML({ name: 'Dev Server', url: 'http://dev:8088', lastSeenAt: null });
|
|
||||||
assert.ok(html.includes('Offline'), 'html should include Offline text');
|
|
||||||
assert.ok(html.includes('source-tile__badge'), 'html should include source-tile__badge class');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildOfflineTileHTML shows relative last-seen time', () => {
|
|
||||||
const fiveMinAgo = Date.now() - 5 * 60 * 1000;
|
|
||||||
const html = app.buildOfflineTileHTML({ name: 'Dev Server', url: 'http://dev:8088', lastSeenAt: fiveMinAgo });
|
|
||||||
assert.ok(html.includes('Last seen'), 'html should include Last seen text');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildOfflineTileHTML escapes device name', () => {
|
|
||||||
const html = app.buildOfflineTileHTML({ name: '<b>bad</b>', url: '', lastSeenAt: null });
|
|
||||||
assert.ok(!html.includes('<b>bad</b>'), 'raw <b>bad</b> must not appear in html');
|
|
||||||
assert.ok(html.includes('<b>bad</b>'), 'device name should be HTML-escaped');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildOfflineTileHTML shows "Never" when lastSeenAt is null', () => {
|
|
||||||
const html = app.buildOfflineTileHTML({ name: 'Dev Server', url: '', lastSeenAt: null });
|
|
||||||
assert.ok(html.includes('Never'), 'html should include Never when lastSeenAt is null');
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- buildStatusTileHTML ---
|
// --- buildStatusTileHTML ---
|
||||||
|
|
||||||
test('buildStatusTileHTML is exported as a function', () => {
|
test('buildStatusTileHTML is exported as a function', () => {
|
||||||
@@ -2901,98 +2801,6 @@ test('buildStatusTileHTML renders statusText in badge span', () => {
|
|||||||
assert.ok(html.includes('source-tile__badge'), 'html should include source-tile__badge class');
|
assert.ok(html.includes('source-tile__badge'), 'html should include source-tile__badge class');
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- formatLastSeen branch coverage ---
|
|
||||||
|
|
||||||
test('formatLastSeen returns seconds ago for diff < 60', () => {
|
|
||||||
const thirtySecondsAgo = Date.now() - 30 * 1000;
|
|
||||||
assert.match(app.formatLastSeen(thirtySecondsAgo), /^\d+s ago$/, 'should return Xs ago for diff < 60s');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('formatLastSeen returns hours ago for diff >= 3600 and < 86400', () => {
|
|
||||||
const twoHoursAgo = Date.now() - 2 * 3600 * 1000;
|
|
||||||
assert.match(app.formatLastSeen(twoHoursAgo), /^\d+h ago$/, 'should return Xh ago for diff in range [3600, 86400)');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('formatLastSeen returns days ago for diff >= 86400', () => {
|
|
||||||
const twoDaysAgo = Date.now() - 2 * 86400 * 1000;
|
|
||||||
assert.match(app.formatLastSeen(twoDaysAgo), /^\d+d ago$/, 'should return Xd ago for diff >= 86400s');
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- openLoginPopup (task-5-login-popup-flow) ---
|
|
||||||
|
|
||||||
test('openLoginPopup is exported as a function', () => {
|
|
||||||
assert.strictEqual(typeof app.openLoginPopup, 'function', 'openLoginPopup should be exported as a function');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('openLoginPopup calls window.open with correct URL and dimensions', () => {
|
|
||||||
const openCalls = [];
|
|
||||||
const origOpen = globalThis.window.open;
|
|
||||||
globalThis.window.open = (url, target, features) => { openCalls.push({ url, target, features }); };
|
|
||||||
|
|
||||||
app.openLoginPopup('http://work:8088');
|
|
||||||
|
|
||||||
globalThis.window.open = origOpen;
|
|
||||||
|
|
||||||
assert.strictEqual(openCalls.length, 1, 'window.open should be called exactly once');
|
|
||||||
assert.strictEqual(openCalls[0].url, 'http://work:8088/login', 'url should be remoteUrl + /login');
|
|
||||||
assert.strictEqual(openCalls[0].target, '_blank', 'target should be _blank');
|
|
||||||
assert.ok(openCalls[0].features.includes('width=500'), 'features should include width=500');
|
|
||||||
assert.ok(openCalls[0].features.includes('height=600'), 'features should include height=600');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('openLoginPopup appends /login to URL without trailing slash', () => {
|
|
||||||
const openCalls = [];
|
|
||||||
const origOpen = globalThis.window.open;
|
|
||||||
globalThis.window.open = (url) => { openCalls.push(url); };
|
|
||||||
|
|
||||||
app.openLoginPopup('http://work:8088');
|
|
||||||
|
|
||||||
globalThis.window.open = origOpen;
|
|
||||||
|
|
||||||
assert.strictEqual(openCalls[0], 'http://work:8088/login', 'should append /login when no trailing slash');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('openLoginPopup handles URL with trailing slash', () => {
|
|
||||||
const openCalls = [];
|
|
||||||
const origOpen = globalThis.window.open;
|
|
||||||
globalThis.window.open = (url) => { openCalls.push(url); };
|
|
||||||
|
|
||||||
app.openLoginPopup('http://work:8088/');
|
|
||||||
|
|
||||||
globalThis.window.open = origOpen;
|
|
||||||
|
|
||||||
assert.strictEqual(openCalls[0], 'http://work:8088/login', 'should strip trailing slash then append /login');
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- formatLastSeen (auto-recovery detection) ---
|
|
||||||
|
|
||||||
test('formatLastSeen returns seconds for recent timestamps', () => {
|
|
||||||
const thirtySecondsAgo = Date.now() - 30000;
|
|
||||||
assert.match(app.formatLastSeen(thirtySecondsAgo), /^\d+s ago$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('formatLastSeen returns minutes for older timestamps', () => {
|
|
||||||
const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;
|
|
||||||
assert.match(app.formatLastSeen(fiveMinutesAgo), /^\d+m ago$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('formatLastSeen returns hours for much older timestamps', () => {
|
|
||||||
const threeHoursAgo = Date.now() - 3 * 3600 * 1000;
|
|
||||||
assert.match(app.formatLastSeen(threeHoursAgo), /^\d+h ago$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('formatLastSeen returns days for very old timestamps', () => {
|
|
||||||
const twoDaysAgo = Date.now() - 2 * 86400 * 1000;
|
|
||||||
assert.match(app.formatLastSeen(twoDaysAgo), /^\d+d ago$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('formatLastSeen returns Never for null', () => {
|
|
||||||
assert.strictEqual(app.formatLastSeen(null), 'Never');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('formatLastSeen returns Never for undefined', () => {
|
|
||||||
assert.strictEqual(app.formatLastSeen(undefined), 'Never');
|
|
||||||
});
|
|
||||||
// --- Issue 1: Loading placeholder tile ---
|
// --- Issue 1: Loading placeholder tile ---
|
||||||
|
|
||||||
test('createNewSession injects tile--loading placeholder after POST succeeds', () => {
|
test('createNewSession injects tile--loading placeholder after POST succeeds', () => {
|
||||||
@@ -3786,90 +3594,41 @@ 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)');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// --- Verification: cross-origin code removed ---
|
||||||
// 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 () => {
|
test('app.js does not export storeFederationToken', () => {
|
||||||
// Store a fake token for https://remote.example.com
|
assert.strictEqual(app.storeFederationToken, undefined, 'storeFederationToken must not be exported');
|
||||||
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 () => {
|
test('app.js does not export buildAuthTileHTML', () => {
|
||||||
// Ensure no tokens stored
|
assert.strictEqual(app.buildAuthTileHTML, undefined, 'buildAuthTileHTML must not be exported');
|
||||||
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 () => {
|
test('app.js does not export buildOfflineTileHTML', () => {
|
||||||
// Store a token for some origin, make sure it does not bleed into local requests
|
assert.strictEqual(app.buildOfflineTileHTML, undefined, 'buildOfflineTileHTML must not be exported');
|
||||||
const tokens = { 'https://remote.example.com': 'fake-token-abc123' };
|
});
|
||||||
localStorage.setItem('muxplex.federation_tokens', JSON.stringify(tokens));
|
|
||||||
|
|
||||||
|
test('app.js does not export openLoginPopup', () => {
|
||||||
|
assert.strictEqual(app.openLoginPopup, undefined, 'openLoginPopup must not be exported');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('app.js does not export formatLastSeen', () => {
|
||||||
|
assert.strictEqual(app.formatLastSeen, undefined, 'formatLastSeen must not be exported');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('api() is same-origin only (no baseUrl parameter support)', async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
globalThis.fetch = (url, opts) => {
|
globalThis.fetch = (url, opts) => {
|
||||||
calls.push({ url, opts });
|
calls.push({ url, opts });
|
||||||
return Promise.resolve({ ok: true, status: 200, json: async () => ([]) });
|
return Promise.resolve({ ok: true, json: async () => ([]) });
|
||||||
};
|
};
|
||||||
|
|
||||||
await app.api('GET', '/api/sessions');
|
await app.api('GET', '/api/sessions');
|
||||||
|
|
||||||
assert.strictEqual(calls.length, 1);
|
assert.strictEqual(calls.length, 1);
|
||||||
assert.ok(!calls[0].opts.headers['X-Muxplex-Token'],
|
assert.strictEqual(calls[0].url, '/api/sessions', 'should call local path directly');
|
||||||
'X-Muxplex-Token header must NOT be present for local requests');
|
assert.ok(!calls[0].opts.credentials, 'no credentials:include for same-origin');
|
||||||
|
globalThis.fetch = undefined;
|
||||||
// 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');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Edge-bar design: failing tests added before implementation ───
|
// ─── Edge-bar design: failing tests added before implementation ───
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ 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 JSONResponse, RedirectResponse
|
from starlette.responses import JSONResponse, RedirectResponse
|
||||||
from starlette.middleware.cors import CORSMiddleware
|
|
||||||
|
|
||||||
from muxplex.auth import (
|
from muxplex.auth import (
|
||||||
AuthMiddleware,
|
AuthMiddleware,
|
||||||
@@ -267,20 +266,6 @@ app.add_middleware(
|
|||||||
federation_key=_federation_key,
|
federation_key=_federation_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
# CORS: allow_origins=["*"] with allow_credentials=True is intentional for
|
|
||||||
# self-hosted federation. Starlette reflects the actual Origin header (rather
|
|
||||||
# than "*") when credentials are requested, so credentialed cross-origin
|
|
||||||
# requests work correctly. Do not restrict to a fixed origin list without
|
|
||||||
# first understanding how remote muxplex peers discover and reach each other.
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=["*"],
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Request / response models
|
# Request / response models
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -858,19 +843,6 @@ 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."""
|
||||||
|
|||||||
@@ -1158,60 +1158,6 @@ def test_instance_info_federation_enabled_true_when_key_exists(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# CORS middleware
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_cors_preflight_returns_200(tmp_path, monkeypatch):
|
|
||||||
"""OPTIONS /api/sessions with CORS preflight headers returns 200 with access-control-allow-origin header.
|
|
||||||
|
|
||||||
NOTE — Spec discrepancy: the acceptance criteria states `access-control-allow-origin: *`,
|
|
||||||
but the middleware is configured with `allow_credentials=True`. The CORS specification
|
|
||||||
(RFC 6454 / Fetch standard) forbids the wildcard value when credentials are included;
|
|
||||||
Starlette therefore reflects the request Origin instead of emitting "*". Keeping
|
|
||||||
`allow_credentials=True` is intentional for cross-origin federation with session cookies,
|
|
||||||
so the assertion uses the reflected origin rather than a literal "*".
|
|
||||||
"""
|
|
||||||
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
|
|
||||||
origin = "http://other-muxplex.local:8088"
|
|
||||||
with TestClient(app) as c:
|
|
||||||
response = c.options(
|
|
||||||
"/api/sessions",
|
|
||||||
headers={
|
|
||||||
"Origin": origin,
|
|
||||||
"Access-Control-Request-Method": "GET",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.headers.get("access-control-allow-origin") == origin
|
|
||||||
|
|
||||||
|
|
||||||
def test_cors_allows_any_origin(client):
|
|
||||||
"""GET /api/sessions with Origin header gets access-control-allow-origin header in response.
|
|
||||||
|
|
||||||
NOTE — Spec discrepancy: the acceptance criteria states `access-control-allow-origin: *`,
|
|
||||||
but this is incompatible with `allow_credentials=True` (CORS spec forbids the two together).
|
|
||||||
Starlette reflects the request Origin instead. This provides equivalent permissiveness
|
|
||||||
(any origin is allowed) while remaining spec-compliant.
|
|
||||||
"""
|
|
||||||
origin = "http://other-muxplex.local:8088"
|
|
||||||
response = client.get(
|
|
||||||
"/api/sessions",
|
|
||||||
headers={"Origin": origin},
|
|
||||||
)
|
|
||||||
assert response.headers.get("access-control-allow-origin") == origin
|
|
||||||
|
|
||||||
|
|
||||||
def test_cors_allows_credentials(client):
|
|
||||||
"""GET /api/sessions with Origin header includes access-control-allow-credentials: true."""
|
|
||||||
response = client.get(
|
|
||||||
"/api/sessions",
|
|
||||||
headers={"Origin": "http://other-muxplex.local:8088"},
|
|
||||||
)
|
|
||||||
assert response.headers.get("access-control-allow-credentials") == "true"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# POST /api/sessions (create new session)
|
# POST /api/sessions (create new session)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1494,30 +1440,6 @@ def test_delete_session_default_template_is_tmux_kill(client, monkeypatch, tmp_p
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 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
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Federation Bearer token auth
|
# Federation Bearer token auth
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -462,30 +462,6 @@ def test_middleware_instance_info_path_excluded():
|
|||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Bearer token auth (server-to-server federation)
|
# Bearer token auth (server-to-server federation)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user