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( admin_email: str = Field(
default="", default="",
description=( description=(
"Optional WeKnora administrator email used only by DeerFlow's " "Optional WeKnora administrator email used by DeerFlow's server-side "
"server-side detail-page iframe proxy. Prefer $ENV references." "WeKnora API client and detail-page iframe proxy. Prefer $ENV references."
), ),
) )
admin_password: str = Field( admin_password: str = Field(
default="", default="",
repr=False, repr=False,
description=( description=(
"Optional WeKnora administrator password used only by DeerFlow's " "Optional WeKnora administrator password used by DeerFlow's server-side "
"server-side detail-page iframe proxy. Prefer $ENV references." "WeKnora API client and detail-page iframe proxy. Prefer $ENV references."
), ),
) )
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
import time
from typing import Any from typing import Any
from urllib.parse import quote from urllib.parse import quote
@@ -41,9 +42,10 @@ class WeKnoraClient:
"""Async client for a trusted-network WeKnora endpoint. """Async client for a trusted-network WeKnora endpoint.
The caller may inject an ``httpx.AsyncClient`` for connection pooling and The caller may inject an ``httpx.AsyncClient`` for connection pooling and
tests. When omitted, each public method creates and closes a short-lived tests. When omitted, each public method creates and closes a short-lived client.
client. DeerFlow deliberately sends no WeKnora credential; the independently If admin credentials are configured, DeerFlow authenticates to WeKnora with a
deployed service or its reverse proxy must allow DeerFlow's internal traffic. cached bearer token; otherwise it keeps the legacy trusted-network anonymous
mode for deployments that allow DeerFlow through a reverse proxy.
""" """
def __init__( def __init__(
@@ -52,13 +54,105 @@ class WeKnoraClient:
base_url: str, base_url: str,
timeout_seconds: float = 30.0, timeout_seconds: float = 30.0,
http_client: httpx.AsyncClient | None = None, http_client: httpx.AsyncClient | None = None,
admin_email: str = "",
admin_password: str = "",
) -> None: ) -> None:
self.base_url = base_url.rstrip("/") self.base_url = base_url.rstrip("/")
self.timeout_seconds = timeout_seconds self.timeout_seconds = timeout_seconds
self._http_client = http_client 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: if not self.base_url:
raise ValueError("WeKnora base URL is required") 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( async def _request(
self, self,
method: str, method: str,
@@ -69,19 +163,21 @@ class WeKnoraClient:
files: Any = None, files: Any = None,
data: Mapping[str, Any] | None = None, data: Mapping[str, Any] | None = None,
) -> Any: ) -> Any:
headers = {"Accept": "application/json"}
owns_client = self._http_client is None owns_client = self._http_client is None
client = self._http_client or httpx.AsyncClient(timeout=httpx.Timeout(self.timeout_seconds), follow_redirects=False) client = self._http_client or httpx.AsyncClient(timeout=httpx.Timeout(self.timeout_seconds), follow_redirects=False)
try: try:
response = await client.request( for attempt in range(2):
method, response = await client.request(
f"{self.base_url}{path}", method,
headers=headers, f"{self.base_url}{path}",
json=json, headers=await self._headers(force_refresh=attempt > 0),
params=params, json=json,
files=files, params=params,
data=data, 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: except httpx.TimeoutException as exc:
raise WeKnoraError("WeKnora request timed out", status_code=504) from exc raise WeKnoraError("WeKnora request timed out", status_code=504) from exc
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
@@ -92,6 +188,8 @@ class WeKnoraClient:
if response.status_code == 404: if response.status_code == 404:
raise WeKnoraError("WeKnora resource was not found", status_code=404) raise WeKnoraError("WeKnora resource was not found", status_code=404)
if response.status_code in {401, 403}: 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( raise WeKnoraError(
"WeKnora rejected DeerFlow's anonymous internal request; allow DeerFlow in WeKnora or its reverse proxy", "WeKnora rejected DeerFlow's anonymous internal request; allow DeerFlow in WeKnora or its reverse proxy",
status_code=502, status_code=502,
@@ -296,12 +394,15 @@ class WeKnoraClient:
follow_redirects=True, follow_redirects=True,
) )
try: try:
response = await client.request( for attempt in range(2):
"GET", response = await client.request(
f"{self.base_url}/api/v1/knowledge/{quote(knowledge_id, safe='')}/preview", "GET",
headers={"Accept": "*/*"}, f"{self.base_url}/api/v1/knowledge/{quote(knowledge_id, safe='')}/preview",
follow_redirects=True, 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: except httpx.TimeoutException as exc:
raise WeKnoraError("WeKnora preview request timed out", status_code=504) from exc raise WeKnoraError("WeKnora preview request timed out", status_code=504) from exc
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
@@ -312,6 +413,8 @@ class WeKnoraClient:
if response.status_code == 404: if response.status_code == 404:
raise WeKnoraError("WeKnora document preview was not found", status_code=404) raise WeKnoraError("WeKnora document preview was not found", status_code=404)
if response.status_code in {401, 403}: 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( raise WeKnoraError(
"WeKnora rejected DeerFlow's anonymous internal request; allow DeerFlow in WeKnora or its reverse proxy", "WeKnora rejected DeerFlow's anonymous internal request; allow DeerFlow in WeKnora or its reverse proxy",
status_code=502, status_code=502,
@@ -40,4 +40,6 @@ def build_weknora_client(
return WeKnoraClient( return WeKnoraClient(
base_url=runtime.api_base_url, base_url=runtime.api_base_url,
http_client=http_client, 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 @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] = [] requests: list[httpx.Request] = []
async def handler(request: httpx.Request) -> httpx.Response: 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" 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 @pytest.mark.asyncio
async def test_weknora_client_merges_required_name_for_description_update() -> None: async def test_weknora_client_merges_required_name_for_description_update() -> None:
requests: list[httpx.Request] = [] requests: list[httpx.Request] = []