generated from developmentseed/eoapi-template
-
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
Merged
Merged
Changes from 40 commits
Commits
Show all changes
48 commits
Select commit
Hold shift + click to select a range
7273fb2
Add optional auth tooling
alukach c64186c
Fix imports
alukach b542594
Run pre-commit
alukach 66830f5
Refactor
alukach 2a26318
Use PKCE by default
alukach 7fa43d1
Update auth.py
alukach aec1edb
Merge branch 'main' into feature/add-auth
alukach 54d4eda
Working OIDC example
alukach b4e4353
Support OIDC in stac-browser
alukach 31b60f5
Cleanup
alukach 0d79d87
Add logging
alukach a08b758
In progress auth for raster
alukach 58e256b
Raster: Finalize / cleanup
alukach 886ca38
STAC: cleanup
alukach 996f5a6
Vector: add auth support
alukach c0d699a
Raster: add client id to swagger ui
alukach 0dc2503
Cleanup
alukach 8ef74c7
STAC: Use same auth module as others
alukach d3a1bc0
Rename
alukach c92bb11
Cleanup imports
alukach a836183
Auth: Update logging
alukach b24c612
Don't buffer Python output
alukach 74fcfc3
Add logging, refactor imports
alukach 357809b
Undo .env & pythonbuffered changes
alukach 3587e2b
Raster: Rm all but auth changes
alukach 9d28d25
Stac: Rm all but required auth code
alukach 0a01be1
Revert unnecessary gitignore change
alukach ac41c1b
Vector: fixup imports
alukach d87f15c
Precommit: fix imports
alukach 4975469
Pre-commit: fix titiler extension
alukach 26fe978
Add stac browser config to env example
alukach c248b7b
Merge branch 'main' into feature/add-auth
alukach 92f4465
Pre-commit fix
alukach b784e1c
Simplify
alukach 1a71e98
Merge branch 'main' of https://github.com/developmentseed/eoapi-devse…
vincentsarago 94628bd
Rm version (deprecated)
alukach aa30f7d
Breakout auth tooling into separate module
alukach 78b75a0
Breakout into files
alukach 4996c8b
Rename things
alukach ca88be8
Fix version path
alukach 51e58d7
Apply suggestions from code review
alukach a7e6333
Mv dependency
alukach f1037a9
Use published eoapi.auth-utils pkg
alukach 2e11d2f
Rework imports
alukach 9810b7d
Rework imports
alukach a7b0163
Upgrade auth dep, use convenience method
alukach 896f185
Simplify (rm concept of public_reads)
alukach 6a14ee1
AuthSettings -> OpenIdConnectSettings
vincentsarago File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
from .auth import OpenIdConnectAuth # noqa | ||
from .config import AuthSettings # noqa | ||
|
||
__version__ = "0.1.0" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,145 @@ | ||
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]) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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", | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
from typing import Optional, TypedDict | ||
|
||
|
||
class OidcFetchError(Exception): | ||
pass | ||
alukach marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
|
||
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] |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.