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:
|
# api_keys:
|
||||||
# my-client: "replace-with-output-of-openssl-rand-hex-32"
|
# 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.
|
# 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
|
# 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
|
# (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)]
|
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:
|
def require_authenticated(label: str) -> None:
|
||||||
"""Reject the anonymous (loopback) auth label.
|
"""Reject the anonymous (loopback) auth label.
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,12 @@ class AuthConfig(BaseSettings):
|
|||||||
"""Authentication configuration."""
|
"""Authentication configuration."""
|
||||||
|
|
||||||
api_keys: dict[str, str] = {} # label: key mapping (empty = auth disabled)
|
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):
|
class AssetsConfig(BaseSettings):
|
||||||
|
|||||||
@@ -264,6 +264,15 @@ async def lifespan(app: FastAPI):
|
|||||||
client_labels = ", ".join(config.auth.api_keys.keys())
|
client_labels = ", ".join(config.auth.api_keys.keys())
|
||||||
logger.info(f"Authorized clients: {client_labels}")
|
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.
|
# One-shot migration: legacy global ``mqtt:`` config block → first MQTTSource.
|
||||||
# No-op once the store has any entries.
|
# No-op once the store has any entries.
|
||||||
try:
|
try:
|
||||||
@@ -671,25 +680,27 @@ async def _access_log(request: Request, call_next):
|
|||||||
|
|
||||||
|
|
||||||
# ── Auth-gated OpenAPI surface ────────────────────────────────────────────
|
# ── Auth-gated OpenAPI surface ────────────────────────────────────────────
|
||||||
# Re-add the docs endpoints we disabled above, now protected by the same
|
# Re-add the docs endpoints we disabled above, protected by the same Bearer
|
||||||
# Bearer auth as the rest of the API. When auth is unconfigured, loopback
|
# auth as the rest of the API. The ``DocsAccess`` dependency relaxes this to
|
||||||
# clients still get in anonymously (per ``verify_api_key`` policy).
|
# 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 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)
|
@app.get("/openapi.json", include_in_schema=False)
|
||||||
async def _openapi(_auth: AuthRequired):
|
async def _openapi(_auth: DocsAccess):
|
||||||
return JSONResponse(app.openapi())
|
return JSONResponse(app.openapi())
|
||||||
|
|
||||||
|
|
||||||
@app.get("/docs", include_in_schema=False)
|
@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")
|
return get_swagger_ui_html(openapi_url="/openapi.json", title=f"{app.title} — API docs")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/redoc", include_in_schema=False)
|
@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")
|
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
|
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:
|
class TestRootEndpoint:
|
||||||
def test_root_returns_html(self, client):
|
def test_root_returns_html(self, client):
|
||||||
resp = client.get("/")
|
resp = client.get("/")
|
||||||
|
|||||||
Reference in New Issue
Block a user