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

@@ -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