Skip to content
Open
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")
7 changes: 6 additions & 1 deletion webapp/next.config.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
/** @type {import('next').NextConfig} */
const nextConfig = { output: "standalone" };
const nextConfig = {
output: "standalone",
experimental: {
optimizePackageImports: ["lucide-react", "recharts", "date-fns"],
},
};

export default nextConfig;
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import { z } from "zod";
import { toast } from "sonner";
import { fetchApi } from "@/utils/api";

export default function MembersList({
users,
Expand Down Expand Up @@ -44,36 +45,14 @@ export default function MembersList({

await toast
.promise(
fetch(
`${process.env.NEXT_PUBLIC_API_URL}/organizations/${organizationId}/add-user`,
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: body,
},
).then(async (result) => {
const data = await result.json();
if (result.status !== 200) {
const errorObject = data.detail;
let errorMessage = "Failed to add user";

if (
Array.isArray(errorObject) &&
errorObject.length > 0
) {
errorMessage = errorObject
.map((error: any) => error.msg)
.join("\n");
} else if (errorObject) {
errorMessage = JSON.stringify(errorObject);
}

throw new Error(errorMessage);
fetchApi(`/organizations/${organizationId}/add-user`, {
method: "POST",
body: body,
}).then(async (result) => {
if (!result) {
throw new Error("Failed to add user");
}
return data;
return result;
}),
{
loading: `Adding user ${email}...`,
Expand Down
29 changes: 25 additions & 4 deletions webapp/src/app/(dashboard)/[organizationId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,35 @@
"use client";

import Image from "next/image";
import dynamic from "next/dynamic";
import { use, useEffect, useState } from "react";

import ErrorMessage from "@/components/error-message";
import Loader from "@/components/loader";
import RadialChart from "@/components/radial-chart";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";

// Lazy-load chart to keep recharts off the critical path
const RadialChart = dynamic(() => import("@/components/radial-chart"), {
loading: () => (
<Card className="flex flex-col h-full items-center justify-center">
<CardContent className="p-0">
<Skeleton className="h-44 w-44 rounded-full" />
</CardContent>
</Card>
),
ssr: false,
});
import {
getEquivalentCarKm,
getEquivalentCitizenPercentage,
getEquivalentTvTime,
} from "@/helpers/constants";
import {
REFRESH_INTERVAL_ONE_MINUTE,
THIRTY_DAYS_MS,
SECONDS_PER_DAY,
} from "@/helpers/time-constants";
import { fetcher } from "@/helpers/swr";
import { getOrganizationEmissionsByProject } from "@/server-functions/organizations";
import { Organization } from "@/types/organization";
Expand All @@ -29,12 +48,12 @@ export default function OrganizationPage({
isLoading,
error,
} = useSWR<Organization>(`/organizations/${organizationId}`, fetcher, {
refreshInterval: 1000 * 60, // Refresh every minute
refreshInterval: REFRESH_INTERVAL_ONE_MINUTE,
});

const today = new Date();
const [date, setDate] = useState<DateRange | undefined>({
from: new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000),
from: new Date(today.getTime() - THIRTY_DAYS_MS),
to: today,
});
const [organizationReport, setOrganizationReport] = useState<
Expand Down Expand Up @@ -86,7 +105,9 @@ export default function OrganizationPage({
label: "days",
value: organizationReport?.duration
? parseFloat(
(organizationReport.duration / 86400, 0).toFixed(2),
(organizationReport.duration / SECONDS_PER_DAY).toFixed(
2,
),
)
: 0,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,11 @@ async function updateProjectAction(projectId: string, formData: FormData) {
const description = formData.get("description") as string;
const isPublic = formData.has("isPublic");

console.log("SAVING PROJECT:", { name, description, public: isPublic });

const response = await updateProject(projectId, {
await updateProject(projectId, {
name,
description,
public: isPublic,
});
console.log("RESPONSE:", response);

revalidatePath(`/projects/${projectId}/settings`);
}
Expand Down
20 changes: 11 additions & 9 deletions webapp/src/app/(dashboard)/[organizationId]/projects/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Table, TableBody } from "@/components/ui/table";
import { fetcher } from "@/helpers/swr";
import { REFRESH_INTERVAL_ONE_MINUTE } from "@/helpers/time-constants";
import { useModal } from "@/hooks/useModal";
import { getProjects, deleteProject } from "@/server-functions/projects";
import { Project } from "@/types/project";
import { use, useEffect, useState } from "react";
Expand All @@ -22,15 +24,15 @@ export default function ProjectsPage({
params: Promise<{ organizationId: string }>;
}) {
const { organizationId } = use(params);
const [isModalOpen, setIsModalOpen] = useState(false);
const createModal = useModal();
const deleteModal = useModal();
const [projectList, setProjectList] = useState<Project[]>([]);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [projectToDelete, setProjectToDelete] = useState<Project | null>(
null,
);

const handleClick = async () => {
setIsModalOpen(true);
createModal.open();
};

const refreshProjectList = async () => {
Expand All @@ -41,7 +43,7 @@ export default function ProjectsPage({

const handleDeleteClick = (project: Project) => {
setProjectToDelete(project);
setDeleteModalOpen(true);
deleteModal.open();
};

const handleDeleteConfirm = async (projectId: string) => {
Expand All @@ -61,7 +63,7 @@ export default function ProjectsPage({
error,
isLoading,
} = useSWR<Project[]>(`/projects?organization=${organizationId}`, fetcher, {
refreshInterval: 1000 * 60, // Refresh every minute
refreshInterval: REFRESH_INTERVAL_ONE_MINUTE,
});

useEffect(() => {
Expand Down Expand Up @@ -104,8 +106,8 @@ export default function ProjectsPage({
</Button>
<CreateProjectModal
organizationId={organizationId}
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
isOpen={createModal.isOpen}
onClose={createModal.close}
onProjectCreated={refreshProjectList}
/>
</div>
Expand Down Expand Up @@ -141,8 +143,8 @@ export default function ProjectsPage({
</Card>
{projectToDelete && (
<DeleteProjectModal
open={deleteModalOpen}
onOpenChange={setDeleteModalOpen}
open={deleteModal.isOpen}
onOpenChange={deleteModal.setIsOpen}
projectName={projectToDelete.name}
projectId={projectToDelete.id}
onDelete={handleDeleteConfirm}
Expand Down
Loading
Loading