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
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@
from typing import Any, Dict, Optional, Tuple

from authlib.integrations.starlette_client import OAuth
from authlib.jose import JsonWebKey
from authlib.jose import jwt as jose_jwt
from fastapi import Response
from fief_client import FiefAsync
from joserfc import jwt as jose_jwt
from joserfc.jwk import KeySet

from carbonserver.config import settings

Expand Down Expand Up @@ -63,10 +63,10 @@ async def _decode_token(self, token: str) -> Dict[str, Any]:
...

jwks_data = await self.client.fetch_jwk_set()
keyset = JsonWebKey.import_key_set(jwks_data)
claims = jose_jwt.decode(token, keyset)
claims.validate()
return dict(claims)
keyset = KeySet.import_key_set(jwks_data)
decoded = jose_jwt.decode(token, keyset)
jose_jwt.JWTClaimsRegistry().validate(decoded.claims)
return dict(decoded.claims)

async def validate_access_token(self, token: str) -> bool:
await self._decode_token(token)
Expand Down
1 change: 1 addition & 0 deletions carbonserver/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ dependencies = [
"PyJWT",
"fastapi-oidc>=0.0.9",
"authlib>=1.6.6",
"joserfc>=1.0.0",
"itsdangerous>=2.2.0",
]

Expand Down
55 changes: 55 additions & 0 deletions carbonserver/tests/api/service/test_auth_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
Unit tests for OIDC authentication provider.
"""

import time
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from carbonserver.api.services.auth_providers import oidc_auth_provider
from carbonserver.api.services.auth_providers.oidc_auth_provider import OIDCAuthProvider
from carbonserver.config import settings

Expand All @@ -26,3 +32,52 @@ def test_oidc_provider_initialization(self):
settings.oidc_client_id,
settings.oidc_client_secret,
)

@pytest.mark.asyncio
async def test_decode_token_falls_back_to_jwks_when_fief_fails(self):
"""When fief.validate_access_token raises, _decode_token must fall back
to the joserfc JWKS verification path and return a plain dict."""
provider = OIDCAuthProvider(
base_url="https://auth.example.com",
client_id="test_client",
client_secret="test_secret",
)

now = int(time.time())
expected_claims = {
"sub": "user-456",
"iat": now - 5,
"exp": now + 600,
"email": "user@example.com",
}

jwks_payload = {"keys": [{"kty": "RSA", "kid": "k1"}]}
provider.client = MagicMock()
provider.client.fetch_jwk_set = AsyncMock(return_value=jwks_payload)

decoded_token = MagicMock()
decoded_token.claims = expected_claims

with (
patch.object(
oidc_auth_provider.fief,
"validate_access_token",
new=AsyncMock(side_effect=Exception("fief unavailable")),
),
patch.object(
oidc_auth_provider.KeySet,
"import_key_set",
return_value="keyset",
) as mock_import,
patch.object(
oidc_auth_provider.jose_jwt,
"decode",
return_value=decoded_token,
) as mock_decode,
):
result = await provider._decode_token("opaque-token")

assert result == expected_claims
assert isinstance(result, dict)
mock_import.assert_called_once_with(jwks_payload)
mock_decode.assert_called_once_with("opaque-token", "keyset")
3 changes: 3 additions & 0 deletions carbonserver/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions codecarbon/cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
import requests
from authlib.common.security import generate_token
from authlib.integrations.requests_client import OAuth2Session
from authlib.jose import JsonWebKey
from authlib.jose import jwt as jose_jwt
from authlib.oauth2.rfc7636 import create_s256_code_challenge
from joserfc import jwt as jose_jwt
from joserfc.jwk import KeySet

AUTH_CLIENT_ID = os.environ.get(
"AUTH_CLIENT_ID",
Expand Down Expand Up @@ -108,9 +108,9 @@ def _validate_access_token(access_token: str) -> bool:
discovery = _discover_endpoints()
jwks_resp = requests.get(discovery["jwks_uri"])
jwks_resp.raise_for_status()
keyset = JsonWebKey.import_key_set(jwks_resp.json())
claims = jose_jwt.decode(access_token, keyset)
claims.validate()
keyset = KeySet.import_key_set(jwks_resp.json())
token = jose_jwt.decode(access_token, keyset)
jose_jwt.JWTClaimsRegistry().validate(token.claims)
return True
except requests.RequestException:
return True # Can't reach auth server — let the API handle it
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ classifiers = [
dependencies = [
"arrow",
"authlib>=1.2.1",
"joserfc>=1.0.0",
"click",
"pandas>=2.3.3;python_version>='3.14'",
"pandas;python_version<'3.14'",
Expand Down
29 changes: 26 additions & 3 deletions tests/cli/test_cli_auth.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import io
import json
import tempfile
import time
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
Expand Down Expand Up @@ -90,20 +91,42 @@ def test_save_and_load_credentials(self, mock_open):
self.assertEqual(loaded, tokens)

@patch("codecarbon.cli.auth.requests.get")
@patch("codecarbon.cli.auth.JsonWebKey.import_key_set")
@patch("codecarbon.cli.auth.KeySet.import_key_set")
@patch("codecarbon.cli.auth.jose_jwt.decode")
def test_validate_access_token_valid(
self, mock_decode, mock_import_key_set, mock_get
):
mock_get.return_value.json.return_value = {"jwks_uri": "jwks"}
mock_get.return_value.raise_for_status.return_value = None
mock_import_key_set.return_value = "keyset"
mock_decode.return_value.validate.return_value = None
now = int(time.time())
mock_decode.return_value.claims = {
"iat": now - 10,
"exp": now + 300,
"sub": "user-123",
}
with patch(
"codecarbon.cli.auth._discover_endpoints", return_value={"jwks_uri": "jwks"}
):
self.assertTrue(auth._validate_access_token("token"))

@patch("codecarbon.cli.auth.requests.get")
@patch("codecarbon.cli.auth.KeySet.import_key_set")
@patch("codecarbon.cli.auth.jose_jwt.decode")
def test_validate_access_token_expired_returns_false(
self, mock_decode, mock_import_key_set, mock_get
):
# Expired exp must trip JWTClaimsRegistry validation
mock_get.return_value.json.return_value = {"jwks_uri": "jwks"}
mock_get.return_value.raise_for_status.return_value = None
mock_import_key_set.return_value = "keyset"
now = int(time.time())
mock_decode.return_value.claims = {"exp": now - 10}
with patch(
"codecarbon.cli.auth._discover_endpoints", return_value={"jwks_uri": "jwks"}
):
self.assertFalse(auth._validate_access_token("token"))

@patch("codecarbon.cli.auth._discover_endpoints", return_value={"jwks_uri": "jwks"})
@patch(
"codecarbon.cli.auth.requests.get",
Expand All @@ -116,7 +139,7 @@ def test_validate_access_token_network_error_returns_true(

@patch("codecarbon.cli.auth._discover_endpoints", return_value={"jwks_uri": "jwks"})
@patch("codecarbon.cli.auth.requests.get")
@patch("codecarbon.cli.auth.JsonWebKey.import_key_set")
@patch("codecarbon.cli.auth.KeySet.import_key_set")
@patch(
"codecarbon.cli.auth.jose_jwt.decode",
side_effect=Exception("invalid"),
Expand Down
2 changes: 2 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading