feat(auth): session/proxy auth for Wiki.js buttons; drop browser API key
The wikijs-integration.js embedded a full-privilege API key that was served to every wiki visitor — it unlocked all 66 authenticated endpoints, including page/vector deletes and index purges. That key has been rotated out of service. The two browser endpoints (/ingest/page, /entity-linking/link-page) now authenticate via the NPM /library-desk/ proxy location instead of a key: Authentik forward-auth for external users, LAN bypass for internal, verified by a trusted proxy marker header. This is safe because library-desk binds loopback-only, so NPM is the sole path that can set that header. The browser holds no secret; the script calls same-origin with credentials. Machine callers (the Scheduler) keep the Bearer key on the container-network endpoints. verify_api_key now compares in constant time. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -765,7 +765,8 @@ def get_volatile_cache_service() -> "VolatileCacheService":
|
||||
|
||||
|
||||
# Authentication
|
||||
from fastapi import Security, HTTPException
|
||||
import secrets
|
||||
from fastapi import Security, HTTPException, Request
|
||||
from fastapi.security import HTTPBearer
|
||||
|
||||
security = HTTPBearer()
|
||||
@@ -788,7 +789,7 @@ async def verify_api_key(
|
||||
Raises:
|
||||
HTTPException: If API key is invalid
|
||||
"""
|
||||
if credentials.credentials != settings.library_api_key:
|
||||
if not secrets.compare_digest(credentials.credentials, settings.library_api_key):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Invalid API key"
|
||||
@@ -796,6 +797,42 @@ async def verify_api_key(
|
||||
return credentials.credentials
|
||||
|
||||
|
||||
# Header set by NPM only on the authenticated /library-desk/ proxy location.
|
||||
# library-desk is bound to loopback (127.0.0.1:8089), so NPM is the only path
|
||||
# that can reach it and set this header — a client cannot forge it. NPM also
|
||||
# overwrites any client-supplied value via proxy_set_header.
|
||||
_PROXY_MARKER_HEADER = "x-library-desk-proxy"
|
||||
|
||||
|
||||
async def verify_browser_request(
|
||||
request: Request,
|
||||
settings: SettingsDep,
|
||||
) -> str:
|
||||
"""
|
||||
Auth for browser-facing endpoints (the Wiki.js integration buttons).
|
||||
|
||||
Accepts the request when it arrives through the authenticated NPM proxy
|
||||
location (Authentik session for external users, or the LAN bypass for
|
||||
internal ones) — identified by the trusted proxy marker header. No secret
|
||||
is carried in the browser. Machine callers may still authenticate with the
|
||||
Bearer API key. Returns the acting user's identity.
|
||||
"""
|
||||
if request.headers.get(_PROXY_MARKER_HEADER) == "1":
|
||||
# Authentik injects the identity for externally-authenticated users;
|
||||
# on the LAN bypass these are empty and the endpoint falls back to the
|
||||
# user supplied in the request body.
|
||||
return request.headers.get("x-authentik-email") or "lan"
|
||||
|
||||
# Fallback: server-to-server Bearer API key.
|
||||
auth = request.headers.get("authorization", "")
|
||||
if auth.startswith("Bearer ") and secrets.compare_digest(
|
||||
auth[len("Bearer "):], settings.library_api_key
|
||||
):
|
||||
return auth[len("Bearer "):]
|
||||
|
||||
raise HTTPException(status_code=401, detail="Unauthenticated")
|
||||
|
||||
|
||||
# Service type aliases for FastAPI endpoint dependencies
|
||||
# These are defined after the factory functions
|
||||
from src.services.vector_service import VectorService
|
||||
|
||||
@@ -18,7 +18,7 @@ from src.core.dependencies import (
|
||||
get_wiki_service,
|
||||
get_graph_service,
|
||||
get_ingestion_service,
|
||||
verify_api_key
|
||||
verify_browser_request,
|
||||
)
|
||||
from src.services.wiki_service import WikiService
|
||||
from src.services.graph_service import GraphService
|
||||
@@ -57,7 +57,7 @@ async def link_entities_in_page(
|
||||
wiki_service: WikiService = Depends(get_wiki_service),
|
||||
graph_service: GraphService = Depends(get_graph_service),
|
||||
ingestion_service: IngestionService = Depends(get_ingestion_service),
|
||||
api_key: str = Depends(verify_api_key)
|
||||
actor: str = Depends(verify_browser_request)
|
||||
) -> EntityLinkingResult:
|
||||
"""
|
||||
Find and link entities mentioned in a wiki page.
|
||||
|
||||
@@ -15,7 +15,8 @@ from src.models.ingestion import (
|
||||
BatchIngestionResult
|
||||
)
|
||||
from src.core.dependencies import (
|
||||
get_ingestion_service, verify_api_key, RequiredUserQuery, JobManagerDep
|
||||
get_ingestion_service, verify_api_key, verify_browser_request,
|
||||
RequiredUserQuery, JobManagerDep
|
||||
)
|
||||
from src.jobs.job_manager import JobManager, JobStatus, JobType
|
||||
|
||||
@@ -65,7 +66,7 @@ async def ingest_page(
|
||||
request: IngestionRequest,
|
||||
ingestion: IngestionService = Depends(get_ingestion_service),
|
||||
job_manager: JobManagerDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
actor: str = Depends(verify_browser_request)
|
||||
):
|
||||
"""
|
||||
Ingest a single wiki page into the knowledge base.
|
||||
|
||||
Reference in New Issue
Block a user