fix(security): require auth on every MCP request; stop stale refreshes banning clients

- Remove the authless "sole connected phone" fallback and SB_REQUIRE_MCP_AUTH:
  an unauthenticated request no longer reaches whichever phone is online alone.
- Mcp-Session-Id is no longer a credential; the Bearer token is checked on
  every request (MCP auth spec).
- Refresh tokens live 90 days (was 30, equal to the access token, so they
  were always dead when first needed).
- A rejected refresh no longer counts toward the IP ban: a client with an
  expired token was retrying into a self-renewing ban on its own IP.
- 401s carry the RFC 9728 WWW-Authenticate discovery header.
This commit is contained in:
AletheiaVox
2026-09-27 12:55:16 +02:00
parent 605f71b742
commit 7bb9d8473b
9 changed files with 124 additions and 53 deletions

View File

@@ -163,5 +163,49 @@ with TestClient(app) as client:
j = r.json() if r.status_code == 200 else {}
check("governor re-enables (one-way ratchet fixed)", j.get("governor_enabled") is True, r.text[:200])
# ── No authless fallback: a lone connected phone is NOT a credential ──
import asyncio
from server.session_registry import registry
from server.auth import verify_token as _vt
class _FakeWS:
async def send(self, data): pass
async def close(self, code=1000, reason=""): pass
uid = _vt(access)["user_id"]
asyncio.run(registry.register(uid, _FakeWS()))
r = client.post("/mcp", json={"jsonrpc": "2.0", "id": 20, "method": "initialize", "params": {}})
check("sole phone online: unauthenticated initialize still 401", r.status_code == 401, f"got {r.status_code}")
r = client.post("/mcp", headers={"mcp-session-id": sess}, json={
"jsonrpc": "2.0", "id": 21, "method": "tools/call",
"params": {"name": "list_devices", "arguments": {}},
})
check("session id without token -> 401", r.status_code == 401, f"got {r.status_code}")
check("401 carries WWW-Authenticate discovery header",
"resource_metadata=" in r.headers.get("www-authenticate", ""), str(dict(r.headers))[:200])
asyncio.run(registry.unregister(uid))
# ── Refresh: rotation works, dead refresh tokens don't ban the IP ──
from server import oauth as _oauth
from server import config as _cfg
check("refresh token outlives access token",
_oauth.REFRESH_TOKEN_EXPIRY_S > _cfg.TOKEN_EXPIRY_HOURS * 3600 * 2,
f"refresh={_oauth.REFRESH_TOKEN_EXPIRY_S}s access={_cfg.TOKEN_EXPIRY_HOURS}h")
rt = tok.get("refresh_token", "")
r = client.post("/oauth/token", json={
"grant_type": "refresh_token", "refresh_token": rt,
"client_id": creds.get("client_id", ""), "client_secret": creds.get("client_secret", ""),
})
check("refresh grant rotates", r.status_code == 200 and r.json().get("refresh_token") not in ("", rt), r.text[:150])
for _ in range(_cfg.BAN_THRESHOLD + 5):
r = client.post("/oauth/token", json={
"grant_type": "refresh_token", "refresh_token": rt, # now revoked
"client_id": creds.get("client_id", ""), "client_secret": creds.get("client_secret", ""),
})
check("stale refresh -> invalid_grant, not a ban",
r.status_code == 400 and r.json().get("error") == "invalid_grant", f"{r.status_code} {r.text[:100]}")
r = client.post("/mcp", headers=hdrs, json={"jsonrpc": "2.0", "id": 22, "method": "tools/list"})
check("same IP still served after repeated stale refreshes", r.status_code == 200, f"got {r.status_code}")
print(f"\n{len(PASS)} passed, {len(FAIL)} failed")
sys.exit(1 if FAIL else 0)