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
4 changes: 2 additions & 2 deletions .github/workflows/build-server.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
- name: Set up Python
run: uv python install 3.12
- name: Install dependencies
run: uv sync --project carbonserver
run: uv sync --project carbonserver --extra dev
- name: Unit tests on api
run: uv run task test-api-unit
test_api_server:
Expand Down Expand Up @@ -60,7 +60,7 @@ jobs:
- name: Set up Python
run: uv python install 3.12
- name: Install dependencies
run: uv sync --project carbonserver
run: uv sync --project carbonserver --extra dev
- name: Setup database
env:
DATABASE_URL: postgresql://codecarbon-user:supersecret@localhost:5480/codecarbon_db
Expand Down
13 changes: 9 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -467,16 +467,21 @@ python examples/api_call_debug.py
<!-- TOC --><a name="test-the-api"></a>
### Test the API

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

```sh
uv run api.test-unit
uv sync --project carbonserver --extra dev
```

Then run:

```sh
export CODECARBON_API_URL=http://localhost:8008
uv run api.test-integ
uv run task test-api-unit
```

```sh
export CODECARBON_API_URL=http://localhost:8008
uv run task test-api-integ
```

<!-- TOC --><a name="restore-database-from-a-production-backup"></a>
Expand Down
7 changes: 6 additions & 1 deletion carbonserver/carbonserver/api/routers/authenticate.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,11 +134,16 @@ async def logout(
"""
if auth_provider is None:
raise HTTPException(status_code=501, detail="Authentication not configured")

# Revoke the access token at the OIDC provider before clearing it locally
access_token = request.cookies.get(SESSION_COOKIE_NAME)
if access_token:
await auth_provider.revoke_token(access_token)

base_url = request.base_url
response = auth_provider.create_redirect_response(str(base_url))
response.delete_cookie(SESSION_COOKIE_NAME)
if hasattr(request, "session"):
request.session.clear()

# TODO: also revoke the token at auth provider level if possible
return response
Original file line number Diff line number Diff line change
Expand Up @@ -56,23 +56,16 @@ def get_client_credentials(self) -> Tuple[str, str]:

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

jwks_data = await self.client.fetch_jwk_set()
LOGGER.debug(f"Jwks_data: {jwks_data}")
keyset = JsonWebKey.import_key_set(jwks_data)
claims = jose_jwt.decode(token, keyset)
claims.validate()
LOGGER.debug(f"Decoded claims: {claims}")
LOGGER.debug(f"Claims validate: {claims.validate()}")
return dict(claims)

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

async def revoke_token(self, token: str) -> None:
"""Revoke an access token at the OIDC provider (RFC 7009).
Best-effort — logs and swallows errors so logout always succeeds.
"""
try:
metadata = await self.client.load_server_metadata()
revocation_endpoint = metadata.get("revocation_endpoint")
if not revocation_endpoint:
LOGGER.debug(
"OIDC provider does not expose a revocation_endpoint, "
"skipping token revocation"
)
return

async with self.client._get_oauth_client(**metadata) as client:
resp = await client.request(
"POST",
revocation_endpoint,
withhold_token=True,
data={
"token": token,
"token_type_hint": "access_token",
},
)
if resp.status_code == 200:
LOGGER.info("Access token revoked successfully")
else:
LOGGER.warning(
"Token revocation returned status %s: %s",
resp.status_code,
resp.text,
)
except Exception as e:
LOGGER.warning("Token revocation failed (non-blocking): %s", e)

@staticmethod
def create_redirect_response(url: str) -> Response:
"""RedirectResponse doesn't work with clevercloud, so we return a HTML page with a script to redirect the user
Expand Down
13 changes: 9 additions & 4 deletions carbonserver/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,19 +31,24 @@ dependencies = [
"sqlalchemy<2.0.0",
"uvicorn[standard]<1.0.0",
"fastapi-pagination<1.0.0",
"mock",
"pytest",
"responses",
"numpy",
"psutil",
"requests-mock",
"rapidfuzz",
"PyJWT",
"fastapi-oidc>=0.0.9",
"authlib>=1.6.6",
"itsdangerous>=2.2.0",
]

[project.optional-dependencies]
dev = [
"mock",
"pytest",
"pytest-asyncio",
"responses",
"requests-mock",
]

[project.urls]
Homepage = "https://codecarbon.io/"
Repository = "https://github.com/mlco2/codecarbon"
Expand Down
12 changes: 10 additions & 2 deletions carbonserver/tests/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,17 @@ A Postman collection of requests is available: ```carbonserver/tests/postman/Tes

## Running the tests:

Test dependencies (pytest, pytest-asyncio, etc.) are in the `dev` optional group. Install them with:

```bash
uv sync --project carbonserver --extra dev
```

Then run:

```bash
uv run --extra api task test-api-unit # Unit tests on api
uv run --extra api task test-api-integ # Integration tests
uv run task test-api-unit # Unit tests on api
uv run task test-api-integ # Integration tests
```

To test the HTTP layer, you can also deploy a local instance:
Expand Down
91 changes: 91 additions & 0 deletions carbonserver/tests/api/routers/test_authenticate.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from starlette.middleware.sessions import SessionMiddleware

from carbonserver.api.routers import authenticate
from carbonserver.api.services.auth_providers.oidc_auth_provider import (
OIDCAuthProvider,
)
from carbonserver.container import ServerContainer

SESSION_COOKIE_NAME = "user_session"
Expand Down Expand Up @@ -55,3 +60,89 @@ class FakeRequest:
)
# We cannot directly check session cleared, but can check that logout returns redirect
assert "window.location.href" in response.text


# --- Token revocation tests ---


@pytest.fixture
def mock_oidc_client():
"""Create a mock OIDC client with load_server_metadata and _get_oauth_client."""
client = MagicMock()
client.load_server_metadata = AsyncMock()
client._get_oauth_client = MagicMock()
return client


@pytest.fixture
def oidc_provider(mock_oidc_client):
"""Create an OIDCAuthProvider with a mocked client."""
with patch.object(OIDCAuthProvider, "__init__", lambda self, **kw: None):
provider = OIDCAuthProvider()
provider.client = mock_oidc_client
return provider


@pytest.mark.asyncio
async def test_revoke_token_success(oidc_provider, mock_oidc_client):
"""Token is revoked successfully when the provider exposes a revocation_endpoint."""
mock_oidc_client.load_server_metadata.return_value = {
"revocation_endpoint": "https://auth.example.com/revoke",
}

mock_response = MagicMock(status_code=200)
mock_http_client = AsyncMock()
mock_http_client.request = AsyncMock(return_value=mock_response)
mock_http_client.__aenter__ = AsyncMock(return_value=mock_http_client)
mock_http_client.__aexit__ = AsyncMock(return_value=False)
mock_oidc_client._get_oauth_client.return_value = mock_http_client

await oidc_provider.revoke_token("test-access-token")

mock_http_client.request.assert_called_once_with(
"POST",
"https://auth.example.com/revoke",
withhold_token=True,
data={"token": "test-access-token", "token_type_hint": "access_token"},
)


@pytest.mark.asyncio
async def test_revoke_token_no_endpoint(oidc_provider, mock_oidc_client):
"""Revocation is silently skipped when the provider has no revocation_endpoint."""
mock_oidc_client.load_server_metadata.return_value = {
"authorization_endpoint": "https://auth.example.com/authorize",
}

await oidc_provider.revoke_token("test-access-token")

mock_oidc_client._get_oauth_client.assert_not_called()


@pytest.mark.asyncio
async def test_revoke_token_http_error(oidc_provider, mock_oidc_client):
"""Revocation failure does not raise — logout must always succeed."""
mock_oidc_client.load_server_metadata.return_value = {
"revocation_endpoint": "https://auth.example.com/revoke",
}

mock_response = MagicMock(status_code=503, text="Service Unavailable")
mock_http_client = AsyncMock()
mock_http_client.request = AsyncMock(return_value=mock_response)
mock_http_client.__aenter__ = AsyncMock(return_value=mock_http_client)
mock_http_client.__aexit__ = AsyncMock(return_value=False)
mock_oidc_client._get_oauth_client.return_value = mock_http_client

# Should not raise
await oidc_provider.revoke_token("test-access-token")


@pytest.mark.asyncio
async def test_revoke_token_exception(oidc_provider, mock_oidc_client):
"""Revocation is non-blocking even when load_server_metadata raises."""
mock_oidc_client.load_server_metadata.side_effect = ConnectionError(
"Network unreachable"
)

# Should not raise
await oidc_provider.revoke_token("test-access-token")
Loading
Loading