Skip to content

Commit ca0bc58

Browse files
committed
Refactor for cleanliness
1 parent 11317ce commit ca0bc58

File tree

2 files changed

+107
-3
lines changed

2 files changed

+107
-3
lines changed

runtime/eoapi/stac/eoapi/stac/app.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
"""FastAPI application using PGStac."""
22

33
from contextlib import asynccontextmanager
4+
from typing import Annotated, Any, Dict
45

6+
import jwt
57
from eoapi.stac.config import ApiSettings, TilesApiSettings
68
from eoapi.stac.extension import TiTilerExtension
79
from eoapi.stac.extension import extensions_map as PgStacExtensions
8-
from fastapi import FastAPI
10+
from fastapi import FastAPI, HTTPException, Security, security, status
911
from fastapi.responses import ORJSONResponse
1012
from stac_fastapi.api.app import StacApi
1113
from stac_fastapi.api.models import create_get_request_model, create_post_request_model
@@ -19,6 +21,8 @@
1921
from starlette.templating import Jinja2Templates
2022
from starlette_cramjam.middleware import CompressionMiddleware
2123

24+
from .auth import KeycloakAuth
25+
2226
try:
2327
from importlib.resources import files as resources_files # type: ignore
2428
except ImportError:
@@ -32,6 +36,13 @@
3236
tiles_settings = TilesApiSettings()
3337
settings = Settings()
3438

39+
keycloak = KeycloakAuth(
40+
realm="eoapi",
41+
client_id="stac-api",
42+
host="http://localhost:8080",
43+
internal_host="http://keycloak:8080",
44+
)
45+
3546

3647
@asynccontextmanager
3748
async def lifespan(app: FastAPI):
@@ -54,7 +65,15 @@ async def lifespan(app: FastAPI):
5465
GETModel = create_get_request_model(extensions)
5566

5667
api = StacApi(
57-
app=FastAPI(title=api_settings.name, lifespan=lifespan),
68+
app=FastAPI(
69+
title=api_settings.name,
70+
lifespan=lifespan,
71+
swagger_ui_init_oauth={
72+
"appName": "eoAPI",
73+
"clientId": keycloak.client_id,
74+
"usePkceWithAuthorizationCodeGrant": True,
75+
},
76+
),
5877
title=api_settings.name,
5978
description=api_settings.name,
6079
settings=settings,
@@ -84,10 +103,18 @@ async def lifespan(app: FastAPI):
84103

85104

86105
@app.get("/index.html", response_class=HTMLResponse)
87-
async def viewer_page(request: Request):
106+
async def viewer_page(
107+
request: Request, token: Annotated[str, Security(keycloak.scheme)]
108+
):
88109
"""Search viewer."""
89110
return templates.TemplateResponse(
90111
"stac-viewer.html",
91112
{"request": request, "endpoint": str(request.url).replace("/index.html", "")},
92113
media_type="text/html",
93114
)
115+
116+
117+
@app.get("/user", tags=["auth"])
118+
def get_user(user_token: Annotated[Dict[Any, Any], Security(keycloak.user_validator)]):
119+
"""View auth token."""
120+
return user_token

runtime/eoapi/stac/eoapi/stac/auth.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
from typing import Annotated, Iterable, Optional
2+
from dataclasses import dataclass
3+
from functools import cached_property
4+
5+
import jwt
6+
from fastapi import FastAPI, HTTPException, Security, security, status
7+
8+
9+
@dataclass
10+
class KeycloakAuth:
11+
realm: str
12+
host: str
13+
client_id: str
14+
internal_host: Optional[str] = None
15+
16+
required_audience: Optional[str | Iterable[str]] = None
17+
18+
def _build_url(self, host: str):
19+
return f"{host}/realms/{self.realm}/protocol/openid-connect"
20+
21+
@property
22+
def user_validator(
23+
self,
24+
):
25+
def valid_user_token(
26+
token_str: Annotated[str, Security(self.scheme)],
27+
required_scopes: security.SecurityScopes,
28+
):
29+
# Parse & validate token
30+
try:
31+
token = jwt.decode(
32+
token_str,
33+
self.jwks_client.get_signing_key_from_jwt(token_str).key,
34+
algorithms=["RS256"],
35+
audience=self.required_audience,
36+
)
37+
except jwt.exceptions.DecodeError as e:
38+
raise HTTPException(
39+
status_code=status.HTTP_401_UNAUTHORIZED,
40+
detail="Could not validate credentials",
41+
headers={"WWW-Authenticate": "Bearer"},
42+
) from e
43+
44+
# Validate scopes (if required)
45+
for scope in required_scopes.scopes:
46+
if scope not in token["scope"]:
47+
raise HTTPException(
48+
status_code=status.HTTP_401_UNAUTHORIZED,
49+
detail="Not enough permissions",
50+
headers={
51+
"WWW-Authenticate": f'Bearer scope="{required_scopes.scope_str}"'
52+
},
53+
)
54+
55+
return token
56+
57+
return valid_user_token
58+
59+
@property
60+
def internal_keycloak_api(self):
61+
return self._build_url(self.internal_host or self.host)
62+
63+
@property
64+
def keycloak_api(self):
65+
return self._build_url(self.host)
66+
67+
@property
68+
def scheme(self):
69+
return security.OAuth2AuthorizationCodeBearer(
70+
authorizationUrl=f"{self.keycloak_api}/auth",
71+
tokenUrl=f"{self.keycloak_api}/token",
72+
scopes={},
73+
)
74+
75+
@cached_property
76+
def jwks_client(self):
77+
return jwt.PyJWKClient(f"{self.internal_keycloak_api}/certs")

0 commit comments

Comments
 (0)