diff --git a/src/familiar/config.py b/src/familiar/config.py index 7858861..3b6447f 100644 --- a/src/familiar/config.py +++ b/src/familiar/config.py @@ -133,6 +133,69 @@ def http_token() -> str: return (os.environ.get("FAMILIAR_HTTP_TOKEN") or "").strip() +# Hosts that are *not* reachable from off-box. A bind to anything else is a +# public interface and must be authenticated (or explicitly waived). +LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost", ""} + + +def is_loopback(host: str) -> bool: + """True if binding ``host`` only exposes the server to the local machine.""" + return (host or "").strip().lower() in LOOPBACK_HOSTS + + +def _env_truthy(name: str) -> bool: + return (os.environ.get(name) or "").strip().lower() in {"1", "true", "yes", "on"} + + +def allow_insecure_bind() -> bool: + """Escape hatch: allow a public (non-loopback) bind without a token. + + Off by default. Only honor ``FAMILIAR_ALLOW_INSECURE_BIND`` when the operator + really means to run unauthenticated (e.g. auth handled entirely upstream). + """ + return _env_truthy("FAMILIAR_ALLOW_INSECURE_BIND") + + +# --------------------------------------------------------------------------- # +# HTTP auth mode: bearer (default) or oauth (OAuth 2.1 resource server). +# --------------------------------------------------------------------------- # +def auth_mode() -> str: + """``bearer`` (default) or ``oauth`` — how the HTTP transport authenticates. + + ``bearer``: a single static token in ``FAMILIAR_HTTP_TOKEN``. + ``oauth``: validate external-issuer JWTs (JWKS) and advertise the + protected-resource metadata so OAuth-only clients (e.g. Claude web) can + discover the authorization server. + """ + mode = (os.environ.get("FAMILIAR_AUTH_MODE") or "bearer").strip().lower() + return mode if mode in {"bearer", "oauth"} else "bearer" + + +def oauth_issuer() -> str: + return (os.environ.get("FAMILIAR_OAUTH_ISSUER") or "").strip() + + +def oauth_audience() -> str: + """Expected token audience. Defaults to the resource URL when unset.""" + aud = (os.environ.get("FAMILIAR_OAUTH_AUDIENCE") or "").strip() + return aud or oauth_resource() + + +def oauth_resource() -> str: + """This server's public base URL (the OAuth *resource*), e.g. https://host. + + Used as the RemoteAuthProvider base_url and advertised in the + protected-resource metadata. Falls back to FAMILIAR_HTTP_PATH's origin only + if explicitly set; otherwise empty (serve will require it in oauth mode). + """ + return (os.environ.get("FAMILIAR_OAUTH_RESOURCE") or "").strip() + + +def oauth_jwks_uri() -> str: + """Explicit JWKS URI, or empty to discover it from the issuer at start.""" + return (os.environ.get("FAMILIAR_OAUTH_JWKS_URI") or "").strip() + + def data_dir() -> Path: """Where the encrypted token and key live. Created with restricted perms.""" override = os.environ.get("FAMILIAR_DATA_DIR") diff --git a/src/familiar/server.py b/src/familiar/server.py index 8c52747..22e075d 100644 --- a/src/familiar/server.py +++ b/src/familiar/server.py @@ -8,8 +8,11 @@ same `mcp` instance. from __future__ import annotations import datetime as _dt +import sys from fastmcp import FastMCP +from starlette.requests import Request +from starlette.responses import JSONResponse from . import ( __version__, @@ -47,6 +50,16 @@ mcp: FastMCP = FastMCP( ) +@mcp.custom_route("/healthz", methods=["GET"]) +async def healthz(_request: Request) -> JSONResponse: + """Unauthenticated liveness probe for Docker / reverse-proxy healthchecks. + + Only reachable over the HTTP transport; on stdio the route is never served. + Deliberately outside the bearer-auth gate so a probe needs no token. + """ + return JSONResponse({"status": "ok", "server": "familiar", "version": __version__}) + + @mcp.tool def ping() -> dict[str, str]: """Health check. Confirms the familiar server is alive and reports its version. @@ -126,21 +139,96 @@ def _bearer_verifier(token: str): return StaticTokenVerifier(tokens={token: {"client_id": "familiar-remote", "scopes": []}}) -def configure_http(token: str | None = None) -> str: - """Prepare the shared ``mcp`` instance for HTTP serving and return the token. +def _resolve_jwks_uri(issuer: str, jwks_uri: str) -> str: + """Return the JWKS URI, discovering it from the issuer's OIDC metadata if unset.""" + if jwks_uri: + return jwks_uri + import httpx - Attaches bearer auth and switches writes to their network default (disabled - unless FAMILIAR_ENABLE_WRITES is explicitly truthy). Raises ``SystemExit`` - if no token is configured — an internet-reachable server must never run open. + disco = issuer.rstrip("/") + "/.well-known/openid-configuration" + try: + resp = httpx.get(disco, timeout=10.0) + resp.raise_for_status() + uri = resp.json().get("jwks_uri") + except Exception as exc: # noqa: BLE001 — surface any discovery failure as a clean exit + raise SystemExit( + f"OAuth discovery failed at {disco}: {exc}. Set FAMILIAR_OAUTH_JWKS_URI explicitly." + ) from exc + if not uri: + raise SystemExit(f"OIDC metadata at {disco} has no jwks_uri; set FAMILIAR_OAUTH_JWKS_URI.") + return uri + + +def _oauth_provider(verifier=None): + """Build the OAuth 2.1 resource-server provider (validates external JWTs). + + familiar acts as a *resource server* only: it validates incoming JWT access + tokens against an external issuer's JWKS and advertises + ``/.well-known/oauth-protected-resource`` so OAuth-only clients can discover + the authorization server. It is NOT an authorization server and does no DCR. + ``verifier`` is injectable for tests; production builds a JWTVerifier. """ + from pydantic import AnyHttpUrl + + from fastmcp.server.auth import RemoteAuthProvider + + issuer = config.oauth_issuer() + resource = config.oauth_resource() + if not issuer or not resource: + raise SystemExit( + "oauth mode needs FAMILIAR_OAUTH_ISSUER (the authorization server) and " + "FAMILIAR_OAUTH_RESOURCE (this server's public https URL)." + ) + if verifier is None: + from fastmcp.server.auth.providers.jwt import JWTVerifier + + jwks = _resolve_jwks_uri(issuer, config.oauth_jwks_uri()) + verifier = JWTVerifier(jwks_uri=jwks, issuer=issuer, audience=config.oauth_audience()) + return RemoteAuthProvider( + token_verifier=verifier, + authorization_servers=[AnyHttpUrl(issuer)], + base_url=resource, + ) + + +def _refuse_no_token(host: str) -> "SystemExit": + where = "a public interface" if not config.is_loopback(host) else "loopback" + return SystemExit( + f"Refusing to start the HTTP server on {host!r} ({where}) without a bearer " + "token. Set FAMILIAR_HTTP_TOKEN to a long random secret, switch to " + "FAMILIAR_AUTH_MODE=oauth, or — only if auth is handled upstream — set " + "FAMILIAR_ALLOW_INSECURE_BIND=true to run unauthenticated." + ) + + +def configure_http(*, host: str | None = None, token: str | None = None, verifier=None) -> str: + """Prepare the shared ``mcp`` instance for HTTP serving; return the mode/token. + + Attaches the auth provider for the configured ``FAMILIAR_AUTH_MODE`` and + switches writes to their network default (disabled unless + FAMILIAR_ENABLE_WRITES is explicitly truthy). Fail-closed: a bind without a + token (bearer mode) raises ``SystemExit`` unless FAMILIAR_ALLOW_INSECURE_BIND + is set. ``verifier`` is injectable for oauth-mode tests. + """ + host = host if host is not None else config.HTTP_HOST + writes.set_http_context(True) + + if config.auth_mode() == "oauth": + mcp.auth = _oauth_provider(verifier=verifier) + return "oauth" + token = token if token is not None else config.http_token() if not token: - raise SystemExit( - "Refusing to start the HTTP server: FAMILIAR_HTTP_TOKEN is not set. " - "Set it to a long random secret (the bearer token your MCP client " - "sends) and try again." - ) - writes.set_http_context(True) + if config.allow_insecure_bind(): + print( + f"WARNING: serving familiar on {host!r} with NO authentication " + "(FAMILIAR_ALLOW_INSECURE_BIND is set). Anyone who can reach this " + "port has full access to your account.", + file=sys.stderr, + ) + mcp.auth = None + return "" + raise _refuse_no_token(host) mcp.auth = _bearer_verifier(token) return token @@ -148,16 +236,17 @@ def configure_http(token: str | None = None) -> str: def serve_http( *, host: str | None = None, port: int | None = None, path: str | None = None ) -> None: - """Run familiar over the streamable-HTTP transport with bearer auth. + """Run familiar over the streamable-HTTP transport. Binds loopback by default (a TLS reverse proxy is expected to terminate HTTPS and forward here). Host/port/path fall back to the FAMILIAR_HTTP_* - config. Refuses to start without FAMILIAR_HTTP_TOKEN. + config. Authentication follows FAMILIAR_AUTH_MODE (bearer by default). """ - configure_http() + host = host or config.HTTP_HOST + configure_http(host=host) mcp.run( transport="http", - host=host or config.HTTP_HOST, + host=host, port=port or config.HTTP_PORT, path=path or config.HTTP_PATH, ) diff --git a/tests/test_serve.py b/tests/test_serve.py index 7acda01..a163ebd 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -79,6 +79,20 @@ def test_valid_token_passes_handshake(http_server): assert status == 200 +def test_healthz_needs_no_token(http_server): + _, app = http_server + + async def _get_health(): + transport = httpx.ASGITransport(app=app) + async with app.router.lifespan_context(app): + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + return await client.get("/healthz") + + resp = asyncio.run(_get_health()) + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + + # --------------------------------------------------------------------------- # # Start refusal without a token # --------------------------------------------------------------------------- # @@ -214,3 +228,166 @@ def test_manual_login_exchanges_and_saves(monkeypatch, tmp_path): assert bundle["character_id"] == 42 assert bundle["character_name"] == "Pilot" assert saved["character_id"] == 42 + + +# --------------------------------------------------------------------------- # +# Fail-closed bind + insecure escape hatch +# --------------------------------------------------------------------------- # +def _reset_auth(): + server.mcp.auth = None + writes.set_http_context(False) + + +def test_public_bind_without_token_refused(monkeypatch): + monkeypatch.delenv("FAMILIAR_HTTP_TOKEN", raising=False) + monkeypatch.delenv("FAMILIAR_ALLOW_INSECURE_BIND", raising=False) + monkeypatch.delenv("FAMILIAR_AUTH_MODE", raising=False) + try: + with pytest.raises(SystemExit) as excinfo: + server.configure_http(host="0.0.0.0") + assert "public interface" in str(excinfo.value) + finally: + _reset_auth() + + +def test_loopback_without_token_still_refused(monkeypatch): + monkeypatch.delenv("FAMILIAR_HTTP_TOKEN", raising=False) + monkeypatch.delenv("FAMILIAR_ALLOW_INSECURE_BIND", raising=False) + monkeypatch.delenv("FAMILIAR_AUTH_MODE", raising=False) + try: + with pytest.raises(SystemExit): + server.configure_http(host="127.0.0.1") + finally: + _reset_auth() + + +def test_insecure_bind_escape_hatch_allows_tokenless(monkeypatch): + monkeypatch.delenv("FAMILIAR_HTTP_TOKEN", raising=False) + monkeypatch.setenv("FAMILIAR_ALLOW_INSECURE_BIND", "true") + monkeypatch.delenv("FAMILIAR_AUTH_MODE", raising=False) + try: + result = server.configure_http(host="0.0.0.0") + assert result == "" + assert server.mcp.auth is None + finally: + _reset_auth() + + +# --------------------------------------------------------------------------- # +# OAuth resource-server mode +# --------------------------------------------------------------------------- # +_ISS = "https://idp.example.com" +_RES = "https://familiar.example.com" + + +@pytest.fixture +def oauth_server(monkeypatch): + """Configure oauth mode with an injected JWTVerifier over a local RSA key.""" + from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair + + monkeypatch.setenv("FAMILIAR_AUTH_MODE", "oauth") + monkeypatch.setenv("FAMILIAR_OAUTH_ISSUER", _ISS) + monkeypatch.setenv("FAMILIAR_OAUTH_RESOURCE", _RES) + monkeypatch.setenv("FAMILIAR_OAUTH_AUDIENCE", _RES) + original = server.mcp.auth + kp = RSAKeyPair.generate() + verifier = JWTVerifier(public_key=kp.public_key, issuer=_ISS, audience=_RES) + server.configure_http(host="0.0.0.0", verifier=verifier) + app = server.mcp.http_app(path="/mcp") + yield kp, app + server.mcp.auth = original + writes.set_http_context(False) + + +def test_oauth_valid_jwt_passes(oauth_server): + kp, app = oauth_server + token = kp.create_token(issuer=_ISS, audience=_RES, subject="user1") + status, _ = asyncio.run(_status_for(app, f"Bearer {token}")) + assert status == 200 + + +def test_oauth_missing_token_401(oauth_server): + _, app = oauth_server + status, www_auth = asyncio.run(_status_for(app, None)) + assert status == 401 + assert www_auth and www_auth.lower().startswith("bearer") + + +def test_oauth_wrong_audience_401(oauth_server): + kp, app = oauth_server + token = kp.create_token(issuer=_ISS, audience="https://other.example.com", subject="user1") + status, _ = asyncio.run(_status_for(app, f"Bearer {token}")) + assert status == 401 + + +def test_oauth_expired_token_401(oauth_server): + kp, app = oauth_server + token = kp.create_token(issuer=_ISS, audience=_RES, subject="user1", expires_in_seconds=-60) + status, _ = asyncio.run(_status_for(app, f"Bearer {token}")) + assert status == 401 + + +def test_oauth_protected_resource_metadata(oauth_server): + _, app = oauth_server + + async def _get_metadata(): + transport = httpx.ASGITransport(app=app) + async with app.router.lifespan_context(app): + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + return await client.get("/.well-known/oauth-protected-resource/mcp") + + resp = asyncio.run(_get_metadata()) + assert resp.status_code == 200 + body = resp.json() + assert body["resource"] == f"{_RES}/mcp" + assert body["authorization_servers"] == [f"{_ISS}/"] + + +def test_oauth_requires_issuer_and_resource(monkeypatch): + monkeypatch.setenv("FAMILIAR_AUTH_MODE", "oauth") + monkeypatch.delenv("FAMILIAR_OAUTH_ISSUER", raising=False) + monkeypatch.delenv("FAMILIAR_OAUTH_RESOURCE", raising=False) + try: + with pytest.raises(SystemExit) as excinfo: + server.configure_http(host="0.0.0.0") + assert "FAMILIAR_OAUTH_ISSUER" in str(excinfo.value) + finally: + _reset_auth() + + +def test_resolve_jwks_uri_passthrough(): + assert server._resolve_jwks_uri(_ISS, "https://idp/keys") == "https://idp/keys" + + +# --------------------------------------------------------------------------- # +# Config readers for the new env vars +# --------------------------------------------------------------------------- # +def test_is_loopback(): + assert config.is_loopback("127.0.0.1") + assert config.is_loopback("localhost") + assert not config.is_loopback("0.0.0.0") + assert not config.is_loopback("192.168.1.10") + + +def test_auth_mode_default_and_values(monkeypatch): + monkeypatch.delenv("FAMILIAR_AUTH_MODE", raising=False) + assert config.auth_mode() == "bearer" + monkeypatch.setenv("FAMILIAR_AUTH_MODE", "OAuth") + assert config.auth_mode() == "oauth" + monkeypatch.setenv("FAMILIAR_AUTH_MODE", "nonsense") + assert config.auth_mode() == "bearer" + + +def test_allow_insecure_bind(monkeypatch): + monkeypatch.delenv("FAMILIAR_ALLOW_INSECURE_BIND", raising=False) + assert config.allow_insecure_bind() is False + monkeypatch.setenv("FAMILIAR_ALLOW_INSECURE_BIND", "yes") + assert config.allow_insecure_bind() is True + + +def test_oauth_audience_defaults_to_resource(monkeypatch): + monkeypatch.setenv("FAMILIAR_OAUTH_RESOURCE", _RES) + monkeypatch.delenv("FAMILIAR_OAUTH_AUDIENCE", raising=False) + assert config.oauth_audience() == _RES + monkeypatch.setenv("FAMILIAR_OAUTH_AUDIENCE", "custom-aud") + assert config.oauth_audience() == "custom-aud"