diff --git a/server/config/default_config.yaml b/server/config/default_config.yaml index 1a41a4b..cafa5d5 100644 --- a/server/config/default_config.yaml +++ b/server/config/default_config.yaml @@ -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 diff --git a/server/src/ledgrab/api/auth.py b/server/src/ledgrab/api/auth.py index f0ebf55..b76fd06 100644 --- a/server/src/ledgrab/api/auth.py +++ b/server/src/ledgrab/api/auth.py @@ -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. diff --git a/server/src/ledgrab/config.py b/server/src/ledgrab/config.py index c42c171..a01a463 100644 --- a/server/src/ledgrab/config.py +++ b/server/src/ledgrab/config.py @@ -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): diff --git a/server/src/ledgrab/main.py b/server/src/ledgrab/main.py index a43ee0f..42d540d 100644 --- a/server/src/ledgrab/main.py +++ b/server/src/ledgrab/main.py @@ -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") diff --git a/server/tests/api/routes/test_system_routes.py b/server/tests/api/routes/test_system_routes.py index ff09374..bc97c42 100644 --- a/server/tests/api/routes/test_system_routes.py +++ b/server/tests/api/routes/test_system_routes.py @@ -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("/")