diff --git a/muxplex/auth.py b/muxplex/auth.py index a6d0e22..91c049c 100644 --- a/muxplex/auth.py +++ b/muxplex/auth.py @@ -3,6 +3,8 @@ muxplex authentication — password and signing secret file management. """ import base64 +import hmac +import logging import secrets from pathlib import Path @@ -11,6 +13,8 @@ from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from starlette.responses import JSONResponse, RedirectResponse, Response +_log = logging.getLogger(__name__) + # --------------------------------------------------------------------------- # Config directory @@ -164,12 +168,14 @@ class AuthMiddleware(BaseHTTPMiddleware): secret: str, ttl_seconds: int, password: str = "", + federation_key: str = "", ): super().__init__(app) self.auth_mode = auth_mode self.secret = secret self.ttl_seconds = ttl_seconds self.password = password + self.federation_key = federation_key async def dispatch(self, request: Request, call_next) -> Response: # 1. Localhost bypass — client.host is the socket-level IP and cannot @@ -192,6 +198,14 @@ class AuthMiddleware(BaseHTTPMiddleware): if cookie and verify_session_cookie(self.secret, cookie, self.ttl_seconds): return await call_next(request) + # 4a. Bearer token (server-to-server federation) + auth_header = request.headers.get("authorization", "") + if self.federation_key and auth_header.lower().startswith("bearer "): + token = auth_header[7:] + if hmac.compare_digest(token, self.federation_key): + 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: diff --git a/muxplex/tests/test_auth.py b/muxplex/tests/test_auth.py index a000854..9e7fd52 100644 --- a/muxplex/tests/test_auth.py +++ b/muxplex/tests/test_auth.py @@ -484,3 +484,68 @@ def test_middleware_invalid_token_header_falls_through_to_redirect(): 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) +# --------------------------------------------------------------------------- + + +def _make_federation_app(federation_key: str = "fed-key") -> FastAPI: + """Create a minimal FastAPI app with AuthMiddleware configured with a federation_key.""" + test_app = FastAPI() + + test_app.add_middleware( + AuthMiddleware, + auth_mode="password", + secret="test-secret", + ttl_seconds=3600, + password="test-pw", + federation_key=federation_key, + ) + + @test_app.get("/protected") + async def protected(): + return PlainTextResponse("OK") + + return test_app + + +def test_middleware_valid_bearer_token_passes(): + """Non-localhost with valid Bearer token and federation_key configured passes through (200).""" + app = _make_federation_app(federation_key="my-federation-secret") + client = TestClient(app, base_url="http://192.168.1.1") + response = client.get( + "/protected", headers={"Authorization": "Bearer my-federation-secret"} + ) + assert response.status_code == 200 + assert response.text == "OK" + + +def test_middleware_invalid_bearer_token_falls_through(): + """Non-localhost with wrong Bearer token falls through to 401 (not accepted).""" + app = _make_federation_app(federation_key="my-federation-secret") + client = TestClient(app, base_url="http://192.168.1.1", follow_redirects=False) + response = client.get( + "/protected", + headers={ + "Authorization": "Bearer wrong-token", + "Accept": "application/json", + }, + ) + assert response.status_code == 401 + + +def test_middleware_bearer_skipped_when_no_federation_key(): + """Empty federation_key means Bearer tokens are never accepted (falls through to 401).""" + app = _make_federation_app(federation_key="") + client = TestClient(app, base_url="http://192.168.1.1", follow_redirects=False) + response = client.get( + "/protected", + headers={ + "Authorization": "Bearer any-token-value", + "Accept": "application/json", + }, + ) + # Bearer should be skipped when federation_key is empty, falls through to 401 + assert response.status_code == 401