diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2096f28..ade9f73 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [1.9.0] - 2026-07-20
+
+### Security
+
+- The Wiki.js integration buttons no longer embed an API key in the browser. The re-index and entity-link endpoints now authenticate via the NPM `/library-desk/` proxy (Authentik session for external users, LAN bypass for internal), verified by a trusted proxy marker header. The previously-embedded key was a full-privilege key served to every wiki visitor; it has been rotated out of service.
+- `verify_api_key` now uses a constant-time comparison.
+
+### Changed
+
+- `static/wikijs-integration.js` calls library-desk same-origin (`/library-desk/...`) with `credentials: same-origin` and no `Authorization` header. Update the Wiki.js code-injection snippet to `
+ * Usage: Add to Wiki.js Code Injection (served same-origin behind Authentik):
+ *
+ *
+ * Auth: none in the browser. Requests go same-origin through the NPM
+ * /library-desk/ location, which is gated by Authentik forward-auth with the
+ * LAN bypass — external users are authenticated, LAN users pass through, and
+ * library-desk trusts the proxy marker header. No API key is embedded here.
*/
(function() {
'use strict';
- // Auto-detect Library Desk URL
+ // Same-origin base: the script is served from /library-desk/static/...,
+ // so strip '/static/...' to get the library-desk mount point on this origin.
const scriptTag = document.currentScript;
const scriptUrl = scriptTag ? scriptTag.src : '';
- const libraryDeskUrl = scriptUrl ? scriptUrl.split('/static/')[0] : 'http://192.168.86.149:8089';
+ const libraryDeskUrl = scriptUrl
+ ? scriptUrl.replace(/^https?:\/\/[^/]+/, '').split('/static/')[0]
+ : '/library-desk';
// Shared configuration
const CONFIG = window.LIBRARY_DESK_CONFIG || {
libraryDeskUrl: libraryDeskUrl,
- apiKey: 'af88ed8f44bed81bdb20d0534f1c4547340b29e2aba4963f61a71b993d7eb6e5',
user: 'jpmschweitzer',
buttonPosition: 'toolbar', // 'toolbar' or 'floating'
debug: true
@@ -229,8 +236,8 @@
// Re-index directly
const response = await fetch(CONFIG.libraryDeskUrl + '/ingest/page', {
method: 'POST',
+ credentials: 'same-origin',
headers: {
- 'Authorization': 'Bearer ' + CONFIG.apiKey,
'Content-Type': 'application/json'
},
body: JSON.stringify({
@@ -334,8 +341,8 @@
// Call entity linking endpoint
const response = await fetch(CONFIG.libraryDeskUrl + '/entity-linking/link-page', {
method: 'POST',
+ credentials: 'same-origin',
headers: {
- 'Authorization': 'Bearer ' + CONFIG.apiKey,
'Content-Type': 'application/json'
},
body: JSON.stringify({
diff --git a/tests/test_browser_auth.py b/tests/test_browser_auth.py
new file mode 100644
index 0000000..3657175
--- /dev/null
+++ b/tests/test_browser_auth.py
@@ -0,0 +1,57 @@
+"""
+Tests for verify_browser_request — the session/proxy auth used by the
+Wiki.js integration endpoints (no secret in the browser).
+"""
+import pytest
+from types import SimpleNamespace
+from fastapi import HTTPException
+from starlette.requests import Request
+
+from src.core.dependencies import verify_browser_request
+
+
+def _request(headers: dict) -> Request:
+ raw = [(k.lower().encode(), v.encode()) for k, v in headers.items()]
+ return Request({"type": "http", "method": "POST", "path": "/ingest/page", "headers": raw})
+
+
+SETTINGS = SimpleNamespace(library_api_key="server-secret-key")
+
+
+@pytest.mark.asyncio
+async def test_proxy_marker_with_authentik_identity_is_accepted():
+ req = _request({"X-Library-Desk-Proxy": "1", "X-Authentik-Email": "user@example.com"})
+ assert await verify_browser_request(req, SETTINGS) == "user@example.com"
+
+
+@pytest.mark.asyncio
+async def test_proxy_marker_on_lan_bypass_falls_back_to_lan():
+ # LAN bypass: proxy marker present, no Authentik identity headers.
+ req = _request({"X-Library-Desk-Proxy": "1"})
+ assert await verify_browser_request(req, SETTINGS) == "lan"
+
+
+@pytest.mark.asyncio
+async def test_valid_api_key_is_accepted_for_machine_callers():
+ req = _request({"Authorization": "Bearer server-secret-key"})
+ assert await verify_browser_request(req, SETTINGS) == "server-secret-key"
+
+
+@pytest.mark.asyncio
+async def test_no_marker_and_no_key_is_rejected():
+ with pytest.raises(HTTPException) as exc:
+ await verify_browser_request(_request({}), SETTINGS)
+ assert exc.value.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_forged_marker_value_is_rejected():
+ # Only the exact NPM-set value "1" is trusted.
+ with pytest.raises(HTTPException):
+ await verify_browser_request(_request({"X-Library-Desk-Proxy": "yes"}), SETTINGS)
+
+
+@pytest.mark.asyncio
+async def test_wrong_api_key_is_rejected():
+ with pytest.raises(HTTPException):
+ await verify_browser_request(_request({"Authorization": "Bearer wrong"}), SETTINGS)
diff --git a/tests/test_required_user.py b/tests/test_required_user.py
index 4e19f0e..6b69d35 100644
--- a/tests/test_required_user.py
+++ b/tests/test_required_user.py
@@ -12,19 +12,21 @@ import pytest
from fastapi.testclient import TestClient
from src.main import app
-from src.core.dependencies import verify_api_key
+from src.core.dependencies import verify_api_key, verify_browser_request
@pytest.fixture(scope="module")
def client():
- """TestClient with API-key auth stubbed out (no lifespan startup)."""
+ """TestClient with auth stubbed out (no lifespan startup)."""
app.dependency_overrides[verify_api_key] = lambda: "test-key"
+ app.dependency_overrides[verify_browser_request] = lambda: "test-user"
try:
# No context manager: startup/lifespan events are NOT triggered,
# so no connections to external services are attempted.
yield TestClient(app)
finally:
app.dependency_overrides.pop(verify_api_key, None)
+ app.dependency_overrides.pop(verify_browser_request, None)
QUERY_PARAM_ENDPOINTS = [