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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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("/")
|
||||
|
||||
Reference in New Issue
Block a user