Skip to content

Commit 80abea7

Browse files
committed
Chore/front cleaning (#946)
* feat(back): add cascade deletion of projects feat(front): add modal to call deletion endpoints for projects refactor: move dashboard logic to custom hook & helper chore: fix date-range picker, remove console.log when not needed chore: replace magic numbers by functional values, standardise api call with fetchApi instead of fetch + injected url, refactor Modal usage with custom hook chore: standardize error handling Test Python 3.14 feat(back): add cascade deletion of projects (#945) feat(front): add modal to call deletion endpoints for projects feat(back): add cascade deletion of projects feat(front): add modal to call deletion endpoints for projects chore: fix date-range picker, remove console.log when not needed chore: finish cleaning chore: lint * chore: add bundle size optimizations * feat(api): add auth server side token revocation * chore: upgrade dependencies, add asyncio # Conflicts: # requirements/requirements-api.txt * chore: upgrade dependencies, add asyncio * chore: extract dev dependencies in carbonserver
1 parent a0c3848 commit 80abea7

37 files changed

Lines changed: 1277 additions & 986 deletions

.github/workflows/build-server.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ jobs:
2323
- name: Set up Python
2424
run: uv python install 3.12
2525
- name: Install dependencies
26-
run: uv sync --project carbonserver
26+
run: uv sync --project carbonserver --extra dev
2727
- name: Unit tests on api
2828
run: uv run task test-api-unit
2929
test_api_server:
@@ -60,7 +60,7 @@ jobs:
6060
- name: Set up Python
6161
run: uv python install 3.12
6262
- name: Install dependencies
63-
run: uv sync --project carbonserver
63+
run: uv sync --project carbonserver --extra dev
6464
- name: Setup database
6565
env:
6666
DATABASE_URL: postgresql://codecarbon-user:supersecret@localhost:5480/codecarbon_db

CONTRIBUTING.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -467,16 +467,21 @@ python examples/api_call_debug.py
467467
<!-- TOC --><a name="test-the-api"></a>
468468
### Test the API
469469

470-
To test the API, you can use the following command:
470+
Test dependencies (pytest, pytest-asyncio, etc.) are in the `dev` optional group. Install them first:
471471

472472
```sh
473-
uv run api.test-unit
473+
uv sync --project carbonserver --extra dev
474474
```
475475

476+
Then run:
477+
476478
```sh
477-
export CODECARBON_API_URL=http://localhost:8008
478-
uv run api.test-integ
479+
uv run task test-api-unit
480+
```
479481

482+
```sh
483+
export CODECARBON_API_URL=http://localhost:8008
484+
uv run task test-api-integ
480485
```
481486

482487
<!-- TOC --><a name="restore-database-from-a-production-backup"></a>

carbonserver/carbonserver/api/routers/authenticate.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,11 +134,16 @@ async def logout(
134134
"""
135135
if auth_provider is None:
136136
raise HTTPException(status_code=501, detail="Authentication not configured")
137+
138+
# Revoke the access token at the OIDC provider before clearing it locally
139+
access_token = request.cookies.get(SESSION_COOKIE_NAME)
140+
if access_token:
141+
await auth_provider.revoke_token(access_token)
142+
137143
base_url = request.base_url
138144
response = auth_provider.create_redirect_response(str(base_url))
139145
response.delete_cookie(SESSION_COOKIE_NAME)
140146
if hasattr(request, "session"):
141147
request.session.clear()
142148

143-
# TODO: also revoke the token at auth provider level if possible
144149
return response

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

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,23 +56,16 @@ def get_client_credentials(self) -> Tuple[str, str]:
5656

5757
async def _decode_token(self, token: str) -> Dict[str, Any]:
5858
try:
59-
LOGGER.debug(f"Jwks_data: {token}")
60-
LOGGER.debug(f"Base url: {fief.base_url}")
61-
LOGGER.debug(f"Client id: {fief.client_id}")
62-
LOGGER.debug(f"User info: {await fief.userinfo(token)}")
6359
access_token_info = await fief.validate_access_token(token)
6460
return access_token_info
6561
except Exception as e:
6662
LOGGER.error(f"Error validating access token: {e}")
6763
...
6864

6965
jwks_data = await self.client.fetch_jwk_set()
70-
LOGGER.debug(f"Jwks_data: {jwks_data}")
7166
keyset = JsonWebKey.import_key_set(jwks_data)
7267
claims = jose_jwt.decode(token, keyset)
7368
claims.validate()
74-
LOGGER.debug(f"Decoded claims: {claims}")
75-
LOGGER.debug(f"Claims validate: {claims.validate()}")
7669
return dict(claims)
7770

7871
async def validate_access_token(self, token: str) -> bool:
@@ -83,6 +76,41 @@ async def get_user_info(self, access_token: str) -> Dict[str, Any]:
8376
decoded_token = await self._decode_token(access_token)
8477
return decoded_token
8578

79+
async def revoke_token(self, token: str) -> None:
80+
"""Revoke an access token at the OIDC provider (RFC 7009).
81+
Best-effort — logs and swallows errors so logout always succeeds.
82+
"""
83+
try:
84+
metadata = await self.client.load_server_metadata()
85+
revocation_endpoint = metadata.get("revocation_endpoint")
86+
if not revocation_endpoint:
87+
LOGGER.debug(
88+
"OIDC provider does not expose a revocation_endpoint, "
89+
"skipping token revocation"
90+
)
91+
return
92+
93+
async with self.client._get_oauth_client(**metadata) as client:
94+
resp = await client.request(
95+
"POST",
96+
revocation_endpoint,
97+
withhold_token=True,
98+
data={
99+
"token": token,
100+
"token_type_hint": "access_token",
101+
},
102+
)
103+
if resp.status_code == 200:
104+
LOGGER.info("Access token revoked successfully")
105+
else:
106+
LOGGER.warning(
107+
"Token revocation returned status %s: %s",
108+
resp.status_code,
109+
resp.text,
110+
)
111+
except Exception as e:
112+
LOGGER.warning("Token revocation failed (non-blocking): %s", e)
113+
86114
@staticmethod
87115
def create_redirect_response(url: str) -> Response:
88116
"""RedirectResponse doesn't work with clevercloud, so we return a HTML page with a script to redirect the user

carbonserver/pyproject.toml

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,19 +31,24 @@ dependencies = [
3131
"sqlalchemy<2.0.0",
3232
"uvicorn[standard]<1.0.0",
3333
"fastapi-pagination<1.0.0",
34-
"mock",
35-
"pytest",
36-
"responses",
3734
"numpy",
3835
"psutil",
39-
"requests-mock",
4036
"rapidfuzz",
4137
"PyJWT",
4238
"fastapi-oidc>=0.0.9",
4339
"authlib>=1.6.6",
4440
"itsdangerous>=2.2.0",
4541
]
4642

43+
[project.optional-dependencies]
44+
dev = [
45+
"mock",
46+
"pytest",
47+
"pytest-asyncio",
48+
"responses",
49+
"requests-mock",
50+
]
51+
4752
[project.urls]
4853
Homepage = "https://codecarbon.io/"
4954
Repository = "https://github.com/mlco2/codecarbon"

carbonserver/tests/TESTING.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,17 @@ A Postman collection of requests is available: ```carbonserver/tests/postman/Tes
3838

3939
## Running the tests:
4040

41+
Test dependencies (pytest, pytest-asyncio, etc.) are in the `dev` optional group. Install them with:
42+
43+
```bash
44+
uv sync --project carbonserver --extra dev
45+
```
46+
47+
Then run:
48+
4149
```bash
42-
uv run --extra api task test-api-unit # Unit tests on api
43-
uv run --extra api task test-api-integ # Integration tests
50+
uv run task test-api-unit # Unit tests on api
51+
uv run task test-api-integ # Integration tests
4452
```
4553

4654
To test the HTTP layer, you can also deploy a local instance:

carbonserver/tests/api/routers/test_authenticate.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1+
from unittest.mock import AsyncMock, MagicMock, patch
2+
13
import pytest
24
from fastapi import FastAPI
35
from fastapi.testclient import TestClient
46
from starlette.middleware.sessions import SessionMiddleware
57

68
from carbonserver.api.routers import authenticate
9+
from carbonserver.api.services.auth_providers.oidc_auth_provider import (
10+
OIDCAuthProvider,
11+
)
712
from carbonserver.container import ServerContainer
813

914
SESSION_COOKIE_NAME = "user_session"
@@ -55,3 +60,89 @@ class FakeRequest:
5560
)
5661
# We cannot directly check session cleared, but can check that logout returns redirect
5762
assert "window.location.href" in response.text
63+
64+
65+
# --- Token revocation tests ---
66+
67+
68+
@pytest.fixture
69+
def mock_oidc_client():
70+
"""Create a mock OIDC client with load_server_metadata and _get_oauth_client."""
71+
client = MagicMock()
72+
client.load_server_metadata = AsyncMock()
73+
client._get_oauth_client = MagicMock()
74+
return client
75+
76+
77+
@pytest.fixture
78+
def oidc_provider(mock_oidc_client):
79+
"""Create an OIDCAuthProvider with a mocked client."""
80+
with patch.object(OIDCAuthProvider, "__init__", lambda self, **kw: None):
81+
provider = OIDCAuthProvider()
82+
provider.client = mock_oidc_client
83+
return provider
84+
85+
86+
@pytest.mark.asyncio
87+
async def test_revoke_token_success(oidc_provider, mock_oidc_client):
88+
"""Token is revoked successfully when the provider exposes a revocation_endpoint."""
89+
mock_oidc_client.load_server_metadata.return_value = {
90+
"revocation_endpoint": "https://auth.example.com/revoke",
91+
}
92+
93+
mock_response = MagicMock(status_code=200)
94+
mock_http_client = AsyncMock()
95+
mock_http_client.request = AsyncMock(return_value=mock_response)
96+
mock_http_client.__aenter__ = AsyncMock(return_value=mock_http_client)
97+
mock_http_client.__aexit__ = AsyncMock(return_value=False)
98+
mock_oidc_client._get_oauth_client.return_value = mock_http_client
99+
100+
await oidc_provider.revoke_token("test-access-token")
101+
102+
mock_http_client.request.assert_called_once_with(
103+
"POST",
104+
"https://auth.example.com/revoke",
105+
withhold_token=True,
106+
data={"token": "test-access-token", "token_type_hint": "access_token"},
107+
)
108+
109+
110+
@pytest.mark.asyncio
111+
async def test_revoke_token_no_endpoint(oidc_provider, mock_oidc_client):
112+
"""Revocation is silently skipped when the provider has no revocation_endpoint."""
113+
mock_oidc_client.load_server_metadata.return_value = {
114+
"authorization_endpoint": "https://auth.example.com/authorize",
115+
}
116+
117+
await oidc_provider.revoke_token("test-access-token")
118+
119+
mock_oidc_client._get_oauth_client.assert_not_called()
120+
121+
122+
@pytest.mark.asyncio
123+
async def test_revoke_token_http_error(oidc_provider, mock_oidc_client):
124+
"""Revocation failure does not raise — logout must always succeed."""
125+
mock_oidc_client.load_server_metadata.return_value = {
126+
"revocation_endpoint": "https://auth.example.com/revoke",
127+
}
128+
129+
mock_response = MagicMock(status_code=503, text="Service Unavailable")
130+
mock_http_client = AsyncMock()
131+
mock_http_client.request = AsyncMock(return_value=mock_response)
132+
mock_http_client.__aenter__ = AsyncMock(return_value=mock_http_client)
133+
mock_http_client.__aexit__ = AsyncMock(return_value=False)
134+
mock_oidc_client._get_oauth_client.return_value = mock_http_client
135+
136+
# Should not raise
137+
await oidc_provider.revoke_token("test-access-token")
138+
139+
140+
@pytest.mark.asyncio
141+
async def test_revoke_token_exception(oidc_provider, mock_oidc_client):
142+
"""Revocation is non-blocking even when load_server_metadata raises."""
143+
mock_oidc_client.load_server_metadata.side_effect = ConnectionError(
144+
"Network unreachable"
145+
)
146+
147+
# Should not raise
148+
await oidc_provider.revoke_token("test-access-token")

0 commit comments

Comments
 (0)