Skip to content
Draft
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
44 changes: 16 additions & 28 deletions carbonserver/carbonserver/api/routers/authenticate.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import base64
import json
import logging
import random
from typing import Optional
from authlib.integrations.starlette_client import OAuth, OAuthError

import requests
from dependency_injector.wiring import Provide, inject
Expand Down Expand Up @@ -83,31 +85,16 @@ async def get_login(
if auth_provider is None:
raise HTTPException(status_code=501, detail="Authentication not configured")
login_url = request.url_for("login")

if code:
client_id, client_secret = auth_provider.get_client_credentials()
res = requests.post(
auth_provider.get_token_endpoint(),
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": login_url,
"client_id": client_id,
"client_secret": client_secret,
},
)

# check if the user exists in local DB ; create if needed
if "id_token" not in res.json():
if "access_token" not in res.json():
return Response(content="Invalid code", status_code=400)
# get profile data from auth provider if not present in response
id_token = await auth_provider.get_user_info(res.json()["access_token"])
sign_up_service.check_jwt_user(id_token)
else:
sign_up_service.check_jwt_user(res.json()["id_token"], create=True)

creds = base64.b64encode(res.content).decode()
try:
token = await auth_provider.client.authorize_access_token(request)
except OAuthError as error:
return "Error"
user = token.get("userinfo")
if user:
request.session["user"] = dict(user)

