Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add optional auth tooling #8

Merged
merged 48 commits into from
Aug 22, 2024
Merged
Show file tree
Hide file tree
Changes from 34 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
7273fb2
Add optional auth tooling
alukach Jul 18, 2024
c64186c
Fix imports
alukach Jul 24, 2024
b542594
Run pre-commit
alukach Jul 24, 2024
66830f5
Refactor
alukach Jul 26, 2024
2a26318
Use PKCE by default
alukach Jul 26, 2024
7fa43d1
Update auth.py
alukach Jul 26, 2024
aec1edb
Merge branch 'main' into feature/add-auth
alukach Aug 2, 2024
54d4eda
Working OIDC example
alukach Aug 7, 2024
b4e4353
Support OIDC in stac-browser
alukach Aug 7, 2024
31b60f5
Cleanup
alukach Aug 7, 2024
0d79d87
Add logging
alukach Aug 7, 2024
a08b758
In progress auth for raster
alukach Aug 7, 2024
58e256b
Raster: Finalize / cleanup
alukach Aug 8, 2024
886ca38
STAC: cleanup
alukach Aug 8, 2024
996f5a6
Vector: add auth support
alukach Aug 8, 2024
c0d699a
Raster: add client id to swagger ui
alukach Aug 8, 2024
0dc2503
Cleanup
alukach Aug 8, 2024
8ef74c7
STAC: Use same auth module as others
alukach Aug 8, 2024
d3a1bc0
Rename
alukach Aug 8, 2024
c92bb11
Cleanup imports
alukach Aug 9, 2024
a836183
Auth: Update logging
alukach Aug 9, 2024
b24c612
Don't buffer Python output
alukach Aug 9, 2024
74fcfc3
Add logging, refactor imports
alukach Aug 9, 2024
357809b
Undo .env & pythonbuffered changes
alukach Aug 10, 2024
3587e2b
Raster: Rm all but auth changes
alukach Aug 10, 2024
9d28d25
Stac: Rm all but required auth code
alukach Aug 10, 2024
0a01be1
Revert unnecessary gitignore change
alukach Aug 10, 2024
ac41c1b
Vector: fixup imports
alukach Aug 10, 2024
d87f15c
Precommit: fix imports
alukach Aug 10, 2024
4975469
Pre-commit: fix titiler extension
alukach Aug 10, 2024
26fe978
Add stac browser config to env example
alukach Aug 10, 2024
c248b7b
Merge branch 'main' into feature/add-auth
alukach Aug 12, 2024
92f4465
Pre-commit fix
alukach Aug 12, 2024
b784e1c
Simplify
alukach Aug 14, 2024
1a71e98
Merge branch 'main' of https://github.com/developmentseed/eoapi-devse…
vincentsarago Aug 19, 2024
94628bd
Rm version (deprecated)
alukach Aug 19, 2024
aa30f7d
Breakout auth tooling into separate module
alukach Aug 19, 2024
78b75a0
Breakout into files
alukach Aug 19, 2024
4996c8b
Rename things
alukach Aug 19, 2024
ca88be8
Fix version path
alukach Aug 19, 2024
51e58d7
Apply suggestions from code review
alukach Aug 20, 2024
a7e6333
Mv dependency
alukach Aug 19, 2024
f1037a9
Use published eoapi.auth-utils pkg
alukach Aug 20, 2024
2e11d2f
Rework imports
alukach Aug 21, 2024
9810b7d
Rework imports
alukach Aug 21, 2024
a7b0163
Upgrade auth dep, use convenience method
alukach Aug 21, 2024
896f185
Simplify (rm concept of public_reads)
alukach Aug 21, 2024
6a14ee1
AuthSettings -> OpenIdConnectSettings
vincentsarago Aug 22, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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/<user pool id>/.well-known/openid-configuration
EOAPI_AUTH_USE_PKCE=true
SB_authConfig={ "type": "openIdConnect", "openIdConnectUrl": "https://cognito-idp.us-east-1.amazonaws.com/<user pool id>/.well-known/openid-configuration", "oidcOptions": { "client_id": "stac-browser" } }
3 changes: 1 addition & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,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
Expand Down
4 changes: 2 additions & 2 deletions dockerfiles/Dockerfile.browser
Original file line number Diff line number Diff line change
Expand Up @@ -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/<!-- <script defer="defer" src=".\/config.js"><\/script> -->/<script defer="defer" src=".\/config.js"><\/script>/g' public/index.html
RUN \[ "${DYNAMIC_CONFIG}" == "true" \] && sed -i "s|<!-- <script defer=\"defer\" src=\"/config.js\"></script> -->|<script defer=\"defer\" src=\"${pathPrefix}config.js\"></script>|g" public/index.html
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical for getting custom config working.

RUN npm run build


Expand All @@ -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
96 changes: 96 additions & 0 deletions dockerfiles/docker-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Customization added in radiantearth/stac-browser#461

object "$value"
;;
array)
array "$value" "$arraytype"
;;
*)
safe_echo "$value"
;;
esac
echo ","
done
echo "}"
} >/usr/share/nginx/html/config.js
30 changes: 29 additions & 1 deletion runtimes/eoapi/raster/eoapi/raster/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,11 @@
from titiler.pgstac.reader import PgSTACReader

from . import __version__ as eoapi_raster_version
from . import config, logs
from . import auth, config, logs

settings = config.ApiSettings()
auth_settings = auth.AuthSettings()


# Logs
logs.init_logging(
Expand Down Expand Up @@ -95,6 +97,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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when client_id is set to "" does Swagger understand that its unavailable?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The clientId property populates the value on the auth form's text input (docs). As such, an empty string is equivalent to null

"usePkceWithAuthorizationCodeGrant": auth_settings.use_pkce,
},
)
add_exception_handlers(app, DEFAULT_STATUS_CODES)
add_exception_handlers(app, MOSAIC_STATUS_CODES)
Expand Down Expand Up @@ -404,3 +410,25 @@ def landing(request: Request):
"urlparams": str(request.url.query),
},
)


# Add dependencies to routes
if auth_settings.openid_configuration_url and not auth_settings.public_reads:
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,
# 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={},
)

restricted_prefixes = ["/searches", "/collections"]
for route in app.routes:
if not any(
route.path.startswith(f"{app.root_path}{prefix}")
for prefix in restricted_prefixes
):
continue
oidc_auth.apply_auth_dependencies(route, required_token_scopes=[])
175 changes: 175 additions & 0 deletions runtimes/eoapi/raster/eoapi/raster/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
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
2 changes: 2 additions & 0 deletions runtimes/eoapi/raster/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading