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:
3 files changed
+344
-15
No files matched your search
@@ -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"
|
||||
Reference in new issue
Block a user