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(
|
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")
|
||||||
|
|||||||
+122
-19
@@ -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,
|
||||||
|
|||||||
+2
@@ -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] = []
|
||||||
|
|||||||
Reference in new issue
Block a user