Skip to content

Commit 93dd3cf

Browse files
authored
feat: allow any authentication provider + fief OIDC (#999)
* feat: allow any authentication provider * fix: use oidc instead of fief * fix: update client id retrieval * fix: remove auth provider abstract * test: add integration test for auth
1 parent ed166c2 commit 93dd3cf

File tree

12 files changed

+4774
-4045
lines changed

12 files changed

+4774
-4045
lines changed

carbonserver/carbonserver/api/routers/authenticate.py

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55

66
import requests
77
from dependency_injector.wiring import Provide, inject
8-
from fastapi import APIRouter, Depends, Query, Request, Response
8+
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
99
from fastapi.responses import RedirectResponse
10-
from fief_client import FiefAsync
1110

11+
from carbonserver.api.services.auth_providers.oidc_auth_provider import (
12+
OIDCAuthProvider,
13+
)
1214
from carbonserver.api.services.auth_service import (
1315
OptionalUserWithAuthDependency,
1416
UserWithAuthDependency,
@@ -24,16 +26,15 @@
2426

2527
router = APIRouter()
2628

27-
fief = FiefAsync(
28-
settings.fief_url, settings.fief_client_id, settings.fief_client_secret
29-
)
30-
3129

3230
@router.get("/auth/check", name="auth-check")
3331
@inject
3432
def check_login(
3533
auth_user: UserWithAuthDependency = Depends(OptionalUserWithAuthDependency),
3634
sign_up_service: SignUpService = Depends(Provide[ServerContainer.sign_up_service]),
35+
auth_provider: Optional[OIDCAuthProvider] = Depends(
36+
Provide[ServerContainer.auth_provider]
37+
),
3738
):
3839
"""
3940
return user data or redirect to login screen
@@ -44,9 +45,19 @@ def check_login(
4445

4546

4647
@router.get("/auth/auth-callback", name="auth_callback")
47-
async def auth_callback(request: Request, response: Response, code: str = Query(...)):
48+
@inject
49+
async def auth_callback(
50+
request: Request,
51+
response: Response,
52+
code: str = Query(...),
53+
auth_provider: Optional[OIDCAuthProvider] = Depends(
54+
Provide[ServerContainer.auth_provider]
55+
),
56+
):
57+
if auth_provider is None:
58+
raise HTTPException(status_code=501, detail="Authentication not configured")
4859
redirect_uri = request.url_for("auth_callback")
49-
tokens, _ = await fief.auth_callback(code, redirect_uri)
60+
tokens, _ = await auth_provider.handle_auth_callback(code, str(redirect_uri))
5061
response = RedirectResponse(request.url_for("auth-user"))
5162
response.set_cookie(
5263
SESSION_COOKIE_NAME,
@@ -65,33 +76,36 @@ async def get_login(
6576
state: Optional[str] = None,
6677
code: Optional[str] = None,
6778
sign_up_service: SignUpService = Depends(Provide[ServerContainer.sign_up_service]),
79+
auth_provider: Optional[OIDCAuthProvider] = Depends(
80+
Provide[ServerContainer.auth_provider]
81+
),
6882
):
6983
"""
7084
login and redirect to frontend app with token
7185
"""
86+
if auth_provider is None:
87+
raise HTTPException(status_code=501, detail="Authentication not configured")
7288
login_url = request.url_for("login")
7389

7490
if code:
91+
client_id, client_secret = auth_provider.get_client_credentials()
7592
res = requests.post(
76-
f"{settings.fief_url}/api/token",
93+
auth_provider.get_token_endpoint(),
7794
data={
7895
"grant_type": "authorization_code",
7996
"code": code,
8097
"redirect_uri": login_url,
81-
"client_id": settings.fief_client_id,
82-
"client_secret": settings.fief_client_secret,
98+
"client_id": client_id,
99+
"client_secret": client_secret,
83100
},
84101
)
85102

86103
# check if the user exists in local DB ; create if needed
87104
if "id_token" not in res.json():
88105
if "access_token" not in res.json():
89106
return Response(content="Invalid code", status_code=400)
90-
# get profile data from fief server if not present in response
91-
id_token = requests.get(
92-
settings.fief_url + "/api/userinfo",
93-
headers={"Authorization": "Bearer " + res.json()["access_token"]},
94-
).json()
107+
# get profile data from auth provider if not present in response
108+
id_token = await auth_provider.get_user_info(res.json()["access_token"])
95109
sign_up_service.check_jwt_user(id_token)
96110
else:
97111
sign_up_service.check_jwt_user(res.json()["id_token"], create=True)
@@ -123,5 +137,7 @@ async def get_login(
123137
return response
124138

125139
state = str(int(random.random() * 1000))
126-
url = f"{settings.fief_url}/authorize?response_type=code&client_id={settings.fief_client_id}&redirect_uri={login_url}&scope={' '.join(OAUTH_SCOPES)}&state={state}"
140+
client_id, _ = auth_provider.get_client_credentials()
141+
authorize_url = auth_provider.get_authorize_endpoint()
142+
url = f"{authorize_url}?response_type=code&client_id={client_id}&redirect_uri={login_url}&scope={' '.join(OAUTH_SCOPES)}&state={state}"
127143
return RedirectResponse(url=url)
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
"""
2+
OIDC Authentication Provider Implementation
3+
4+
This module provides a generic OIDC authentication provider implementation using fastapi-oidc.
5+
It can work with any OIDC-compliant provider (Fief, Keycloak, Auth0, etc.).
6+
"""
7+
8+
import asyncio
9+
from typing import Any, Dict, List, Optional, Tuple
10+
from urllib.parse import urlencode
11+
12+
import httpx
13+
from fastapi_oidc import discovery
14+
from jose import jwt
15+
16+
DEFAULT_SIGNATURE_CACHE_TTL = 3600 # seconds
17+
18+
19+
class OIDCAuthProvider:
20+
"""
21+
Generic OIDC authentication provider implementation.
22+
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+
"""
26+
27+
def __init__(
28+
self,
29+
base_url: str,
30+
client_id: str,
31+
client_secret: str,
32+
*,
33+
signature_cache_ttl: int = DEFAULT_SIGNATURE_CACHE_TTL,
34+
openid_configuration: Optional[Dict[str, Any]] = None,
35+
):
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(
68+
self, token: str, *, audience: Optional[str] = None
69+
) -> Dict[str, Any]:
70+
oidc_config = await self._get_openid_configuration()
71+
jwks = await self._get_jwks()
72+
algorithms = await self._get_algorithms()
73+
return jwt.decode(
74+
token,
75+
jwks,
76+
algorithms=algorithms,
77+
audience=audience or self.client_id,
78+
issuer=oidc_config.get("issuer", self.base_url),
79+
options={"verify_at_hash": False},
80+
)
81+
82+
async def get_auth_url(
83+
self, redirect_uri: str, scope: List[str], state: Optional[str] = None
84+
) -> str:
85+
"""
86+
Generate the authorization URL for the OAuth2 flow.
87+
88+
Args:
89+
redirect_uri: The URI to redirect to after authentication
90+
scope: List of OAuth2 scopes to request
91+
state: Optional state parameter for CSRF protection
92+
93+
Returns:
94+
The authorization URL to redirect the user to
95+
"""
96+
oidc_config = await self._get_openid_configuration()
97+
authorize_endpoint = oidc_config.get(
98+
"authorization_endpoint", f"{self.base_url}/authorize"
99+
)
100+
params = {
101+
"response_type": "code",
102+
"client_id": self.client_id,
103+
"redirect_uri": redirect_uri,
104+
"scope": " ".join(scope),
105+
}
106+
if state is not None:
107+
params["state"] = state
108+
109+
return f"{authorize_endpoint}?{urlencode(params)}"
110+
111+
async def handle_auth_callback(
112+
self, code: str, redirect_uri: str
113+
) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]:
114+
"""
115+
Handle the OAuth2 callback and exchange the code for tokens.
116+
117+
Args:
118+
code: The authorization code from the OAuth2 provider
119+
redirect_uri: The redirect URI used in the initial auth request
120+
121+
Returns:
122+
A tuple of (tokens, user_info) where:
123+
- tokens: Dict containing access_token, refresh_token, expires_in, etc.
124+
- user_info: Optional dict containing user information
125+
"""
126+
oidc_config = await self._get_openid_configuration()
127+
token_endpoint = oidc_config.get("token_endpoint", f"{self.base_url}/api/token")
128+
async with httpx.AsyncClient() as client:
129+
response = await client.post(
130+
token_endpoint,
131+
data={
132+
"grant_type": "authorization_code",
133+
"code": code,
134+
"redirect_uri": redirect_uri,
135+
"client_id": self.client_id,
136+
"client_secret": self.client_secret,
137+
},
138+
headers={"accept": "application/json"},
139+
)
140+
response.raise_for_status()
141+
tokens: Dict[str, Any] = response.json()
142+
143+
user_info: Optional[Dict[str, Any]] = None
144+
if "id_token" in tokens:
145+
user_info = await self._decode_token(tokens["id_token"])
146+
elif "access_token" in tokens:
147+
try:
148+
user_info = await self.get_user_info(tokens["access_token"])
149+
except Exception:
150+
# If userinfo fails we still return tokens
151+
user_info = None
152+
153+
return (tokens, user_info)
154+
155+
async def validate_access_token(self, token: str) -> bool:
156+
"""
157+
Validate an access token.
158+
159+
Args:
160+
token: The access token to validate
161+
162+
Returns:
163+
True if the token is valid
164+
165+
Raises:
166+
Exception if validation fails
167+
"""
168+
await self._decode_token(token)
169+
return True
170+
171+
async def get_user_info(self, access_token: str) -> Dict[str, Any]:
172+
"""
173+
Get user information from the OIDC provider.
174+
175+
Args:
176+
access_token: The access token for the user
177+
178+
Returns:
179+
Dict containing user information (sub, email, name, etc.)
180+
"""
181+
oidc_config = await self._get_openid_configuration()
182+
userinfo_endpoint = oidc_config.get(
183+
"userinfo_endpoint", f"{self.base_url}/api/userinfo"
184+
)
185+
headers = {"Authorization": f"Bearer {access_token}"}
186+
async with httpx.AsyncClient() as client:
187+
response = await client.get(userinfo_endpoint, headers=headers)
188+
response.raise_for_status()
189+
return response.json()
190+
191+
def get_token_endpoint(self) -> str:
192+
"""
193+
Get the token endpoint URL.
194+
195+
Returns:
196+
The token endpoint URL
197+
"""
198+
if (
199+
self._openid_configuration
200+
and "token_endpoint" in self._openid_configuration
201+
):
202+
return self._openid_configuration["token_endpoint"]
203+
return f"{self.base_url}/api/token"
204+
205+
def get_authorize_endpoint(self) -> str:
206+
"""
207+
Get the authorization endpoint URL.
208+
209+
Returns:
210+
The authorization endpoint URL
211+
"""
212+
if (
213+
self._openid_configuration
214+
and "authorization_endpoint" in self._openid_configuration
215+
):
216+
return self._openid_configuration["authorization_endpoint"]
217+
return f"{self.base_url}/authorize"
218+
219+
def get_client_credentials(self) -> Tuple[str, str]:
220+
"""
221+
Get the client ID and client secret.
222+
223+
Returns:
224+
A tuple of (client_id, client_secret)
225+
"""
226+
return (self.client_id, self.client_secret)

0 commit comments

Comments
 (0)