Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 35 additions & 8 deletions docs/user-guide/tips.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
...
)
```
10 changes: 8 additions & 2 deletions src/stac_auth_proxy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
106 changes: 49 additions & 57 deletions src/stac_auth_proxy/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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)
#
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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
24 changes: 24 additions & 0 deletions tests/test_configure_app.py
Original file line number Diff line number Diff line change
@@ -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
31 changes: 29 additions & 2 deletions tests/test_lifespan.py
Original file line number Diff line number Diff line change
@@ -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 build_lifespan
from stac_auth_proxy.lifespan import check_conformance, check_server_health
from stac_auth_proxy.utils.middleware import required_conformance


Expand Down Expand Up @@ -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=build_lifespan(
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)
Loading