diff --git a/muxplex/auth.py b/muxplex/auth.py index 91c049c..d440d33 100644 --- a/muxplex/auth.py +++ b/muxplex/auth.py @@ -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 "): diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index eec9b7b..8dcd570 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -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 ( - '
' + - '' + escapedName + '' + - '' + - 'Authenticate to see sessions' + - '
' - ); -} - -/** - * 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 ( - '
' + - '' + escapedName + '' + - 'Offline' + - 'Last seen ' + escapeHtml(lastSeen) + '' + - '
' - ); -} - /** * 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, diff --git a/muxplex/frontend/index.html b/muxplex/frontend/index.html index 407cf90..7334556 100644 --- a/muxplex/frontend/index.html +++ b/muxplex/frontend/index.html @@ -15,27 +15,6 @@ muxplex - -
diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs index cb7261f..37a48c7 100644 --- a/muxplex/frontend/tests/test_app.mjs +++ b/muxplex/frontend/tests/test_app.mjs @@ -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(' { - 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: '', url: '' }); - assert.ok(!html.includes(''), '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(' { - 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: 'bad', url: '', lastSeenAt: null }); - assert.ok(!html.includes('bad'), 'raw bad 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 --- 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 ─── diff --git a/muxplex/main.py b/muxplex/main.py index 6199738..fcd9c19 100644 --- a/muxplex/main.py +++ b/muxplex/main.py @@ -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.""" diff --git a/muxplex/tests/test_api.py b/muxplex/tests/test_api.py index d148709..378a80d 100644 --- a/muxplex/tests/test_api.py +++ b/muxplex/tests/test_api.py @@ -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: } 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 # --------------------------------------------------------------------------- diff --git a/muxplex/tests/test_auth.py b/muxplex/tests/test_auth.py index 9e7fd52..d88da8d 100644 --- a/muxplex/tests/test_auth.py +++ b/muxplex/tests/test_auth.py @@ -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) # ---------------------------------------------------------------------------