fix: authenticate WeKnora API client
This commit is contained in:
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")
|
||||
|
||||
+122
-19
@@ -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,
|
||||
|
||||
+2
@@ -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] = []
|
||||
|
||||
Reference in new issue
Block a user