Skip to content

Commit c06daf5

Browse files
prmths128inimaz
andauthored
use oauth lib for oidc (#1040)
* use oauth lib for oidc let the lib do the work instead of building requests and urls uses the well-known endpoint fixes the urls only working on fief * fix: add authlib + format code * fix: starlette version * use oauth lib for oidc let the lib do the work instead of building requests and urls uses the well-known endpoint fixes the urls only working on fief * fix: add authlib + format code * chore(carbonserver) adjust auth test * update lock file * fix: itsdangerous added to pyproject --------- Co-authored-by: inimaz <inigo.imazchacon@ansys.com> Co-authored-by: inimaz <93inigo93@gmail.com>
1 parent f13351a commit c06daf5

11 files changed

Lines changed: 6400 additions & 4786 deletions

File tree

carbonserver/carbonserver/api/routers/authenticate.py

Lines changed: 17 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import base64
2+
import json
23
import logging
34
import random
45
from typing import Optional
56

6-
import requests
7+
from authlib.integrations.starlette_client import OAuthError
78
from dependency_injector.wiring import Provide, inject
89
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
910
from fastapi.responses import RedirectResponse
@@ -83,31 +84,16 @@ async def get_login(
8384
if auth_provider is None:
8485
raise HTTPException(status_code=501, detail="Authentication not configured")
8586
login_url = request.url_for("login")
86-
8787
if code:
88-
client_id, client_secret = auth_provider.get_client_credentials()
89-
res = requests.post(
90-
auth_provider.get_token_endpoint(),
91-
data={
92-
"grant_type": "authorization_code",
93-
"code": code,
94-
"redirect_uri": login_url,
95-
"client_id": client_id,
96-
"client_secret": client_secret,
97-
},
98-
)
99-
100-
# check if the user exists in local DB ; create if needed
101-
if "id_token" not in res.json():
102-
if "access_token" not in res.json():
103-
return Response(content="Invalid code", status_code=400)
104-
# get profile data from auth provider if not present in response
105-
id_token = await auth_provider.get_user_info(res.json()["access_token"])
106-
sign_up_service.check_jwt_user(id_token)
107-
else:
108-
sign_up_service.check_jwt_user(res.json()["id_token"], create=True)
109-
110-
creds = base64.b64encode(res.content).decode()
88+
try:
89+
token = await auth_provider.client.authorize_access_token(request)
90+
except OAuthError:
91+
return "Error"
92+
user = token.get("userinfo")
93+
if user:
94+
request.session["user"] = dict(user)
95+
96+
creds = base64.b64encode(json.dumps(token).encode()).decode()
11197
base_url = request.base_url
11298
if settings.frontend_url != "":
11399
base_url = settings.frontend_url + "/"
@@ -127,14 +113,15 @@ async def get_login(
127113

128114
response.set_cookie(
129115
SESSION_COOKIE_NAME,
130-
res.json()["access_token"],
116+
token["access_token"],
131117
httponly=True,
132118
secure=True,
133119
)
134120
return response
121+
return await auth_provider.get_authorize_url(request, str(login_url))
135122

136-
state = str(int(random.random() * 1000))
123+
str(int(random.random() * 1000))
137124
client_id, _ = auth_provider.get_client_credentials()
138-
authorize_url = auth_provider.get_authorize_endpoint()
139-
url = f"{authorize_url}?response_type=code&client_id={client_id}&redirect_uri={login_url}&scope={' '.join(OAUTH_SCOPES)}&state={state}"
140-
return RedirectResponse(url=url)
125+
return await auth_provider.client.authorize_redirect(
126+
request, str(login_url), scope=" ".join(OAUTH_SCOPES)
127+
)

carbonserver/carbonserver/api/services/auth_providers/oidc_auth_provider.py

Lines changed: 19 additions & 196 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,27 @@
55
It can work with any OIDC-compliant provider (Fief, Keycloak, Auth0, etc.).
66
"""
77

8-
import asyncio
9-
from typing import Any, Dict, List, Optional, Tuple
10-
from urllib.parse import urlencode
8+
from typing import Any, Dict, Optional, Tuple
119

12-
import httpx
13-
from fastapi_oidc import discovery
14-
from jose import jwt
10+
from authlib.integrations.starlette_client import OAuth
11+
12+
from carbonserver.config import settings
1513

1614
DEFAULT_SIGNATURE_CACHE_TTL = 3600 # seconds
15+
OAUTH_SCOPES = ["openid", "email", "profile"]
1716

1817

19-
class OIDCAuthProvider:
20-
"""
21-
Generic OIDC authentication provider implementation.
18+
oauth = OAuth()
19+
oauth.register(
20+
"client",
21+
client_id=settings.oidc_client_id,
22+
client_secret=settings.oidc_client_secret,
23+
server_metadata_url=settings.oidc_well_known_url,
24+
client_kwargs={"scope": "openid profile email"},
25+
)
2226

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

28+
class OIDCAuthProvider:
2729
def __init__(
2830
self,
2931
base_url: str,
@@ -33,191 +35,12 @@ def __init__(
3335
signature_cache_ttl: int = DEFAULT_SIGNATURE_CACHE_TTL,
3436
openid_configuration: Optional[Dict[str, Any]] = None,
3537
):
36-
"""
37-
Initialize the OIDC authentication provider.
38-
39-
Args:
40-
base_url: The OIDC issuer URL (base URL of the authentication server)
41-
client_id: The OAuth2 client ID
42-
client_secret: The OAuth2 client secret
43-
signature_cache_ttl: Seconds to cache the OIDC discovery/JWKS responses
44-
openid_configuration: Optional pre-loaded OIDC configuration (used mainly for testing)
45-
"""
46-
self.base_url = base_url.rstrip("/")
47-
self.client_id = client_id
48-
self.client_secret = client_secret
49-
self._discovery = discovery.configure(cache_ttl=signature_cache_ttl)
50-
self._openid_configuration = openid_configuration
51-
52-
async def _get_openid_configuration(self) -> Dict[str, Any]:
53-
if self._openid_configuration is None:
54-
self._openid_configuration = await asyncio.to_thread(
55-
self._discovery.auth_server, base_url=self.base_url
56-
)
57-
return self._openid_configuration
58-
59-
async def _get_jwks(self) -> Dict[str, Any]:
60-
oidc_config = await self._get_openid_configuration()
61-
return await asyncio.to_thread(self._discovery.public_keys, oidc_config)
62-
63-
async def _get_algorithms(self) -> List[str]:
64-
oidc_config = await self._get_openid_configuration()
65-
return await asyncio.to_thread(self._discovery.signing_algos, oidc_config)
66-
67-
async def _decode_token(self, token: str) -> Dict[str, Any]:
68-
oidc_config = await self._get_openid_configuration()
69-
jwks = await self._get_jwks()
70-
algorithms = await self._get_algorithms()
71-
return jwt.decode(
72-
token,
73-
jwks,
74-
algorithms=algorithms,
75-
issuer=oidc_config.get("issuer", self.base_url),
76-
options={"verify_aud": False, "verify_at_hash": False},
77-
)
78-
79-
async def get_auth_url(
80-
self, redirect_uri: str, scope: List[str], state: Optional[str] = None
81-
) -> str:
82-
"""
83-
Generate the authorization URL for the OAuth2 flow.
84-
85-
Args:
86-
redirect_uri: The URI to redirect to after authentication
87-
scope: List of OAuth2 scopes to request
88-
state: Optional state parameter for CSRF protection
89-
90-
Returns:
91-
The authorization URL to redirect the user to
92-
"""
93-
oidc_config = await self._get_openid_configuration()
94-
authorize_endpoint = oidc_config.get(
95-
"authorization_endpoint", f"{self.base_url}/authorize"
96-
)
97-
params = {
98-
"response_type": "code",
99-
"client_id": self.client_id,
100-
"redirect_uri": redirect_uri,
101-
"scope": " ".join(scope),
102-
}
103-
if state is not None:
104-
params["state"] = state
105-
106-
return f"{authorize_endpoint}?{urlencode(params)}"
107-
108-
async def handle_auth_callback(
109-
self, code: str, redirect_uri: str
110-
) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]:
111-
"""
112-
Handle the OAuth2 callback and exchange the code for tokens.
38+
self.client = oauth._clients["client"]
11339

114-
Args:
115-
code: The authorization code from the OAuth2 provider
116-
redirect_uri: The redirect URI used in the initial auth request
117-
118-
Returns:
119-
A tuple of (tokens, user_info) where:
120-
- tokens: Dict containing access_token, refresh_token, expires_in, etc.
121-
- user_info: Optional dict containing user information
122-
"""
123-
oidc_config = await self._get_openid_configuration()
124-
token_endpoint = oidc_config.get("token_endpoint", f"{self.base_url}/api/token")
125-
async with httpx.AsyncClient() as client:
126-
response = await client.post(
127-
token_endpoint,
128-
data={
129-
"grant_type": "authorization_code",
130-
"code": code,
131-
"redirect_uri": redirect_uri,
132-
"client_id": self.client_id,
133-
"client_secret": self.client_secret,
134-
},
135-
headers={"accept": "application/json"},
136-
)
137-
response.raise_for_status()
138-
tokens: Dict[str, Any] = response.json()
139-
140-
user_info: Optional[Dict[str, Any]] = None
141-
if "id_token" in tokens:
142-
user_info = await self._decode_token(tokens["id_token"])
143-
elif "access_token" in tokens:
144-
try:
145-
user_info = await self.get_user_info(tokens["access_token"])
146-
except Exception:
147-
# If userinfo fails we still return tokens
148-
user_info = None
149-
150-
return (tokens, user_info)
151-
152-
async def validate_access_token(self, token: str) -> bool:
153-
"""
154-
Validate an access token.
155-
156-
Args:
157-
token: The access token to validate
158-
159-
Returns:
160-
True if the token is valid
161-
162-
Raises:
163-
Exception if validation fails
164-
"""
165-
await self._decode_token(token)
166-
return True
167-
168-
async def get_user_info(self, access_token: str) -> Dict[str, Any]:
169-
"""
170-
Get user information from the OIDC provider.
171-
172-
Args:
173-
access_token: The access token for the user
174-
175-
Returns:
176-
Dict containing user information (sub, email, name, etc.)
177-
"""
178-
oidc_config = await self._get_openid_configuration()
179-
userinfo_endpoint = oidc_config.get(
180-
"userinfo_endpoint", f"{self.base_url}/api/userinfo"
40+
async def get_authorize_url(self, request, login_url):
41+
return await self.client.authorize_redirect(
42+
request, str(login_url), scope=" ".join(OAUTH_SCOPES)
18143
)
182-
headers = {"Authorization": f"Bearer {access_token}"}
183-
async with httpx.AsyncClient() as client:
184-
response = await client.get(userinfo_endpoint, headers=headers)
185-
response.raise_for_status()
186-
return response.json()
187-
188-
def get_token_endpoint(self) -> str:
189-
"""
190-
Get the token endpoint URL.
191-
192-
Returns:
193-
The token endpoint URL
194-
"""
195-
if (
196-
self._openid_configuration
197-
and "token_endpoint" in self._openid_configuration
198-
):
199-
return self._openid_configuration["token_endpoint"]
200-
return f"{self.base_url}/api/token"
201-
202-
def get_authorize_endpoint(self) -> str:
203-
"""
204-
Get the authorization endpoint URL.
205-
206-
Returns:
207-
The authorization endpoint URL
208-
"""
209-
if (
210-
self._openid_configuration
211-
and "authorization_endpoint" in self._openid_configuration
212-
):
213-
return self._openid_configuration["authorization_endpoint"]
214-
return f"{self.base_url}/authorize"
21544

21645
def get_client_credentials(self) -> Tuple[str, str]:
217-
"""
218-
Get the client ID and client secret.
219-
220-
Returns:
221-
A tuple of (client_id, client_secret)
222-
"""
223-
return (self.client_id, self.client_secret)
46+
return (self.client.client_id, self.client.client_secret)

carbonserver/carbonserver/api/services/auth_service.py

Lines changed: 1 addition & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import jwt
55
from dependency_injector.wiring import Provide
66
from fastapi import Depends, HTTPException
7-
from fastapi.security import APIKeyCookie, HTTPBearer, OAuth2AuthorizationCodeBearer
7+
from fastapi.security import APIKeyCookie, HTTPBearer
88

99
from carbonserver.api.services.auth_providers.oidc_auth_provider import (
1010
OIDCAuthProvider,
@@ -25,34 +25,6 @@ class FullUser:
2525
SESSION_COOKIE_NAME = "user_session"
2626

2727

28-
def get_oauth_scheme(
29-
auth_provider: Optional[OIDCAuthProvider],
30-
) -> OAuth2AuthorizationCodeBearer:
31-
"""
32-
Get the OAuth2 scheme for the configured auth provider.
33-
34-
Args:
35-
auth_provider: The authentication provider instance (None if auth disabled)
36-
37-
Returns:
38-
OAuth2AuthorizationCodeBearer configured for the provider
39-
"""
40-
if auth_provider is None:
41-
# Return a dummy scheme when auth is disabled
42-
return OAuth2AuthorizationCodeBearer(
43-
"http://localhost/authorize",
44-
"http://localhost/token",
45-
scopes={x: x for x in OAUTH_SCOPES},
46-
auto_error=False,
47-
)
48-
return OAuth2AuthorizationCodeBearer(
49-
auth_provider.get_authorize_endpoint(),
50-
auth_provider.get_token_endpoint(),
51-
scopes={x: x for x in OAUTH_SCOPES},
52-
auto_error=False,
53-
)
54-
55-
5628
web_scheme = APIKeyCookie(name=SESSION_COOKIE_NAME, auto_error=False)
5729

5830

carbonserver/carbonserver/config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ class Settings(BaseSettings):
1515
oidc_client_id: str = ""
1616
oidc_client_secret: str = ""
1717
oidc_issuer_url: str = "https://auth.codecarbon.io/codecarbon-dev"
18+
oidc_well_known_url: str = ""
1819

1920
# Deprecated: Old Fief-specific settings (use OIDC settings instead)
2021
@property
@@ -41,6 +42,12 @@ class Config:
4142
"oidc_client_id": {"env": ["OIDC_CLIENT_ID", "FIEF_CLIENT_ID"]},
4243
"oidc_client_secret": {"env": ["OIDC_CLIENT_SECRET", "FIEF_CLIENT_SECRET"]},
4344
"oidc_issuer_url": {"env": ["OIDC_ISSUER_URL", "FIEF_URL"]},
45+
"oidc_well_known_url": {
46+
"env": [
47+
"OIDC_WELL_KNOWN_URL",
48+
"FIEF_URL" + "/.well-known/openid-configuration",
49+
]
50+
},
4451
}
4552

4653

carbonserver/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from fastapi_pagination import add_pagination
55
from pydantic import ValidationError
66
from starlette.middleware.cors import CORSMiddleware
7+
from starlette.middleware.sessions import SessionMiddleware
78
from starlette.requests import Request
89
from starlette.responses import JSONResponse
910

@@ -53,6 +54,7 @@ def create_app() -> FastAPI:
5354
server.add_exception_handler(DBException, db_exception_handler)
5455
server.add_exception_handler(ValidationError, validation_exception_handler)
5556
server.add_exception_handler(Exception, generic_exception_handler)
57+
server.add_middleware(SessionMiddleware, secret_key="some-random-string")
5658

5759
return server
5860

0 commit comments

Comments
 (0)