-
Notifications
You must be signed in to change notification settings - Fork 3
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
Changes from 34 commits
7273fb2
c64186c
b542594
66830f5
2a26318
7fa43d1
aec1edb
54d4eda
b4e4353
31b60f5
0d79d87
a08b758
58e256b
886ca38
996f5a6
c0d699a
0dc2503
8ef74c7
d3a1bc0
c92bb11
a836183
b24c612
74fcfc3
357809b
3587e2b
9d28d25
0a01be1
ac41c1b
d87f15c
4975469
26fe978
c248b7b
92f4465
b784e1c
1a71e98
94628bd
aa30f7d
78b75a0
4996c8b
ca88be8
51e58d7
a7e6333
f1037a9
2e11d2f
9810b7d
a7b0163
896f185
6a14ee1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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( | ||
|
@@ -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, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. when client_id is set to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
"usePkceWithAuthorizationCodeGrant": auth_settings.use_pkce, | ||
}, | ||
) | ||
add_exception_handlers(app, DEFAULT_STATUS_CODES) | ||
add_exception_handlers(app, MOSAIC_STATUS_CODES) | ||
|
@@ -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=[]) |
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 |
There was a problem hiding this comment.
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.