Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ name = "quartz-api"
dynamic = ["version"] # Set automatically using git: https://setuptools-git-versioning.readthedocs.io/en/stable/
description = "Quartz API for wind and solar data"
readme = {file = "README.md", content-type = "text/markdown"}
requires-python = ">=3.11.0"
requires-python = ">=3.11.0, <3.14"
license = {text = "MIT License"}
authors = [
{ name = "Sol Cotton", email = "sol@openclimatefix.org"},
Expand All @@ -30,6 +30,7 @@ dependencies = [
"sentry-sdk >= 2.1.1",
"pyhocon>=0.3.61",
"apitally[fastapi]>=0.22.3",
"auth0-fastapi-api>=1.0.0b5",
]

[dependency-groups]
Expand Down
14 changes: 7 additions & 7 deletions src/quartz_api/cmd/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,9 @@ def _create_server(conf: ConfigTree) -> FastAPI:
],
docs_url="/swagger",
redoc_url=None,
swagger_ui_init_oauth={
"usePkceWithAuthorizationCodeGrant": True,
},
)

# Add the default routes
Expand Down Expand Up @@ -196,17 +199,14 @@ def redoc_html() -> FileResponse:

# Override dependencies according to configuration
match (conf.get_string("auth0.domain"), conf.get_string("auth0.audience")):
case (_, "") | ("", _):
auth_instance = auth.DummyAuth()
server.dependency_overrides[auth.get_auth] = auth_instance
case (_, "") | ("", _) | ("", ""):
auth.auth_instance.instantiate_dummy()
log.warning("disabled authentication. NOT recommended for production")
case (domain, audience):
auth_instance = auth.Auth0(
auth.auth_instance.instantiate_auth0(
domain=domain,
api_audience=audience,
algorithm="RS256",
audience=audience,
)
server.dependency_overrides[auth.get_auth] = auth_instance
case _:
raise ValueError("Invalid Auth0 configuration")

Expand Down
1 change: 1 addition & 0 deletions src/quartz_api/cmd/server.conf
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ auth0 {
domain = ${?AUTH0_DOMAIN}
audience = ""
audience = ${?AUTH0_AUDIENCE}
client_id = "not-sure-yet"
}

// Sentry configuration
Expand Down
128 changes: 69 additions & 59 deletions src/quartz_api/internal/middleware/auth.py
Original file line number Diff line number Diff line change
@@ -1,76 +1,86 @@
"""Authentication dependency for FastAPI using Auth0 JWT tokens."""

# ruff: noqa: B008
import logging
from collections.abc import Awaitable, Callable
from typing import Annotated

import jwt
from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

token_auth_scheme = HTTPBearer()
from fastapi.security import HTTPBearer
from fastapi_plugin.fast_api_client import Auth0FastAPI

log = logging.getLogger(__name__)
EMAIL_KEY = "https://openclimatefix.org/email"

# Uninstantiated OAuth2 scheme that enables authorization button in swagger.
# Must be overwritten when configuring server.
oauth2_scheme = HTTPBearer(auto_error=False)

class Auth0:
"""Fast api dependency that validates an JWT token."""

def __init__(self, domain: str, api_audience: str, algorithm: str) -> None:
"""Initialize the Auth dependency."""
self._domain = domain
self._api_audience = api_audience
self._algorithm = algorithm
class DummyBackend:
"""Mock backend for testing without auth."""

self._jwks_client = jwt.PyJWKClient(f"https://{domain}/.well-known/jwks.json")
def __init__(self) -> None:
"""Initialize the dummy backend."""
log.warning("Using DummyBackend for authentication. This should not be used in production!")

def __call__(
def require_auth(
self,
request: Request,
auth_credentials: HTTPAuthorizationCredentials = Depends(token_auth_scheme),
) -> dict[str, str]:
"""Validate the JWT token and return the payload."""
token = auth_credentials.credentials

try:
signing_key = self._jwks_client.get_signing_key_from_jwt(token).key
except (jwt.exceptions.PyJWKClientError, jwt.exceptions.DecodeError) as e:
raise HTTPException(status_code=401, detail=str(e)) from e

try:
payload: dict[str, str] = jwt.decode(
token,
signing_key,
algorithms=self._algorithm,
audience=self._api_audience,
issuer=f"https://{self._domain}/",
)
except Exception as e:
raise HTTPException(status_code=401, detail=str(e)) from e

request.state.auth = payload

return payload


class DummyAuth:
"""Dummy auth dependency for testing purposes."""

def __call__(self) -> dict[str, str]:
"""Return a dummy authentication payload."""
return {
EMAIL_KEY: "test@test.com",
"sub": "google-oath2|012345678909876543210",
}

def get_auth() -> dict[str, str]:
"""Get the authentication payload.

Note: This should be overridden via FastAPI's dependency injection system with an actual
authentication method (e.g., Auth0 or DummyAuth).
scopes: str | list[str] | None = None, # noqa: ARG002
) -> Callable[[Request], Awaitable[dict[str, str]]]:
"""Return a simulated authentication function."""
async def _dummy_dependency(_: Request) -> dict[str, str]:
return {
"sub": "dummy|123456",
EMAIL_KEY: "test@test.com",
"scope": "openid profile email",
}
return _dummy_dependency

class AuthClient:
"""Generic client interface for authorization.

Must be instantiated with a backend implementation.
"""
raise HTTPException(status_code=401, detail="No authentication method configured.")

AuthDependency = Annotated[dict[str, str], Depends(get_auth)]
def __init__(self) -> None:
"""Initialize with a dummy backend by default."""
self._backend: Auth0FastAPI | DummyBackend | None = None

def instantiate_auth0(self, domain: str, audience: str) -> None:
"""Instantiate the Auth0 backend."""
self._backend = Auth0FastAPI(
domain=domain,
audience=audience,
)

def instantiate_dummy(self) -> None:
"""Instantiate the dummy backend."""
self._backend = DummyBackend()

def require_auth(self, scopes: str | list[str] | None = None) -> Callable[[Request], Awaitable[dict[str, str]]]: # noqa
"""Authentication function to be used as a FastAPI dependency."""
async def _proxy_dependency(
request: Request,
token: str = Depends(oauth2_scheme), # noqa: ARG001
) -> dict[str, str]:
if self._backend is None:
raise HTTPException(status_code=500, detail="Auth backend not configured")

validator_dependency = self._backend.require_auth(scopes)
try:
claims = await validator_dependency(request)
except HTTPException as e:
if e.status_code == 403:
log.info(f"Unauthorized access attempt: {e.detail}")

raise e

return claims

return _proxy_dependency

auth_instance = AuthClient()

AuthDependency = Annotated[dict[str, str], Depends(auth_instance.require_auth())]

def get_oauth_id_from_sub(auth0_sub: str) -> str:
"""Extract the auth ID from a auth0 sub ID.
Expand Down
27 changes: 10 additions & 17 deletions src/quartz_api/internal/middleware/sentry.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from collections.abc import Awaitable, Callable

from fastapi import FastAPI, Request, Response
from fastapi.security import HTTPAuthorizationCredentials
from starlette.middleware.base import BaseHTTPMiddleware

from quartz_api.internal.middleware import auth
Expand All @@ -18,7 +17,7 @@ class SentryUserMiddleware(BaseHTTPMiddleware):
def __init__(
self,
server: FastAPI,
auth_instance: auth.Auth0 | auth.DummyAuth,
auth_instance: auth.AuthClient | None,
) -> None:
"""Initialize FastAPI server and auth instance."""
super().__init__(server)
Expand All @@ -31,23 +30,17 @@ async def dispatch(
) -> Response:
"""Add user details to a context before processing request."""
if self.auth_instance is not None and not isinstance(
self.auth_instance, auth.DummyAuth,
self.auth_instance, auth.DummyBackend,
):
try:
authorization = request.headers.get("Authorization", "")
if authorization.startswith("Bearer "):
token = authorization.replace("Bearer ", "")
credentials = HTTPAuthorizationCredentials(
scheme="Bearer", credentials=token,
)
payload = self.auth_instance(request, credentials)
if payload:
import sentry_sdk

sentry_sdk.set_user({
"id": payload.get("sub"),
"email": payload.get(auth.EMAIL_KEY),
})
payload = await self.auth_instance.require_auth()(request)
if payload:
import sentry_sdk

sentry_sdk.set_user({
"id": payload.get("sub"),
"email": payload.get(auth.EMAIL_KEY),
})
except Exception:
# silently fail to not break requests
log.debug("Could not extract user for Sentry")
Expand Down
Loading