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
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
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")
5 changes: 1 addition & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,11 @@ services:
ui:
build:
context: ./webapp
dockerfile: dev.Dockerfile
dockerfile: Dockerfile

# Set environment variables based on the .env file
env_file:
- ./webapp/.env.development
volumes:
- ./webapp/src:/app/src
- ./webapp/public:/app/public
restart: always
labels:
- "traefik.enable=true"
Expand Down
6 changes: 3 additions & 3 deletions webapp/.env.development
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
NEXT_PUBLIC_BASE_URL=http://codecarbon.local
NEXT_PUBLIC_API_URL=http://codecarbon.local/api
FIEF_BASE_URL=http://fief.local
VITE_BASE_URL=http://codecarbon.local
VITE_API_URL=http://codecarbon.local/api
VITE_FIEF_BASE_URL=http://fief.local
7 changes: 3 additions & 4 deletions webapp/.env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
NEXT_PUBLIC_BASE_URL=http://codecarbon.local
NEXT_PUBLIC_API_URL=http://codecarbon.local/api
FIEF_BASE_URL=http://fief.local
PROJECT_ENCRYPTION_KEY=<choose
VITE_BASE_URL=http://codecarbon.local
VITE_API_URL=http://codecarbon.local/api
VITE_FIEF_BASE_URL=http://fief.local
12 changes: 12 additions & 0 deletions webapp/Caddyfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
:{$CC_STATIC_PORT:8080} {
root * dist
encode gzip
try_files {path} /index.html
file_server

header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
}
}
38 changes: 0 additions & 38 deletions webapp/dev.Dockerfile

This file was deleted.

14 changes: 14 additions & 0 deletions webapp/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/logo.svg" />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
<title>CodeCarbon</title>
</head>
<body class="font-mono">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
4 changes: 0 additions & 4 deletions webapp/next.config.mjs

This file was deleted.

28 changes: 17 additions & 11 deletions webapp/package.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
{
"name": "carboncode_nextjs",
"name": "codecarbon-webapp",
"version": "0.1.0",
"private": true,
"main": "myapp.js",
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint .",
"test": "vitest",
"test:run": "vitest run"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
Expand All @@ -25,13 +27,11 @@
"clsx": "^2.1.1",
"copy-to-clipboard": "^3.3.3",
"date-fns": "^3.6.0",
"glob": "^11.1.0",
"lucide-react": "^0.411.0",
"next": "15.4.10",
"next-themes": "^0.4.6",
"react": "19.1.0",
"react-day-picker": "^9.13.0",
"react-dom": "19.1.0",
"react-router-dom": "^6.28.0",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"swr": "^2.3.8",
Expand All @@ -41,15 +41,21 @@
"zod": "^3.25.76"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^20.19.30",
"@types/react": "19.1.0",
"@types/react-dom": "19.1.1",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.39.2",
"eslint-config-next": "15.2.4",
"eslint-config-prettier": "^9.1.2",
"jsdom": "^25.0.1",
"postcss": "^8.5.6",
"prettier": "3.3.3",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vite": "^6.0.5",
"vitest": "^2.1.8"
},
"overrides": {
"@types/react": "19.1.0",
Expand Down
Loading
Loading