feat(serve): /healthz, fail-closed bind, and OAuth resource-server mode

Extend the HTTP transport (from the remote-MCP work) so it's production-ready.

- /healthz: an unauthenticated GET liveness route that returns only
  {status, server, version} — no account data, no token state. For Docker and
  reverse-proxy probes; on stdio the route is never served.
- Fail-closed bind: refuse to start without authentication, and make the
  message distinguish a loopback bind from a public (0.0.0.0) one. The only
  override is the explicit FAMILIAR_ALLOW_INSECURE_BIND=true escape hatch, which
  permits an unauthenticated bind (with a loud warning).
- FAMILIAR_AUTH_MODE=oauth: run as an OAuth 2.1 resource server. A JWTVerifier
  validates external-issuer JWTs (JWKS from FAMILIAR_OAUTH_JWKS_URI, else
  discovered from FAMILIAR_OAUTH_ISSUER's OIDC metadata) against issuer +
  audience, wrapped in a RemoteAuthProvider that serves
  /.well-known/oauth-protected-resource so OAuth-only clients (Claude web) can
  discover the authorization server. familiar is a resource server only — no
  authorization server, no DCR. bearer mode is unchanged and stays the default.

Tests cover healthz (200, no leak), fail-closed vs escape hatch, and oauth
(valid JWT 200; missing/expired/wrong-audience 401 + WWW-Authenticate; the
protected-resource metadata), plus the new config readers.
This commit is contained in:
Latte committed 2026-07-04 19:25:52 +02:00
1 parent 3b2c04cde3
commit 7bb99d94bf
3 files changed
+344 -15

No files matched your search

+63
View File
@@ -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")
+104 -15
View File
@@ -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,
)
+177
View File
@@ -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"