From 7273fb2da664922e9d78074e0ca562ab8abbed13 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 18 Jul 2024 11:18:27 -0700 Subject: [PATCH 01/45] Add optional auth tooling --- runtimes/eoapi/stac/eoapi/stac/app.py | 70 ++++++++++++++++++++++++ runtimes/eoapi/stac/eoapi/stac/config.py | 4 ++ 2 files changed, 74 insertions(+) diff --git a/runtimes/eoapi/stac/eoapi/stac/app.py b/runtimes/eoapi/stac/eoapi/stac/app.py index f581107..da14e81 100644 --- a/runtimes/eoapi/stac/eoapi/stac/app.py +++ b/runtimes/eoapi/stac/eoapi/stac/app.py @@ -142,3 +142,73 @@ async def viewer_page(request: Request): }, media_type="text/html", ) + +if settings.jwks_url: + jwks_client = jwt.PyJWKClient(settings.jwks_url) # Caches JWKS + + # Setup auth requirements + oauth2_scheme = ( + security.OAuth2AuthorizationCodeBearer( + authorizationUrl=f"{settings.oauth2_authorization_url}", + tokenUrl=f"{settings.oauth2_token_url}", + scopes={ + # NOTE: Add requested scopes here if needed... + }, + ) + if (settings.oauth2_authorization_url and settings.oauth2_token_url) + else security.HTTPAuthorizationCredentials() + ) + + + def user_token( + token_str: Annotated[str, Security(oauth2_scheme)], + required_scopes: security.SecurityScopes, + ): + # Parse & validate token + try: + payload = jwt.decode( + token_str, + jwks_client.get_signing_key_from_jwt(token_str).key, + algorithms=["RS256"], + audience=settings.permitted_jwt_audiences, + ) + except jwt.exceptions.InvalidTokenError as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) from e + + # Validate scopes (if required) + for scope in required_scopes.scopes: + if scope not in payload["scope"]: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not enough permissions", + headers={ + "WWW-Authenticate": f'Bearer scope="{required_scopes.scope_str}"' + }, + ) + + return payload + + + # Add dependency to all endpoints that create, modify or delete data. + api.add_route_dependencies( + [ + { + "path": path, + "method": method, + "type": "http", + } + for method in ["POST", "PUT", "DELETE"] + for path in [ + "/collections", + "/collections/{collectionId}", + "/collections/{collectionId}/items", + "/collections/{collectionId}/bulk_items", + "/collections/{collectionId}/items/{itemId}", + ] + ], + [Security(user_token, scopes=[])], # NOTE: Add required scopes here if desired... + ) diff --git a/runtimes/eoapi/stac/eoapi/stac/config.py b/runtimes/eoapi/stac/eoapi/stac/config.py index 1a0e4a0..e916375 100644 --- a/runtimes/eoapi/stac/eoapi/stac/config.py +++ b/runtimes/eoapi/stac/eoapi/stac/config.py @@ -14,6 +14,10 @@ class ApiSettings(BaseSettings): cors_methods: str = "GET,POST,OPTIONS" cachecontrol: str = "public, max-age=3600" debug: bool = False + + jwks_url: Optional[str] = None + oauth2_token_url: Optional[str] = None + oauth2_authorization_url: Optional[str] = None titiler_endpoint: Optional[str] = None From c64186cba3276a401c022894e739bf89c4ea35b7 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 24 Jul 2024 15:51:39 -0700 Subject: [PATCH 02/45] Fix imports --- runtimes/eoapi/stac/eoapi/stac/app.py | 14 +++++++++----- runtimes/eoapi/stac/pyproject.toml | 1 + 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/runtimes/eoapi/stac/eoapi/stac/app.py b/runtimes/eoapi/stac/eoapi/stac/app.py index da14e81..c27bcd4 100644 --- a/runtimes/eoapi/stac/eoapi/stac/app.py +++ b/runtimes/eoapi/stac/eoapi/stac/app.py @@ -1,10 +1,11 @@ """eoapi.stac app.""" from contextlib import asynccontextmanager +from typing import Annotated from eoapi.stac.config import ApiSettings from eoapi.stac.extension import TiTilerExtension -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException, security, status from fastapi.responses import ORJSONResponse from stac_fastapi.api.app import StacApi from stac_fastapi.api.models import ( @@ -34,6 +35,7 @@ from starlette.responses import HTMLResponse from starlette.templating import Jinja2Templates from starlette_cramjam.middleware import CompressionMiddleware +import jwt try: from importlib.resources import files as resources_files # type: ignore @@ -143,6 +145,7 @@ async def viewer_page(request: Request): media_type="text/html", ) + if settings.jwks_url: jwks_client = jwt.PyJWKClient(settings.jwks_url) # Caches JWKS @@ -159,9 +162,8 @@ async def viewer_page(request: Request): else security.HTTPAuthorizationCredentials() ) - def user_token( - token_str: Annotated[str, Security(oauth2_scheme)], + token_str: Annotated[str, security.Security(oauth2_scheme)], required_scopes: security.SecurityScopes, ): # Parse & validate token @@ -192,7 +194,6 @@ def user_token( return payload - # Add dependency to all endpoints that create, modify or delete data. api.add_route_dependencies( [ @@ -210,5 +211,8 @@ def user_token( "/collections/{collectionId}/items/{itemId}", ] ], - [Security(user_token, scopes=[])], # NOTE: Add required scopes here if desired... + [ + # NOTE: Add required scopes here if desired... + security.Security(user_token, scopes=[]) + ], ) diff --git a/runtimes/eoapi/stac/pyproject.toml b/runtimes/eoapi/stac/pyproject.toml index bf7cf43..17d3dd3 100644 --- a/runtimes/eoapi/stac/pyproject.toml +++ b/runtimes/eoapi/stac/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ "starlette-cramjam>=0.3,<0.4", "importlib_resources>=1.1.0;python_version<'3.9'", "psycopg_pool", + "pyjwt", ] [project.optional-dependencies] From b5425947c7c56d6e556a60099b931b788ebecad9 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 24 Jul 2024 16:20:38 -0700 Subject: [PATCH 03/45] Run pre-commit --- runtimes/eoapi/stac/eoapi/stac/app.py | 2 +- runtimes/eoapi/stac/eoapi/stac/config.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/runtimes/eoapi/stac/eoapi/stac/app.py b/runtimes/eoapi/stac/eoapi/stac/app.py index c27bcd4..35087b6 100644 --- a/runtimes/eoapi/stac/eoapi/stac/app.py +++ b/runtimes/eoapi/stac/eoapi/stac/app.py @@ -3,6 +3,7 @@ from contextlib import asynccontextmanager from typing import Annotated +import jwt from eoapi.stac.config import ApiSettings from eoapi.stac.extension import TiTilerExtension from fastapi import FastAPI, HTTPException, security, status @@ -35,7 +36,6 @@ from starlette.responses import HTMLResponse from starlette.templating import Jinja2Templates from starlette_cramjam.middleware import CompressionMiddleware -import jwt try: from importlib.resources import files as resources_files # type: ignore diff --git a/runtimes/eoapi/stac/eoapi/stac/config.py b/runtimes/eoapi/stac/eoapi/stac/config.py index e916375..c0592a6 100644 --- a/runtimes/eoapi/stac/eoapi/stac/config.py +++ b/runtimes/eoapi/stac/eoapi/stac/config.py @@ -14,10 +14,10 @@ class ApiSettings(BaseSettings): cors_methods: str = "GET,POST,OPTIONS" cachecontrol: str = "public, max-age=3600" debug: bool = False - + jwks_url: Optional[str] = None - oauth2_token_url: Optional[str] = None - oauth2_authorization_url: Optional[str] = None + oauth2_token_url: Optional[str] = None + oauth2_authorization_url: Optional[str] = None titiler_endpoint: Optional[str] = None From 66830f52138621d034daf95d27645997fc717ae0 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 26 Jul 2024 13:13:48 -0700 Subject: [PATCH 04/45] Refactor --- docker-compose.yml | 6 ++ runtimes/eoapi/stac/eoapi/stac/app.py | 100 ++++++-------------- runtimes/eoapi/stac/eoapi/stac/auth.py | 113 +++++++++++++++++++++++ runtimes/eoapi/stac/eoapi/stac/config.py | 4 - 4 files changed, 148 insertions(+), 75 deletions(-) create mode 100644 runtimes/eoapi/stac/eoapi/stac/auth.py diff --git a/docker-compose.yml b/docker-compose.yml index b67b13d..cccf437 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,12 @@ services: # PgSTAC extensions # - EOAPI_STAC_EXTENSIONS=["filter", "query", "sort", "fields", "pagination", "titiler", "transaction"] # defaults # - EOAPI_STAC_CORS_METHODS='GET,POST,PUT,OPTIONS' + # Auth controls + # - EOAPI_AUTH_JWKS_URL=https://cognito-idp.us-east-1.amazonaws.com/us-east-1_{USER_POOL_ID}/.well-known/jwks.json + # - EOAPI_AUTH_OAUTH2_TOKEN_URL=https://cognito-idp.us-east-1.amazonaws.com/us-east-1_{USER_POOL_ID}/oauth2/token + # - EOAPI_AUTH_OAUTH2_AUTHORIZATION_URL=https://cognito-idp.us-east-1.amazonaws.com/us-east-1_{USER_POOL_ID}/oauth2/authorize + # - EOAPI_AUTH_USE_PKCE=true + # - EOAPI_AUTH_CLIENT_ID=XXXXXX depends_on: - database command: diff --git a/runtimes/eoapi/stac/eoapi/stac/app.py b/runtimes/eoapi/stac/eoapi/stac/app.py index 35087b6..ae28d1e 100644 --- a/runtimes/eoapi/stac/eoapi/stac/app.py +++ b/runtimes/eoapi/stac/eoapi/stac/app.py @@ -1,12 +1,11 @@ """eoapi.stac app.""" from contextlib import asynccontextmanager -from typing import Annotated -import jwt +from eoapi.stac.auth import AuthSettings, JwtAuth from eoapi.stac.config import ApiSettings from eoapi.stac.extension import TiTilerExtension -from fastapi import FastAPI, HTTPException, security, status +from fastapi import FastAPI from fastapi.responses import ORJSONResponse from stac_fastapi.api.app import StacApi from stac_fastapi.api.models import ( @@ -47,6 +46,7 @@ templates = Jinja2Templates(directory=str(resources_files(__package__) / "templates")) # type: ignore api_settings = ApiSettings() +auth_settings = AuthSettings() settings = Settings(enable_response_models=True) # Extensions @@ -118,6 +118,10 @@ async def lifespan(app: FastAPI): openapi_url="/api", docs_url="/api.html", redoc_url=None, + swagger_ui_init_oauth={ + "clientId": auth_settings.client_id, + "usePkceWithAuthorizationCodeGrant": auth_settings.use_pkce, + }, ), title=api_settings.name, description=api_settings.name, @@ -146,73 +150,27 @@ async def viewer_page(request: Request): ) -if settings.jwks_url: - jwks_client = jwt.PyJWKClient(settings.jwks_url) # Caches JWKS - - # Setup auth requirements - oauth2_scheme = ( - security.OAuth2AuthorizationCodeBearer( - authorizationUrl=f"{settings.oauth2_authorization_url}", - tokenUrl=f"{settings.oauth2_token_url}", - scopes={ - # NOTE: Add requested scopes here if needed... - }, - ) - if (settings.oauth2_authorization_url and settings.oauth2_token_url) - else security.HTTPAuthorizationCredentials() - ) - - def user_token( - token_str: Annotated[str, security.Security(oauth2_scheme)], - required_scopes: security.SecurityScopes, - ): - # Parse & validate token - try: - payload = jwt.decode( - token_str, - jwks_client.get_signing_key_from_jwt(token_str).key, - algorithms=["RS256"], - audience=settings.permitted_jwt_audiences, - ) - except jwt.exceptions.InvalidTokenError as e: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) from e - - # Validate scopes (if required) - for scope in required_scopes.scopes: - if scope not in payload["scope"]: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Not enough permissions", - headers={ - "WWW-Authenticate": f'Bearer scope="{required_scopes.scope_str}"' - }, - ) - - return payload - - # Add dependency to all endpoints that create, modify or delete data. - api.add_route_dependencies( - [ - { - "path": path, - "method": method, - "type": "http", - } - for method in ["POST", "PUT", "DELETE"] - for path in [ - "/collections", - "/collections/{collectionId}", - "/collections/{collectionId}/items", - "/collections/{collectionId}/bulk_items", - "/collections/{collectionId}/items/{itemId}", +if auth_settings.jwks_url: + JwtAuth( + # JWT Validation configuration + jwks_url=auth_settings.jwks_url, + allowed_jwt_audiences=auth_settings.allowed_jwt_audiences, + # Authorization Code Flow configuration + oauth2_authorization_url=auth_settings.oauth2_authorization_url, + oauth2_token_url=auth_settings.oauth2_token_url, + # To render scopes form on Swagger UI's login pop-up, populate with mapping of scopes to descriptions + oauth2_supported_scopes={}, + ).require_auth( + api=api, + routes={ + f"{app.root_path}/{route}": ["POST", "PUT", "DELETE"] + for route in [ + "collections", + "collections/{collectionId}", + "collections/{collectionId}/items", + "collections/{collectionId}/bulk_items", + "collections/{collectionId}/items/{itemId}", ] - ], - [ - # NOTE: Add required scopes here if desired... - security.Security(user_token, scopes=[]) - ], + }, + required_scopes=[], ) diff --git a/runtimes/eoapi/stac/eoapi/stac/auth.py b/runtimes/eoapi/stac/eoapi/stac/auth.py new file mode 100644 index 0000000..9431039 --- /dev/null +++ b/runtimes/eoapi/stac/eoapi/stac/auth.py @@ -0,0 +1,113 @@ +from dataclasses import dataclass, field +from typing import Annotated, Any, Callable, Dict, Optional, Sequence + +import jwt +from fastapi import HTTPException, Security, security, status +from fastapi.security.base import SecurityBase +from pydantic_settings import BaseSettings +from stac_fastapi.api.app import StacApi + + +class AuthSettings(BaseSettings): + jwks_url: Optional[str] = None + + # Swagger UI config for Authorization Code Flow + client_id: Optional[str] = "" + use_pkce: bool = False + oauth2_token_url: Optional[str] = None + oauth2_authorization_url: Optional[str] = None + + allowed_jwt_audiences: Optional[Sequence[str]] = [] + + model_config = { + "env_prefix": "EOAPI_AUTH_", + "env_file": ".env", + "extra": "allow", + } + + +@dataclass +class JwtAuth: + jwks_url: str + allowed_jwt_audiences: Optional[Sequence[str]] + + oauth2_authorization_url: Optional[str] = None + oauth2_token_url: Optional[str] = None + oauth2_supported_scopes: Optional[Dict[str, str]] = None + + # Generated attributes + auth_scheme: SecurityBase = field(init=False) + jwks_client: jwt.PyJWKClient = field(init=False) + valid_token_dependency: Callable[..., Any] = field(init=False) + + def __post_init__(self): + self.auth_scheme = self.create_auth_scheme() + self.jwks_client = jwt.PyJWKClient(self.jwks_url) + self.valid_token_dependency = self.create_user_token_dependency() + + def create_auth_scheme(self): + return ( + security.OAuth2AuthorizationCodeBearer( + authorizationUrl=self.oauth2_authorization_url, + tokenUrl=self.oauth2_token_url, + scopes=self.oauth2_supported_scopes, + ) + if all([self.oauth2_authorization_url, self.oauth2_token_url]) + else security.HTTPBearer() + ) + + def create_user_token_dependency(self): + def user_token( + token_str: Annotated[str, Security(self.auth_scheme)], + required_scopes: security.SecurityScopes, + ): + # Parse & validate token + try: + payload = jwt.decode( + token_str, + self.jwks_client.get_signing_key_from_jwt(token_str).key, + algorithms=["RS256"], + audience=self.allowed_jwt_audiences, + ) + except jwt.exceptions.InvalidTokenError as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) from e + + # Validate scopes (if required) + for scope in required_scopes.scopes: + if scope not in payload["scope"]: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not enough permissions", + headers={ + "WWW-Authenticate": f'Bearer scope="{required_scopes.scope_str}"' + }, + ) + + return payload + + return user_token + + def require_auth( + self, + *, + api: StacApi, + routes: Dict[str, Sequence[str]], + required_scopes: Optional[Sequence[str]], + ): + api.add_route_dependencies( + [ + { + "path": path, + "method": method, + "type": "http", + } + for path, methods in routes.items() + for method in methods + ], + [Security(self.valid_token_dependency, scopes=required_scopes)], + ) + return self diff --git a/runtimes/eoapi/stac/eoapi/stac/config.py b/runtimes/eoapi/stac/eoapi/stac/config.py index c0592a6..1a0e4a0 100644 --- a/runtimes/eoapi/stac/eoapi/stac/config.py +++ b/runtimes/eoapi/stac/eoapi/stac/config.py @@ -15,10 +15,6 @@ class ApiSettings(BaseSettings): cachecontrol: str = "public, max-age=3600" debug: bool = False - jwks_url: Optional[str] = None - oauth2_token_url: Optional[str] = None - oauth2_authorization_url: Optional[str] = None - titiler_endpoint: Optional[str] = None extensions: List[str] = [ From 2a26318883202b0147d176f14c34bd54bd9cc7d9 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 26 Jul 2024 14:11:04 -0700 Subject: [PATCH 05/45] Use PKCE by default --- runtimes/eoapi/stac/eoapi/stac/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runtimes/eoapi/stac/eoapi/stac/auth.py b/runtimes/eoapi/stac/eoapi/stac/auth.py index 9431039..2294411 100644 --- a/runtimes/eoapi/stac/eoapi/stac/auth.py +++ b/runtimes/eoapi/stac/eoapi/stac/auth.py @@ -13,7 +13,7 @@ class AuthSettings(BaseSettings): # Swagger UI config for Authorization Code Flow client_id: Optional[str] = "" - use_pkce: bool = False + use_pkce: bool = True oauth2_token_url: Optional[str] = None oauth2_authorization_url: Optional[str] = None From 7fa43d1cce5934a3b1d6b9846f223e933efef719 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 26 Jul 2024 14:11:42 -0700 Subject: [PATCH 06/45] Update auth.py --- runtimes/eoapi/stac/eoapi/stac/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runtimes/eoapi/stac/eoapi/stac/auth.py b/runtimes/eoapi/stac/eoapi/stac/auth.py index 2294411..546ba0b 100644 --- a/runtimes/eoapi/stac/eoapi/stac/auth.py +++ b/runtimes/eoapi/stac/eoapi/stac/auth.py @@ -12,7 +12,7 @@ class AuthSettings(BaseSettings): jwks_url: Optional[str] = None # Swagger UI config for Authorization Code Flow - client_id: Optional[str] = "" + client_id: str = "" use_pkce: bool = True oauth2_token_url: Optional[str] = None oauth2_authorization_url: Optional[str] = None From 54d4eda962c3e5e6e03c12b7ef2d54a0ccdd20d2 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 7 Aug 2024 09:52:29 -0700 Subject: [PATCH 07/45] Working OIDC example --- .env.example | 4 ++ docker-compose.yml | 14 ++--- runtimes/eoapi/stac/eoapi/stac/app.py | 19 +++--- runtimes/eoapi/stac/eoapi/stac/auth.py | 86 +++++++++++++++++--------- runtimes/eoapi/stac/pyproject.toml | 1 + 5 files changed, 77 insertions(+), 47 deletions(-) diff --git a/.env.example b/.env.example index c93d5fd..cceb280 100644 --- a/.env.example +++ b/.env.example @@ -38,3 +38,7 @@ VSI_CACHE_SIZE=536870912 MOSAIC_CONCURRENCY=1 EOAPI_RASTER_ENABLE_MOSAIC_SEARCH=TRUE +# AUTH +EOAPI_AUTH_CLIENT_ID=my-client-id +EOAPI_AUTH_OPENID_CONFIGURATION_URL=https://cognito-idp.us-east-1.amazonaws.com//.well-known/openid-configuration +EOAPI_AUTH_USE_PKCE=true diff --git a/docker-compose.yml b/docker-compose.yml index cdba955..7fa9ddb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,16 +38,14 @@ services: # PgSTAC extensions # - EOAPI_STAC_EXTENSIONS=["filter", "query", "sort", "fields", "pagination", "titiler", "transaction"] # defaults # - EOAPI_STAC_CORS_METHODS='GET,POST,PUT,OPTIONS' - # Auth controls - # - EOAPI_AUTH_JWKS_URL=https://cognito-idp.us-east-1.amazonaws.com/us-east-1_{USER_POOL_ID}/.well-known/jwks.json - # - EOAPI_AUTH_OAUTH2_TOKEN_URL=https://cognito-idp.us-east-1.amazonaws.com/us-east-1_{USER_POOL_ID}/oauth2/token - # - EOAPI_AUTH_OAUTH2_AUTHORIZATION_URL=https://cognito-idp.us-east-1.amazonaws.com/us-east-1_{USER_POOL_ID}/oauth2/authorize - # - EOAPI_AUTH_USE_PKCE=true - # - EOAPI_AUTH_CLIENT_ID=XXXXXX + env_file: + - path: stac.env + required: false + - path: .env + required: false depends_on: - database - command: - bash -c "bash /tmp/scripts/wait-for-it.sh -t 120 -h database -p 5432 && /start.sh" + command: bash -c "bash /tmp/scripts/wait-for-it.sh -t 120 -h database -p 5432 && /start.sh" develop: watch: - action: sync+restart diff --git a/runtimes/eoapi/stac/eoapi/stac/app.py b/runtimes/eoapi/stac/eoapi/stac/app.py index ae28d1e..d01711e 100644 --- a/runtimes/eoapi/stac/eoapi/stac/app.py +++ b/runtimes/eoapi/stac/eoapi/stac/app.py @@ -2,7 +2,7 @@ from contextlib import asynccontextmanager -from eoapi.stac.auth import AuthSettings, JwtAuth +from eoapi.stac.auth import AuthSettings, OidcAuth from eoapi.stac.config import ApiSettings from eoapi.stac.extension import TiTilerExtension from fastapi import FastAPI @@ -150,17 +150,17 @@ async def viewer_page(request: Request): ) -if auth_settings.jwks_url: - JwtAuth( - # JWT Validation configuration - jwks_url=auth_settings.jwks_url, +if auth_settings.openid_configuration_url: + jwt_auth = OidcAuth( + # URL to the OpenID Connect discovery document (https://openid.net/specs/openid-connect-discovery-1_0.html) + openid_configuration_url=auth_settings.openid_configuration_url, + openid_configuration_internal_url=auth_settings.openid_configuration_internal_url, + # Optionally validate the "aud" claim in the JWT allowed_jwt_audiences=auth_settings.allowed_jwt_audiences, - # Authorization Code Flow configuration - oauth2_authorization_url=auth_settings.oauth2_authorization_url, - oauth2_token_url=auth_settings.oauth2_token_url, # To render scopes form on Swagger UI's login pop-up, populate with mapping of scopes to descriptions oauth2_supported_scopes={}, - ).require_auth( + ) + jwt_auth.require_auth( api=api, routes={ f"{app.root_path}/{route}": ["POST", "PUT", "DELETE"] @@ -172,5 +172,6 @@ async def viewer_page(request: Request): "collections/{collectionId}/items/{itemId}", ] }, + # Populate with scopes required for these routes required_scopes=[], ) diff --git a/runtimes/eoapi/stac/eoapi/stac/auth.py b/runtimes/eoapi/stac/eoapi/stac/auth.py index 546ba0b..285a78c 100644 --- a/runtimes/eoapi/stac/eoapi/stac/auth.py +++ b/runtimes/eoapi/stac/eoapi/stac/auth.py @@ -1,21 +1,22 @@ from dataclasses import dataclass, field from typing import Annotated, Any, Callable, Dict, Optional, Sequence +import urllib.request +import json -import jwt from fastapi import HTTPException, Security, security, status from fastapi.security.base import SecurityBase +from pydantic import AnyHttpUrl from pydantic_settings import BaseSettings from stac_fastapi.api.app import StacApi +import jwt class AuthSettings(BaseSettings): - jwks_url: Optional[str] = None - # Swagger UI config for Authorization Code Flow client_id: str = "" use_pkce: bool = True - oauth2_token_url: Optional[str] = None - oauth2_authorization_url: Optional[str] = None + openid_configuration_url: Optional[AnyHttpUrl] = None + openid_configuration_internal_url: Optional[AnyHttpUrl] = None allowed_jwt_audiences: Optional[Sequence[str]] = [] @@ -27,13 +28,11 @@ class AuthSettings(BaseSettings): @dataclass -class JwtAuth: - jwks_url: str - allowed_jwt_audiences: Optional[Sequence[str]] - - oauth2_authorization_url: Optional[str] = None - oauth2_token_url: Optional[str] = None - oauth2_supported_scopes: Optional[Dict[str, str]] = None +class OidcAuth: + openid_configuration_url: AnyHttpUrl + openid_configuration_internal_url: Optional[AnyHttpUrl] = None + allowed_jwt_audiences: Optional[Sequence[str]] = None + oauth2_supported_scopes: Dict[str, str] = field(default_factory=dict) # Generated attributes auth_scheme: SecurityBase = field(init=False) @@ -41,35 +40,59 @@ class JwtAuth: valid_token_dependency: Callable[..., Any] = field(init=False) def __post_init__(self): - self.auth_scheme = self.create_auth_scheme() - self.jwks_client = jwt.PyJWKClient(self.jwks_url) - self.valid_token_dependency = self.create_user_token_dependency() - - def create_auth_scheme(self): - return ( - security.OAuth2AuthorizationCodeBearer( - authorizationUrl=self.oauth2_authorization_url, - tokenUrl=self.oauth2_token_url, - scopes=self.oauth2_supported_scopes, - ) - if all([self.oauth2_authorization_url, self.oauth2_token_url]) - else security.HTTPBearer() + oidc_config_url = str( + self.openid_configuration_internal_url or self.openid_configuration_url + ) + with urllib.request.urlopen(oidc_config_url) as response: + if response.status != 200: + raise Exception( + f"Request for OIDC config failed with status {response.status}" + ) + oidc_config = json.load(response) + self.jwks_client = jwt.PyJWKClient(oidc_config["jwks_uri"]) + self.auth_scheme = security.OpenIdConnect( + openIdConnectUrl=self.openid_configuration_url.unicode_string() ) + self.valid_token_dependency = self.create_user_token_dependency( + auth_scheme=self.auth_scheme, + jwks_client=self.jwks_client, + allowed_jwt_audiences=self.allowed_jwt_audiences, + ) + + @staticmethod + def create_user_token_dependency( + auth_scheme: SecurityBase, + jwks_client: jwt.PyJWKClient, + allowed_jwt_audiences: Sequence[str], + ): + """ + Create a dependency that validates JWT tokens & scopes. + """ - def create_user_token_dependency(self): def user_token( - token_str: Annotated[str, Security(self.auth_scheme)], + token_str: Annotated[str, Security(auth_scheme)], required_scopes: security.SecurityScopes, ): + token_parts = token_str.split(" ") + if len(token_parts) != 2 or token_parts[0].lower() != "bearer": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authorization header", + headers={"WWW-Authenticate": "Bearer"}, + ) + else: + [_, token] = token_parts # Parse & validate token try: payload = jwt.decode( - token_str, - self.jwks_client.get_signing_key_from_jwt(token_str).key, + token, + jwks_client.get_signing_key_from_jwt(token).key, algorithms=["RS256"], - audience=self.allowed_jwt_audiences, + # NOTE: Audience validation MUST match audience claim if set in token (https://pyjwt.readthedocs.io/en/stable/changelog.html?highlight=audience#id40) + audience=allowed_jwt_audiences, ) except jwt.exceptions.InvalidTokenError as e: + print(f"InvalidTokenError: {e=}") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", @@ -98,6 +121,9 @@ def require_auth( routes: Dict[str, Sequence[str]], required_scopes: Optional[Sequence[str]], ): + """ + Helper to add auth dependencies to existing routes. + """ api.add_route_dependencies( [ { diff --git a/runtimes/eoapi/stac/pyproject.toml b/runtimes/eoapi/stac/pyproject.toml index 17d3dd3..faa533c 100644 --- a/runtimes/eoapi/stac/pyproject.toml +++ b/runtimes/eoapi/stac/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "importlib_resources>=1.1.0;python_version<'3.9'", "psycopg_pool", "pyjwt", + "cryptography" ] [project.optional-dependencies] From b4e4353896fedd7379c3c269b2b8155136eb6443 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 7 Aug 2024 15:30:44 -0700 Subject: [PATCH 08/45] Support OIDC in stac-browser --- docker-compose.yml | 7 +++ dockerfiles/Dockerfile.browser | 4 +- dockerfiles/docker-entrypoint.sh | 96 ++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 2 deletions(-) create mode 100755 dockerfiles/docker-entrypoint.sh diff --git a/docker-compose.yml b/docker-compose.yml index 7fa9ddb..1cbd3d4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,11 +4,18 @@ services: # change to official image when available https://github.com/radiantearth/stac-browser/pull/386 stac-browser: + # build: https://github.com/radiantearth/stac-browser.git + # TODO: Rm when https://github.com/radiantearth/stac-browser/pull/461 is merged build: context: dockerfiles dockerfile: Dockerfile.browser ports: - "${MY_DOCKER_IP:-127.0.0.1}:8085:8085" + env_file: + - path: .env + required: false + - path: stac-browser.env + required: false depends_on: - stac - raster diff --git a/dockerfiles/Dockerfile.browser b/dockerfiles/Dockerfile.browser index 9848025..e234443 100644 --- a/dockerfiles/Dockerfile.browser +++ b/dockerfiles/Dockerfile.browser @@ -12,7 +12,7 @@ RUN rm config.js RUN npm install # replace the default config.js with our config file COPY ./browser_config.js ./config.js -RUN \[ "${DYNAMIC_CONFIG}" == "true" \] && sed -i 's//|g" public/index.html RUN npm run build @@ -31,4 +31,4 @@ EXPOSE 8085 STOPSIGNAL SIGTERM # override entrypoint, which calls nginx-entrypoint underneath -COPY --from=build-step /app/docker/docker-entrypoint.sh ./docker-entrypoint.d/40-stac-browser-entrypoint.sh +ADD ./docker-entrypoint.sh ./docker-entrypoint.d/40-stac-browser-entrypoint.sh diff --git a/dockerfiles/docker-entrypoint.sh b/dockerfiles/docker-entrypoint.sh new file mode 100755 index 0000000..a518574 --- /dev/null +++ b/dockerfiles/docker-entrypoint.sh @@ -0,0 +1,96 @@ +# TODO: Rm when https://github.com/radiantearth/stac-browser/pull/461 is merged +# echo a string, handling different types +safe_echo() { + # $1 = value + if [ -z "$1" ]; then + echo -n "null" + elif printf '%s\n' "$1" | grep -qE '\n.+\n$'; then + echo -n "\`$1\`" + else + echo -n "'$1'" + fi +} + +# handle boolean +bool() { + # $1 = value + case "$1" in + true | TRUE | yes | t | True) + echo -n true + ;; + false | FALSE | no | n | False) + echo -n false + ;; + *) + echo "Err: Unknown boolean value \"$1\"" >&2 + exit 1 + ;; + esac +} + +# handle array values +array() { + # $1 = value + # $2 = arraytype + if [ -z "$1" ]; then + echo -n "[]" + else + case "$2" in + string) + echo -n "['$(echo "$1" | sed "s/,/', '/g")']" + ;; + *) + echo -n "[$1]" + ;; + esac + fi +} + +# handle object values +object() { + # $1 = value + if [ -z "$1" ]; then + echo -n "null" + else + echo -n "$1" + fi +} + +config_schema=$(cat /etc/nginx/conf.d/config.schema.json) + +# Iterate over environment variables with "SB_" prefix +env -0 | cut -f1 -d= | tr '\0' '\n' | grep "^SB_" | { + echo "window.STAC_BROWSER_CONFIG = {" + while IFS='=' read -r name; do + # Strip the prefix + argname="${name#SB_}" + # Read the variable's value + value="$(eval "echo \"\$$name\"")" + + # Get the argument type from the schema + argtype="$(echo "$config_schema" | jq -r ".properties.$argname.type[0]")" + arraytype="$(echo "$config_schema" | jq -r ".properties.$argname.items.type[0]")" + + # Encode key/value + echo -n " $argname: " + case "$argtype" in + string) + safe_echo "$value" + ;; + boolean) + bool "$value" + ;; + integer | number | object) + object "$value" + ;; + array) + array "$value" "$arraytype" + ;; + *) + safe_echo "$value" + ;; + esac + echo "," + done + echo "}" +} >/usr/share/nginx/html/config.js From 31b60f5667d78bc6841099eb618082c5db23ea76 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 7 Aug 2024 15:31:29 -0700 Subject: [PATCH 09/45] Cleanup --- docker-compose.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 1cbd3d4..6637133 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,6 @@ -version: '3' +version: "3" services: - # change to official image when available https://github.com/radiantearth/stac-browser/pull/386 stac-browser: # build: https://github.com/radiantearth/stac-browser.git @@ -46,10 +45,10 @@ services: # - EOAPI_STAC_EXTENSIONS=["filter", "query", "sort", "fields", "pagination", "titiler", "transaction"] # defaults # - EOAPI_STAC_CORS_METHODS='GET,POST,PUT,OPTIONS' env_file: - - path: stac.env - required: false - path: .env required: false + - path: stac.env + required: false depends_on: - database command: bash -c "bash /tmp/scripts/wait-for-it.sh -t 120 -h database -p 5432 && /start.sh" From 0d79d879d0c9f0040db558350f18df162b79ea5f Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 7 Aug 2024 16:58:45 -0700 Subject: [PATCH 10/45] Add logging --- runtimes/eoapi/stac/eoapi/stac/auth.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/runtimes/eoapi/stac/eoapi/stac/auth.py b/runtimes/eoapi/stac/eoapi/stac/auth.py index 285a78c..8aea274 100644 --- a/runtimes/eoapi/stac/eoapi/stac/auth.py +++ b/runtimes/eoapi/stac/eoapi/stac/auth.py @@ -1,5 +1,6 @@ from dataclasses import dataclass, field from typing import Annotated, Any, Callable, Dict, Optional, Sequence +import logging import urllib.request import json @@ -10,6 +11,8 @@ from stac_fastapi.api.app import StacApi import jwt +logger = logging.getLogger(__name__) + class AuthSettings(BaseSettings): # Swagger UI config for Authorization Code Flow @@ -92,7 +95,7 @@ def user_token( audience=allowed_jwt_audiences, ) except jwt.exceptions.InvalidTokenError as e: - print(f"InvalidTokenError: {e=}") + logger.exception(f"InvalidTokenError: {e=}") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", From a08b7588f2de6df8d7400138f20ce8f5fbf1a8e2 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 7 Aug 2024 16:59:07 -0700 Subject: [PATCH 11/45] In progress auth for raster --- runtimes/eoapi/raster/eoapi/raster/app.py | 19 +++++- runtimes/eoapi/raster/eoapi/raster/auth.py | 69 ++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 runtimes/eoapi/raster/eoapi/raster/auth.py diff --git a/runtimes/eoapi/raster/eoapi/raster/app.py b/runtimes/eoapi/raster/eoapi/raster/app.py index 8407916..f166777 100644 --- a/runtimes/eoapi/raster/eoapi/raster/app.py +++ b/runtimes/eoapi/raster/eoapi/raster/app.py @@ -9,7 +9,7 @@ import pystac from eoapi.raster import __version__ as eoapi_raster_version from eoapi.raster.config import ApiSettings -from fastapi import Depends, FastAPI, Query +from fastapi import Depends, FastAPI, Query, security, Security from psycopg import OperationalError from psycopg.rows import dict_row from psycopg_pool import PoolTimeout @@ -39,6 +39,8 @@ ) from titiler.pgstac.reader import PgSTACReader +from .auth import add_route_dependencies + logging.getLogger("botocore.credentials").disabled = True logging.getLogger("botocore.utils").disabled = True logging.getLogger("rio-tiler").setLevel(logging.ERROR) @@ -381,3 +383,18 @@ def landing(request: Request): "urlparams": str(request.url.query), }, ) + + +# Add dependencies to routes +# TODO: Why doesn't the API docs do this correctly? +auth_scheme = security.OpenIdConnect( + openIdConnectUrl="http://localhost:8080/auth/realms/eoapi" +) +PROTECTED_METHODS = ["POST", "PUT", "DELETE"] +add_route_dependencies( + routes=app.routes, + scopes=[ + {"path": "*", "method": method, "type": "http"} for method in PROTECTED_METHODS + ], + dependencies=[Security(auth_scheme)], +) diff --git a/runtimes/eoapi/raster/eoapi/raster/auth.py b/runtimes/eoapi/raster/eoapi/raster/auth.py new file mode 100644 index 0000000..644f33e --- /dev/null +++ b/runtimes/eoapi/raster/eoapi/raster/auth.py @@ -0,0 +1,69 @@ +import copy +import logging +from typing import List, Optional, TypedDict + +from fastapi import params +from fastapi.dependencies.utils import get_parameterless_sub_dependant +from starlette.routing import BaseRoute, Match + +logger = logging.getLogger(__name__) + + +class Scope(TypedDict, total=False): + """More strict version of Starlette's Scope.""" + + # https://github.com/encode/starlette/blob/6af5c515e0a896cbf3f86ee043b88f6c24200bcf/starlette/types.py#L3 + path: str + method: str + type: Optional[str] + + +def add_route_dependencies( + routes: List[BaseRoute], scopes: List[Scope], dependencies=List[params.Depends] +) -> None: + """Add dependencies to routes. + + Allows a developer to add dependencies to a route after the route has been + defined. + + "*" can be used for path or method to match all allowed routes. + + Returns: + None + """ + for scope in scopes: + _scope = copy.deepcopy(scope) + for route in routes: + if scope["path"] == "*": + _scope["path"] = route.path + + if scope["method"] == "*": + _scope["method"] = list(route.methods)[0] + + match, _ = route.matches({"type": "http", **_scope}) + if match != Match.FULL: + continue + + # Ignore paths without dependants, e.g. /api, /api.html, /docs/oauth2-redirect + if not hasattr(route, "dependant"): + continue + + # Mimicking how APIRoute handles dependencies: + # https://github.com/tiangolo/fastapi/blob/1760da0efa55585c19835d81afa8ca386036c325/fastapi/routing.py#L408-L412 + for depends in dependencies[::-1]: + print( + f"Adding dependency {depends} to {route.methods} on route {route.path}" + ) + route.dependant.dependencies.insert( + 0, + get_parameterless_sub_dependant( + depends=depends, path=route.path_format + ), + ) + + # Register dependencies directly on route so that they aren't ignored if + # the routes are later associated with an app (e.g. + # app.include_router(router)) + # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/applications.py#L337-L360 + # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/routing.py#L677-L678 + route.dependencies.extend(dependencies) From 58e256b98fad05ce73055f9f3aed619dda5583f3 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 8 Aug 2024 11:54:34 -0700 Subject: [PATCH 12/45] Raster: Finalize / cleanup --- docker-compose.yml | 8 +- runtimes/eoapi/raster/eoapi/raster/app.py | 32 +-- runtimes/eoapi/raster/eoapi/raster/auth.py | 227 ++++++++++++++++----- runtimes/eoapi/raster/pyproject.toml | 2 + 4 files changed, 208 insertions(+), 61 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 6637133..3afea92 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -97,10 +97,14 @@ services: - MOSAIC_CONCURRENCY=1 - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} + env_file: + - path: .env + required: false + - path: raster.env + required: false depends_on: - database - command: - bash -c "bash /tmp/scripts/wait-for-it.sh -t 120 -h database -p 5432 && /start.sh" + command: bash -c "bash /tmp/scripts/wait-for-it.sh -t 120 -h database -p 5432 && /start.sh" develop: watch: - action: sync+restart diff --git a/runtimes/eoapi/raster/eoapi/raster/app.py b/runtimes/eoapi/raster/eoapi/raster/app.py index f166777..8e40dcf 100644 --- a/runtimes/eoapi/raster/eoapi/raster/app.py +++ b/runtimes/eoapi/raster/eoapi/raster/app.py @@ -39,13 +39,15 @@ ) from titiler.pgstac.reader import PgSTACReader -from .auth import add_route_dependencies +from .auth import AuthSettings, OidcAuth logging.getLogger("botocore.credentials").disabled = True logging.getLogger("botocore.utils").disabled = True logging.getLogger("rio-tiler").setLevel(logging.ERROR) +logging.getLogger(__name__).setLevel(logging.DEBUG) settings = ApiSettings() +auth_settings = AuthSettings() jinja2_env = jinja2.Environment( loader=jinja2.ChoiceLoader( @@ -386,15 +388,19 @@ def landing(request: Request): # Add dependencies to routes -# TODO: Why doesn't the API docs do this correctly? -auth_scheme = security.OpenIdConnect( - openIdConnectUrl="http://localhost:8080/auth/realms/eoapi" -) -PROTECTED_METHODS = ["POST", "PUT", "DELETE"] -add_route_dependencies( - routes=app.routes, - scopes=[ - {"path": "*", "method": method, "type": "http"} for method in PROTECTED_METHODS - ], - dependencies=[Security(auth_scheme)], -) +if auth_settings.openid_configuration_url and not auth_settings.public_reads: + oidc_auth = OidcAuth( + # URL to the OpenID Connect discovery document (https://openid.net/specs/openid-connect-discovery-1_0.html) + openid_configuration_url=auth_settings.openid_configuration_url, + openid_configuration_internal_url=auth_settings.openid_configuration_internal_url, + # Optionally validate the "aud" claim in the JWT + allowed_jwt_audiences=auth_settings.allowed_jwt_audiences, + # To render scopes form on Swagger UI's login pop-up, populate with mapping of scopes to descriptions + oauth2_supported_scopes={}, + ) + + protected_prefixes = ["/searches", "/collections"] + for route in app.routes: + if not any(route.path.startswith(prefix) for prefix in protected_prefixes): + continue + oidc_auth.apply_auth_dependencies(route, required_token_scopes=[]) diff --git a/runtimes/eoapi/raster/eoapi/raster/auth.py b/runtimes/eoapi/raster/eoapi/raster/auth.py index 644f33e..8e75b15 100644 --- a/runtimes/eoapi/raster/eoapi/raster/auth.py +++ b/runtimes/eoapi/raster/eoapi/raster/auth.py @@ -1,10 +1,18 @@ +from dataclasses import dataclass, field +from typing import Annotated, Any, Callable, Dict, Optional, Sequence, TypedDict import copy +import json import logging -from typing import List, Optional, TypedDict +import urllib.request -from fastapi import params +from fastapi import HTTPException, Security, routing, security, status from fastapi.dependencies.utils import get_parameterless_sub_dependant -from starlette.routing import BaseRoute, Match +from fastapi.security.base import SecurityBase +from pydantic import AnyHttpUrl +from pydantic_settings import BaseSettings +from starlette.routing import Match +import jwt + logger = logging.getLogger(__name__) @@ -18,52 +26,179 @@ class Scope(TypedDict, total=False): type: Optional[str] -def add_route_dependencies( - routes: List[BaseRoute], scopes: List[Scope], dependencies=List[params.Depends] -) -> None: - """Add dependencies to routes. +class AuthSettings(BaseSettings): + # Swagger UI config for Authorization Code Flow + client_id: str = "" + use_pkce: bool = True + openid_configuration_url: Optional[AnyHttpUrl] = None + openid_configuration_internal_url: Optional[AnyHttpUrl] = None - Allows a developer to add dependencies to a route after the route has been - defined. + allowed_jwt_audiences: Optional[Sequence[str]] = [] - "*" can be used for path or method to match all allowed routes. + public_reads: bool = True - Returns: - None - """ - for scope in scopes: - _scope = copy.deepcopy(scope) - for route in routes: - if scope["path"] == "*": - _scope["path"] = route.path - - if scope["method"] == "*": - _scope["method"] = list(route.methods)[0] - - match, _ = route.matches({"type": "http", **_scope}) - if match != Match.FULL: - continue - - # Ignore paths without dependants, e.g. /api, /api.html, /docs/oauth2-redirect - if not hasattr(route, "dependant"): - continue - - # Mimicking how APIRoute handles dependencies: - # https://github.com/tiangolo/fastapi/blob/1760da0efa55585c19835d81afa8ca386036c325/fastapi/routing.py#L408-L412 - for depends in dependencies[::-1]: - print( - f"Adding dependency {depends} to {route.methods} on route {route.path}" + model_config = { + "env_prefix": "EOAPI_AUTH_", + "env_file": ".env", + "extra": "allow", + } + + +@dataclass +class OidcAuth: + openid_configuration_url: AnyHttpUrl + openid_configuration_internal_url: Optional[AnyHttpUrl] = None + allowed_jwt_audiences: Optional[Sequence[str]] = None + oauth2_supported_scopes: Dict[str, str] = field(default_factory=dict) + + # Generated attributes + auth_scheme: SecurityBase = field(init=False) + jwks_client: jwt.PyJWKClient = field(init=False) + valid_token_dependency: Callable[..., Any] = field(init=False) + + def __post_init__(self): + with urllib.request.urlopen( + str(self.openid_configuration_internal_url or self.openid_configuration_url) + ) as response: + if response.status != 200: + raise Exception( + f"Request for OIDC config failed with status {response.status}" + ) + oidc_config = json.load(response) + self.jwks_client = jwt.PyJWKClient(oidc_config["jwks_uri"]) + + self.auth_scheme = security.OpenIdConnect( + openIdConnectUrl=str(self.openid_configuration_url) + ) + self.valid_token_dependency = self.create_auth_token_dependency( + auth_scheme=self.auth_scheme, + jwks_client=self.jwks_client, + allowed_jwt_audiences=self.allowed_jwt_audiences, + ) + + @staticmethod + def create_auth_token_dependency( + auth_scheme: SecurityBase, + jwks_client: jwt.PyJWKClient, + allowed_jwt_audiences: Sequence[str], + ): + """ + Create a dependency that validates JWT tokens & scopes. + """ + + def auth_token( + token_str: Annotated[str, Security(auth_scheme)], + required_scopes: security.SecurityScopes, + ): + token_parts = token_str.split(" ") + if len(token_parts) != 2 or token_parts[0].lower() != "bearer": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authorization header", + headers={"WWW-Authenticate": "Bearer"}, + ) + else: + [_, token] = token_parts + # Parse & validate token + try: + payload = jwt.decode( + token, + jwks_client.get_signing_key_from_jwt(token).key, + algorithms=["RS256"], + # NOTE: Audience validation MUST match audience claim if set in token (https://pyjwt.readthedocs.io/en/stable/changelog.html?highlight=audience#id40) + audience=allowed_jwt_audiences, ) - route.dependant.dependencies.insert( - 0, - get_parameterless_sub_dependant( - depends=depends, path=route.path_format - ), + except jwt.exceptions.InvalidTokenError as e: + logger.exception(f"InvalidTokenError: {e=}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) from e + + # Validate scopes (if required) + for scope in required_scopes.scopes: + if scope not in payload["scope"]: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not enough permissions", + headers={ + "WWW-Authenticate": f'Bearer scope="{required_scopes.scope_str}"' + }, + ) + + return payload + + return auth_token + + def require_auth( + self, + *, + routes: Sequence[routing.APIRoute], + potection_scopes: Sequence[Scope], + required_token_scopes: Optional[Sequence[str]] = None, + ): + """ + Convenience method to run many routes through many protection scopes. + """ + for scope in potection_scopes: + for route in routes: + if not self.test_route_match(route, scope): + continue + self.apply_auth_dependencies( + route, required_token_scopes=required_token_scopes ) - # Register dependencies directly on route so that they aren't ignored if - # the routes are later associated with an app (e.g. - # app.include_router(router)) - # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/applications.py#L337-L360 - # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/routing.py#L677-L678 - route.dependencies.extend(dependencies) + @staticmethod + def test_route_match(route: routing.APIRoute, scope: Scope) -> bool: + """ + Check if a route matches a given scope. + """ + _scope = copy.deepcopy(scope) + if scope["path"] == "*": + _scope["path"] = route.path + + if scope["method"] == "*": + _scope["method"] = list(route.methods)[0] + + match, _ = route.matches({"type": "http", **_scope}) + return match == Match.FULL + + def apply_auth_dependencies( + self, + api_route: routing.APIRoute, + required_token_scopes: Optional[Sequence[str]] = None, + dependency: Optional[Callable[..., Any]] = None, + ): + """ + Apply auth dependencies to a route. + """ + # Ignore paths without dependants, e.g. /api, /api.html, /docs/oauth2-redirect + if not hasattr(api_route, "dependant"): + logger.warn( + f"Route {api_route} has no dependant, not apply auth dependency" + ) + return + + depends = Security( + dependency or self.valid_token_dependency, scopes=required_token_scopes + ) + + # Mimicking how APIRoute handles dependencies: + # https://github.com/tiangolo/fastapi/blob/1760da0efa55585c19835d81afa8ca386036c325/fastapi/routing.py#L408-L412 + logger.debug( + f"Adding dependency {depends} to {api_route.methods} on route {api_route.path}" + ) + api_route.dependant.dependencies.insert( + 0, + get_parameterless_sub_dependant( + depends=depends, path=api_route.path_format + ), + ) + + # Register dependencies directly on route so that they aren't ignored if + # the routes are later associated with an app (e.g. + # app.include_router(router)) + # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/applications.py#L337-L360 + # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/routing.py#L677-L678 + api_route.dependencies.extend([depends]) diff --git a/runtimes/eoapi/raster/pyproject.toml b/runtimes/eoapi/raster/pyproject.toml index 4743b83..b7220bc 100644 --- a/runtimes/eoapi/raster/pyproject.toml +++ b/runtimes/eoapi/raster/pyproject.toml @@ -24,6 +24,8 @@ dependencies = [ "titiler.extensions", "starlette-cramjam>=0.3,<0.4", "importlib_resources>=1.1.0;python_version<'3.9'", + "pyjwt", + "cryptography", ] [project.optional-dependencies] From 886ca388e14edf388d433df49c5b6c66f6db3506 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 8 Aug 2024 11:54:42 -0700 Subject: [PATCH 13/45] STAC: cleanup --- runtimes/eoapi/stac/eoapi/stac/app.py | 26 +++++++++------ runtimes/eoapi/stac/eoapi/stac/auth.py | 45 ++++++-------------------- 2 files changed, 27 insertions(+), 44 deletions(-) diff --git a/runtimes/eoapi/stac/eoapi/stac/app.py b/runtimes/eoapi/stac/eoapi/stac/app.py index d01711e..f47e8c6 100644 --- a/runtimes/eoapi/stac/eoapi/stac/app.py +++ b/runtimes/eoapi/stac/eoapi/stac/app.py @@ -5,7 +5,7 @@ from eoapi.stac.auth import AuthSettings, OidcAuth from eoapi.stac.config import ApiSettings from eoapi.stac.extension import TiTilerExtension -from fastapi import FastAPI +from fastapi import FastAPI, Security from fastapi.responses import ORJSONResponse from stac_fastapi.api.app import StacApi from stac_fastapi.api.models import ( @@ -151,7 +151,7 @@ async def viewer_page(request: Request): if auth_settings.openid_configuration_url: - jwt_auth = OidcAuth( + oidc_auth = OidcAuth( # URL to the OpenID Connect discovery document (https://openid.net/specs/openid-connect-discovery-1_0.html) openid_configuration_url=auth_settings.openid_configuration_url, openid_configuration_internal_url=auth_settings.openid_configuration_internal_url, @@ -160,10 +160,13 @@ async def viewer_page(request: Request): # To render scopes form on Swagger UI's login pop-up, populate with mapping of scopes to descriptions oauth2_supported_scopes={}, ) - jwt_auth.require_auth( - api=api, - routes={ - f"{app.root_path}/{route}": ["POST", "PUT", "DELETE"] + api.add_route_dependencies( + [ + { + "path": f"{app.root_path}/{route}", + "method": method, + "type": "http", + } for route in [ "collections", "collections/{collectionId}", @@ -171,7 +174,12 @@ async def viewer_page(request: Request): "collections/{collectionId}/bulk_items", "collections/{collectionId}/items/{itemId}", ] - }, - # Populate with scopes required for these routes - required_scopes=[], + for method in ["POST", "PUT", "DELETE"] + ], + [ + Security( + oidc_auth.valid_token_dependency, + scopes=None, # Populate with scopes required for these routes + ) + ], ) diff --git a/runtimes/eoapi/stac/eoapi/stac/auth.py b/runtimes/eoapi/stac/eoapi/stac/auth.py index 8aea274..b2687d7 100644 --- a/runtimes/eoapi/stac/eoapi/stac/auth.py +++ b/runtimes/eoapi/stac/eoapi/stac/auth.py @@ -8,7 +8,6 @@ from fastapi.security.base import SecurityBase from pydantic import AnyHttpUrl from pydantic_settings import BaseSettings -from stac_fastapi.api.app import StacApi import jwt logger = logging.getLogger(__name__) @@ -43,27 +42,27 @@ class OidcAuth: valid_token_dependency: Callable[..., Any] = field(init=False) def __post_init__(self): - oidc_config_url = str( - self.openid_configuration_internal_url or self.openid_configuration_url - ) - with urllib.request.urlopen(oidc_config_url) as response: + with urllib.request.urlopen( + str(self.openid_configuration_internal_url or self.openid_configuration_url) + ) as response: if response.status != 200: raise Exception( f"Request for OIDC config failed with status {response.status}" ) oidc_config = json.load(response) - self.jwks_client = jwt.PyJWKClient(oidc_config["jwks_uri"]) + self.jwks_client = jwt.PyJWKClient(oidc_config["jwks_uri"]) + self.auth_scheme = security.OpenIdConnect( - openIdConnectUrl=self.openid_configuration_url.unicode_string() + openIdConnectUrl=str(self.openid_configuration_url) ) - self.valid_token_dependency = self.create_user_token_dependency( + self.valid_token_dependency = self.create_auth_token_dependency( auth_scheme=self.auth_scheme, jwks_client=self.jwks_client, allowed_jwt_audiences=self.allowed_jwt_audiences, ) @staticmethod - def create_user_token_dependency( + def create_auth_token_dependency( auth_scheme: SecurityBase, jwks_client: jwt.PyJWKClient, allowed_jwt_audiences: Sequence[str], @@ -72,7 +71,7 @@ def create_user_token_dependency( Create a dependency that validates JWT tokens & scopes. """ - def user_token( + def auth_token( token_str: Annotated[str, Security(auth_scheme)], required_scopes: security.SecurityScopes, ): @@ -115,28 +114,4 @@ def user_token( return payload - return user_token - - def require_auth( - self, - *, - api: StacApi, - routes: Dict[str, Sequence[str]], - required_scopes: Optional[Sequence[str]], - ): - """ - Helper to add auth dependencies to existing routes. - """ - api.add_route_dependencies( - [ - { - "path": path, - "method": method, - "type": "http", - } - for path, methods in routes.items() - for method in methods - ], - [Security(self.valid_token_dependency, scopes=required_scopes)], - ) - return self + return auth_token From 996f5a6ca8a2ba6da0953a797a30e3650f951de1 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 8 Aug 2024 15:11:20 -0700 Subject: [PATCH 14/45] Vector: add auth support --- runtimes/eoapi/vector/eoapi/vector/app.py | 27 ++++ runtimes/eoapi/vector/eoapi/vector/auth.py | 169 +++++++++++++++++++++ runtimes/eoapi/vector/pyproject.toml | 2 + 3 files changed, 198 insertions(+) create mode 100644 runtimes/eoapi/vector/eoapi/vector/auth.py diff --git a/runtimes/eoapi/vector/eoapi/vector/app.py b/runtimes/eoapi/vector/eoapi/vector/app.py index a8cad74..f31939b 100644 --- a/runtimes/eoapi/vector/eoapi/vector/app.py +++ b/runtimes/eoapi/vector/eoapi/vector/app.py @@ -4,6 +4,7 @@ import jinja2 from eoapi.vector import __version__ as eoapi_vector_version +from eoapi.vector.auth import AuthSettings, OidcAuth from eoapi.vector.config import ApiSettings from fastapi import FastAPI, Request from starlette.middleware.cors import CORSMiddleware @@ -27,6 +28,7 @@ settings = ApiSettings() postgres_settings = PostgresSettings() +auth_settings = AuthSettings() @asynccontextmanager @@ -62,6 +64,10 @@ async def lifespan(app: FastAPI): docs_url="/api.html", lifespan=lifespan, root_path=settings.root_path, + swagger_ui_init_oauth={ + "clientId": auth_settings.client_id, + "usePkceWithAuthorizationCodeGrant": auth_settings.use_pkce, + }, ) # add eoapi_vector templates and tipg templates @@ -142,3 +148,24 @@ async def refresh(request: Request): ) return request.app.state.collection_catalog + + +if auth_settings.openid_configuration_url and not auth_settings.public_reads: + oidc_auth = OidcAuth( + # URL to the OpenID Connect discovery document (https://openid.net/specs/openid-connect-discovery-1_0.html) + openid_configuration_url=auth_settings.openid_configuration_url, + openid_configuration_internal_url=auth_settings.openid_configuration_internal_url, + # Optionally validate the "aud" claim in the JWT + allowed_jwt_audiences=auth_settings.allowed_jwt_audiences, + # To render scopes form on Swagger UI's login pop-up, populate with mapping of scopes to descriptions + oauth2_supported_scopes={}, + ) + + protected_prefixes = ["/collections"] + for route in app.routes: + if not any( + route.path.startswith(f"{app.root_path}{prefix}") + for prefix in protected_prefixes + ): + continue + oidc_auth.apply_auth_dependencies(route, required_token_scopes=[]) diff --git a/runtimes/eoapi/vector/eoapi/vector/auth.py b/runtimes/eoapi/vector/eoapi/vector/auth.py new file mode 100644 index 0000000..bbd20ff --- /dev/null +++ b/runtimes/eoapi/vector/eoapi/vector/auth.py @@ -0,0 +1,169 @@ +from dataclasses import dataclass, field +from typing import Annotated, Any, Callable, Dict, Optional, Sequence, TypedDict +import json +import logging +import urllib.request + +from fastapi import HTTPException, Security, routing, security, status +from fastapi.dependencies.utils import get_parameterless_sub_dependant +from fastapi.security.base import SecurityBase +from pydantic import AnyHttpUrl +from pydantic_settings import BaseSettings +import jwt + + +logger = logging.getLogger(__name__) + + +class Scope(TypedDict, total=False): + """More strict version of Starlette's Scope.""" + + # https://github.com/encode/starlette/blob/6af5c515e0a896cbf3f86ee043b88f6c24200bcf/starlette/types.py#L3 + path: str + method: str + type: Optional[str] + + +class AuthSettings(BaseSettings): + # Swagger UI config for Authorization Code Flow + client_id: str = "" + use_pkce: bool = True + openid_configuration_url: Optional[AnyHttpUrl] = None + openid_configuration_internal_url: Optional[AnyHttpUrl] = None + + allowed_jwt_audiences: Optional[Sequence[str]] = [] + + public_reads: bool = True + + model_config = { + "env_prefix": "EOAPI_AUTH_", + "env_file": ".env", + "extra": "allow", + } + + +@dataclass +class OidcAuth: + openid_configuration_url: AnyHttpUrl + openid_configuration_internal_url: Optional[AnyHttpUrl] = None + allowed_jwt_audiences: Optional[Sequence[str]] = None + oauth2_supported_scopes: Dict[str, str] = field(default_factory=dict) + + # Generated attributes + auth_scheme: SecurityBase = field(init=False) + jwks_client: jwt.PyJWKClient = field(init=False) + valid_token_dependency: Callable[..., Any] = field(init=False) + + def __post_init__(self): + with urllib.request.urlopen( + str(self.openid_configuration_internal_url or self.openid_configuration_url) + ) as response: + if response.status != 200: + raise Exception( + f"Request for OIDC config failed with status {response.status}" + ) + oidc_config = json.load(response) + self.jwks_client = jwt.PyJWKClient(oidc_config["jwks_uri"]) + + self.auth_scheme = security.OpenIdConnect( + openIdConnectUrl=str(self.openid_configuration_url) + ) + self.valid_token_dependency = self.create_auth_token_dependency( + auth_scheme=self.auth_scheme, + jwks_client=self.jwks_client, + allowed_jwt_audiences=self.allowed_jwt_audiences, + ) + + @staticmethod + def create_auth_token_dependency( + auth_scheme: SecurityBase, + jwks_client: jwt.PyJWKClient, + allowed_jwt_audiences: Sequence[str], + ): + """ + Create a dependency that validates JWT tokens & scopes. + """ + + def auth_token( + token_str: Annotated[str, Security(auth_scheme)], + required_scopes: security.SecurityScopes, + ): + token_parts = token_str.split(" ") + if len(token_parts) != 2 or token_parts[0].lower() != "bearer": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authorization header", + headers={"WWW-Authenticate": "Bearer"}, + ) + else: + [_, token] = token_parts + # Parse & validate token + try: + payload = jwt.decode( + token, + jwks_client.get_signing_key_from_jwt(token).key, + algorithms=["RS256"], + # NOTE: Audience validation MUST match audience claim if set in token (https://pyjwt.readthedocs.io/en/stable/changelog.html?highlight=audience#id40) + audience=allowed_jwt_audiences, + ) + except jwt.exceptions.InvalidTokenError as e: + logger.exception(f"InvalidTokenError: {e=}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) from e + + # Validate scopes (if required) + for scope in required_scopes.scopes: + if scope not in payload["scope"]: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not enough permissions", + headers={ + "WWW-Authenticate": f'Bearer scope="{required_scopes.scope_str}"' + }, + ) + + return payload + + return auth_token + + def apply_auth_dependencies( + self, + api_route: routing.APIRoute, + required_token_scopes: Optional[Sequence[str]] = None, + dependency: Optional[Callable[..., Any]] = None, + ): + """ + Apply auth dependencies to a route. + """ + # Ignore paths without dependants, e.g. /api, /api.html, /docs/oauth2-redirect + if not hasattr(api_route, "dependant"): + logger.warn( + f"Route {api_route} has no dependant, not apply auth dependency" + ) + return + + depends = Security( + dependency or self.valid_token_dependency, scopes=required_token_scopes + ) + + # Mimicking how APIRoute handles dependencies: + # https://github.com/tiangolo/fastapi/blob/1760da0efa55585c19835d81afa8ca386036c325/fastapi/routing.py#L408-L412 + logger.debug( + f"Adding dependency {depends} to {api_route.methods} on route {api_route.path}" + ) + api_route.dependant.dependencies.insert( + 0, + get_parameterless_sub_dependant( + depends=depends, path=api_route.path_format + ), + ) + + # Register dependencies directly on route so that they aren't ignored if + # the routes are later associated with an app (e.g. + # app.include_router(router)) + # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/applications.py#L337-L360 + # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/routing.py#L677-L678 + api_route.dependencies.extend([depends]) diff --git a/runtimes/eoapi/vector/pyproject.toml b/runtimes/eoapi/vector/pyproject.toml index 52a57d0..ef7ba4a 100644 --- a/runtimes/eoapi/vector/pyproject.toml +++ b/runtimes/eoapi/vector/pyproject.toml @@ -21,6 +21,8 @@ classifiers = [ dynamic = ["version"] dependencies = [ "tipg==0.7.1", + "pyjwt", + "cryptography", ] [project.optional-dependencies] From c0d699a2ef4b2b82d44aff910320c5892ae8ed26 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 8 Aug 2024 15:13:02 -0700 Subject: [PATCH 15/45] Raster: add client id to swagger ui --- runtimes/eoapi/raster/eoapi/raster/app.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/runtimes/eoapi/raster/eoapi/raster/app.py b/runtimes/eoapi/raster/eoapi/raster/app.py index 8e40dcf..57fd487 100644 --- a/runtimes/eoapi/raster/eoapi/raster/app.py +++ b/runtimes/eoapi/raster/eoapi/raster/app.py @@ -76,6 +76,10 @@ async def lifespan(app: FastAPI): docs_url="/api.html", root_path=settings.root_path, lifespan=lifespan, + swagger_ui_init_oauth={ + "clientId": auth_settings.client_id, + "usePkceWithAuthorizationCodeGrant": auth_settings.use_pkce, + }, ) add_exception_handlers(app, DEFAULT_STATUS_CODES) add_exception_handlers(app, MOSAIC_STATUS_CODES) @@ -401,6 +405,9 @@ def landing(request: Request): protected_prefixes = ["/searches", "/collections"] for route in app.routes: - if not any(route.path.startswith(prefix) for prefix in protected_prefixes): + if not any( + route.path.startswith(f"{app.root_path}{prefix}") + for prefix in protected_prefixes + ): continue oidc_auth.apply_auth_dependencies(route, required_token_scopes=[]) From 0dc2503718bfa26843aa5c41998a5032635ed289 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 8 Aug 2024 15:13:25 -0700 Subject: [PATCH 16/45] Cleanup --- .gitignore | 2 +- docker-compose.yml | 14 +++++---- runtimes/eoapi/raster/eoapi/raster/auth.py | 35 ---------------------- runtimes/eoapi/stac/eoapi/stac/app.py | 12 ++++---- 4 files changed, 16 insertions(+), 47 deletions(-) diff --git a/.gitignore b/.gitignore index 556c695..0889d95 100644 --- a/.gitignore +++ b/.gitignore @@ -120,7 +120,7 @@ celerybeat.pid *.sage.py # Environments -.env +*.env .venv env/ venv/ diff --git a/docker-compose.yml b/docker-compose.yml index 3afea92..7f7459c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,7 +13,7 @@ services: env_file: - path: .env required: false - - path: stac-browser.env + - path: .stac-browser.env required: false depends_on: - stac @@ -47,7 +47,7 @@ services: env_file: - path: .env required: false - - path: stac.env + - path: .stac.env required: false depends_on: - database @@ -100,7 +100,7 @@ services: env_file: - path: .env required: false - - path: raster.env + - path: .raster.env required: false depends_on: - database @@ -134,8 +134,12 @@ services: - DB_MIN_CONN_SIZE=1 - DB_MAX_CONN_SIZE=10 - EOAPI_VECTOR_DEBUG=TRUE - command: - bash -c "bash /tmp/scripts/wait-for-it.sh -t 120 -h database -p 5432 && /start.sh" + env_file: + - path: .env + required: false + - path: .vector.env + required: false + command: bash -c "bash /tmp/scripts/wait-for-it.sh -t 120 -h database -p 5432 && /start.sh" develop: watch: - action: sync+restart diff --git a/runtimes/eoapi/raster/eoapi/raster/auth.py b/runtimes/eoapi/raster/eoapi/raster/auth.py index 8e75b15..bbd20ff 100644 --- a/runtimes/eoapi/raster/eoapi/raster/auth.py +++ b/runtimes/eoapi/raster/eoapi/raster/auth.py @@ -1,6 +1,5 @@ from dataclasses import dataclass, field from typing import Annotated, Any, Callable, Dict, Optional, Sequence, TypedDict -import copy import json import logging import urllib.request @@ -10,7 +9,6 @@ from fastapi.security.base import SecurityBase from pydantic import AnyHttpUrl from pydantic_settings import BaseSettings -from starlette.routing import Match import jwt @@ -131,39 +129,6 @@ def auth_token( return auth_token - def require_auth( - self, - *, - routes: Sequence[routing.APIRoute], - potection_scopes: Sequence[Scope], - required_token_scopes: Optional[Sequence[str]] = None, - ): - """ - Convenience method to run many routes through many protection scopes. - """ - for scope in potection_scopes: - for route in routes: - if not self.test_route_match(route, scope): - continue - self.apply_auth_dependencies( - route, required_token_scopes=required_token_scopes - ) - - @staticmethod - def test_route_match(route: routing.APIRoute, scope: Scope) -> bool: - """ - Check if a route matches a given scope. - """ - _scope = copy.deepcopy(scope) - if scope["path"] == "*": - _scope["path"] = route.path - - if scope["method"] == "*": - _scope["method"] = list(route.methods)[0] - - match, _ = route.matches({"type": "http", **_scope}) - return match == Match.FULL - def apply_auth_dependencies( self, api_route: routing.APIRoute, diff --git a/runtimes/eoapi/stac/eoapi/stac/app.py b/runtimes/eoapi/stac/eoapi/stac/app.py index f47e8c6..76123ce 100644 --- a/runtimes/eoapi/stac/eoapi/stac/app.py +++ b/runtimes/eoapi/stac/eoapi/stac/app.py @@ -163,16 +163,16 @@ async def viewer_page(request: Request): api.add_route_dependencies( [ { - "path": f"{app.root_path}/{route}", + "path": f"{app.root_path}{route}", "method": method, "type": "http", } for route in [ - "collections", - "collections/{collectionId}", - "collections/{collectionId}/items", - "collections/{collectionId}/bulk_items", - "collections/{collectionId}/items/{itemId}", + "/collections", + "/collections/{collectionId}", + "/collections/{collectionId}/items", + "/collections/{collectionId}/bulk_items", + "/collections/{collectionId}/items/{itemId}", ] for method in ["POST", "PUT", "DELETE"] ], From 8ef74c722d08babebe52477f1c56a3476a0a8779 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 8 Aug 2024 15:42:11 -0700 Subject: [PATCH 17/45] STAC: Use same auth module as others --- runtimes/eoapi/stac/eoapi/stac/app.py | 38 +++++++---------- runtimes/eoapi/stac/eoapi/stac/auth.py | 58 ++++++++++++++++++++++++-- 2 files changed, 71 insertions(+), 25 deletions(-) diff --git a/runtimes/eoapi/stac/eoapi/stac/app.py b/runtimes/eoapi/stac/eoapi/stac/app.py index 76123ce..b7b00a5 100644 --- a/runtimes/eoapi/stac/eoapi/stac/app.py +++ b/runtimes/eoapi/stac/eoapi/stac/app.py @@ -160,26 +160,20 @@ async def viewer_page(request: Request): # To render scopes form on Swagger UI's login pop-up, populate with mapping of scopes to descriptions oauth2_supported_scopes={}, ) - api.add_route_dependencies( - [ - { - "path": f"{app.root_path}{route}", - "method": method, - "type": "http", - } - for route in [ - "/collections", - "/collections/{collectionId}", - "/collections/{collectionId}/items", - "/collections/{collectionId}/bulk_items", - "/collections/{collectionId}/items/{itemId}", - ] - for method in ["POST", "PUT", "DELETE"] + restricted_prefixes_methods = { + "/collections": [ + "POST", + "PUT", + "DELETE", + *([] if auth_settings.public_reads else ["GET"]), ], - [ - Security( - oidc_auth.valid_token_dependency, - scopes=None, # Populate with scopes required for these routes - ) - ], - ) + "/search": [] if auth_settings.public_reads else ["POST", "GET"], + } + for route in app.routes: + restricted = any( + route.path.startswith(f"{app.root_path}{prefix}") + and set(route.methods).intersection(set(restricted_methods)) + for prefix, restricted_methods in restricted_prefixes_methods.items() + ) + if restricted: + oidc_auth.apply_auth_dependencies(route, required_token_scopes=[]) diff --git a/runtimes/eoapi/stac/eoapi/stac/auth.py b/runtimes/eoapi/stac/eoapi/stac/auth.py index b2687d7..bbd20ff 100644 --- a/runtimes/eoapi/stac/eoapi/stac/auth.py +++ b/runtimes/eoapi/stac/eoapi/stac/auth.py @@ -1,18 +1,29 @@ from dataclasses import dataclass, field -from typing import Annotated, Any, Callable, Dict, Optional, Sequence +from typing import Annotated, Any, Callable, Dict, Optional, Sequence, TypedDict +import json import logging import urllib.request -import json -from fastapi import HTTPException, Security, security, status +from fastapi import HTTPException, Security, routing, security, status +from fastapi.dependencies.utils import get_parameterless_sub_dependant from fastapi.security.base import SecurityBase from pydantic import AnyHttpUrl from pydantic_settings import BaseSettings import jwt + logger = logging.getLogger(__name__) +class Scope(TypedDict, total=False): + """More strict version of Starlette's Scope.""" + + # https://github.com/encode/starlette/blob/6af5c515e0a896cbf3f86ee043b88f6c24200bcf/starlette/types.py#L3 + path: str + method: str + type: Optional[str] + + class AuthSettings(BaseSettings): # Swagger UI config for Authorization Code Flow client_id: str = "" @@ -22,6 +33,8 @@ class AuthSettings(BaseSettings): allowed_jwt_audiences: Optional[Sequence[str]] = [] + public_reads: bool = True + model_config = { "env_prefix": "EOAPI_AUTH_", "env_file": ".env", @@ -115,3 +128,42 @@ def auth_token( return payload return auth_token + + def apply_auth_dependencies( + self, + api_route: routing.APIRoute, + required_token_scopes: Optional[Sequence[str]] = None, + dependency: Optional[Callable[..., Any]] = None, + ): + """ + Apply auth dependencies to a route. + """ + # Ignore paths without dependants, e.g. /api, /api.html, /docs/oauth2-redirect + if not hasattr(api_route, "dependant"): + logger.warn( + f"Route {api_route} has no dependant, not apply auth dependency" + ) + return + + depends = Security( + dependency or self.valid_token_dependency, scopes=required_token_scopes + ) + + # Mimicking how APIRoute handles dependencies: + # https://github.com/tiangolo/fastapi/blob/1760da0efa55585c19835d81afa8ca386036c325/fastapi/routing.py#L408-L412 + logger.debug( + f"Adding dependency {depends} to {api_route.methods} on route {api_route.path}" + ) + api_route.dependant.dependencies.insert( + 0, + get_parameterless_sub_dependant( + depends=depends, path=api_route.path_format + ), + ) + + # Register dependencies directly on route so that they aren't ignored if + # the routes are later associated with an app (e.g. + # app.include_router(router)) + # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/applications.py#L337-L360 + # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/routing.py#L677-L678 + api_route.dependencies.extend([depends]) From d3a1bc0b8319310ffd54e94002086bc3a8dc467a Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 8 Aug 2024 15:43:53 -0700 Subject: [PATCH 18/45] Rename --- runtimes/eoapi/raster/eoapi/raster/app.py | 4 ++-- runtimes/eoapi/vector/eoapi/vector/app.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/runtimes/eoapi/raster/eoapi/raster/app.py b/runtimes/eoapi/raster/eoapi/raster/app.py index 57fd487..360871a 100644 --- a/runtimes/eoapi/raster/eoapi/raster/app.py +++ b/runtimes/eoapi/raster/eoapi/raster/app.py @@ -403,11 +403,11 @@ def landing(request: Request): oauth2_supported_scopes={}, ) - protected_prefixes = ["/searches", "/collections"] + restricted_prefixes = ["/searches", "/collections"] for route in app.routes: if not any( route.path.startswith(f"{app.root_path}{prefix}") - for prefix in protected_prefixes + for prefix in restricted_prefixes ): continue oidc_auth.apply_auth_dependencies(route, required_token_scopes=[]) diff --git a/runtimes/eoapi/vector/eoapi/vector/app.py b/runtimes/eoapi/vector/eoapi/vector/app.py index f31939b..8b20bb0 100644 --- a/runtimes/eoapi/vector/eoapi/vector/app.py +++ b/runtimes/eoapi/vector/eoapi/vector/app.py @@ -161,11 +161,11 @@ async def refresh(request: Request): oauth2_supported_scopes={}, ) - protected_prefixes = ["/collections"] + restricted_prefixes = ["/collections"] for route in app.routes: if not any( route.path.startswith(f"{app.root_path}{prefix}") - for prefix in protected_prefixes + for prefix in restricted_prefixes ): continue oidc_auth.apply_auth_dependencies(route, required_token_scopes=[]) From c92bb11deb8eed4af9699ea3d32cff5213996519 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 9 Aug 2024 10:01:16 -0700 Subject: [PATCH 19/45] Cleanup imports --- runtimes/eoapi/raster/eoapi/raster/app.py | 2 +- runtimes/eoapi/stac/eoapi/stac/app.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/runtimes/eoapi/raster/eoapi/raster/app.py b/runtimes/eoapi/raster/eoapi/raster/app.py index 360871a..cc73c1d 100644 --- a/runtimes/eoapi/raster/eoapi/raster/app.py +++ b/runtimes/eoapi/raster/eoapi/raster/app.py @@ -9,7 +9,7 @@ import pystac from eoapi.raster import __version__ as eoapi_raster_version from eoapi.raster.config import ApiSettings -from fastapi import Depends, FastAPI, Query, security, Security +from fastapi import Depends, FastAPI, Query from psycopg import OperationalError from psycopg.rows import dict_row from psycopg_pool import PoolTimeout diff --git a/runtimes/eoapi/stac/eoapi/stac/app.py b/runtimes/eoapi/stac/eoapi/stac/app.py index b7b00a5..9f1b5ac 100644 --- a/runtimes/eoapi/stac/eoapi/stac/app.py +++ b/runtimes/eoapi/stac/eoapi/stac/app.py @@ -5,7 +5,7 @@ from eoapi.stac.auth import AuthSettings, OidcAuth from eoapi.stac.config import ApiSettings from eoapi.stac.extension import TiTilerExtension -from fastapi import FastAPI, Security +from fastapi import FastAPI from fastapi.responses import ORJSONResponse from stac_fastapi.api.app import StacApi from stac_fastapi.api.models import ( From a8361830dcd63e349a78aeac12c58d993a741a31 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 9 Aug 2024 11:39:39 -0700 Subject: [PATCH 20/45] Auth: Update logging --- runtimes/eoapi/raster/eoapi/raster/auth.py | 15 +++++++++++---- runtimes/eoapi/stac/eoapi/stac/auth.py | 15 +++++++++++---- runtimes/eoapi/vector/eoapi/vector/auth.py | 15 +++++++++++---- 3 files changed, 33 insertions(+), 12 deletions(-) diff --git a/runtimes/eoapi/raster/eoapi/raster/auth.py b/runtimes/eoapi/raster/eoapi/raster/auth.py index bbd20ff..c213bbe 100644 --- a/runtimes/eoapi/raster/eoapi/raster/auth.py +++ b/runtimes/eoapi/raster/eoapi/raster/auth.py @@ -55,11 +55,16 @@ class OidcAuth: valid_token_dependency: Callable[..., Any] = field(init=False) def __post_init__(self): + logger.debug("Requesting OIDC config") with urllib.request.urlopen( str(self.openid_configuration_internal_url or self.openid_configuration_url) ) as response: if response.status != 200: - raise Exception( + logger.error( + "Received a non-200 response when fetching OIDC config: %s", + response.text, + ) + raise OidcFetchError( f"Request for OIDC config failed with status {response.status}" ) oidc_config = json.load(response) @@ -148,12 +153,10 @@ def apply_auth_dependencies( depends = Security( dependency or self.valid_token_dependency, scopes=required_token_scopes ) + logger.debug(f"{depends} -> {','.join(api_route.methods)} @ {api_route.path}") # Mimicking how APIRoute handles dependencies: # https://github.com/tiangolo/fastapi/blob/1760da0efa55585c19835d81afa8ca386036c325/fastapi/routing.py#L408-L412 - logger.debug( - f"Adding dependency {depends} to {api_route.methods} on route {api_route.path}" - ) api_route.dependant.dependencies.insert( 0, get_parameterless_sub_dependant( @@ -167,3 +170,7 @@ def apply_auth_dependencies( # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/applications.py#L337-L360 # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/routing.py#L677-L678 api_route.dependencies.extend([depends]) + + +class OidcFetchError(Exception): + pass diff --git a/runtimes/eoapi/stac/eoapi/stac/auth.py b/runtimes/eoapi/stac/eoapi/stac/auth.py index bbd20ff..c213bbe 100644 --- a/runtimes/eoapi/stac/eoapi/stac/auth.py +++ b/runtimes/eoapi/stac/eoapi/stac/auth.py @@ -55,11 +55,16 @@ class OidcAuth: valid_token_dependency: Callable[..., Any] = field(init=False) def __post_init__(self): + logger.debug("Requesting OIDC config") with urllib.request.urlopen( str(self.openid_configuration_internal_url or self.openid_configuration_url) ) as response: if response.status != 200: - raise Exception( + logger.error( + "Received a non-200 response when fetching OIDC config: %s", + response.text, + ) + raise OidcFetchError( f"Request for OIDC config failed with status {response.status}" ) oidc_config = json.load(response) @@ -148,12 +153,10 @@ def apply_auth_dependencies( depends = Security( dependency or self.valid_token_dependency, scopes=required_token_scopes ) + logger.debug(f"{depends} -> {','.join(api_route.methods)} @ {api_route.path}") # Mimicking how APIRoute handles dependencies: # https://github.com/tiangolo/fastapi/blob/1760da0efa55585c19835d81afa8ca386036c325/fastapi/routing.py#L408-L412 - logger.debug( - f"Adding dependency {depends} to {api_route.methods} on route {api_route.path}" - ) api_route.dependant.dependencies.insert( 0, get_parameterless_sub_dependant( @@ -167,3 +170,7 @@ def apply_auth_dependencies( # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/applications.py#L337-L360 # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/routing.py#L677-L678 api_route.dependencies.extend([depends]) + + +class OidcFetchError(Exception): + pass diff --git a/runtimes/eoapi/vector/eoapi/vector/auth.py b/runtimes/eoapi/vector/eoapi/vector/auth.py index bbd20ff..c213bbe 100644 --- a/runtimes/eoapi/vector/eoapi/vector/auth.py +++ b/runtimes/eoapi/vector/eoapi/vector/auth.py @@ -55,11 +55,16 @@ class OidcAuth: valid_token_dependency: Callable[..., Any] = field(init=False) def __post_init__(self): + logger.debug("Requesting OIDC config") with urllib.request.urlopen( str(self.openid_configuration_internal_url or self.openid_configuration_url) ) as response: if response.status != 200: - raise Exception( + logger.error( + "Received a non-200 response when fetching OIDC config: %s", + response.text, + ) + raise OidcFetchError( f"Request for OIDC config failed with status {response.status}" ) oidc_config = json.load(response) @@ -148,12 +153,10 @@ def apply_auth_dependencies( depends = Security( dependency or self.valid_token_dependency, scopes=required_token_scopes ) + logger.debug(f"{depends} -> {','.join(api_route.methods)} @ {api_route.path}") # Mimicking how APIRoute handles dependencies: # https://github.com/tiangolo/fastapi/blob/1760da0efa55585c19835d81afa8ca386036c325/fastapi/routing.py#L408-L412 - logger.debug( - f"Adding dependency {depends} to {api_route.methods} on route {api_route.path}" - ) api_route.dependant.dependencies.insert( 0, get_parameterless_sub_dependant( @@ -167,3 +170,7 @@ def apply_auth_dependencies( # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/applications.py#L337-L360 # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/routing.py#L677-L678 api_route.dependencies.extend([depends]) + + +class OidcFetchError(Exception): + pass From b24c612d3e3b2845820bc5befb84df87e046a928 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 9 Aug 2024 15:30:04 -0700 Subject: [PATCH 21/45] Don't buffer Python output --- docker-compose.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 7f7459c..eb3bbd7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,7 @@ services: ports: - "${MY_DOCKER_IP:-127.0.0.1}:8081:8081" environment: + - PYTHONUNBUFFERED=1 # Application - HOST=0.0.0.0 - PORT=8081 @@ -72,6 +73,7 @@ services: ports: - "${MY_DOCKER_IP:-127.0.0.1}:8082:8082" environment: + - PYTHONUNBUFFERED=1 # Application - HOST=0.0.0.0 - PORT=8082 @@ -122,6 +124,7 @@ services: ports: - "${MY_DOCKER_IP:-127.0.0.1}:8083:8083" environment: + - PYTHONUNBUFFERED=1 # Application - HOST=0.0.0.0 - PORT=8083 From 74fcfc36d026173885f03caa45ead1b8b8a4c061 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 9 Aug 2024 15:30:34 -0700 Subject: [PATCH 22/45] Add logging, refactor imports --- runtimes/eoapi/raster/eoapi/raster/app.py | 39 ++++++++++++++++------ runtimes/eoapi/raster/eoapi/raster/logs.py | 31 +++++++++++++++++ runtimes/eoapi/stac/eoapi/stac/app.py | 22 ++++++++---- runtimes/eoapi/stac/eoapi/stac/logs.py | 29 ++++++++++++++++ 4 files changed, 104 insertions(+), 17 deletions(-) create mode 100644 runtimes/eoapi/raster/eoapi/raster/logs.py create mode 100644 runtimes/eoapi/stac/eoapi/stac/logs.py diff --git a/runtimes/eoapi/raster/eoapi/raster/app.py b/runtimes/eoapi/raster/eoapi/raster/app.py index cc73c1d..79e8adb 100644 --- a/runtimes/eoapi/raster/eoapi/raster/app.py +++ b/runtimes/eoapi/raster/eoapi/raster/app.py @@ -7,8 +7,6 @@ import jinja2 import pystac -from eoapi.raster import __version__ as eoapi_raster_version -from eoapi.raster.config import ApiSettings from fastapi import Depends, FastAPI, Query from psycopg import OperationalError from psycopg.rows import dict_row @@ -39,16 +37,33 @@ ) from titiler.pgstac.reader import PgSTACReader -from .auth import AuthSettings, OidcAuth +from . import __version__ as eoapi_raster_version, auth, config, logs -logging.getLogger("botocore.credentials").disabled = True -logging.getLogger("botocore.utils").disabled = True -logging.getLogger("rio-tiler").setLevel(logging.ERROR) -logging.getLogger(__name__).setLevel(logging.DEBUG) +settings = config.ApiSettings() +auth_settings = auth.AuthSettings() + +# Logs +logs.init_logging( + debug=settings.debug, + loggers={ + "botocore.credentials": { + "level": "CRITICAL", + "propagate": False, + }, + "botocore.utils": { + "level": "CRITICAL", + "propagate": False, + }, + "rio-tiler": { + "level": "ERROR", + "propagate": False, + }, + }, +) +logger = logging.getLogger(__name__) -settings = ApiSettings() -auth_settings = AuthSettings() +logger.debug("Loading jinja2 templates...") jinja2_env = jinja2.Environment( loader=jinja2.ChoiceLoader( [ @@ -63,10 +78,14 @@ async def lifespan(app: FastAPI): """FastAPI Lifespan.""" # Create Connection Pool + logger.debug("Creating Connection Pool..") await connect_to_db(app) + logger.debug("Created Connection Pool.") yield # Close the Connection Pool + logger.debug("Closing Connection Pool..") await close_db_connection(app) + logger.debug("Closed Connection Pool.") app = FastAPI( @@ -393,7 +412,7 @@ def landing(request: Request): # Add dependencies to routes if auth_settings.openid_configuration_url and not auth_settings.public_reads: - oidc_auth = OidcAuth( + oidc_auth = auth.OidcAuth( # URL to the OpenID Connect discovery document (https://openid.net/specs/openid-connect-discovery-1_0.html) openid_configuration_url=auth_settings.openid_configuration_url, openid_configuration_internal_url=auth_settings.openid_configuration_internal_url, diff --git a/runtimes/eoapi/raster/eoapi/raster/logs.py b/runtimes/eoapi/raster/eoapi/raster/logs.py new file mode 100644 index 0000000..ae478af --- /dev/null +++ b/runtimes/eoapi/raster/eoapi/raster/logs.py @@ -0,0 +1,31 @@ +from typing import Dict +import logging + + +def init_logging(debug: bool = False, loggers: Dict[str, str] = {}): + logging.config.dictConfig( + # https://docs.python.org/3/library/logging.config.html#logging-config-dictschema + { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "std": { + # "format": "[%(asctime)s] %(levelname)s %(message)s", + "format": "[%(asctime)s +0000] [%(process)d] [%(levelname)s] %(name)s: %(message)s", + # "class": "pythonjsonlogger.jsonlogger.JsonFormatter", + } + }, + "handlers": { + "stdout": { + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout", + "formatter": "std", + } + }, + "loggers": { + # Root logger config + "": {"handlers": ["stdout"], "level": "DEBUG" if debug else "INFO"}, + **loggers, + }, + } + ) diff --git a/runtimes/eoapi/stac/eoapi/stac/app.py b/runtimes/eoapi/stac/eoapi/stac/app.py index 9f1b5ac..35eb8f6 100644 --- a/runtimes/eoapi/stac/eoapi/stac/app.py +++ b/runtimes/eoapi/stac/eoapi/stac/app.py @@ -1,10 +1,8 @@ """eoapi.stac app.""" +import logging from contextlib import asynccontextmanager -from eoapi.stac.auth import AuthSettings, OidcAuth -from eoapi.stac.config import ApiSettings -from eoapi.stac.extension import TiTilerExtension from fastapi import FastAPI from fastapi.responses import ORJSONResponse from stac_fastapi.api.app import StacApi @@ -36,6 +34,8 @@ from starlette.templating import Jinja2Templates from starlette_cramjam.middleware import CompressionMiddleware +from . import auth, config, extension, logs + try: from importlib.resources import files as resources_files # type: ignore except ImportError: @@ -45,10 +45,14 @@ templates = Jinja2Templates(directory=str(resources_files(__package__) / "templates")) # type: ignore -api_settings = ApiSettings() -auth_settings = AuthSettings() +api_settings = config.ApiSettings() +auth_settings = auth.AuthSettings() settings = Settings(enable_response_models=True) +# Logs +logs.init_logging(debug=api_settings.debug) +logger = logging.getLogger(__name__) + # Extensions extensions_map = { "transaction": TransactionExtension( @@ -62,7 +66,9 @@ "pagination": TokenPaginationExtension(), "filter": FilterExtension(client=FiltersClient()), "bulk_transactions": BulkTransactionExtension(client=BulkTransactionsClient()), - "titiler": TiTilerExtension(titiler_endpoint=api_settings.titiler_endpoint), + "titiler": extension.TiTilerExtension( + titiler_endpoint=api_settings.titiler_endpoint + ), } if enabled_extensions := api_settings.extensions: @@ -79,9 +85,11 @@ async def lifespan(app: FastAPI): """FastAPI Lifespan.""" # Create Connection Pool + logger.debug("Connecting to DB...") await connect_to_db(app) yield # Close the Connection Pool + logger.debug("Disconnecting from DB...") await close_db_connection(app) @@ -151,7 +159,7 @@ async def viewer_page(request: Request): if auth_settings.openid_configuration_url: - oidc_auth = OidcAuth( + oidc_auth = auth.OidcAuth( # URL to the OpenID Connect discovery document (https://openid.net/specs/openid-connect-discovery-1_0.html) openid_configuration_url=auth_settings.openid_configuration_url, openid_configuration_internal_url=auth_settings.openid_configuration_internal_url, diff --git a/runtimes/eoapi/stac/eoapi/stac/logs.py b/runtimes/eoapi/stac/eoapi/stac/logs.py new file mode 100644 index 0000000..1881900 --- /dev/null +++ b/runtimes/eoapi/stac/eoapi/stac/logs.py @@ -0,0 +1,29 @@ +from typing import Dict +import logging + + +def init_logging(debug: bool = False, loggers: Dict[str, str] = {}): + logging.config.dictConfig( + # https://docs.python.org/3/library/logging.config.html#logging-config-dictschema + { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "std": { + "format": "[%(asctime)s +0000] [%(process)d] [%(levelname)s] %(name)s: %(message)s", + } + }, + "handlers": { + "stdout": { + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout", + "formatter": "std", + } + }, + "loggers": { + # Root logger config + "": {"handlers": ["stdout"], "level": "DEBUG" if debug else "INFO"}, + **loggers, + }, + } + ) From 357809bd3e13b34b56f9a82296843f6c0d7133f1 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 9 Aug 2024 19:37:54 -0700 Subject: [PATCH 23/45] Undo .env & pythonbuffered changes --- docker-compose.yml | 35 ++++++++--------------------------- 1 file changed, 8 insertions(+), 27 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index eb3bbd7..c92d090 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,7 @@ -version: "3" +version: '3' services: + # change to official image when available https://github.com/radiantearth/stac-browser/pull/386 stac-browser: # build: https://github.com/radiantearth/stac-browser.git @@ -10,11 +11,6 @@ services: dockerfile: Dockerfile.browser ports: - "${MY_DOCKER_IP:-127.0.0.1}:8085:8085" - env_file: - - path: .env - required: false - - path: .stac-browser.env - required: false depends_on: - stac - raster @@ -27,7 +23,6 @@ services: ports: - "${MY_DOCKER_IP:-127.0.0.1}:8081:8081" environment: - - PYTHONUNBUFFERED=1 # Application - HOST=0.0.0.0 - PORT=8081 @@ -45,14 +40,10 @@ services: # PgSTAC extensions # - EOAPI_STAC_EXTENSIONS=["filter", "query", "sort", "fields", "pagination", "titiler", "transaction"] # defaults # - EOAPI_STAC_CORS_METHODS='GET,POST,PUT,OPTIONS' - env_file: - - path: .env - required: false - - path: .stac.env - required: false depends_on: - database - command: bash -c "bash /tmp/scripts/wait-for-it.sh -t 120 -h database -p 5432 && /start.sh" + command: + bash -c "bash /tmp/scripts/wait-for-it.sh -t 120 -h database -p 5432 && /start.sh" develop: watch: - action: sync+restart @@ -73,7 +64,6 @@ services: ports: - "${MY_DOCKER_IP:-127.0.0.1}:8082:8082" environment: - - PYTHONUNBUFFERED=1 # Application - HOST=0.0.0.0 - PORT=8082 @@ -99,14 +89,10 @@ services: - MOSAIC_CONCURRENCY=1 - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} - env_file: - - path: .env - required: false - - path: .raster.env - required: false depends_on: - database - command: bash -c "bash /tmp/scripts/wait-for-it.sh -t 120 -h database -p 5432 && /start.sh" + command: + bash -c "bash /tmp/scripts/wait-for-it.sh -t 120 -h database -p 5432 && /start.sh" develop: watch: - action: sync+restart @@ -124,7 +110,6 @@ services: ports: - "${MY_DOCKER_IP:-127.0.0.1}:8083:8083" environment: - - PYTHONUNBUFFERED=1 # Application - HOST=0.0.0.0 - PORT=8083 @@ -137,12 +122,8 @@ services: - DB_MIN_CONN_SIZE=1 - DB_MAX_CONN_SIZE=10 - EOAPI_VECTOR_DEBUG=TRUE - env_file: - - path: .env - required: false - - path: .vector.env - required: false - command: bash -c "bash /tmp/scripts/wait-for-it.sh -t 120 -h database -p 5432 && /start.sh" + command: + bash -c "bash /tmp/scripts/wait-for-it.sh -t 120 -h database -p 5432 && /start.sh" develop: watch: - action: sync+restart From 3587e2b89f35effa4a3749abaf9345e4921d14b1 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 9 Aug 2024 19:43:53 -0700 Subject: [PATCH 24/45] Raster: Rm all but auth changes --- runtimes/eoapi/raster/eoapi/raster/app.py | 32 ++++------------------ runtimes/eoapi/raster/eoapi/raster/logs.py | 31 --------------------- 2 files changed, 5 insertions(+), 58 deletions(-) delete mode 100644 runtimes/eoapi/raster/eoapi/raster/logs.py diff --git a/runtimes/eoapi/raster/eoapi/raster/app.py b/runtimes/eoapi/raster/eoapi/raster/app.py index 79e8adb..d4622bc 100644 --- a/runtimes/eoapi/raster/eoapi/raster/app.py +++ b/runtimes/eoapi/raster/eoapi/raster/app.py @@ -37,33 +37,15 @@ ) from titiler.pgstac.reader import PgSTACReader -from . import __version__ as eoapi_raster_version, auth, config, logs +from . import __version__ as eoapi_raster_version, auth, config + +logging.getLogger("botocore.credentials").disabled = True +logging.getLogger("botocore.utils").disabled = True +logging.getLogger("rio-tiler").setLevel(logging.ERROR) settings = config.ApiSettings() auth_settings = auth.AuthSettings() -# Logs -logs.init_logging( - debug=settings.debug, - loggers={ - "botocore.credentials": { - "level": "CRITICAL", - "propagate": False, - }, - "botocore.utils": { - "level": "CRITICAL", - "propagate": False, - }, - "rio-tiler": { - "level": "ERROR", - "propagate": False, - }, - }, -) -logger = logging.getLogger(__name__) - - -logger.debug("Loading jinja2 templates...") jinja2_env = jinja2.Environment( loader=jinja2.ChoiceLoader( [ @@ -78,14 +60,10 @@ async def lifespan(app: FastAPI): """FastAPI Lifespan.""" # Create Connection Pool - logger.debug("Creating Connection Pool..") await connect_to_db(app) - logger.debug("Created Connection Pool.") yield # Close the Connection Pool - logger.debug("Closing Connection Pool..") await close_db_connection(app) - logger.debug("Closed Connection Pool.") app = FastAPI( diff --git a/runtimes/eoapi/raster/eoapi/raster/logs.py b/runtimes/eoapi/raster/eoapi/raster/logs.py deleted file mode 100644 index ae478af..0000000 --- a/runtimes/eoapi/raster/eoapi/raster/logs.py +++ /dev/null @@ -1,31 +0,0 @@ -from typing import Dict -import logging - - -def init_logging(debug: bool = False, loggers: Dict[str, str] = {}): - logging.config.dictConfig( - # https://docs.python.org/3/library/logging.config.html#logging-config-dictschema - { - "version": 1, - "disable_existing_loggers": False, - "formatters": { - "std": { - # "format": "[%(asctime)s] %(levelname)s %(message)s", - "format": "[%(asctime)s +0000] [%(process)d] [%(levelname)s] %(name)s: %(message)s", - # "class": "pythonjsonlogger.jsonlogger.JsonFormatter", - } - }, - "handlers": { - "stdout": { - "class": "logging.StreamHandler", - "stream": "ext://sys.stdout", - "formatter": "std", - } - }, - "loggers": { - # Root logger config - "": {"handlers": ["stdout"], "level": "DEBUG" if debug else "INFO"}, - **loggers, - }, - } - ) From 9d28d25cf7f9696951e2d5037ce227e1264ae424 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 9 Aug 2024 19:46:30 -0700 Subject: [PATCH 25/45] Stac: Rm all but required auth code --- runtimes/eoapi/stac/eoapi/stac/app.py | 9 +------- runtimes/eoapi/stac/eoapi/stac/logs.py | 29 -------------------------- 2 files changed, 1 insertion(+), 37 deletions(-) delete mode 100644 runtimes/eoapi/stac/eoapi/stac/logs.py diff --git a/runtimes/eoapi/stac/eoapi/stac/app.py b/runtimes/eoapi/stac/eoapi/stac/app.py index 35eb8f6..bb08b9a 100644 --- a/runtimes/eoapi/stac/eoapi/stac/app.py +++ b/runtimes/eoapi/stac/eoapi/stac/app.py @@ -1,6 +1,5 @@ """eoapi.stac app.""" -import logging from contextlib import asynccontextmanager from fastapi import FastAPI @@ -34,7 +33,7 @@ from starlette.templating import Jinja2Templates from starlette_cramjam.middleware import CompressionMiddleware -from . import auth, config, extension, logs +from . import auth, config, extension try: from importlib.resources import files as resources_files # type: ignore @@ -49,10 +48,6 @@ auth_settings = auth.AuthSettings() settings = Settings(enable_response_models=True) -# Logs -logs.init_logging(debug=api_settings.debug) -logger = logging.getLogger(__name__) - # Extensions extensions_map = { "transaction": TransactionExtension( @@ -85,11 +80,9 @@ async def lifespan(app: FastAPI): """FastAPI Lifespan.""" # Create Connection Pool - logger.debug("Connecting to DB...") await connect_to_db(app) yield # Close the Connection Pool - logger.debug("Disconnecting from DB...") await close_db_connection(app) diff --git a/runtimes/eoapi/stac/eoapi/stac/logs.py b/runtimes/eoapi/stac/eoapi/stac/logs.py deleted file mode 100644 index 1881900..0000000 --- a/runtimes/eoapi/stac/eoapi/stac/logs.py +++ /dev/null @@ -1,29 +0,0 @@ -from typing import Dict -import logging - - -def init_logging(debug: bool = False, loggers: Dict[str, str] = {}): - logging.config.dictConfig( - # https://docs.python.org/3/library/logging.config.html#logging-config-dictschema - { - "version": 1, - "disable_existing_loggers": False, - "formatters": { - "std": { - "format": "[%(asctime)s +0000] [%(process)d] [%(levelname)s] %(name)s: %(message)s", - } - }, - "handlers": { - "stdout": { - "class": "logging.StreamHandler", - "stream": "ext://sys.stdout", - "formatter": "std", - } - }, - "loggers": { - # Root logger config - "": {"handlers": ["stdout"], "level": "DEBUG" if debug else "INFO"}, - **loggers, - }, - } - ) From 0a01be15109d7d3b8432e11b3e5236f54dccfcc9 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 9 Aug 2024 19:48:38 -0700 Subject: [PATCH 26/45] Revert unnecessary gitignore change --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 0889d95..556c695 100644 --- a/.gitignore +++ b/.gitignore @@ -120,7 +120,7 @@ celerybeat.pid *.sage.py # Environments -*.env +.env .venv env/ venv/ From ac41c1b5f3db28b02e5fe97075b82dd0d18b1634 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 9 Aug 2024 19:50:01 -0700 Subject: [PATCH 27/45] Vector: fixup imports --- runtimes/eoapi/vector/eoapi/vector/app.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/runtimes/eoapi/vector/eoapi/vector/app.py b/runtimes/eoapi/vector/eoapi/vector/app.py index 8b20bb0..8c5c253 100644 --- a/runtimes/eoapi/vector/eoapi/vector/app.py +++ b/runtimes/eoapi/vector/eoapi/vector/app.py @@ -3,9 +3,6 @@ from contextlib import asynccontextmanager import jinja2 -from eoapi.vector import __version__ as eoapi_vector_version -from eoapi.vector.auth import AuthSettings, OidcAuth -from eoapi.vector.config import ApiSettings from fastapi import FastAPI, Request from starlette.middleware.cors import CORSMiddleware from starlette.templating import Jinja2Templates @@ -17,6 +14,8 @@ from tipg.middleware import CacheControlMiddleware, CatalogUpdateMiddleware from tipg.settings import PostgresSettings +from . import __version__ as eoapi_vector_version, auth, config + try: from importlib.resources import files as resources_files # type: ignore except ImportError: @@ -26,9 +25,9 @@ CUSTOM_SQL_DIRECTORY = resources_files(__package__) / "sql" -settings = ApiSettings() +settings = config.ApiSettings() postgres_settings = PostgresSettings() -auth_settings = AuthSettings() +auth_settings = auth.AuthSettings() @asynccontextmanager @@ -151,7 +150,7 @@ async def refresh(request: Request): if auth_settings.openid_configuration_url and not auth_settings.public_reads: - oidc_auth = OidcAuth( + oidc_auth = auth.OidcAuth( # URL to the OpenID Connect discovery document (https://openid.net/specs/openid-connect-discovery-1_0.html) openid_configuration_url=auth_settings.openid_configuration_url, openid_configuration_internal_url=auth_settings.openid_configuration_internal_url, From d87f15c8d9e864ffbbd034dbb6cdec1f582aa2a2 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 9 Aug 2024 19:55:09 -0700 Subject: [PATCH 28/45] Precommit: fix imports --- runtimes/eoapi/raster/eoapi/raster/app.py | 3 ++- runtimes/eoapi/raster/eoapi/raster/auth.py | 7 +++---- runtimes/eoapi/stac/eoapi/stac/auth.py | 7 +++---- runtimes/eoapi/vector/eoapi/vector/app.py | 3 ++- runtimes/eoapi/vector/eoapi/vector/auth.py | 7 +++---- 5 files changed, 13 insertions(+), 14 deletions(-) diff --git a/runtimes/eoapi/raster/eoapi/raster/app.py b/runtimes/eoapi/raster/eoapi/raster/app.py index d4622bc..0793c08 100644 --- a/runtimes/eoapi/raster/eoapi/raster/app.py +++ b/runtimes/eoapi/raster/eoapi/raster/app.py @@ -37,7 +37,8 @@ ) from titiler.pgstac.reader import PgSTACReader -from . import __version__ as eoapi_raster_version, auth, config +from . import __version__ as eoapi_raster_version +from . import auth, config logging.getLogger("botocore.credentials").disabled = True logging.getLogger("botocore.utils").disabled = True diff --git a/runtimes/eoapi/raster/eoapi/raster/auth.py b/runtimes/eoapi/raster/eoapi/raster/auth.py index c213bbe..117cca3 100644 --- a/runtimes/eoapi/raster/eoapi/raster/auth.py +++ b/runtimes/eoapi/raster/eoapi/raster/auth.py @@ -1,16 +1,15 @@ -from dataclasses import dataclass, field -from typing import Annotated, Any, Callable, Dict, Optional, Sequence, TypedDict import json import logging import urllib.request +from dataclasses import dataclass, field +from typing import Annotated, Any, Callable, Dict, Optional, Sequence, TypedDict +import jwt from fastapi import HTTPException, Security, routing, security, status from fastapi.dependencies.utils import get_parameterless_sub_dependant from fastapi.security.base import SecurityBase from pydantic import AnyHttpUrl from pydantic_settings import BaseSettings -import jwt - logger = logging.getLogger(__name__) diff --git a/runtimes/eoapi/stac/eoapi/stac/auth.py b/runtimes/eoapi/stac/eoapi/stac/auth.py index c213bbe..117cca3 100644 --- a/runtimes/eoapi/stac/eoapi/stac/auth.py +++ b/runtimes/eoapi/stac/eoapi/stac/auth.py @@ -1,16 +1,15 @@ -from dataclasses import dataclass, field -from typing import Annotated, Any, Callable, Dict, Optional, Sequence, TypedDict import json import logging import urllib.request +from dataclasses import dataclass, field +from typing import Annotated, Any, Callable, Dict, Optional, Sequence, TypedDict +import jwt from fastapi import HTTPException, Security, routing, security, status from fastapi.dependencies.utils import get_parameterless_sub_dependant from fastapi.security.base import SecurityBase from pydantic import AnyHttpUrl from pydantic_settings import BaseSettings -import jwt - logger = logging.getLogger(__name__) diff --git a/runtimes/eoapi/vector/eoapi/vector/app.py b/runtimes/eoapi/vector/eoapi/vector/app.py index 8c5c253..7dc5165 100644 --- a/runtimes/eoapi/vector/eoapi/vector/app.py +++ b/runtimes/eoapi/vector/eoapi/vector/app.py @@ -14,7 +14,8 @@ from tipg.middleware import CacheControlMiddleware, CatalogUpdateMiddleware from tipg.settings import PostgresSettings -from . import __version__ as eoapi_vector_version, auth, config +from . import __version__ as eoapi_vector_version +from . import auth, config try: from importlib.resources import files as resources_files # type: ignore diff --git a/runtimes/eoapi/vector/eoapi/vector/auth.py b/runtimes/eoapi/vector/eoapi/vector/auth.py index c213bbe..117cca3 100644 --- a/runtimes/eoapi/vector/eoapi/vector/auth.py +++ b/runtimes/eoapi/vector/eoapi/vector/auth.py @@ -1,16 +1,15 @@ -from dataclasses import dataclass, field -from typing import Annotated, Any, Callable, Dict, Optional, Sequence, TypedDict import json import logging import urllib.request +from dataclasses import dataclass, field +from typing import Annotated, Any, Callable, Dict, Optional, Sequence, TypedDict +import jwt from fastapi import HTTPException, Security, routing, security, status from fastapi.dependencies.utils import get_parameterless_sub_dependant from fastapi.security.base import SecurityBase from pydantic import AnyHttpUrl from pydantic_settings import BaseSettings -import jwt - logger = logging.getLogger(__name__) From 497546935f1ff422d7ceaa5218e5c66d2bf5225e Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 9 Aug 2024 20:01:49 -0700 Subject: [PATCH 29/45] Pre-commit: fix titiler extension --- runtimes/eoapi/stac/eoapi/stac/app.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/runtimes/eoapi/stac/eoapi/stac/app.py b/runtimes/eoapi/stac/eoapi/stac/app.py index bb08b9a..7bb1c59 100644 --- a/runtimes/eoapi/stac/eoapi/stac/app.py +++ b/runtimes/eoapi/stac/eoapi/stac/app.py @@ -61,8 +61,10 @@ "pagination": TokenPaginationExtension(), "filter": FilterExtension(client=FiltersClient()), "bulk_transactions": BulkTransactionExtension(client=BulkTransactionsClient()), - "titiler": extension.TiTilerExtension( - titiler_endpoint=api_settings.titiler_endpoint + "titiler": ( + extension.TiTilerExtension(titiler_endpoint=api_settings.titiler_endpoint) + if api_settings.titiler_endpoint + else None ), } From 26fe978b721c59cda645588c8f94887d486bb34b Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Sat, 10 Aug 2024 10:45:38 -0700 Subject: [PATCH 30/45] Add stac browser config to env example --- .env.example | 1 + 1 file changed, 1 insertion(+) diff --git a/.env.example b/.env.example index cceb280..11dba92 100644 --- a/.env.example +++ b/.env.example @@ -42,3 +42,4 @@ EOAPI_RASTER_ENABLE_MOSAIC_SEARCH=TRUE EOAPI_AUTH_CLIENT_ID=my-client-id EOAPI_AUTH_OPENID_CONFIGURATION_URL=https://cognito-idp.us-east-1.amazonaws.com//.well-known/openid-configuration EOAPI_AUTH_USE_PKCE=true +SB_authConfig={ "type": "openIdConnect", "openIdConnectUrl": "https://cognito-idp.us-east-1.amazonaws.com//.well-known/openid-configuration", "oidcOptions": { "client_id": "stac-browser" } } From 92f4465795837c4ee13768d45acca191dd46eab5 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Mon, 12 Aug 2024 07:24:00 -0700 Subject: [PATCH 31/45] Pre-commit fix --- runtimes/eoapi/raster/eoapi/raster/app.py | 1 - 1 file changed, 1 deletion(-) diff --git a/runtimes/eoapi/raster/eoapi/raster/app.py b/runtimes/eoapi/raster/eoapi/raster/app.py index a5b0d05..5d84c7f 100644 --- a/runtimes/eoapi/raster/eoapi/raster/app.py +++ b/runtimes/eoapi/raster/eoapi/raster/app.py @@ -40,7 +40,6 @@ from . import __version__ as eoapi_raster_version from . import auth, config, logs - settings = config.ApiSettings() auth_settings = auth.AuthSettings() From b784e1c3f502f87fe950ac251b251853550dc67b Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 14 Aug 2024 08:50:55 -0700 Subject: [PATCH 32/45] Simplify --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9069e16..20bee72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,8 +52,7 @@ jobs: # see https://github.com/developmentseed/tipg/issues/37 - name: Restart the Vector service run: | - docker compose stop vector - docker compose up -d vector + docker compose restart vector - name: Sleep for 10 seconds run: sleep 10s From 94628bd93c5c8d53648838f876a5b8c3c344cec4 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Mon, 19 Aug 2024 14:08:48 -0700 Subject: [PATCH 33/45] Rm version (deprecated) https://github.com/compose-spec/compose-spec/blob/main/spec.md#version-and-name-top-level-elements --- docker-compose.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index eb3bbd7..743afcd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3" - services: # change to official image when available https://github.com/radiantearth/stac-browser/pull/386 stac-browser: From aa30f7d3283c6b75d42073a2109228bec2ff3046 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Mon, 19 Aug 2024 15:35:13 -0700 Subject: [PATCH 34/45] Breakout auth tooling into separate module --- dockerfiles/Dockerfile.raster | 3 + dockerfiles/Dockerfile.stac | 3 + dockerfiles/Dockerfile.vector | 3 + runtimes/eoapi/auth/README.md | 0 .../eoapi/raster => auth/eoapi}/auth.py | 3 + runtimes/eoapi/auth/pyproject.toml | 40 ++++ runtimes/eoapi/raster/eoapi/raster/app.py | 3 +- runtimes/eoapi/raster/pyproject.toml | 2 - runtimes/eoapi/stac/eoapi/stac/app.py | 3 +- runtimes/eoapi/stac/eoapi/stac/auth.py | 175 ------------------ runtimes/eoapi/stac/pyproject.toml | 3 +- runtimes/eoapi/vector/eoapi/vector/app.py | 3 +- runtimes/eoapi/vector/eoapi/vector/auth.py | 175 ------------------ runtimes/eoapi/vector/pyproject.toml | 2 - 14 files changed, 59 insertions(+), 359 deletions(-) create mode 100644 runtimes/eoapi/auth/README.md rename runtimes/eoapi/{raster/eoapi/raster => auth/eoapi}/auth.py (99%) create mode 100644 runtimes/eoapi/auth/pyproject.toml delete mode 100644 runtimes/eoapi/stac/eoapi/stac/auth.py delete mode 100644 runtimes/eoapi/vector/eoapi/vector/auth.py diff --git a/dockerfiles/Dockerfile.raster b/dockerfiles/Dockerfile.raster index 06ded2c..884d135 100644 --- a/dockerfiles/Dockerfile.raster +++ b/dockerfiles/Dockerfile.raster @@ -9,6 +9,9 @@ RUN python -m pip install psycopg[binary,pool] COPY runtimes/eoapi/raster /tmp/raster RUN python -m pip install /tmp/raster RUN rm -rf /tmp/raster +COPY runtimes/eoapi/auth /tmp/auth +RUN pip install /tmp/auth +RUN rm -rf /tmp/auth ENV MODULE_NAME eoapi.raster.app ENV VARIABLE_NAME app diff --git a/dockerfiles/Dockerfile.stac b/dockerfiles/Dockerfile.stac index cfcd493..e8db475 100644 --- a/dockerfiles/Dockerfile.stac +++ b/dockerfiles/Dockerfile.stac @@ -7,6 +7,9 @@ ENV CURL_CA_BUNDLE /etc/ssl/certs/ca-certificates.crt COPY runtimes/eoapi/stac /tmp/stac RUN python -m pip install /tmp/stac RUN rm -rf /tmp/stac +COPY runtimes/eoapi/auth /tmp/auth +RUN pip install /tmp/auth +RUN rm -rf /tmp/auth ENV MODULE_NAME eoapi.stac.app ENV VARIABLE_NAME app diff --git a/dockerfiles/Dockerfile.vector b/dockerfiles/Dockerfile.vector index b4ba5b8..dd8a9d7 100644 --- a/dockerfiles/Dockerfile.vector +++ b/dockerfiles/Dockerfile.vector @@ -5,6 +5,9 @@ FROM ghcr.io/vincentsarago/uvicorn-gunicorn:${PYTHON_VERSION} COPY runtimes/eoapi/vector /tmp/vector RUN python -m pip install /tmp/vector RUN rm -rf /tmp/vector +COPY runtimes/eoapi/auth /tmp/auth +RUN pip install /tmp/auth +RUN rm -rf /tmp/auth ENV MODULE_NAME eoapi.vector.app ENV VARIABLE_NAME app diff --git a/runtimes/eoapi/auth/README.md b/runtimes/eoapi/auth/README.md new file mode 100644 index 0000000..e69de29 diff --git a/runtimes/eoapi/raster/eoapi/raster/auth.py b/runtimes/eoapi/auth/eoapi/auth.py similarity index 99% rename from runtimes/eoapi/raster/eoapi/raster/auth.py rename to runtimes/eoapi/auth/eoapi/auth.py index 117cca3..a7caded 100644 --- a/runtimes/eoapi/raster/eoapi/raster/auth.py +++ b/runtimes/eoapi/auth/eoapi/auth.py @@ -14,6 +14,9 @@ logger = logging.getLogger(__name__) +__version__ = "0.1.0" + + class Scope(TypedDict, total=False): """More strict version of Starlette's Scope.""" diff --git a/runtimes/eoapi/auth/pyproject.toml b/runtimes/eoapi/auth/pyproject.toml new file mode 100644 index 0000000..65ecc96 --- /dev/null +++ b/runtimes/eoapi/auth/pyproject.toml @@ -0,0 +1,40 @@ +[build-system] +build-backend = "pdm.pep517.api" +requires = ["pdm-pep517"] + +[tool.pdm.version] +path = "eoapi/auth.py" +source = "file" + +[tool.pdm.build] +excludes = ["tests/", "**/.mypy_cache", "**/.DS_Store"] +includes = ["eoapi"] + +[project] +authors = [ + {name = "Anthony Lukach", email = "anthony@developmentseed.org"}, +] +dependencies = [ + "pydantic-settings>=2.2.1", + "pyjwt>=2.9.0", + "cryptography>=43.0.0", +] +description = "Authentication module for EOAPI" +dynamic = ["version"] +license = {text = "MIT"} +name = "eoapi.auth" +readme = "README.md" +requires-python = ">=3.8" + +[project.optional-dependencies] +testing = [ + "pytest>=6.0", + "coverage", +] + +[tool.setuptools] +include-package-data = true +py-modules = ["eoapi.auth"] + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/runtimes/eoapi/raster/eoapi/raster/app.py b/runtimes/eoapi/raster/eoapi/raster/app.py index 5d84c7f..e2d71e9 100644 --- a/runtimes/eoapi/raster/eoapi/raster/app.py +++ b/runtimes/eoapi/raster/eoapi/raster/app.py @@ -7,6 +7,7 @@ import jinja2 import pystac +from eoapi import auth from fastapi import Depends, FastAPI, Query from psycopg import OperationalError from psycopg.rows import dict_row @@ -38,7 +39,7 @@ from titiler.pgstac.reader import PgSTACReader from . import __version__ as eoapi_raster_version -from . import auth, config, logs +from . import config, logs settings = config.ApiSettings() auth_settings = auth.AuthSettings() diff --git a/runtimes/eoapi/raster/pyproject.toml b/runtimes/eoapi/raster/pyproject.toml index b7220bc..4743b83 100644 --- a/runtimes/eoapi/raster/pyproject.toml +++ b/runtimes/eoapi/raster/pyproject.toml @@ -24,8 +24,6 @@ dependencies = [ "titiler.extensions", "starlette-cramjam>=0.3,<0.4", "importlib_resources>=1.1.0;python_version<'3.9'", - "pyjwt", - "cryptography", ] [project.optional-dependencies] diff --git a/runtimes/eoapi/stac/eoapi/stac/app.py b/runtimes/eoapi/stac/eoapi/stac/app.py index 52584f3..2023723 100644 --- a/runtimes/eoapi/stac/eoapi/stac/app.py +++ b/runtimes/eoapi/stac/eoapi/stac/app.py @@ -3,6 +3,7 @@ import logging from contextlib import asynccontextmanager +from eoapi import auth from fastapi import FastAPI from fastapi.responses import ORJSONResponse from stac_fastapi.api.app import StacApi @@ -34,7 +35,7 @@ from starlette.templating import Jinja2Templates from starlette_cramjam.middleware import CompressionMiddleware -from . import auth, config, extension, logs +from . import config, extension, logs try: from importlib.resources import files as resources_files # type: ignore diff --git a/runtimes/eoapi/stac/eoapi/stac/auth.py b/runtimes/eoapi/stac/eoapi/stac/auth.py deleted file mode 100644 index 117cca3..0000000 --- a/runtimes/eoapi/stac/eoapi/stac/auth.py +++ /dev/null @@ -1,175 +0,0 @@ -import json -import logging -import urllib.request -from dataclasses import dataclass, field -from typing import Annotated, Any, Callable, Dict, Optional, Sequence, TypedDict - -import jwt -from fastapi import HTTPException, Security, routing, security, status -from fastapi.dependencies.utils import get_parameterless_sub_dependant -from fastapi.security.base import SecurityBase -from pydantic import AnyHttpUrl -from pydantic_settings import BaseSettings - -logger = logging.getLogger(__name__) - - -class Scope(TypedDict, total=False): - """More strict version of Starlette's Scope.""" - - # https://github.com/encode/starlette/blob/6af5c515e0a896cbf3f86ee043b88f6c24200bcf/starlette/types.py#L3 - path: str - method: str - type: Optional[str] - - -class AuthSettings(BaseSettings): - # Swagger UI config for Authorization Code Flow - client_id: str = "" - use_pkce: bool = True - openid_configuration_url: Optional[AnyHttpUrl] = None - openid_configuration_internal_url: Optional[AnyHttpUrl] = None - - allowed_jwt_audiences: Optional[Sequence[str]] = [] - - public_reads: bool = True - - model_config = { - "env_prefix": "EOAPI_AUTH_", - "env_file": ".env", - "extra": "allow", - } - - -@dataclass -class OidcAuth: - openid_configuration_url: AnyHttpUrl - openid_configuration_internal_url: Optional[AnyHttpUrl] = None - allowed_jwt_audiences: Optional[Sequence[str]] = None - oauth2_supported_scopes: Dict[str, str] = field(default_factory=dict) - - # Generated attributes - auth_scheme: SecurityBase = field(init=False) - jwks_client: jwt.PyJWKClient = field(init=False) - valid_token_dependency: Callable[..., Any] = field(init=False) - - def __post_init__(self): - logger.debug("Requesting OIDC config") - with urllib.request.urlopen( - str(self.openid_configuration_internal_url or self.openid_configuration_url) - ) as response: - if response.status != 200: - logger.error( - "Received a non-200 response when fetching OIDC config: %s", - response.text, - ) - raise OidcFetchError( - f"Request for OIDC config failed with status {response.status}" - ) - oidc_config = json.load(response) - self.jwks_client = jwt.PyJWKClient(oidc_config["jwks_uri"]) - - self.auth_scheme = security.OpenIdConnect( - openIdConnectUrl=str(self.openid_configuration_url) - ) - self.valid_token_dependency = self.create_auth_token_dependency( - auth_scheme=self.auth_scheme, - jwks_client=self.jwks_client, - allowed_jwt_audiences=self.allowed_jwt_audiences, - ) - - @staticmethod - def create_auth_token_dependency( - auth_scheme: SecurityBase, - jwks_client: jwt.PyJWKClient, - allowed_jwt_audiences: Sequence[str], - ): - """ - Create a dependency that validates JWT tokens & scopes. - """ - - def auth_token( - token_str: Annotated[str, Security(auth_scheme)], - required_scopes: security.SecurityScopes, - ): - token_parts = token_str.split(" ") - if len(token_parts) != 2 or token_parts[0].lower() != "bearer": - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid authorization header", - headers={"WWW-Authenticate": "Bearer"}, - ) - else: - [_, token] = token_parts - # Parse & validate token - try: - payload = jwt.decode( - token, - jwks_client.get_signing_key_from_jwt(token).key, - algorithms=["RS256"], - # NOTE: Audience validation MUST match audience claim if set in token (https://pyjwt.readthedocs.io/en/stable/changelog.html?highlight=audience#id40) - audience=allowed_jwt_audiences, - ) - except jwt.exceptions.InvalidTokenError as e: - logger.exception(f"InvalidTokenError: {e=}") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) from e - - # Validate scopes (if required) - for scope in required_scopes.scopes: - if scope not in payload["scope"]: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Not enough permissions", - headers={ - "WWW-Authenticate": f'Bearer scope="{required_scopes.scope_str}"' - }, - ) - - return payload - - return auth_token - - def apply_auth_dependencies( - self, - api_route: routing.APIRoute, - required_token_scopes: Optional[Sequence[str]] = None, - dependency: Optional[Callable[..., Any]] = None, - ): - """ - Apply auth dependencies to a route. - """ - # Ignore paths without dependants, e.g. /api, /api.html, /docs/oauth2-redirect - if not hasattr(api_route, "dependant"): - logger.warn( - f"Route {api_route} has no dependant, not apply auth dependency" - ) - return - - depends = Security( - dependency or self.valid_token_dependency, scopes=required_token_scopes - ) - logger.debug(f"{depends} -> {','.join(api_route.methods)} @ {api_route.path}") - - # Mimicking how APIRoute handles dependencies: - # https://github.com/tiangolo/fastapi/blob/1760da0efa55585c19835d81afa8ca386036c325/fastapi/routing.py#L408-L412 - api_route.dependant.dependencies.insert( - 0, - get_parameterless_sub_dependant( - depends=depends, path=api_route.path_format - ), - ) - - # Register dependencies directly on route so that they aren't ignored if - # the routes are later associated with an app (e.g. - # app.include_router(router)) - # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/applications.py#L337-L360 - # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/routing.py#L677-L678 - api_route.dependencies.extend([depends]) - - -class OidcFetchError(Exception): - pass diff --git a/runtimes/eoapi/stac/pyproject.toml b/runtimes/eoapi/stac/pyproject.toml index faa533c..e4612f1 100644 --- a/runtimes/eoapi/stac/pyproject.toml +++ b/runtimes/eoapi/stac/pyproject.toml @@ -24,8 +24,7 @@ dependencies = [ "starlette-cramjam>=0.3,<0.4", "importlib_resources>=1.1.0;python_version<'3.9'", "psycopg_pool", - "pyjwt", - "cryptography" + "fastapi-authorization-gateway<=0.0.3" ] [project.optional-dependencies] diff --git a/runtimes/eoapi/vector/eoapi/vector/app.py b/runtimes/eoapi/vector/eoapi/vector/app.py index cc574d8..9e77eb3 100644 --- a/runtimes/eoapi/vector/eoapi/vector/app.py +++ b/runtimes/eoapi/vector/eoapi/vector/app.py @@ -4,6 +4,7 @@ from contextlib import asynccontextmanager import jinja2 +from eoapi import auth from fastapi import FastAPI, Request from starlette.middleware.cors import CORSMiddleware from starlette.templating import Jinja2Templates @@ -16,7 +17,7 @@ from tipg.settings import PostgresSettings from . import __version__ as eoapi_vector_version -from . import auth, config, logs +from . import config, logs try: from importlib.resources import files as resources_files # type: ignore diff --git a/runtimes/eoapi/vector/eoapi/vector/auth.py b/runtimes/eoapi/vector/eoapi/vector/auth.py deleted file mode 100644 index 117cca3..0000000 --- a/runtimes/eoapi/vector/eoapi/vector/auth.py +++ /dev/null @@ -1,175 +0,0 @@ -import json -import logging -import urllib.request -from dataclasses import dataclass, field -from typing import Annotated, Any, Callable, Dict, Optional, Sequence, TypedDict - -import jwt -from fastapi import HTTPException, Security, routing, security, status -from fastapi.dependencies.utils import get_parameterless_sub_dependant -from fastapi.security.base import SecurityBase -from pydantic import AnyHttpUrl -from pydantic_settings import BaseSettings - -logger = logging.getLogger(__name__) - - -class Scope(TypedDict, total=False): - """More strict version of Starlette's Scope.""" - - # https://github.com/encode/starlette/blob/6af5c515e0a896cbf3f86ee043b88f6c24200bcf/starlette/types.py#L3 - path: str - method: str - type: Optional[str] - - -class AuthSettings(BaseSettings): - # Swagger UI config for Authorization Code Flow - client_id: str = "" - use_pkce: bool = True - openid_configuration_url: Optional[AnyHttpUrl] = None - openid_configuration_internal_url: Optional[AnyHttpUrl] = None - - allowed_jwt_audiences: Optional[Sequence[str]] = [] - - public_reads: bool = True - - model_config = { - "env_prefix": "EOAPI_AUTH_", - "env_file": ".env", - "extra": "allow", - } - - -@dataclass -class OidcAuth: - openid_configuration_url: AnyHttpUrl - openid_configuration_internal_url: Optional[AnyHttpUrl] = None - allowed_jwt_audiences: Optional[Sequence[str]] = None - oauth2_supported_scopes: Dict[str, str] = field(default_factory=dict) - - # Generated attributes - auth_scheme: SecurityBase = field(init=False) - jwks_client: jwt.PyJWKClient = field(init=False) - valid_token_dependency: Callable[..., Any] = field(init=False) - - def __post_init__(self): - logger.debug("Requesting OIDC config") - with urllib.request.urlopen( - str(self.openid_configuration_internal_url or self.openid_configuration_url) - ) as response: - if response.status != 200: - logger.error( - "Received a non-200 response when fetching OIDC config: %s", - response.text, - ) - raise OidcFetchError( - f"Request for OIDC config failed with status {response.status}" - ) - oidc_config = json.load(response) - self.jwks_client = jwt.PyJWKClient(oidc_config["jwks_uri"]) - - self.auth_scheme = security.OpenIdConnect( - openIdConnectUrl=str(self.openid_configuration_url) - ) - self.valid_token_dependency = self.create_auth_token_dependency( - auth_scheme=self.auth_scheme, - jwks_client=self.jwks_client, - allowed_jwt_audiences=self.allowed_jwt_audiences, - ) - - @staticmethod - def create_auth_token_dependency( - auth_scheme: SecurityBase, - jwks_client: jwt.PyJWKClient, - allowed_jwt_audiences: Sequence[str], - ): - """ - Create a dependency that validates JWT tokens & scopes. - """ - - def auth_token( - token_str: Annotated[str, Security(auth_scheme)], - required_scopes: security.SecurityScopes, - ): - token_parts = token_str.split(" ") - if len(token_parts) != 2 or token_parts[0].lower() != "bearer": - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid authorization header", - headers={"WWW-Authenticate": "Bearer"}, - ) - else: - [_, token] = token_parts - # Parse & validate token - try: - payload = jwt.decode( - token, - jwks_client.get_signing_key_from_jwt(token).key, - algorithms=["RS256"], - # NOTE: Audience validation MUST match audience claim if set in token (https://pyjwt.readthedocs.io/en/stable/changelog.html?highlight=audience#id40) - audience=allowed_jwt_audiences, - ) - except jwt.exceptions.InvalidTokenError as e: - logger.exception(f"InvalidTokenError: {e=}") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) from e - - # Validate scopes (if required) - for scope in required_scopes.scopes: - if scope not in payload["scope"]: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Not enough permissions", - headers={ - "WWW-Authenticate": f'Bearer scope="{required_scopes.scope_str}"' - }, - ) - - return payload - - return auth_token - - def apply_auth_dependencies( - self, - api_route: routing.APIRoute, - required_token_scopes: Optional[Sequence[str]] = None, - dependency: Optional[Callable[..., Any]] = None, - ): - """ - Apply auth dependencies to a route. - """ - # Ignore paths without dependants, e.g. /api, /api.html, /docs/oauth2-redirect - if not hasattr(api_route, "dependant"): - logger.warn( - f"Route {api_route} has no dependant, not apply auth dependency" - ) - return - - depends = Security( - dependency or self.valid_token_dependency, scopes=required_token_scopes - ) - logger.debug(f"{depends} -> {','.join(api_route.methods)} @ {api_route.path}") - - # Mimicking how APIRoute handles dependencies: - # https://github.com/tiangolo/fastapi/blob/1760da0efa55585c19835d81afa8ca386036c325/fastapi/routing.py#L408-L412 - api_route.dependant.dependencies.insert( - 0, - get_parameterless_sub_dependant( - depends=depends, path=api_route.path_format - ), - ) - - # Register dependencies directly on route so that they aren't ignored if - # the routes are later associated with an app (e.g. - # app.include_router(router)) - # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/applications.py#L337-L360 - # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/routing.py#L677-L678 - api_route.dependencies.extend([depends]) - - -class OidcFetchError(Exception): - pass diff --git a/runtimes/eoapi/vector/pyproject.toml b/runtimes/eoapi/vector/pyproject.toml index ef7ba4a..52a57d0 100644 --- a/runtimes/eoapi/vector/pyproject.toml +++ b/runtimes/eoapi/vector/pyproject.toml @@ -21,8 +21,6 @@ classifiers = [ dynamic = ["version"] dependencies = [ "tipg==0.7.1", - "pyjwt", - "cryptography", ] [project.optional-dependencies] From 78b75a0921a0eccc056fff35f3fbfccd2634f270 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Mon, 19 Aug 2024 16:06:58 -0700 Subject: [PATCH 35/45] Breakout into files --- runtimes/eoapi/auth/eoapi/auth/__init__.py | 4 ++ runtimes/eoapi/auth/eoapi/{ => auth}/auth.py | 39 ++------------------ runtimes/eoapi/auth/eoapi/auth/config.py | 22 +++++++++++ runtimes/eoapi/auth/eoapi/auth/types.py | 14 +++++++ 4 files changed, 43 insertions(+), 36 deletions(-) create mode 100644 runtimes/eoapi/auth/eoapi/auth/__init__.py rename runtimes/eoapi/auth/eoapi/{ => auth}/auth.py (87%) create mode 100644 runtimes/eoapi/auth/eoapi/auth/config.py create mode 100644 runtimes/eoapi/auth/eoapi/auth/types.py diff --git a/runtimes/eoapi/auth/eoapi/auth/__init__.py b/runtimes/eoapi/auth/eoapi/auth/__init__.py new file mode 100644 index 0000000..47f96be --- /dev/null +++ b/runtimes/eoapi/auth/eoapi/auth/__init__.py @@ -0,0 +1,4 @@ +from .auth import OidcAuth # noqa +from .config import AuthSettings # noqa + +__version__ = "0.1.0" diff --git a/runtimes/eoapi/auth/eoapi/auth.py b/runtimes/eoapi/auth/eoapi/auth/auth.py similarity index 87% rename from runtimes/eoapi/auth/eoapi/auth.py rename to runtimes/eoapi/auth/eoapi/auth/auth.py index a7caded..69f622d 100644 --- a/runtimes/eoapi/auth/eoapi/auth.py +++ b/runtimes/eoapi/auth/eoapi/auth/auth.py @@ -2,46 +2,17 @@ import logging import urllib.request from dataclasses import dataclass, field -from typing import Annotated, Any, Callable, Dict, Optional, Sequence, TypedDict +from typing import Annotated, Any, Callable, Dict, Optional, Sequence import jwt from fastapi import HTTPException, Security, routing, security, status from fastapi.dependencies.utils import get_parameterless_sub_dependant from fastapi.security.base import SecurityBase from pydantic import AnyHttpUrl -from pydantic_settings import BaseSettings - -logger = logging.getLogger(__name__) - - -__version__ = "0.1.0" - - -class Scope(TypedDict, total=False): - """More strict version of Starlette's Scope.""" - - # https://github.com/encode/starlette/blob/6af5c515e0a896cbf3f86ee043b88f6c24200bcf/starlette/types.py#L3 - path: str - method: str - type: Optional[str] +from .types import OidcFetchError -class AuthSettings(BaseSettings): - # Swagger UI config for Authorization Code Flow - client_id: str = "" - use_pkce: bool = True - openid_configuration_url: Optional[AnyHttpUrl] = None - openid_configuration_internal_url: Optional[AnyHttpUrl] = None - - allowed_jwt_audiences: Optional[Sequence[str]] = [] - - public_reads: bool = True - - model_config = { - "env_prefix": "EOAPI_AUTH_", - "env_file": ".env", - "extra": "allow", - } +logger = logging.getLogger(__name__) @dataclass @@ -172,7 +143,3 @@ def apply_auth_dependencies( # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/applications.py#L337-L360 # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/routing.py#L677-L678 api_route.dependencies.extend([depends]) - - -class OidcFetchError(Exception): - pass diff --git a/runtimes/eoapi/auth/eoapi/auth/config.py b/runtimes/eoapi/auth/eoapi/auth/config.py new file mode 100644 index 0000000..54ca6e2 --- /dev/null +++ b/runtimes/eoapi/auth/eoapi/auth/config.py @@ -0,0 +1,22 @@ +from typing import Optional, Sequence + +from pydantic import AnyHttpUrl +from pydantic_settings import BaseSettings + + +class AuthSettings(BaseSettings): + # Swagger UI config for Authorization Code Flow + client_id: str = "" + use_pkce: bool = True + openid_configuration_url: Optional[AnyHttpUrl] = None + openid_configuration_internal_url: Optional[AnyHttpUrl] = None + + allowed_jwt_audiences: Optional[Sequence[str]] = [] + + public_reads: bool = True + + model_config = { + "env_prefix": "EOAPI_AUTH_", + "env_file": ".env", + "extra": "allow", + } diff --git a/runtimes/eoapi/auth/eoapi/auth/types.py b/runtimes/eoapi/auth/eoapi/auth/types.py new file mode 100644 index 0000000..166ca8b --- /dev/null +++ b/runtimes/eoapi/auth/eoapi/auth/types.py @@ -0,0 +1,14 @@ +from typing import Optional, TypedDict + + +class OidcFetchError(Exception): + pass + + +class Scope(TypedDict, total=False): + """More strict version of Starlette's Scope.""" + + # https://github.com/encode/starlette/blob/6af5c515e0a896cbf3f86ee043b88f6c24200bcf/starlette/types.py#L3 + path: str + method: str + type: Optional[str] From 4996c8b1c10cebfc02308e26920ddff706d2cd47 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Mon, 19 Aug 2024 16:10:31 -0700 Subject: [PATCH 36/45] Rename things --- runtimes/eoapi/auth/eoapi/auth/__init__.py | 2 +- runtimes/eoapi/auth/eoapi/auth/auth.py | 2 +- runtimes/eoapi/raster/eoapi/raster/app.py | 2 +- runtimes/eoapi/stac/eoapi/stac/app.py | 2 +- runtimes/eoapi/vector/eoapi/vector/app.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/runtimes/eoapi/auth/eoapi/auth/__init__.py b/runtimes/eoapi/auth/eoapi/auth/__init__.py index 47f96be..f8cf385 100644 --- a/runtimes/eoapi/auth/eoapi/auth/__init__.py +++ b/runtimes/eoapi/auth/eoapi/auth/__init__.py @@ -1,4 +1,4 @@ -from .auth import OidcAuth # noqa +from .auth import OpenIdConnectAuth # noqa from .config import AuthSettings # noqa __version__ = "0.1.0" diff --git a/runtimes/eoapi/auth/eoapi/auth/auth.py b/runtimes/eoapi/auth/eoapi/auth/auth.py index 69f622d..1fa2537 100644 --- a/runtimes/eoapi/auth/eoapi/auth/auth.py +++ b/runtimes/eoapi/auth/eoapi/auth/auth.py @@ -16,7 +16,7 @@ @dataclass -class OidcAuth: +class OpenIdConnectAuth: openid_configuration_url: AnyHttpUrl openid_configuration_internal_url: Optional[AnyHttpUrl] = None allowed_jwt_audiences: Optional[Sequence[str]] = None diff --git a/runtimes/eoapi/raster/eoapi/raster/app.py b/runtimes/eoapi/raster/eoapi/raster/app.py index e2d71e9..603d37f 100644 --- a/runtimes/eoapi/raster/eoapi/raster/app.py +++ b/runtimes/eoapi/raster/eoapi/raster/app.py @@ -415,7 +415,7 @@ def landing(request: Request): # Add dependencies to routes if auth_settings.openid_configuration_url and not auth_settings.public_reads: - oidc_auth = auth.OidcAuth( + oidc_auth = auth.OpenIdConnectAuth( # URL to the OpenID Connect discovery document (https://openid.net/specs/openid-connect-discovery-1_0.html) openid_configuration_url=auth_settings.openid_configuration_url, openid_configuration_internal_url=auth_settings.openid_configuration_internal_url, diff --git a/runtimes/eoapi/stac/eoapi/stac/app.py b/runtimes/eoapi/stac/eoapi/stac/app.py index 2023723..a27b34a 100644 --- a/runtimes/eoapi/stac/eoapi/stac/app.py +++ b/runtimes/eoapi/stac/eoapi/stac/app.py @@ -164,7 +164,7 @@ async def viewer_page(request: Request): if auth_settings.openid_configuration_url: - oidc_auth = auth.OidcAuth( + oidc_auth = auth.OpenIdConnectAuth( # URL to the OpenID Connect discovery document (https://openid.net/specs/openid-connect-discovery-1_0.html) openid_configuration_url=auth_settings.openid_configuration_url, openid_configuration_internal_url=auth_settings.openid_configuration_internal_url, diff --git a/runtimes/eoapi/vector/eoapi/vector/app.py b/runtimes/eoapi/vector/eoapi/vector/app.py index 9e77eb3..6cf0720 100644 --- a/runtimes/eoapi/vector/eoapi/vector/app.py +++ b/runtimes/eoapi/vector/eoapi/vector/app.py @@ -177,7 +177,7 @@ async def refresh(request: Request): if auth_settings.openid_configuration_url and not auth_settings.public_reads: - oidc_auth = auth.OidcAuth( + oidc_auth = auth.OpenIdConnectAuth( # URL to the OpenID Connect discovery document (https://openid.net/specs/openid-connect-discovery-1_0.html) openid_configuration_url=auth_settings.openid_configuration_url, openid_configuration_internal_url=auth_settings.openid_configuration_internal_url, From ca88be8685142addd111aa23898c8f12f89d87e9 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Mon, 19 Aug 2024 16:14:44 -0700 Subject: [PATCH 37/45] Fix version path --- runtimes/eoapi/auth/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runtimes/eoapi/auth/pyproject.toml b/runtimes/eoapi/auth/pyproject.toml index 65ecc96..0494644 100644 --- a/runtimes/eoapi/auth/pyproject.toml +++ b/runtimes/eoapi/auth/pyproject.toml @@ -3,7 +3,7 @@ build-backend = "pdm.pep517.api" requires = ["pdm-pep517"] [tool.pdm.version] -path = "eoapi/auth.py" +path = "eoapi/auth/__init__.py" source = "file" [tool.pdm.build] From 51e58d742ca3ac19179770a9eb72e2cc0d057073 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 20 Aug 2024 14:34:00 -0700 Subject: [PATCH 38/45] Apply suggestions from code review Co-authored-by: Vincent Sarago --- dockerfiles/Dockerfile.raster | 2 +- dockerfiles/Dockerfile.stac | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dockerfiles/Dockerfile.raster b/dockerfiles/Dockerfile.raster index 884d135..94ae72a 100644 --- a/dockerfiles/Dockerfile.raster +++ b/dockerfiles/Dockerfile.raster @@ -10,7 +10,7 @@ COPY runtimes/eoapi/raster /tmp/raster RUN python -m pip install /tmp/raster RUN rm -rf /tmp/raster COPY runtimes/eoapi/auth /tmp/auth -RUN pip install /tmp/auth +RUN python -m pip install /tmp/auth RUN rm -rf /tmp/auth ENV MODULE_NAME eoapi.raster.app diff --git a/dockerfiles/Dockerfile.stac b/dockerfiles/Dockerfile.stac index e8db475..301b47c 100644 --- a/dockerfiles/Dockerfile.stac +++ b/dockerfiles/Dockerfile.stac @@ -8,7 +8,7 @@ COPY runtimes/eoapi/stac /tmp/stac RUN python -m pip install /tmp/stac RUN rm -rf /tmp/stac COPY runtimes/eoapi/auth /tmp/auth -RUN pip install /tmp/auth +RUN python -m pip install /tmp/auth RUN rm -rf /tmp/auth ENV MODULE_NAME eoapi.stac.app From a7e6333b345c9bdee6ee2a1c284a1ff4f88279f7 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Mon, 19 Aug 2024 16:30:33 -0700 Subject: [PATCH 39/45] Mv dependency --- runtimes/eoapi/auth/pyproject.toml | 2 ++ runtimes/eoapi/stac/pyproject.toml | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/runtimes/eoapi/auth/pyproject.toml b/runtimes/eoapi/auth/pyproject.toml index 0494644..a838129 100644 --- a/runtimes/eoapi/auth/pyproject.toml +++ b/runtimes/eoapi/auth/pyproject.toml @@ -18,6 +18,8 @@ dependencies = [ "pydantic-settings>=2.2.1", "pyjwt>=2.9.0", "cryptography>=43.0.0", + "fastapi-authorization-gateway>=0.0.3" + ] description = "Authentication module for EOAPI" dynamic = ["version"] diff --git a/runtimes/eoapi/stac/pyproject.toml b/runtimes/eoapi/stac/pyproject.toml index e4612f1..bf7cf43 100644 --- a/runtimes/eoapi/stac/pyproject.toml +++ b/runtimes/eoapi/stac/pyproject.toml @@ -24,7 +24,6 @@ dependencies = [ "starlette-cramjam>=0.3,<0.4", "importlib_resources>=1.1.0;python_version<'3.9'", "psycopg_pool", - "fastapi-authorization-gateway<=0.0.3" ] [project.optional-dependencies] From f1037a9cb0e02a61d962a1f1de36ebd850dce4f0 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 20 Aug 2024 16:27:55 -0700 Subject: [PATCH 40/45] Use published eoapi.auth-utils pkg --- dockerfiles/Dockerfile.raster | 3 - dockerfiles/Dockerfile.stac | 3 - dockerfiles/Dockerfile.vector | 3 - runtimes/eoapi/auth/README.md | 0 runtimes/eoapi/auth/eoapi/auth/__init__.py | 4 - runtimes/eoapi/auth/eoapi/auth/auth.py | 145 --------------------- runtimes/eoapi/auth/eoapi/auth/config.py | 22 ---- runtimes/eoapi/auth/eoapi/auth/types.py | 14 -- runtimes/eoapi/auth/pyproject.toml | 42 ------ runtimes/eoapi/raster/pyproject.toml | 1 + runtimes/eoapi/stac/pyproject.toml | 1 + runtimes/eoapi/vector/pyproject.toml | 1 + 12 files changed, 3 insertions(+), 236 deletions(-) delete mode 100644 runtimes/eoapi/auth/README.md delete mode 100644 runtimes/eoapi/auth/eoapi/auth/__init__.py delete mode 100644 runtimes/eoapi/auth/eoapi/auth/auth.py delete mode 100644 runtimes/eoapi/auth/eoapi/auth/config.py delete mode 100644 runtimes/eoapi/auth/eoapi/auth/types.py delete mode 100644 runtimes/eoapi/auth/pyproject.toml diff --git a/dockerfiles/Dockerfile.raster b/dockerfiles/Dockerfile.raster index 94ae72a..06ded2c 100644 --- a/dockerfiles/Dockerfile.raster +++ b/dockerfiles/Dockerfile.raster @@ -9,9 +9,6 @@ RUN python -m pip install psycopg[binary,pool] COPY runtimes/eoapi/raster /tmp/raster RUN python -m pip install /tmp/raster RUN rm -rf /tmp/raster -COPY runtimes/eoapi/auth /tmp/auth -RUN python -m pip install /tmp/auth -RUN rm -rf /tmp/auth ENV MODULE_NAME eoapi.raster.app ENV VARIABLE_NAME app diff --git a/dockerfiles/Dockerfile.stac b/dockerfiles/Dockerfile.stac index 301b47c..cfcd493 100644 --- a/dockerfiles/Dockerfile.stac +++ b/dockerfiles/Dockerfile.stac @@ -7,9 +7,6 @@ ENV CURL_CA_BUNDLE /etc/ssl/certs/ca-certificates.crt COPY runtimes/eoapi/stac /tmp/stac RUN python -m pip install /tmp/stac RUN rm -rf /tmp/stac -COPY runtimes/eoapi/auth /tmp/auth -RUN python -m pip install /tmp/auth -RUN rm -rf /tmp/auth ENV MODULE_NAME eoapi.stac.app ENV VARIABLE_NAME app diff --git a/dockerfiles/Dockerfile.vector b/dockerfiles/Dockerfile.vector index dd8a9d7..b4ba5b8 100644 --- a/dockerfiles/Dockerfile.vector +++ b/dockerfiles/Dockerfile.vector @@ -5,9 +5,6 @@ FROM ghcr.io/vincentsarago/uvicorn-gunicorn:${PYTHON_VERSION} COPY runtimes/eoapi/vector /tmp/vector RUN python -m pip install /tmp/vector RUN rm -rf /tmp/vector -COPY runtimes/eoapi/auth /tmp/auth -RUN pip install /tmp/auth -RUN rm -rf /tmp/auth ENV MODULE_NAME eoapi.vector.app ENV VARIABLE_NAME app diff --git a/runtimes/eoapi/auth/README.md b/runtimes/eoapi/auth/README.md deleted file mode 100644 index e69de29..0000000 diff --git a/runtimes/eoapi/auth/eoapi/auth/__init__.py b/runtimes/eoapi/auth/eoapi/auth/__init__.py deleted file mode 100644 index f8cf385..0000000 --- a/runtimes/eoapi/auth/eoapi/auth/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .auth import OpenIdConnectAuth # noqa -from .config import AuthSettings # noqa - -__version__ = "0.1.0" diff --git a/runtimes/eoapi/auth/eoapi/auth/auth.py b/runtimes/eoapi/auth/eoapi/auth/auth.py deleted file mode 100644 index 1fa2537..0000000 --- a/runtimes/eoapi/auth/eoapi/auth/auth.py +++ /dev/null @@ -1,145 +0,0 @@ -import json -import logging -import urllib.request -from dataclasses import dataclass, field -from typing import Annotated, Any, Callable, Dict, Optional, Sequence - -import jwt -from fastapi import HTTPException, Security, routing, security, status -from fastapi.dependencies.utils import get_parameterless_sub_dependant -from fastapi.security.base import SecurityBase -from pydantic import AnyHttpUrl - -from .types import OidcFetchError - -logger = logging.getLogger(__name__) - - -@dataclass -class OpenIdConnectAuth: - openid_configuration_url: AnyHttpUrl - openid_configuration_internal_url: Optional[AnyHttpUrl] = None - allowed_jwt_audiences: Optional[Sequence[str]] = None - oauth2_supported_scopes: Dict[str, str] = field(default_factory=dict) - - # Generated attributes - auth_scheme: SecurityBase = field(init=False) - jwks_client: jwt.PyJWKClient = field(init=False) - valid_token_dependency: Callable[..., Any] = field(init=False) - - def __post_init__(self): - logger.debug("Requesting OIDC config") - with urllib.request.urlopen( - str(self.openid_configuration_internal_url or self.openid_configuration_url) - ) as response: - if response.status != 200: - logger.error( - "Received a non-200 response when fetching OIDC config: %s", - response.text, - ) - raise OidcFetchError( - f"Request for OIDC config failed with status {response.status}" - ) - oidc_config = json.load(response) - self.jwks_client = jwt.PyJWKClient(oidc_config["jwks_uri"]) - - self.auth_scheme = security.OpenIdConnect( - openIdConnectUrl=str(self.openid_configuration_url) - ) - self.valid_token_dependency = self.create_auth_token_dependency( - auth_scheme=self.auth_scheme, - jwks_client=self.jwks_client, - allowed_jwt_audiences=self.allowed_jwt_audiences, - ) - - @staticmethod - def create_auth_token_dependency( - auth_scheme: SecurityBase, - jwks_client: jwt.PyJWKClient, - allowed_jwt_audiences: Sequence[str], - ): - """ - Create a dependency that validates JWT tokens & scopes. - """ - - def auth_token( - token_str: Annotated[str, Security(auth_scheme)], - required_scopes: security.SecurityScopes, - ): - token_parts = token_str.split(" ") - if len(token_parts) != 2 or token_parts[0].lower() != "bearer": - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid authorization header", - headers={"WWW-Authenticate": "Bearer"}, - ) - else: - [_, token] = token_parts - # Parse & validate token - try: - payload = jwt.decode( - token, - jwks_client.get_signing_key_from_jwt(token).key, - algorithms=["RS256"], - # NOTE: Audience validation MUST match audience claim if set in token (https://pyjwt.readthedocs.io/en/stable/changelog.html?highlight=audience#id40) - audience=allowed_jwt_audiences, - ) - except jwt.exceptions.InvalidTokenError as e: - logger.exception(f"InvalidTokenError: {e=}") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) from e - - # Validate scopes (if required) - for scope in required_scopes.scopes: - if scope not in payload["scope"]: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Not enough permissions", - headers={ - "WWW-Authenticate": f'Bearer scope="{required_scopes.scope_str}"' - }, - ) - - return payload - - return auth_token - - def apply_auth_dependencies( - self, - api_route: routing.APIRoute, - required_token_scopes: Optional[Sequence[str]] = None, - dependency: Optional[Callable[..., Any]] = None, - ): - """ - Apply auth dependencies to a route. - """ - # Ignore paths without dependants, e.g. /api, /api.html, /docs/oauth2-redirect - if not hasattr(api_route, "dependant"): - logger.warn( - f"Route {api_route} has no dependant, not apply auth dependency" - ) - return - - depends = Security( - dependency or self.valid_token_dependency, scopes=required_token_scopes - ) - logger.debug(f"{depends} -> {','.join(api_route.methods)} @ {api_route.path}") - - # Mimicking how APIRoute handles dependencies: - # https://github.com/tiangolo/fastapi/blob/1760da0efa55585c19835d81afa8ca386036c325/fastapi/routing.py#L408-L412 - api_route.dependant.dependencies.insert( - 0, - get_parameterless_sub_dependant( - depends=depends, path=api_route.path_format - ), - ) - - # Register dependencies directly on route so that they aren't ignored if - # the routes are later associated with an app (e.g. - # app.include_router(router)) - # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/applications.py#L337-L360 - # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/routing.py#L677-L678 - api_route.dependencies.extend([depends]) diff --git a/runtimes/eoapi/auth/eoapi/auth/config.py b/runtimes/eoapi/auth/eoapi/auth/config.py deleted file mode 100644 index 54ca6e2..0000000 --- a/runtimes/eoapi/auth/eoapi/auth/config.py +++ /dev/null @@ -1,22 +0,0 @@ -from typing import Optional, Sequence - -from pydantic import AnyHttpUrl -from pydantic_settings import BaseSettings - - -class AuthSettings(BaseSettings): - # Swagger UI config for Authorization Code Flow - client_id: str = "" - use_pkce: bool = True - openid_configuration_url: Optional[AnyHttpUrl] = None - openid_configuration_internal_url: Optional[AnyHttpUrl] = None - - allowed_jwt_audiences: Optional[Sequence[str]] = [] - - public_reads: bool = True - - model_config = { - "env_prefix": "EOAPI_AUTH_", - "env_file": ".env", - "extra": "allow", - } diff --git a/runtimes/eoapi/auth/eoapi/auth/types.py b/runtimes/eoapi/auth/eoapi/auth/types.py deleted file mode 100644 index 166ca8b..0000000 --- a/runtimes/eoapi/auth/eoapi/auth/types.py +++ /dev/null @@ -1,14 +0,0 @@ -from typing import Optional, TypedDict - - -class OidcFetchError(Exception): - pass - - -class Scope(TypedDict, total=False): - """More strict version of Starlette's Scope.""" - - # https://github.com/encode/starlette/blob/6af5c515e0a896cbf3f86ee043b88f6c24200bcf/starlette/types.py#L3 - path: str - method: str - type: Optional[str] diff --git a/runtimes/eoapi/auth/pyproject.toml b/runtimes/eoapi/auth/pyproject.toml deleted file mode 100644 index a838129..0000000 --- a/runtimes/eoapi/auth/pyproject.toml +++ /dev/null @@ -1,42 +0,0 @@ -[build-system] -build-backend = "pdm.pep517.api" -requires = ["pdm-pep517"] - -[tool.pdm.version] -path = "eoapi/auth/__init__.py" -source = "file" - -[tool.pdm.build] -excludes = ["tests/", "**/.mypy_cache", "**/.DS_Store"] -includes = ["eoapi"] - -[project] -authors = [ - {name = "Anthony Lukach", email = "anthony@developmentseed.org"}, -] -dependencies = [ - "pydantic-settings>=2.2.1", - "pyjwt>=2.9.0", - "cryptography>=43.0.0", - "fastapi-authorization-gateway>=0.0.3" - -] -description = "Authentication module for EOAPI" -dynamic = ["version"] -license = {text = "MIT"} -name = "eoapi.auth" -readme = "README.md" -requires-python = ">=3.8" - -[project.optional-dependencies] -testing = [ - "pytest>=6.0", - "coverage", -] - -[tool.setuptools] -include-package-data = true -py-modules = ["eoapi.auth"] - -[tool.setuptools.packages.find] -where = ["src"] diff --git a/runtimes/eoapi/raster/pyproject.toml b/runtimes/eoapi/raster/pyproject.toml index 4743b83..6ea732d 100644 --- a/runtimes/eoapi/raster/pyproject.toml +++ b/runtimes/eoapi/raster/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ "titiler.extensions", "starlette-cramjam>=0.3,<0.4", "importlib_resources>=1.1.0;python_version<'3.9'", + "eoapi.auth-utils", ] [project.optional-dependencies] diff --git a/runtimes/eoapi/stac/pyproject.toml b/runtimes/eoapi/stac/pyproject.toml index bf7cf43..2a93866 100644 --- a/runtimes/eoapi/stac/pyproject.toml +++ b/runtimes/eoapi/stac/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ "starlette-cramjam>=0.3,<0.4", "importlib_resources>=1.1.0;python_version<'3.9'", "psycopg_pool", + "eoapi.auth-utils", ] [project.optional-dependencies] diff --git a/runtimes/eoapi/vector/pyproject.toml b/runtimes/eoapi/vector/pyproject.toml index 52a57d0..f5eb56c 100644 --- a/runtimes/eoapi/vector/pyproject.toml +++ b/runtimes/eoapi/vector/pyproject.toml @@ -21,6 +21,7 @@ classifiers = [ dynamic = ["version"] dependencies = [ "tipg==0.7.1", + "eoapi.auth-utils", ] [project.optional-dependencies] From 2e11d2f61e359228ed32d8ba5c5b285bf0581418 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 21 Aug 2024 10:57:27 -0700 Subject: [PATCH 41/45] Rework imports --- runtimes/eoapi/raster/eoapi/raster/app.py | 13 +++++++------ runtimes/eoapi/stac/eoapi/stac/app.py | 10 +++++----- runtimes/eoapi/vector/eoapi/vector/app.py | 13 +++++++------ 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/runtimes/eoapi/raster/eoapi/raster/app.py b/runtimes/eoapi/raster/eoapi/raster/app.py index 603d37f..9ab4986 100644 --- a/runtimes/eoapi/raster/eoapi/raster/app.py +++ b/runtimes/eoapi/raster/eoapi/raster/app.py @@ -7,7 +7,7 @@ import jinja2 import pystac -from eoapi import auth +from eoapi.auth_utils import AuthSettings, OpenIdConnectAuth from fastapi import Depends, FastAPI, Query from psycopg import OperationalError from psycopg.rows import dict_row @@ -39,14 +39,15 @@ from titiler.pgstac.reader import PgSTACReader from . import __version__ as eoapi_raster_version -from . import config, logs +from .config import ApiSettings +from .logs import init_logging -settings = config.ApiSettings() -auth_settings = auth.AuthSettings() +settings = ApiSettings() +auth_settings = AuthSettings() # Logs -logs.init_logging( +init_logging( debug=settings.debug, loggers={ "botocore.credentials": { @@ -415,7 +416,7 @@ def landing(request: Request): # Add dependencies to routes if auth_settings.openid_configuration_url and not auth_settings.public_reads: - oidc_auth = auth.OpenIdConnectAuth( + oidc_auth = OpenIdConnectAuth( # URL to the OpenID Connect discovery document (https://openid.net/specs/openid-connect-discovery-1_0.html) openid_configuration_url=auth_settings.openid_configuration_url, openid_configuration_internal_url=auth_settings.openid_configuration_internal_url, diff --git a/runtimes/eoapi/stac/eoapi/stac/app.py b/runtimes/eoapi/stac/eoapi/stac/app.py index a27b34a..3f42604 100644 --- a/runtimes/eoapi/stac/eoapi/stac/app.py +++ b/runtimes/eoapi/stac/eoapi/stac/app.py @@ -3,7 +3,7 @@ import logging from contextlib import asynccontextmanager -from eoapi import auth +from eoapi.auth_utils import AuthSettings, OpenIdConnectAuth from fastapi import FastAPI from fastapi.responses import ORJSONResponse from stac_fastapi.api.app import StacApi @@ -46,12 +46,12 @@ templates = Jinja2Templates(directory=str(resources_files(__package__) / "templates")) # type: ignore -api_settings = config.ApiSettings() -auth_settings = auth.AuthSettings() +api_settings = ApiSettings() +auth_settings = AuthSettings() settings = Settings(enable_response_models=True) # Logs -logs.init_logging(debug=api_settings.debug) +init_logging(debug=api_settings.debug) logger = logging.getLogger(__name__) # Extensions @@ -164,7 +164,7 @@ async def viewer_page(request: Request): if auth_settings.openid_configuration_url: - oidc_auth = auth.OpenIdConnectAuth( + oidc_auth = OpenIdConnectAuth( # URL to the OpenID Connect discovery document (https://openid.net/specs/openid-connect-discovery-1_0.html) openid_configuration_url=auth_settings.openid_configuration_url, openid_configuration_internal_url=auth_settings.openid_configuration_internal_url, diff --git a/runtimes/eoapi/vector/eoapi/vector/app.py b/runtimes/eoapi/vector/eoapi/vector/app.py index 6cf0720..1afe2e1 100644 --- a/runtimes/eoapi/vector/eoapi/vector/app.py +++ b/runtimes/eoapi/vector/eoapi/vector/app.py @@ -4,7 +4,7 @@ from contextlib import asynccontextmanager import jinja2 -from eoapi import auth +from eoapi.auth_utils import AuthSettings, OpenIdConnectAuth from fastapi import FastAPI, Request from starlette.middleware.cors import CORSMiddleware from starlette.templating import Jinja2Templates @@ -17,7 +17,8 @@ from tipg.settings import PostgresSettings from . import __version__ as eoapi_vector_version -from . import config, logs +from .config import ApiSettings +from .logs import init_logging try: from importlib.resources import files as resources_files # type: ignore @@ -28,12 +29,12 @@ CUSTOM_SQL_DIRECTORY = resources_files(__package__) / "sql" -settings = config.ApiSettings() +settings = ApiSettings() postgres_settings = PostgresSettings() -auth_settings = auth.AuthSettings() +auth_settings = AuthSettings() # Logs -logs.init_logging( +init_logging( debug=settings.debug, loggers={ "botocore.credentials": { @@ -177,7 +178,7 @@ async def refresh(request: Request): if auth_settings.openid_configuration_url and not auth_settings.public_reads: - oidc_auth = auth.OpenIdConnectAuth( + oidc_auth = OpenIdConnectAuth( # URL to the OpenID Connect discovery document (https://openid.net/specs/openid-connect-discovery-1_0.html) openid_configuration_url=auth_settings.openid_configuration_url, openid_configuration_internal_url=auth_settings.openid_configuration_internal_url, From 9810b7d6283771d001ea5e2659a7f2f98054ae3b Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 21 Aug 2024 10:57:27 -0700 Subject: [PATCH 42/45] Rework imports --- runtimes/eoapi/stac/eoapi/stac/app.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/runtimes/eoapi/stac/eoapi/stac/app.py b/runtimes/eoapi/stac/eoapi/stac/app.py index 3f42604..48f92d2 100644 --- a/runtimes/eoapi/stac/eoapi/stac/app.py +++ b/runtimes/eoapi/stac/eoapi/stac/app.py @@ -35,7 +35,9 @@ from starlette.templating import Jinja2Templates from starlette_cramjam.middleware import CompressionMiddleware -from . import config, extension, logs +from .config import ApiSettings +from .extension import TiTilerExtension +from .logs import init_logging try: from importlib.resources import files as resources_files # type: ignore @@ -68,7 +70,7 @@ "filter": FilterExtension(client=FiltersClient()), "bulk_transactions": BulkTransactionExtension(client=BulkTransactionsClient()), "titiler": ( - extension.TiTilerExtension(titiler_endpoint=api_settings.titiler_endpoint) + TiTilerExtension(titiler_endpoint=api_settings.titiler_endpoint) if api_settings.titiler_endpoint else None ), From a7b01639db1b310ec90efaff31222cfb127a014b Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 21 Aug 2024 14:51:02 -0700 Subject: [PATCH 43/45] Upgrade auth dep, use convenience method --- runtimes/eoapi/raster/eoapi/raster/app.py | 10 +--------- runtimes/eoapi/raster/pyproject.toml | 2 +- runtimes/eoapi/stac/eoapi/stac/app.py | 10 +--------- runtimes/eoapi/stac/pyproject.toml | 2 +- runtimes/eoapi/vector/eoapi/vector/app.py | 10 +--------- runtimes/eoapi/vector/pyproject.toml | 2 +- 6 files changed, 6 insertions(+), 30 deletions(-) diff --git a/runtimes/eoapi/raster/eoapi/raster/app.py b/runtimes/eoapi/raster/eoapi/raster/app.py index 9ab4986..5d450de 100644 --- a/runtimes/eoapi/raster/eoapi/raster/app.py +++ b/runtimes/eoapi/raster/eoapi/raster/app.py @@ -416,15 +416,7 @@ def landing(request: Request): # Add dependencies to routes if auth_settings.openid_configuration_url and not auth_settings.public_reads: - oidc_auth = OpenIdConnectAuth( - # URL to the OpenID Connect discovery document (https://openid.net/specs/openid-connect-discovery-1_0.html) - openid_configuration_url=auth_settings.openid_configuration_url, - openid_configuration_internal_url=auth_settings.openid_configuration_internal_url, - # Optionally validate the "aud" claim in the JWT - allowed_jwt_audiences=auth_settings.allowed_jwt_audiences, - # To render scopes form on Swagger UI's login pop-up, populate with mapping of scopes to descriptions - oauth2_supported_scopes={}, - ) + oidc_auth = OpenIdConnectAuth.from_settings(auth_settings) restricted_prefixes = ["/searches", "/collections"] for route in app.routes: diff --git a/runtimes/eoapi/raster/pyproject.toml b/runtimes/eoapi/raster/pyproject.toml index 6ea732d..7df3ff2 100644 --- a/runtimes/eoapi/raster/pyproject.toml +++ b/runtimes/eoapi/raster/pyproject.toml @@ -24,7 +24,7 @@ dependencies = [ "titiler.extensions", "starlette-cramjam>=0.3,<0.4", "importlib_resources>=1.1.0;python_version<'3.9'", - "eoapi.auth-utils", + "eoapi.auth-utils>=0.2.0", ] [project.optional-dependencies] diff --git a/runtimes/eoapi/stac/eoapi/stac/app.py b/runtimes/eoapi/stac/eoapi/stac/app.py index 48f92d2..da04eb9 100644 --- a/runtimes/eoapi/stac/eoapi/stac/app.py +++ b/runtimes/eoapi/stac/eoapi/stac/app.py @@ -166,15 +166,7 @@ async def viewer_page(request: Request): if auth_settings.openid_configuration_url: - oidc_auth = OpenIdConnectAuth( - # URL to the OpenID Connect discovery document (https://openid.net/specs/openid-connect-discovery-1_0.html) - openid_configuration_url=auth_settings.openid_configuration_url, - openid_configuration_internal_url=auth_settings.openid_configuration_internal_url, - # Optionally validate the "aud" claim in the JWT - allowed_jwt_audiences=auth_settings.allowed_jwt_audiences, - # To render scopes form on Swagger UI's login pop-up, populate with mapping of scopes to descriptions - oauth2_supported_scopes={}, - ) + oidc_auth = OpenIdConnectAuth.from_settings(auth_settings) restricted_prefixes_methods = { "/collections": [ "POST", diff --git a/runtimes/eoapi/stac/pyproject.toml b/runtimes/eoapi/stac/pyproject.toml index 2a93866..f5cfcbc 100644 --- a/runtimes/eoapi/stac/pyproject.toml +++ b/runtimes/eoapi/stac/pyproject.toml @@ -24,7 +24,7 @@ dependencies = [ "starlette-cramjam>=0.3,<0.4", "importlib_resources>=1.1.0;python_version<'3.9'", "psycopg_pool", - "eoapi.auth-utils", + "eoapi.auth-utils>=0.2.0", ] [project.optional-dependencies] diff --git a/runtimes/eoapi/vector/eoapi/vector/app.py b/runtimes/eoapi/vector/eoapi/vector/app.py index 1afe2e1..3839e50 100644 --- a/runtimes/eoapi/vector/eoapi/vector/app.py +++ b/runtimes/eoapi/vector/eoapi/vector/app.py @@ -178,15 +178,7 @@ async def refresh(request: Request): if auth_settings.openid_configuration_url and not auth_settings.public_reads: - oidc_auth = OpenIdConnectAuth( - # URL to the OpenID Connect discovery document (https://openid.net/specs/openid-connect-discovery-1_0.html) - openid_configuration_url=auth_settings.openid_configuration_url, - openid_configuration_internal_url=auth_settings.openid_configuration_internal_url, - # Optionally validate the "aud" claim in the JWT - allowed_jwt_audiences=auth_settings.allowed_jwt_audiences, - # To render scopes form on Swagger UI's login pop-up, populate with mapping of scopes to descriptions - oauth2_supported_scopes={}, - ) + oidc_auth = OpenIdConnectAuth.from_settings(auth_settings) restricted_prefixes = ["/collections"] for route in app.routes: diff --git a/runtimes/eoapi/vector/pyproject.toml b/runtimes/eoapi/vector/pyproject.toml index f5eb56c..0b307f1 100644 --- a/runtimes/eoapi/vector/pyproject.toml +++ b/runtimes/eoapi/vector/pyproject.toml @@ -21,7 +21,7 @@ classifiers = [ dynamic = ["version"] dependencies = [ "tipg==0.7.1", - "eoapi.auth-utils", + "eoapi.auth-utils>=0.2.0", ] [project.optional-dependencies] From 896f18592471622fd20e2c706b7dd732414925be Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 21 Aug 2024 15:18:10 -0700 Subject: [PATCH 44/45] Simplify (rm concept of public_reads) --- runtimes/eoapi/raster/eoapi/raster/app.py | 9 ++++----- runtimes/eoapi/stac/eoapi/stac/app.py | 19 +++++-------------- runtimes/eoapi/vector/eoapi/vector/app.py | 7 +++---- 3 files changed, 12 insertions(+), 23 deletions(-) diff --git a/runtimes/eoapi/raster/eoapi/raster/app.py b/runtimes/eoapi/raster/eoapi/raster/app.py index 5d450de..b129560 100644 --- a/runtimes/eoapi/raster/eoapi/raster/app.py +++ b/runtimes/eoapi/raster/eoapi/raster/app.py @@ -415,14 +415,13 @@ def landing(request: Request): # Add dependencies to routes -if auth_settings.openid_configuration_url and not auth_settings.public_reads: +if auth_settings.openid_configuration_url: oidc_auth = OpenIdConnectAuth.from_settings(auth_settings) - restricted_prefixes = ["/searches", "/collections"] + restricted_prefixes = ["/collections", "/searches"] for route in app.routes: - if not any( + if any( route.path.startswith(f"{app.root_path}{prefix}") for prefix in restricted_prefixes ): - continue - oidc_auth.apply_auth_dependencies(route, required_token_scopes=[]) + oidc_auth.apply_auth_dependencies(route, required_token_scopes=[]) diff --git a/runtimes/eoapi/stac/eoapi/stac/app.py b/runtimes/eoapi/stac/eoapi/stac/app.py index da04eb9..b4bbca0 100644 --- a/runtimes/eoapi/stac/eoapi/stac/app.py +++ b/runtimes/eoapi/stac/eoapi/stac/app.py @@ -167,20 +167,11 @@ async def viewer_page(request: Request): if auth_settings.openid_configuration_url: oidc_auth = OpenIdConnectAuth.from_settings(auth_settings) - restricted_prefixes_methods = { - "/collections": [ - "POST", - "PUT", - "DELETE", - *([] if auth_settings.public_reads else ["GET"]), - ], - "/search": [] if auth_settings.public_reads else ["POST", "GET"], - } + + restricted_prefixes = ["/collections", "/search"] for route in app.routes: - restricted = any( + if any( route.path.startswith(f"{app.root_path}{prefix}") - and set(route.methods).intersection(set(restricted_methods)) - for prefix, restricted_methods in restricted_prefixes_methods.items() - ) - if restricted: + for prefix in restricted_prefixes + ): oidc_auth.apply_auth_dependencies(route, required_token_scopes=[]) diff --git a/runtimes/eoapi/vector/eoapi/vector/app.py b/runtimes/eoapi/vector/eoapi/vector/app.py index 3839e50..53032ba 100644 --- a/runtimes/eoapi/vector/eoapi/vector/app.py +++ b/runtimes/eoapi/vector/eoapi/vector/app.py @@ -177,14 +177,13 @@ async def refresh(request: Request): return request.app.state.collection_catalog -if auth_settings.openid_configuration_url and not auth_settings.public_reads: +if auth_settings.openid_configuration_url: oidc_auth = OpenIdConnectAuth.from_settings(auth_settings) restricted_prefixes = ["/collections"] for route in app.routes: - if not any( + if any( route.path.startswith(f"{app.root_path}{prefix}") for prefix in restricted_prefixes ): - continue - oidc_auth.apply_auth_dependencies(route, required_token_scopes=[]) + oidc_auth.apply_auth_dependencies(route, required_token_scopes=[]) From 6a14ee165380d1e664d6fb0338b8f0f4dc06f1b9 Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Thu, 22 Aug 2024 11:49:20 +0200 Subject: [PATCH 45/45] AuthSettings -> OpenIdConnectSettings --- runtimes/eoapi/raster/eoapi/raster/app.py | 4 ++-- runtimes/eoapi/stac/eoapi/stac/app.py | 4 ++-- runtimes/eoapi/vector/eoapi/vector/app.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/runtimes/eoapi/raster/eoapi/raster/app.py b/runtimes/eoapi/raster/eoapi/raster/app.py index b129560..68c893e 100644 --- a/runtimes/eoapi/raster/eoapi/raster/app.py +++ b/runtimes/eoapi/raster/eoapi/raster/app.py @@ -7,7 +7,7 @@ import jinja2 import pystac -from eoapi.auth_utils import AuthSettings, OpenIdConnectAuth +from eoapi.auth_utils import OpenIdConnectAuth, OpenIdConnectSettings from fastapi import Depends, FastAPI, Query from psycopg import OperationalError from psycopg.rows import dict_row @@ -43,7 +43,7 @@ from .logs import init_logging settings = ApiSettings() -auth_settings = AuthSettings() +auth_settings = OpenIdConnectSettings() # Logs diff --git a/runtimes/eoapi/stac/eoapi/stac/app.py b/runtimes/eoapi/stac/eoapi/stac/app.py index b4bbca0..2f42296 100644 --- a/runtimes/eoapi/stac/eoapi/stac/app.py +++ b/runtimes/eoapi/stac/eoapi/stac/app.py @@ -3,7 +3,7 @@ import logging from contextlib import asynccontextmanager -from eoapi.auth_utils import AuthSettings, OpenIdConnectAuth +from eoapi.auth_utils import OpenIdConnectAuth, OpenIdConnectSettings from fastapi import FastAPI from fastapi.responses import ORJSONResponse from stac_fastapi.api.app import StacApi @@ -49,7 +49,7 @@ templates = Jinja2Templates(directory=str(resources_files(__package__) / "templates")) # type: ignore api_settings = ApiSettings() -auth_settings = AuthSettings() +auth_settings = OpenIdConnectSettings() settings = Settings(enable_response_models=True) # Logs diff --git a/runtimes/eoapi/vector/eoapi/vector/app.py b/runtimes/eoapi/vector/eoapi/vector/app.py index 53032ba..25493aa 100644 --- a/runtimes/eoapi/vector/eoapi/vector/app.py +++ b/runtimes/eoapi/vector/eoapi/vector/app.py @@ -4,7 +4,7 @@ from contextlib import asynccontextmanager import jinja2 -from eoapi.auth_utils import AuthSettings, OpenIdConnectAuth +from eoapi.auth_utils import OpenIdConnectAuth, OpenIdConnectSettings from fastapi import FastAPI, Request from starlette.middleware.cors import CORSMiddleware from starlette.templating import Jinja2Templates @@ -31,7 +31,7 @@ settings = ApiSettings() postgres_settings = PostgresSettings() -auth_settings = AuthSettings() +auth_settings = OpenIdConnectSettings() # Logs init_logging(