mirror of
https://github.com/AletheiaVox/signal_bridge_remote.git
synced 2026-10-07 03:18:17 +08:00
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:
@@ -29,7 +29,7 @@ from .auth import (
|
||||
)
|
||||
from .mcp_tools import TOOLS, HANDLERS, current_user_id
|
||||
from .oauth import init_oauth_db
|
||||
from .oauth_routes import router as oauth_router
|
||||
from .oauth_routes import router as oauth_router, _base_url
|
||||
from .relay_hub import check_ws_ip_limit, release_ws_ip_slot, get_ip_from_headers
|
||||
from .session_registry import registry
|
||||
from .governor import governor
|
||||
@@ -55,7 +55,6 @@ async def lifespan(app: FastAPI):
|
||||
await dead_man_switch.start()
|
||||
log.info(f"Signal Bridge Remote started on {config.HOST}:{config.PORT}")
|
||||
log.info(f"Registration {'OPEN' if config.REGISTRATION_OPEN else 'CLOSED'}")
|
||||
log.info(f"MCP auth {'REQUIRED' if config.REQUIRE_MCP_AUTH else 'optional (sole-phone fallback enabled)'}")
|
||||
yield
|
||||
await dead_man_switch.stop()
|
||||
log.info("Signal Bridge Remote shutting down")
|
||||
@@ -166,7 +165,7 @@ async def login(request: Request):
|
||||
# - POST: JSON-RPC requests from client
|
||||
# - GET: SSE stream for server-to-client notifications (kept open)
|
||||
# - Mcp-Session-Id header for session tracking
|
||||
# - Authless mode for claude.ai connector, Bearer token for Claude Desktop
|
||||
# - Bearer token (OAuth or login JWT) required on every request
|
||||
# ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# In-memory MCP session tracking (maps session_id → user_id)
|
||||
@@ -175,28 +174,16 @@ _mcp_sessions: dict[str, str] = {}
|
||||
|
||||
async def _resolve_mcp_user(request: Request) -> dict | None:
|
||||
"""
|
||||
Resolve the user for an MCP request.
|
||||
Priority: Bearer token > Mcp-Session-Id lookup > sole active phone session.
|
||||
Resolve the user for an MCP request: a valid Bearer token, on every
|
||||
request, or nobody.
|
||||
|
||||
There used to be two more paths here — an Mcp-Session-Id lookup and a
|
||||
"sole connected phone" fallback for unauthenticated requests. Both are
|
||||
gone: the fallback handed an anonymous caller whichever single phone was
|
||||
online, and a session ID must not outlive or replace the token that
|
||||
opened it (MCP auth spec: authorization on every HTTP request).
|
||||
"""
|
||||
# 1. Try Bearer token auth (Claude Desktop)
|
||||
user = await _require_auth(request)
|
||||
if user:
|
||||
return user
|
||||
|
||||
# 2. Try Mcp-Session-Id (subsequent requests from claude.ai)
|
||||
session_id = request.headers.get("mcp-session-id", "")
|
||||
if session_id and session_id in _mcp_sessions:
|
||||
return {"user_id": _mcp_sessions[session_id]}
|
||||
|
||||
# 3. Fall back to sole active phone session (authless / claude.ai init)
|
||||
# Disabled when SB_REQUIRE_MCP_AUTH=true (multi-user mode).
|
||||
if not config.REQUIRE_MCP_AUTH:
|
||||
fallback_user_id = await registry.get_sole_user_id()
|
||||
if fallback_user_id:
|
||||
log.info(f"MCP request without auth — using active session: {fallback_user_id}")
|
||||
return {"user_id": fallback_user_id}
|
||||
|
||||
return None
|
||||
return await _require_auth(request)
|
||||
|
||||
|
||||
@app.post("/mcp")
|
||||
@@ -231,9 +218,15 @@ async def mcp_endpoint(request: Request):
|
||||
# Resolve user
|
||||
user = await _resolve_mcp_user(request)
|
||||
if not user:
|
||||
# RFC 9728: the challenge header is how MCP clients discover where
|
||||
# to start the OAuth flow — a bare 401 leaves them stranded.
|
||||
base = _base_url(request)
|
||||
return JSONResponse(
|
||||
{"jsonrpc": "2.0", "error": {"code": -32000, "message": "No auth token and no active phone session"}},
|
||||
{"jsonrpc": "2.0", "error": {"code": -32000, "message": "Authentication required: send a Bearer token (OAuth or login JWT)"}},
|
||||
status_code=401,
|
||||
headers={
|
||||
"WWW-Authenticate": f'Bearer resource_metadata="{base}/.well-known/oauth-protected-resource"'
|
||||
},
|
||||
)
|
||||
|
||||
# Rate limit per user for commands
|
||||
|
||||
@@ -19,7 +19,8 @@ CORS_ORIGINS = os.getenv("SB_CORS_ORIGINS", "*").split(",")
|
||||
# ── Auth ────────────────────────────────────────────────────────────────
|
||||
TOKEN_EXPIRY_HOURS = int(os.getenv("SB_TOKEN_EXPIRY_HOURS", "168")) # 1 week
|
||||
REGISTRATION_OPEN = os.getenv("SB_REGISTRATION_OPEN", "true").lower() == "true"
|
||||
REQUIRE_MCP_AUTH = os.getenv("SB_REQUIRE_MCP_AUTH", "false").lower() == "true"
|
||||
# SB_REQUIRE_MCP_AUTH is gone: MCP auth is always required. An old .env
|
||||
# that still sets it is harmless.
|
||||
|
||||
# ── Rate Limiting ───────────────────────────────────────────────────────
|
||||
# Format: "count/period" — e.g. "5/minute", "100/hour"
|
||||
|
||||
@@ -40,7 +40,12 @@ log = logging.getLogger("signal_bridge.oauth")
|
||||
# ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
AUTH_CODE_EXPIRY_S = 300 # 5 minutes — per OAuth spec recommendation
|
||||
REFRESH_TOKEN_EXPIRY_S = 86400 * 30 # 30 days
|
||||
# Must comfortably outlive the access token (SB_TOKEN_EXPIRY_HOURS, often
|
||||
# 30 days): clients only refresh once the access token has expired, so a
|
||||
# refresh token with the same lifetime is already dead when it's needed.
|
||||
# Rotation issues a fresh one on every refresh, so active clients never
|
||||
# reach this limit.
|
||||
REFRESH_TOKEN_EXPIRY_S = 86400 * 90 # 90 days
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -70,12 +70,32 @@ async def oauth_metadata(request: Request):
|
||||
"registration_endpoint": f"{base}/oauth/register",
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||
"code_challenge_methods_supported": ["S256", "plain"],
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
"token_endpoint_auth_methods_supported": ["client_secret_post"],
|
||||
"scopes_supported": ["signal_bridge"],
|
||||
})
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════
|
||||
# RFC 9728 — OAuth Protected Resource Metadata
|
||||
# ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@router.get("/.well-known/oauth-protected-resource")
|
||||
async def protected_resource_metadata(request: Request):
|
||||
"""
|
||||
Discovery endpoint for the MCP auth spec: clients that get a 401 from
|
||||
/mcp follow the WWW-Authenticate header here, then on to the
|
||||
authorization server metadata above.
|
||||
"""
|
||||
base = _base_url(request)
|
||||
return JSONResponse({
|
||||
"resource": base,
|
||||
"authorization_servers": [base],
|
||||
"scopes_supported": ["signal_bridge"],
|
||||
"bearer_methods_supported": ["header"],
|
||||
})
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════
|
||||
# RFC 7591 — Dynamic Client Registration
|
||||
# ════════════════════════════════════════════════════════════════════════
|
||||
@@ -374,7 +394,13 @@ async def _handle_refresh_token(body: dict, client_id: str, ip: str):
|
||||
|
||||
result = await asyncio.to_thread(consume_refresh_token, token, client_id)
|
||||
if not result:
|
||||
await ip_tracker.record_failure(ip)
|
||||
# Deliberately NOT counted toward the IP ban. The client has already
|
||||
# proven its client_secret above, and refresh tokens are 512 bits of
|
||||
# randomness — nothing to brute-force. A dead refresh token only
|
||||
# ever comes from a legitimate client whose token expired, and such
|
||||
# clients retry on a timer: counting them banned the owner's own
|
||||
# home IP overnight.
|
||||
log.info(f"OAuth refresh rejected (expired/revoked) for client={client_id[:12]}...")
|
||||
return JSONResponse({"error": "invalid_grant"}, status_code=400)
|
||||
|
||||
# Look up user
|
||||
|
||||
@@ -154,14 +154,6 @@ class SessionRegistry:
|
||||
async with self._lock:
|
||||
return dict(self._sessions)
|
||||
|
||||
async def get_sole_user_id(self) -> Optional[str]:
|
||||
"""If exactly one phone session is active, return its user_id.
|
||||
Used for authless MCP access (e.g. claude.ai connector)."""
|
||||
async with self._lock:
|
||||
if len(self._sessions) == 1:
|
||||
return next(iter(self._sessions))
|
||||
return None
|
||||
|
||||
@property
|
||||
def active_count(self) -> int:
|
||||
return len(self._sessions)
|
||||
|
||||
Reference in New Issue
Block a user