diff --git a/.env.example b/.env.example index c93d5fd..11dba92 100644 --- a/.env.example +++ b/.env.example @@ -38,3 +38,8 @@ 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 +SB_authConfig={ "type": "openIdConnect", "openIdConnectUrl": "https://cognito-idp.us-east-1.amazonaws.com//.well-known/openid-configuration", "oidcOptions": { "client_id": "stac-browser" } } 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 diff --git a/docker-compose.yml b/docker-compose.yml index 2ec13b0..743afcd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,8 @@ -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 + # TODO: Rm when https://github.com/radiantearth/stac-browser/pull/461 is merged build: context: dockerfiles dockerfile: Dockerfile.browser 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 diff --git a/runtimes/eoapi/raster/eoapi/raster/app.py b/runtimes/eoapi/raster/eoapi/raster/app.py index fe8e3a8..68c893e 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.auth_utils import OpenIdConnectAuth, OpenIdConnectSettings from fastapi import Depends, FastAPI, Query from psycopg import OperationalError from psycopg.rows import dict_row @@ -38,12 +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 = ApiSettings() +auth_settings = OpenIdConnectSettings() -settings = config.ApiSettings() # Logs -logs.init_logging( +init_logging( debug=settings.debug, loggers={ "botocore.credentials": { @@ -95,6 +99,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) @@ -404,3 +412,16 @@ def landing(request: Request): "urlparams": str(request.url.query), }, ) + + +# Add dependencies to routes +if auth_settings.openid_configuration_url: + oidc_auth = OpenIdConnectAuth.from_settings(auth_settings) + + restricted_prefixes = ["/collections", "/searches"] + for route in app.routes: + if any( + route.path.startswith(f"{app.root_path}{prefix}") + for prefix in restricted_prefixes + ): + oidc_auth.apply_auth_dependencies(route, required_token_scopes=[]) diff --git a/runtimes/eoapi/raster/pyproject.toml b/runtimes/eoapi/raster/pyproject.toml index 4743b83..7df3ff2 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>=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 9fc1a57..2f42296 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.auth_utils import OpenIdConnectAuth, OpenIdConnectSettings from fastapi import FastAPI from fastapi.responses import ORJSONResponse from stac_fastapi.api.app import StacApi @@ -34,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 @@ -45,11 +48,12 @@ templates = Jinja2Templates(directory=str(resources_files(__package__) / "templates")) # type: ignore -api_settings = config.ApiSettings() +api_settings = ApiSettings() +auth_settings = OpenIdConnectSettings() 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 @@ -66,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 ), @@ -129,6 +133,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, @@ -155,3 +163,15 @@ async def viewer_page(request: Request): }, media_type="text/html", ) + + +if auth_settings.openid_configuration_url: + oidc_auth = OpenIdConnectAuth.from_settings(auth_settings) + + restricted_prefixes = ["/collections", "/search"] + for route in app.routes: + if any( + route.path.startswith(f"{app.root_path}{prefix}") + for prefix in restricted_prefixes + ): + oidc_auth.apply_auth_dependencies(route, required_token_scopes=[]) diff --git a/runtimes/eoapi/stac/pyproject.toml b/runtimes/eoapi/stac/pyproject.toml index bf7cf43..f5cfcbc 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>=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 0a1183e..25493aa 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.auth_utils import OpenIdConnectAuth, OpenIdConnectSettings from fastapi import FastAPI, Request from starlette.middleware.cors import CORSMiddleware from starlette.templating import Jinja2Templates @@ -16,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 @@ -27,11 +29,12 @@ CUSTOM_SQL_DIRECTORY = resources_files(__package__) / "sql" -settings = config.ApiSettings() +settings = ApiSettings() postgres_settings = PostgresSettings() +auth_settings = OpenIdConnectSettings() # Logs -logs.init_logging( +init_logging( debug=settings.debug, loggers={ "botocore.credentials": { @@ -88,6 +91,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 @@ -168,3 +175,15 @@ async def refresh(request: Request): ) return request.app.state.collection_catalog + + +if auth_settings.openid_configuration_url: + oidc_auth = OpenIdConnectAuth.from_settings(auth_settings) + + restricted_prefixes = ["/collections"] + for route in app.routes: + if any( + route.path.startswith(f"{app.root_path}{prefix}") + for prefix in restricted_prefixes + ): + oidc_auth.apply_auth_dependencies(route, required_token_scopes=[]) diff --git a/runtimes/eoapi/vector/pyproject.toml b/runtimes/eoapi/vector/pyproject.toml index 52a57d0..0b307f1 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>=0.2.0", ] [project.optional-dependencies]