diff --git a/docs/user-guide/tips.md b/docs/user-guide/tips.md index 544e0e0..8c49d91 100644 --- a/docs/user-guide/tips.md +++ b/docs/user-guide/tips.md @@ -2,12 +2,12 @@ ## CORS -The STAC Auth Proxy does not modify the [CORS response headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS#the_http_response_headers) from the upstream STAC API. All CORS configuration must be handled by the upstream API. +The STAC Auth Proxy does not modify the [CORS response headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS#the_http_response_headers) from the upstream STAC API. All CORS configuration must be handled by the upstream API. Because the STAC Auth Proxy introduces authentication, the upstream API’s CORS settings may need adjustment to support credentials. In most cases, this means: -* [`Access-Control-Allow-Credentials`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Credentials) must be `true` -* [`Access-Control-Allow-Origin`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Origin) must _not_ be `*`[^CORSNotSupportingCredentials] +- [`Access-Control-Allow-Credentials`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Credentials) must be `true` +- [`Access-Control-Allow-Origin`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Origin) must _not_ be `*`[^CORSNotSupportingCredentials] [^CORSNotSupportingCredentials]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS/Errors/CORSNotSupportingCredentials @@ -32,13 +32,40 @@ Rather than performing the login flow, the Swagger UI can be configured to accep ```sh OPENAPI_AUTH_SCHEME_NAME=jwtAuth OPENAPI_AUTH_SCHEME_OVERRIDE='{ - "type": "http", - "scheme": "bearer", - "bearerFormat": "JWT", + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", "description": "Paste your raw JWT here. This API uses Bearer token authorization." }' ``` -## Runtime Customization +## Non-proxy Configuration -While the project is designed to work out-of-the-box as an application, it might not address every projects needs. When the need for customization arises, the codebase can instead be treated as a library of components that can be used to augment any [ASGI](https://asgi.readthedocs.io/en/latest/)-compliant webserver (e.g. [Django](https://docs.djangoproject.com/en/3.0/topics/async/), [Falcon](https://falconframework.org/), [FastAPI](https://github.com/tiangolo/fastapi), [Litestar](https://litestar.dev/), [Responder](https://responder.readthedocs.io/en/latest/), [Sanic](https://sanic.dev/), [Starlette](https://www.starlette.io/)). Review [`app.py`](https://github.com/developmentseed/stac-auth-proxy/blob/main/src/stac_auth_proxy/app.py) to get a sense of how we make use of the various components to construct a FastAPI application. +While the project is designed to work out-of-the-box as an application, it might not address every projects needs. When the need for customization arises, the codebase can instead be treated as a library of components that can be used to augment a FastAPI server. This may look something like the following: + +```py +from fastapi import FastAPI +from stac_fastapi.api.app import StacApi +from stac_auth_proxy import build_lifespan, configure_app, Settings as StacAuthSettings + +# Create Auth Settings +auth_settings = StacAuthSettings( + upstream_url='https://stac-server', + oidc_discovery_url='https://auth-server/.well-known/openid-configuration', +) + +# Setup App +app = FastAPI( + ... + lifespan=build_lifespan(auth_settings), +) + +# Apply STAC Auth Proxy middleware +configure_app(app, auth_settings) + +# Setup STAC API +api = StacApi( + app, + ... +) +``` diff --git a/src/stac_auth_proxy/__init__.py b/src/stac_auth_proxy/__init__.py index 35eaf7b..4fdde39 100644 --- a/src/stac_auth_proxy/__init__.py +++ b/src/stac_auth_proxy/__init__.py @@ -6,7 +6,13 @@ with some internal STAC API. """ -from .app import create_app +from .app import configure_app, create_app from .config import Settings +from .lifespan import build_lifespan -__all__ = ["create_app", "Settings"] +__all__ = [ + "build_lifespan", + "create_app", + "configure_app", + "Settings", +] diff --git a/src/stac_auth_proxy/app.py b/src/stac_auth_proxy/app.py index b497c0d..a5c0aa9 100644 --- a/src/stac_auth_proxy/app.py +++ b/src/stac_auth_proxy/app.py @@ -6,14 +6,14 @@ """ import logging -from contextlib import asynccontextmanager -from typing import Optional +from typing import Any, Optional from fastapi import FastAPI from starlette_cramjam.middleware import CompressionMiddleware from .config import Settings from .handlers import HealthzHandler, ReverseProxyHandler, SwaggerUI +from .lifespan import build_lifespan from .middleware import ( AddProcessTimeHeaderMiddleware, AuthenticationExtensionMiddleware, @@ -26,58 +26,33 @@ ProcessLinksMiddleware, RemoveRootPathMiddleware, ) -from .utils.lifespan import check_conformance, check_server_health logger = logging.getLogger(__name__) -def create_app(settings: Optional[Settings] = None) -> FastAPI: - """FastAPI Application Factory.""" - settings = settings or Settings() +def configure_app( + app: FastAPI, + settings: Optional[Settings] = None, + **settings_kwargs: Any, +) -> FastAPI: + """ + Apply routes and middleware to a FastAPI app. + + Parameters + ---------- + app : FastAPI + The FastAPI app to configure. + settings : Settings | None, optional + Pre-built settings instance. If omitted, a new one is constructed from + ``settings_kwargs``. + **settings_kwargs : Any + Keyword arguments used to configure the health and conformance checks if + ``settings`` is not provided. + """ + settings = settings or Settings(**settings_kwargs) # - # Application - # - - @asynccontextmanager - async def lifespan(app: FastAPI): - assert settings - - # Wait for upstream servers to become available - if settings.wait_for_upstream: - logger.info("Running upstream server health checks...") - urls = [settings.upstream_url, settings.oidc_discovery_internal_url] - for url in urls: - await check_server_health(url=url) - logger.info( - "Upstream servers are healthy:\n%s", - "\n".join([f" - {url}" for url in urls]), - ) - - # Log all middleware connected to the app - logger.info( - "Connected middleware:\n%s", - "\n".join([f" - {m.cls.__name__}" for m in app.user_middleware]), - ) - - if settings.check_conformance: - await check_conformance( - app.user_middleware, - str(settings.upstream_url), - ) - - yield - - app = FastAPI( - openapi_url=None, # Disable OpenAPI schema endpoint, we want to serve upstream's schema - lifespan=lifespan, - root_path=settings.root_path, - ) - if app.root_path: - logger.debug("Mounted app at %s", app.root_path) - - # - # Handlers (place catch-all proxy handler last) + # Route Handlers # # If we have customized Swagger UI Init settings (e.g. a provided client_id) @@ -105,15 +80,6 @@ async def lifespan(app: FastAPI): prefix=settings.healthz_prefix, ) - app.add_api_route( - "/{path:path}", - ReverseProxyHandler( - upstream=str(settings.upstream_url), - override_host=settings.override_host, - ).proxy_request, - methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], - ) - # # Middleware (order is important, last added = first to run) # @@ -186,3 +152,29 @@ async def lifespan(app: FastAPI): ) return app + + +def create_app(settings: Optional[Settings] = None) -> FastAPI: + """FastAPI Application Factory.""" + settings = settings or Settings() + + app = FastAPI( + openapi_url=None, # Disable OpenAPI schema endpoint, we want to serve upstream's schema + lifespan=build_lifespan(settings=settings), + root_path=settings.root_path, + ) + if app.root_path: + logger.debug("Mounted app at %s", app.root_path) + + configure_app(app, settings) + + app.add_api_route( + "/{path:path}", + ReverseProxyHandler( + upstream=str(settings.upstream_url), + override_host=settings.override_host, + ).proxy_request, + methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + ) + + return app diff --git a/src/stac_auth_proxy/utils/lifespan.py b/src/stac_auth_proxy/lifespan.py similarity index 59% rename from src/stac_auth_proxy/utils/lifespan.py rename to src/stac_auth_proxy/lifespan.py index 412fc41..04368e0 100644 --- a/src/stac_auth_proxy/utils/lifespan.py +++ b/src/stac_auth_proxy/lifespan.py @@ -1,14 +1,31 @@ -"""Health check implementations for lifespan events.""" +"""Reusable lifespan handler for FastAPI applications.""" import asyncio import logging import re +from contextlib import asynccontextmanager +from typing import Any import httpx +from fastapi import FastAPI from pydantic import HttpUrl from starlette.middleware import Middleware +from .config import Settings + logger = logging.getLogger(__name__) +__all__ = ["build_lifespan", "check_conformance", "check_server_health"] + + +async def check_server_healths(*urls: str | HttpUrl) -> None: + """Wait for upstream APIs to become available.""" + logger.info("Running upstream server health checks...") + for url in urls: + await check_server_health(url) + logger.info( + "Upstream servers are healthy:\n%s", + "\n".join([f" - {url}" for url in urls]), + ) async def check_server_health( @@ -91,3 +108,48 @@ def conformance_str(conformance: str) -> str: "Upstream catalog conforms to the following required conformance classes: \n%s", "\n".join([conformance_str(c) for c in required_conformances]), ) + + +def build_lifespan(settings: Settings | None = None, **settings_kwargs: Any): + """ + Create a lifespan handler that runs startup checks. + + Parameters + ---------- + settings : Settings | None, optional + Pre-built settings instance. If omitted, a new one is constructed from + ``settings_kwargs``. + **settings_kwargs : Any + Keyword arguments used to configure the health and conformance checks if + ``settings`` is not provided. + + Returns + ------- + Callable[[FastAPI], AsyncContextManager[Any]] + A callable suitable for the ``lifespan`` parameter of ``FastAPI``. + """ + if settings is None: + settings = Settings(**settings_kwargs) + + @asynccontextmanager + async def lifespan(app: "FastAPI"): + assert settings is not None # Required for type checking + + # Wait for upstream servers to become available + if settings.wait_for_upstream: + await check_server_healths( + settings.upstream_url, settings.oidc_discovery_internal_url + ) + + # Log all middleware connected to the app + logger.info( + "Connected middleware:\n%s", + "\n".join([f" - {m.cls.__name__}" for m in app.user_middleware]), + ) + + if settings.check_conformance: + await check_conformance(app.user_middleware, str(settings.upstream_url)) + + yield + + return lifespan diff --git a/tests/test_configure_app.py b/tests/test_configure_app.py new file mode 100644 index 0000000..71959f9 --- /dev/null +++ b/tests/test_configure_app.py @@ -0,0 +1,24 @@ +"""Tests for configuring an external FastAPI application.""" + +from fastapi import FastAPI +from fastapi.routing import APIRoute + +from stac_auth_proxy import Settings, configure_app + + +def test_configure_app_excludes_proxy_route(): + """Ensure `configure_app` adds health route and omits proxy route.""" + app = FastAPI() + settings = Settings( + upstream_url="https://example.com", + oidc_discovery_url="https://example.com/.well-known/openid-configuration", + wait_for_upstream=False, + check_conformance=False, + default_public=True, + ) + + configure_app(app, settings) + + routes = [r.path for r in app.router.routes if isinstance(r, APIRoute)] + assert settings.healthz_prefix in routes + assert "/{path:path}" not in routes diff --git a/tests/test_lifespan.py b/tests/test_lifespan.py index b039601..7908e1b 100644 --- a/tests/test_lifespan.py +++ b/tests/test_lifespan.py @@ -1,13 +1,16 @@ """Tests for lifespan module.""" from dataclasses import dataclass -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient from starlette.middleware import Middleware from starlette.types import ASGIApp -from stac_auth_proxy.utils.lifespan import check_conformance, check_server_health +from stac_auth_proxy import check_conformance, check_server_health +from stac_auth_proxy import lifespan as lifespan_handler from stac_auth_proxy.utils.middleware import required_conformance @@ -80,3 +83,27 @@ def __init__(self, app): middleware = [Middleware(NoConformanceMiddleware)] await check_conformance(middleware, source_api_server) + + +def test_lifespan_reusable(): + """Ensure the public lifespan handler runs health and conformance checks.""" + upstream_url = "https://example.com" + oidc_discovery_url = "https://example.com/.well-known/openid-configuration" + with patch( + "stac_auth_proxy.lifespan.check_server_health", + new=AsyncMock(), + ) as mock_health, patch( + "stac_auth_proxy.lifespan.check_conformance", + new=AsyncMock(), + ) as mock_conf: + app = FastAPI( + lifespan=lifespan_handler( + upstream_url=upstream_url, + oidc_discovery_url=oidc_discovery_url, + ) + ) + with TestClient(app): + pass + assert mock_health.await_count == 2 + expected_upstream = upstream_url.rstrip("/") + "/" + mock_conf.assert_awaited_once_with(app.user_middleware, expected_upstream)