creds = base64.b64encode(json.dumps(token).encode()).decode()
base_url = request.base_url
if settings.frontend_url != "":
base_url = settings.frontend_url + "/"
Expand All @@ -127,14 +114,15 @@ async def get_login(

response.set_cookie(
SESSION_COOKIE_NAME,
res.json()["access_token"],
token["access_token"],
httponly=True,
secure=True,
)
return response
return await auth_provider.get_authorize_url(request, str(login_url))

state = str(int(random.random() * 1000))
client_id, _ = auth_provider.get_client_credentials()
authorize_url = auth_provider.get_authorize_endpoint()
url = f"{authorize_url}?response_type=code&client_id={client_id}&redirect_uri={login_url}&scope={' '.join(OAUTH_SCOPES)}&state={state}"
return RedirectResponse(url=url)
return await auth_provider.client.authorize_redirect(
request, str(login_url), scope=" ".join(OAUTH_SCOPES)
)
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,26 @@
import asyncio
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlencode
from carbonserver.config import settings

import httpx
from fastapi_oidc import discovery
from fastapi_oidc import discovery, get_auth
from jose import jwt

DEFAULT_SIGNATURE_CACHE_TTL = 3600 # seconds

OAUTH_SCOPES = ["openid", "email", "profile"]

from authlib.integrations.starlette_client import OAuth
oauth = OAuth()
oauth.register(
"client",
client_id=settings.oidc_client_id,
client_secret=settings.oidc_client_secret,
server_metadata_url=settings.oidc_well_known_url,
client_kwargs={"scope": "openid profile email"},
)

class OIDCAuthProvider:
"""
Generic OIDC authentication provider implementation.

This class uses OIDC discovery and validation (via fastapi-oidc) to interact with
any OIDC-compliant authentication server (such as Fief, Keycloak, Auth0, etc.).
"""

def __init__(
self,
base_url: str,
Expand All @@ -33,191 +37,12 @@ def __init__(
signature_cache_ttl: int = DEFAULT_SIGNATURE_CACHE_TTL,
openid_configuration: Optional[Dict[str, Any]] = None,
):
"""
Initialize the OIDC authentication provider.

Args:
base_url: The OIDC issuer URL (base URL of the authentication server)
client_id: The OAuth2 client ID
client_secret: The OAuth2 client secret
signature_cache_ttl: Seconds to cache the OIDC discovery/JWKS responses
openid_configuration: Optional pre-loaded OIDC configuration (used mainly for testing)
"""
self.base_url = base_url.rstrip("/")
self.client_id = client_id
self.client_secret = client_secret
self._discovery = discovery.configure(cache_ttl=signature_cache_ttl)
self._openid_configuration = openid_configuration

async def _get_openid_configuration(self) -> Dict[str, Any]:
if self._openid_configuration is None:
self._openid_configuration = await asyncio.to_thread(
self._discovery.auth_server, base_url=self.base_url
)
return self._openid_configuration

async def _get_jwks(self) -> Dict[str, Any]:
oidc_config = await self._get_openid_configuration()
return await asyncio.to_thread(self._discovery.public_keys, oidc_config)

async def _get_algorithms(self) -> List[str]:
oidc_config = await self._get_openid_configuration()
return await asyncio.to_thread(self._discovery.signing_algos, oidc_config)

async def _decode_token(self, token: str) -> Dict[str, Any]:
oidc_config = await self._get_openid_configuration()
jwks = await self._get_jwks()
algorithms = await self._get_algorithms()
return jwt.decode(
token,
jwks,
algorithms=algorithms,
issuer=oidc_config.get("issuer", self.base_url),
options={"verify_aud": False, "verify_at_hash": False},
)

async def get_auth_url(
self, redirect_uri: str, scope: List[str], state: Optional[str] = None
) -> str:
"""
Generate the authorization URL for the OAuth2 flow.

Args:
redirect_uri: The URI to redirect to after authentication
scope: List of OAuth2 scopes to request
state: Optional state parameter for CSRF protection

Returns:
The authorization URL to redirect the user to
"""
oidc_config = await self._get_openid_configuration()
authorize_endpoint = oidc_config.get(
"authorization_endpoint", f"{self.base_url}/authorize"
)
params = {
"response_type": "code",
"client_id": self.client_id,
"redirect_uri": redirect_uri,
"scope": " ".join(scope),
}
if state is not None:
params["state"] = state

return f"{authorize_endpoint}?{urlencode(params)}"

async def handle_auth_callback(
self, code: str, redirect_uri: str
) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]:
"""
Handle the OAuth2 callback and exchange the code for tokens.
self.client = oauth._clients["client"]

Args:
code: The authorization code from the OAuth2 provider
redirect_uri: The redirect URI used in the initial auth request

Returns:
A tuple of (tokens, user_info) where:
- tokens: Dict containing access_token, refresh_token, expires_in, etc.
- user_info: Optional dict containing user information
"""
oidc_config = await self._get_openid_configuration()
token_endpoint = oidc_config.get("token_endpoint", f"{self.base_url}/api/token")
async with httpx.AsyncClient() as client:
response = await client.post(
token_endpoint,
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": self.client_id,
"client_secret": self.client_secret,
},
headers={"accept": "application/json"},
)
response.raise_for_status()
tokens: Dict[str, Any] = response.json()

user_info: Optional[Dict[str, Any]] = None
if "id_token" in tokens:
user_info = await self._decode_token(tokens["id_token"])
elif "access_token" in tokens:
try:
user_info = await self.get_user_info(tokens["access_token"])
except Exception:
# If userinfo fails we still return tokens
user_info = None

return (tokens, user_info)

async def validate_access_token(self, token: str) -> bool:
"""
Validate an access token.

Args:
token: The access token to validate

Returns:
True if the token is valid

Raises:
Exception if validation fails
"""
await self._decode_token(token)
return True

