feat(auth): add auth.expose_docs flag to view API docs without a token

The /docs, /redoc and /openapi.json routes are gated by AuthRequired, so a
browser can't open them on plain navigation (no way to attach a Bearer token).
Add an opt-in auth.expose_docs flag (default off) that relaxes ONLY those three
routes to anonymous access (loopback + LAN) via a new verify_docs_access
dependency. Every real endpoint stays protected, and a startup WARNING fires
when the flag is on.

- config: AuthConfig.expose_docs: bool = False
- auth: verify_docs_access / DocsAccess dependency
- main: docs routes use DocsAccess; startup warning
- default_config.yaml: documented flag
- tests: docs anonymous when exposed; real endpoints still 401
This commit is contained in:
2026-06-11 00:14:48 +03:00
parent e584235676
commit 126d8f2449
5 changed files with 86 additions and 7 deletions
+8
View File
@@ -22,6 +22,14 @@ auth:
# api_keys:
# my-client: "replace-with-output-of-openssl-rand-hex-32"
# Expose the interactive API docs (/docs, /redoc, /openapi.json) WITHOUT a
# Bearer token so they can be opened directly in a browser. When true, this
# applies to loopback AND LAN clients. Only the API *surface* (route paths +
# parameter schemas) is exposed — calling an endpoint from Swagger still
# requires the token via its "Authorize" button, and every other route stays
# protected. Leave false unless you want browsable docs on your network.
expose_docs: false
# Storage paths default to ./data relative to the server's working directory.
# Set LEDGRAB_DATA_DIR in the environment to point at a different data root
# (the whole dir — both the database and assets), or uncomment the block
+25
View File
@@ -235,6 +235,31 @@ def verify_api_key(
AuthRequired = Annotated[str, Depends(verify_api_key)]
def verify_docs_access(
request: Request,
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(security)],
) -> str:
"""Auth gate for the OpenAPI docs routes (/docs, /redoc, /openapi.json).
When ``auth.expose_docs`` is True, the docs pages load anonymously from any
client (loopback and LAN) so they can be viewed in a browser without a
Bearer token. Only the API *surface* is exposed this way — every other
endpoint still goes through :func:`verify_api_key`.
When ``auth.expose_docs`` is False (default), this delegates to
:func:`verify_api_key`, so docs require a token exactly like the rest of
the API.
"""
if get_config().auth.expose_docs:
request.state.auth_label = "anonymous-docs"
return "anonymous-docs"
return verify_api_key(request, credentials)
# Dependency for the OpenAPI docs routes — relaxed when auth.expose_docs is set
DocsAccess = Annotated[str, Depends(verify_docs_access)]
def require_authenticated(label: str) -> None:
"""Reject the anonymous (loopback) auth label.
+6
View File
@@ -68,6 +68,12 @@ class AuthConfig(BaseSettings):
"""Authentication configuration."""
api_keys: dict[str, str] = {} # label: key mapping (empty = auth disabled)
# When True, the OpenAPI docs routes (/docs, /redoc, /openapi.json) load
# WITHOUT a Bearer token from any client (loopback and LAN). This exposes
# the API *surface* (route paths + parameter schemas), not data — actually
# invoking an endpoint from Swagger still requires the token via its
# "Authorize" button. All other endpoints stay protected. Default off.
expose_docs: bool = False
class AssetsConfig(BaseSettings):
+18 -7
View File
@@ -264,6 +264,15 @@ async def lifespan(app: FastAPI):
client_labels = ", ".join(config.auth.api_keys.keys())
logger.info(f"Authorized clients: {client_labels}")
# Warn when the OpenAPI docs surface is exposed without a token.
if config.auth.expose_docs:
logger.warning(
"auth.expose_docs is ON: /docs, /redoc and /openapi.json load "
"without an API key (loopback and LAN). The API surface (routes + "
"schemas) is readable by anyone who can reach the server; endpoint "
"calls still require a token."
)
# One-shot migration: legacy global ``mqtt:`` config block → first MQTTSource.
# No-op once the store has any entries.
try:
@@ -671,25 +680,27 @@ async def _access_log(request: Request, call_next):
# ── Auth-gated OpenAPI surface ────────────────────────────────────────────
# Re-add the docs endpoints we disabled above, now protected by the same
# Bearer auth as the rest of the API. When auth is unconfigured, loopback
# clients still get in anonymously (per ``verify_api_key`` policy).
# Re-add the docs endpoints we disabled above, protected by the same Bearer
# auth as the rest of the API. The ``DocsAccess`` dependency relaxes this to
# anonymous access (loopback + LAN) when ``auth.expose_docs`` is set, so the
# docs can be viewed in a browser without a token. When auth is unconfigured,
# loopback clients still get in anonymously (per ``verify_api_key`` policy).
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html # noqa: E402
from ledgrab.api.auth import AuthRequired # noqa: E402
from ledgrab.api.auth import DocsAccess # noqa: E402
@app.get("/openapi.json", include_in_schema=False)
async def _openapi(_auth: AuthRequired):
async def _openapi(_auth: DocsAccess):
return JSONResponse(app.openapi())
@app.get("/docs", include_in_schema=False)
async def _swagger_docs(_auth: AuthRequired):
async def _swagger_docs(_auth: DocsAccess):
return get_swagger_ui_html(openapi_url="/openapi.json", title=f"{app.title} — API docs")
@app.get("/redoc", include_in_schema=False)
async def _redoc_docs(_auth: AuthRequired):
async def _redoc_docs(_auth: DocsAccess):
return get_redoc_html(openapi_url="/openapi.json", title=f"{app.title} — API docs")
@@ -87,6 +87,35 @@ class TestOpenAPIEndpoint:
assert resp.status_code == 401
class TestExposeDocsFlag:
"""auth.expose_docs relaxes the docs routes to anonymous access."""
@pytest.fixture
def expose_docs(self, monkeypatch):
"""Turn auth.expose_docs ON for the duration of a test."""
monkeypatch.setattr(get_config().auth, "expose_docs", True)
def test_openapi_anonymous_when_exposed(self, client, expose_docs):
resp = client.get("/openapi.json")
assert resp.status_code == 200
assert resp.json()["info"]["version"] == __version__
def test_swagger_anonymous_when_exposed(self, client, expose_docs):
resp = client.get("/docs")
assert resp.status_code == 200
assert "text/html" in resp.headers["content-type"]
def test_redoc_anonymous_when_exposed(self, client, expose_docs):
resp = client.get("/redoc")
assert resp.status_code == 200
assert "text/html" in resp.headers["content-type"]
def test_real_endpoints_still_protected_when_exposed(self, client, expose_docs):
"""Exposing docs must NOT open up actual API endpoints."""
resp = client.get("/api/v1/system/api-keys")
assert resp.status_code == 401
class TestRootEndpoint:
def test_root_returns_html(self, client):
resp = client.get("/")