From e930b8336d68baabe32046f7ecd9fe89049e6133 Mon Sep 17 00:00:00 2001 From: Matthew Jamison Date: Tue, 19 May 2026 07:06:59 -0500 Subject: [PATCH 1/2] fix: migrate authlib.jose to joserfc (#1197) authlib.jose emits AuthlibDeprecationWarning and will be incompatible before authlib 2.0.0. Replace with joserfc, the authlib-recommended successor library. Changes: - codecarbon/cli/auth.py: KeySet.import_key_set + jwt.decode + JWTClaimsRegistry().validate() for access-token validation - carbonserver oidc_auth_provider.py: same migration in _decode_token - Add joserfc>=1.0.0 to lib and server dependencies - Update cli auth tests to patch joserfc.jwk.KeySet instead of authlib.jose.JsonWebKey; token.claims now an attribute, not a dict with validate() method --- .../services/auth_providers/oidc_auth_provider.py | 12 ++++++------ carbonserver/pyproject.toml | 1 + carbonserver/uv.lock | 3 +++ codecarbon/cli/auth.py | 10 +++++----- pyproject.toml | 1 + tests/cli/test_cli_auth.py | 6 +++--- uv.lock | 2 ++ 7 files changed, 21 insertions(+), 14 deletions(-) diff --git a/carbonserver/carbonserver/api/services/auth_providers/oidc_auth_provider.py b/carbonserver/carbonserver/api/services/auth_providers/oidc_auth_provider.py index b890cd2ff..e3758a15e 100644 --- a/carbonserver/carbonserver/api/services/auth_providers/oidc_auth_provider.py +++ b/carbonserver/carbonserver/api/services/auth_providers/oidc_auth_provider.py @@ -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 @@ -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) diff --git a/carbonserver/pyproject.toml b/carbonserver/pyproject.toml index 9f1a5193d..97e62e4d6 100644 --- a/carbonserver/pyproject.toml +++ b/carbonserver/pyproject.toml @@ -37,6 +37,7 @@ dependencies = [ "PyJWT", "fastapi-oidc>=0.0.9", "authlib>=1.6.6", + "joserfc>=1.0.0", "itsdangerous>=2.2.0", ] diff --git a/carbonserver/uv.lock b/carbonserver/uv.lock index 3a1ea5258..c46039e1f 100644 --- a/carbonserver/uv.lock +++ b/carbonserver/uv.lock @@ -120,6 +120,7 @@ dependencies = [ { name = "fief-client", extra = ["fastapi"] }, { name = "httpx" }, { name = "itsdangerous" }, + { name = "joserfc" }, { name = "numpy" }, { name = "psutil" }, { name = "psycopg2-binary" }, @@ -156,6 +157,7 @@ requires-dist = [ { name = "fief-client", extras = ["fastapi"] }, { name = "httpx" }, { name = "itsdangerous", specifier = ">=2.2.0" }, + { name = "joserfc", specifier = ">=1.0.0" }, { name = "mock", marker = "extra == 'dev'" }, { name = "numpy" }, { name = "psutil" }, @@ -489,6 +491,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, diff --git a/codecarbon/cli/auth.py b/codecarbon/cli/auth.py index 9b6fd0656..cace1bb42 100644 --- a/codecarbon/cli/auth.py +++ b/codecarbon/cli/auth.py @@ -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", @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 59d610015..e4dc276ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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'", diff --git a/tests/cli/test_cli_auth.py b/tests/cli/test_cli_auth.py index d30c5474e..0f22cf2cc 100644 --- a/tests/cli/test_cli_auth.py +++ b/tests/cli/test_cli_auth.py @@ -90,7 +90,7 @@ 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 @@ -98,7 +98,7 @@ def test_validate_access_token_valid( 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 + mock_decode.return_value.claims = {} with patch( "codecarbon.cli.auth._discover_endpoints", return_value={"jwks_uri": "jwks"} ): @@ -116,7 +116,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"), diff --git a/uv.lock b/uv.lock index c0ceab00d..21b2df3f6 100644 --- a/uv.lock +++ b/uv.lock @@ -419,6 +419,7 @@ dependencies = [ { name = "arrow" }, { name = "authlib" }, { name = "click" }, + { name = "joserfc" }, { name = "nvidia-ml-py" }, { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -491,6 +492,7 @@ requires-dist = [ { name = "dash-bootstrap-components", marker = "extra == 'viz-legacy'", specifier = ">1.0.0" }, { name = "fire", marker = "extra == 'carbonboard'" }, { name = "fire", marker = "extra == 'viz-legacy'" }, + { name = "joserfc", specifier = ">=1.0.0" }, { name = "nvidia-ml-py" }, { name = "pandas", marker = "python_full_version < '3.14'" }, { name = "pandas", marker = "python_full_version >= '3.14'", specifier = ">=2.3.3" }, From f8ecb27d0c6c2852066ea28b33611731827f3f1e Mon Sep 17 00:00:00 2001 From: Matthew Jamison Date: Tue, 19 May 2026 07:14:28 -0500 Subject: [PATCH 2/2] test: cover joserfc migration paths (#1197) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review follow-ups on the authlib.jose -> joserfc migration: - tests/cli/test_cli_auth.py: strengthen test_validate_access_token_valid with a realistic claims dict (exp/iat/sub) so JWTClaimsRegistry actually exercises its default validators instead of trivially passing on an empty payload. Add test_validate_access_token_expired_returns_false to pin down the expiry-rejection behaviour through the real registry. - carbonserver/tests/api/service/test_auth_provider.py: add test_decode_token_falls_back_to_jwks_when_fief_fails covering the previously-untested JWKS fallback in OIDCAuthProvider._decode_token — the only joserfc-using path in carbonserver. --- .../tests/api/service/test_auth_provider.py | 55 +++++++++++++++++++ tests/cli/test_cli_auth.py | 25 ++++++++- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/carbonserver/tests/api/service/test_auth_provider.py b/carbonserver/tests/api/service/test_auth_provider.py index bab5f5621..48eb7179d 100644 --- a/carbonserver/tests/api/service/test_auth_provider.py +++ b/carbonserver/tests/api/service/test_auth_provider.py @@ -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 @@ -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") diff --git a/tests/cli/test_cli_auth.py b/tests/cli/test_cli_auth.py index 0f22cf2cc..70df96d13 100644 --- a/tests/cli/test_cli_auth.py +++ b/tests/cli/test_cli_auth.py @@ -1,6 +1,7 @@ import io import json import tempfile +import time import unittest from pathlib import Path from unittest.mock import MagicMock, patch @@ -98,12 +99,34 @@ def test_validate_access_token_valid( 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.claims = {} + 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",