async def get_user_info(self, access_token: str) -> Dict[str, Any]:
"""
Get user information from the OIDC provider.

Args:
access_token: The access token for the user

Returns:
Dict containing user information (sub, email, name, etc.)
"""
oidc_config = await self._get_openid_configuration()
userinfo_endpoint = oidc_config.get(
"userinfo_endpoint", f"{self.base_url}/api/userinfo"
async def get_authorize_url(self, request, login_url):
return await self.client.authorize_redirect(
request, str(login_url), scope=" ".join(OAUTH_SCOPES)
)
headers = {"Authorization": f"Bearer {access_token}"}
async with httpx.AsyncClient() as client:
response = await client.get(userinfo_endpoint, headers=headers)
response.raise_for_status()
return response.json()

def get_token_endpoint(self) -> str:
"""
Get the token endpoint URL.

Returns:
The token endpoint URL
"""
if (
self._openid_configuration
and "token_endpoint" in self._openid_configuration
):
return self._openid_configuration["token_endpoint"]
return f"{self.base_url}/api/token"

def get_authorize_endpoint(self) -> str:
"""
Get the authorization endpoint URL.

Returns:
The authorization endpoint URL
"""
if (
self._openid_configuration
and "authorization_endpoint" in self._openid_configuration
):
return self._openid_configuration["authorization_endpoint"]
return f"{self.base_url}/authorize"

def get_client_credentials(self) -> Tuple[str, str]:
"""
Get the client ID and client secret.

Returns:
A tuple of (client_id, client_secret)
"""
return (self.client_id, self.client_secret)
return (self.client.client_id, self.client.client_secret)
28 changes: 0 additions & 28 deletions carbonserver/carbonserver/api/services/auth_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,34 +25,6 @@ class FullUser:
SESSION_COOKIE_NAME = "user_session"


def get_oauth_scheme(
auth_provider: Optional[OIDCAuthProvider],
) -> OAuth2AuthorizationCodeBearer:
"""
Get the OAuth2 scheme for the configured auth provider.

Args:
auth_provider: The authentication provider instance (None if auth disabled)

Returns:
OAuth2AuthorizationCodeBearer configured for the provider
"""
if auth_provider is None:
# Return a dummy scheme when auth is disabled
return OAuth2AuthorizationCodeBearer(
"http://localhost/authorize",
"http://localhost/token",
scopes={x: x for x in OAUTH_SCOPES},
auto_error=False,
)
return OAuth2AuthorizationCodeBearer(
auth_provider.get_authorize_endpoint(),
auth_provider.get_token_endpoint(),
scopes={x: x for x in OAUTH_SCOPES},
auto_error=False,
)


web_scheme = APIKeyCookie(name=SESSION_COOKIE_NAME, auto_error=False)


Expand Down
7 changes: 7 additions & 0 deletions carbonserver/carbonserver/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class Settings(BaseSettings):
oidc_client_id: str = ""
oidc_client_secret: str = ""
oidc_issuer_url: str = "https://auth.codecarbon.io/codecarbon-dev"
oidc_well_known_url: str = ""

# Deprecated: Old Fief-specific settings (use OIDC settings instead)
@property
Expand Down Expand Up @@ -43,6 +44,12 @@ class Config:
"oidc_client_id": {"env": ["OIDC_CLIENT_ID", "FIEF_CLIENT_ID"]},
"oidc_client_secret": {"env": ["OIDC_CLIENT_SECRET", "FIEF_CLIENT_SECRET"]},
"oidc_issuer_url": {"env": ["OIDC_ISSUER_URL", "FIEF_URL"]},
"oidc_well_known_url": {
"env": [
"OIDC_WELL_KNOWN_URL",
"FIEF_URL" + "/.well-known/openid-configuration",
]
},
}


Expand Down
2 changes: 2 additions & 0 deletions carbonserver/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from carbonserver.container import ServerContainer
from carbonserver.database.database import engine
from carbonserver.logger import logger
from starlette.middleware.sessions import SessionMiddleware


async def db_exception_handler(request: Request, exc: DBException):
Expand Down Expand Up @@ -54,6 +55,7 @@ def create_app() -> FastAPI:
server.add_exception_handler(DBException, db_exception_handler)
server.add_exception_handler(ValidationError, validation_exception_handler)
server.add_exception_handler(Exception, generic_exception_handler)
server.add_middleware(SessionMiddleware, secret_key="some-random-string")

return server

Expand Down
2 changes: 2 additions & 0 deletions carbonserver/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ dependencies = [
"PyJWT",
"logfire[fastapi]>=1.0.1",
"fastapi-oidc>=0.0.9",
"authlib>=1.6.6",
"itsdangerous>=2.2.0",
]

[project.urls]
Expand Down
Loading
Loading