fix: authenticate WeKnora API client

This commit is contained in:
Administrator committed 2026-08-13 17:41:30 +08:00
1 parent 56044d9cb7
commit 3aeb6ac5ce
4 files changed
+200 -24

No files matched your search

@@ -38,16 +38,16 @@ class WeKnoraConnectionConfig(BaseModel):
admin_email: str = Field(
default="",
description=(
"Optional WeKnora administrator email used only by DeerFlow's "
"server-side detail-page iframe proxy. Prefer $ENV references."
"Optional WeKnora administrator email used by DeerFlow's server-side "
"WeKnora API client and detail-page iframe proxy. Prefer $ENV references."
),
)
admin_password: str = Field(
default="",
repr=False,
description=(
"Optional WeKnora administrator password used only by DeerFlow's "
"server-side detail-page iframe proxy. Prefer $ENV references."
"Optional WeKnora administrator password used by DeerFlow's server-side "
"WeKnora API client and detail-page iframe proxy. Prefer $ENV references."
),
)
model_config = ConfigDict(extra="ignore")
@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
import time
from typing import Any
from urllib.parse import quote
@@ -41,9 +42,10 @@ class WeKnoraClient:
"""Async client for a trusted-network WeKnora endpoint.
The caller may inject an ``httpx.AsyncClient`` for connection pooling and
tests. When omitted, each public method creates and closes a short-lived
client. DeerFlow deliberately sends no WeKnora credential; the independently
deployed service or its reverse proxy must allow DeerFlow's internal traffic.
tests. When omitted, each public method creates and closes a short-lived client.
If admin credentials are configured, DeerFlow authenticates to WeKnora with a
cached bearer token; otherwise it keeps the legacy trusted-network anonymous
mode for deployments that allow DeerFlow through a reverse proxy.
"""
def __init__(
@@ -52,13 +54,105 @@ class WeKnoraClient:
base_url: str,
timeout_seconds: float = 30.0,
http_client: httpx.AsyncClient | None = None,
admin_email: str = "",
admin_password: str = "",
) -> None:
self.base_url = base_url.rstrip("/")
self.timeout_seconds = timeout_seconds
self._http_client = http_client
self.admin_email = admin_email.strip()
self.admin_password = admin_password
self._auth_token = ""
self._tenant_id = ""
self._auth_expires_at = 0.0
if not self.base_url:
raise ValueError("WeKnora base URL is required")
@property
def _has_admin_credentials(self) -> bool:
return bool(self.admin_email and self.admin_password)
@staticmethod
def _extract_login_token(payload: Any) -> tuple[str, str]:
if not isinstance(payload, dict):
return "", ""
source = payload.get("data") if isinstance(payload.get("data"), dict) else payload
token = str(
source.get("token")
or source.get("access_token")
or payload.get("token")
or payload.get("access_token")
or ""
)
tenant = (
source.get("tenant")
if isinstance(source.get("tenant"), dict)
else source.get("active_tenant")
if isinstance(source.get("active_tenant"), dict)
else payload.get("tenant")
)
user = source.get("user") if isinstance(source.get("user"), dict) else payload.get("user")
tenant_id = ""
if isinstance(tenant, dict):
tenant_id = str(tenant.get("id") or "")
if not tenant_id and isinstance(user, dict):
tenant_id = str(user.get("tenant_id") or "")
return token, tenant_id
async def _login(self, *, force_refresh: bool = False) -> None:
if not self._has_admin_credentials:
return
now = time.time()
if self._auth_token and not force_refresh and self._auth_expires_at > now + 60:
return
owns_client = self._http_client is None
client = self._http_client or httpx.AsyncClient(
timeout=httpx.Timeout(self.timeout_seconds),
follow_redirects=False,
)
try:
response = await client.post(
f"{self.base_url}/api/v1/auth/login",
json={"email": self.admin_email, "password": self.admin_password},
headers={"Accept": "application/json"},
)
except httpx.TimeoutException as exc:
raise WeKnoraError("WeKnora admin login timed out", status_code=504) from exc
except httpx.HTTPError as exc:
raise WeKnoraError("WeKnora admin login is unavailable", status_code=502) from exc
finally:
if owns_client:
await client.aclose()
if response.status_code >= 400:
raise WeKnoraError(f"WeKnora admin login failed ({response.status_code})", status_code=502)
try:
payload = response.json()
except ValueError as exc:
raise WeKnoraError("WeKnora admin login returned invalid JSON", status_code=502) from exc
if isinstance(payload, dict) and payload.get("success") is False:
raise WeKnoraError(str(payload.get("message") or "WeKnora admin login failed"), status_code=502)
token, tenant_id = self._extract_login_token(payload)
if not token:
raise WeKnoraError("WeKnora admin login did not return a token", status_code=502)
self._auth_token = token
self._tenant_id = tenant_id
# WeKnora login responses do not consistently expose expiry. Refresh
# periodically and retry once if upstream says the token is stale.
self._auth_expires_at = now + 25 * 60
async def _headers(self, *, force_refresh: bool = False, accept: str = "application/json") -> dict[str, str]:
headers = {"Accept": accept}
if self._has_admin_credentials:
await self._login(force_refresh=force_refresh)
if self._auth_token:
headers["Authorization"] = f"Bearer {self._auth_token}"
if self._tenant_id:
headers["X-Tenant-ID"] = self._tenant_id
return headers
async def _request(
self,
method: str,
@@ -69,19 +163,21 @@ class WeKnoraClient:
files: Any = None,
data: Mapping[str, Any] | None = None,
) -> Any:
headers = {"Accept": "application/json"}
owns_client = self._http_client is None
client = self._http_client or httpx.AsyncClient(timeout=httpx.Timeout(self.timeout_seconds), follow_redirects=False)
try:
response = await client.request(
method,
f"{self.base_url}{path}",
headers=headers,
json=json,
params=params,
files=files,
data=data,
)
for attempt in range(2):
response = await client.request(
method,
f"{self.base_url}{path}",
headers=await self._headers(force_refresh=attempt > 0),
json=json,
params=params,
files=files,
data=data,
)
if response.status_code not in {401, 403} or not self._has_admin_credentials or attempt > 0:
break
except httpx.TimeoutException as exc:
raise WeKnoraError("WeKnora request timed out", status_code=504) from exc
except httpx.HTTPError as exc:
@@ -92,6 +188,8 @@ class WeKnoraClient:
if response.status_code == 404:
raise WeKnoraError("WeKnora resource was not found", status_code=404)
if response.status_code in {401, 403}:
if self._has_admin_credentials:
raise WeKnoraError("WeKnora rejected DeerFlow's authenticated internal request", status_code=502)
raise WeKnoraError(
"WeKnora rejected DeerFlow's anonymous internal request; allow DeerFlow in WeKnora or its reverse proxy",
status_code=502,
@@ -296,12 +394,15 @@ class WeKnoraClient:
follow_redirects=True,
)
try:
response = await client.request(
"GET",
f"{self.base_url}/api/v1/knowledge/{quote(knowledge_id, safe='')}/preview",
headers={"Accept": "*/*"},
follow_redirects=True,
)
for attempt in range(2):
response = await client.request(
"GET",
f"{self.base_url}/api/v1/knowledge/{quote(knowledge_id, safe='')}/preview",
headers=await self._headers(force_refresh=attempt > 0, accept="*/*"),
follow_redirects=True,
)
if response.status_code not in {401, 403} or not self._has_admin_credentials or attempt > 0:
break
except httpx.TimeoutException as exc:
raise WeKnoraError("WeKnora preview request timed out", status_code=504) from exc
except httpx.HTTPError as exc:
@@ -312,6 +413,8 @@ class WeKnoraClient:
if response.status_code == 404:
raise WeKnoraError("WeKnora document preview was not found", status_code=404)
if response.status_code in {401, 403}:
if self._has_admin_credentials:
raise WeKnoraError("WeKnora rejected DeerFlow's authenticated preview request", status_code=502)
raise WeKnoraError(
"WeKnora rejected DeerFlow's anonymous internal request; allow DeerFlow in WeKnora or its reverse proxy",
status_code=502,
@@ -40,4 +40,6 @@ def build_weknora_client(
return WeKnoraClient(
base_url=runtime.api_base_url,
http_client=http_client,
admin_email=runtime.admin_email,
admin_password=runtime.admin_password,
)
@@ -230,7 +230,7 @@ async def test_weknora_session_endpoint_sets_cookie_and_returns_platform_path(mo
@pytest.mark.asyncio
async def test_weknora_client_sends_no_credentials_and_normalizes_search() -> None:
async def test_weknora_client_keeps_anonymous_mode_without_admin_credentials() -> None:
requests: list[httpx.Request] = []
async def handler(request: httpx.Request) -> httpx.Response:
@@ -270,6 +270,77 @@ async def test_weknora_client_sends_no_credentials_and_normalizes_search() -> No
assert results[0]["title"] == "Guide"
@pytest.mark.asyncio
async def test_weknora_client_uses_admin_token_when_credentials_are_configured() -> None:
requests: list[httpx.Request] = []
async def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
if request.url.path == "/api/v1/auth/login":
assert json.loads(request.content) == {"email": "admin@example.test", "password": "secret"}
return httpx.Response(
200,
json={
"success": True,
"data": {
"token": "token-one",
"tenant": {"id": "tenant-one"},
},
},
)
assert request.headers["Authorization"] == "Bearer token-one"
assert request.headers["X-Tenant-ID"] == "tenant-one"
return httpx.Response(200, json={"success": True, "data": []})
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client:
client = WeKnoraClient(
base_url="http://weknora.local:8080",
http_client=http_client,
admin_email="admin@example.test",
admin_password="secret",
)
results = await client.search("deployment guide", ["kb-one"])
assert results == []
assert [request.url.path for request in requests] == [
"/api/v1/auth/login",
"/api/v1/knowledge-search",
]
@pytest.mark.asyncio
async def test_weknora_client_refreshes_admin_token_after_rejection() -> None:
login_count = 0
search_count = 0
async def handler(request: httpx.Request) -> httpx.Response:
nonlocal login_count, search_count
if request.url.path == "/api/v1/auth/login":
login_count += 1
return httpx.Response(
200,
json={"success": True, "data": {"token": f"token-{login_count}"}},
)
search_count += 1
if search_count == 1:
assert request.headers["Authorization"] == "Bearer token-1"
return httpx.Response(401, json={"message": "expired"})
assert request.headers["Authorization"] == "Bearer token-2"
return httpx.Response(200, json={"success": True, "data": []})
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client:
client = WeKnoraClient(
base_url="http://weknora.local:8080",
http_client=http_client,
admin_email="admin@example.test",
admin_password="secret",
)
await client.search("deployment guide", ["kb-one"])
assert login_count == 2
assert search_count == 2
@pytest.mark.asyncio
async def test_weknora_client_merges_required_name_for_description_update() -> None:
requests: list[httpx.Request] = []