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:
Brian Krabach
2026-04-01 17:30:18 -07:00
parent c48456a14a
commit 046d123149
7 changed files with 23 additions and 542 deletions
-6
View File
@@ -206,12 +206,6 @@ class AuthMiddleware(BaseHTTPMiddleware):
return await call_next(request)
_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
auth_header = request.headers.get("authorization", "")
if auth_header.lower().startswith("basic "):
+2 -123
View File
@@ -168,32 +168,13 @@ function isMobile() {
}
// ─── Fetch wrapper ────────────────────────────────────────────────────────────
async function api(method, path, body, baseUrl) {
async function api(method, path, body) {
const opts = { method, headers: {} };
if (body !== undefined) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
let url = path;
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);
const res = await fetch(path, opts);
if (!res.ok) {
const err = new Error(`HTTP ${res.status}: ${res.statusText}`);
err.status = res.status;
@@ -203,36 +184,6 @@ 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';
@@ -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).
* @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.
* Only hides LOCAL sessions (those with no remoteId) matching the
@@ -2159,14 +2052,6 @@ function bindStaticEventListeners() {
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);
var newSessionBtn = $('new-session-btn');
if (newSessionBtn) on(newSessionBtn, 'click', function() { showNewSessionInput(newSessionBtn); });
@@ -2566,13 +2451,7 @@ if (typeof module !== 'undefined' && module.exports) {
// Filter bar
renderFilterBar,
// Federation tiles
buildAuthTileHTML,
buildOfflineTileHTML,
buildStatusTileHTML,
openLoginPopup,
formatLastSeen,
// Federation auth token relay
storeFederationToken,
// Constants
NEW_SESSION_DEFAULT_TEMPLATE,
DELETE_SESSION_DEFAULT_TEMPLATE,
-21
View File
@@ -15,27 +15,6 @@
<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">
<header class="app-header">
+21 -262
View File
@@ -2298,38 +2298,7 @@ test('api with no baseUrl uses relative path', async () => {
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)', () => {
// 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('&lt;script&gt;'), '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('&lt;b&gt;bad&lt;/b&gt;'), '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 ---
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');
});
// --- 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 ---
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)');
});
// ---------------------------------------------------------------------------
// Federation auth token relay tests (postMessage / X-Muxplex-Token)
// ---------------------------------------------------------------------------
// --- Verification: cross-origin code removed ---
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('app.js does not export storeFederationToken', () => {
assert.strictEqual(app.storeFederationToken, undefined, 'storeFederationToken must not be exported');
});
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('app.js does not export buildAuthTileHTML', () => {
assert.strictEqual(app.buildAuthTileHTML, undefined, 'buildAuthTileHTML must not be exported');
});
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));
test('app.js does not export buildOfflineTileHTML', () => {
assert.strictEqual(app.buildOfflineTileHTML, undefined, 'buildOfflineTileHTML must not be exported');
});
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 = [];
globalThis.fetch = (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');
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');
assert.strictEqual(calls[0].url, '/api/sessions', 'should call local path directly');
assert.ok(!calls[0].opts.credentials, 'no credentials:include for same-origin');
globalThis.fetch = undefined;
});
// ─── Edge-bar design: failing tests added before implementation ───
-28
View File
@@ -32,7 +32,6 @@ from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, field_validator
from starlette.responses import JSONResponse, RedirectResponse
from starlette.middleware.cors import CORSMiddleware
from muxplex.auth import (
AuthMiddleware,
@@ -267,20 +266,6 @@ app.add_middleware(
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
# ---------------------------------------------------------------------------
@@ -858,19 +843,6 @@ async def logout() -> RedirectResponse:
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")
async def auth_mode_endpoint():
"""Return the current auth mode and running username."""
-78
View File
@@ -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)
# ---------------------------------------------------------------------------
@@ -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
# ---------------------------------------------------------------------------
-24
View File
@@ -462,30 +462,6 @@ def test_middleware_instance_info_path_excluded():
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)
# ---------------------------------------------------------------------------