diff --git a/pyproject.toml b/pyproject.toml index 3409ae4..fe9b75f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "loguru>=0.7.3", "pydantic>=2.11.7", "pydantic-settings>=2.10.1", + "pyjwt>=2.10.1", "pytz>=2025.2", "ray[serve]==2.53.0", "sentry-sdk[fastapi]>=2.33.0", diff --git a/pytest.ini b/pytest.ini index 9adb23e..87681a8 100644 --- a/pytest.ini +++ b/pytest.ini @@ -29,6 +29,6 @@ env = ; plugins__proxy__force_stream_apis=[] sentry__enable=false test__silent=true - auth__rules={{"/api/v1/echo":["i_am_general_auth_keys"],"/api/v1/proxy/remote":["i_am_local_proxy_auth_keys"]}} + auth__rules={{"/api/v1/openapi.json":["i_am_docs_key"],"/api/v1/echo":["i_am_general_auth_keys"],"/api/v1/proxy/remote":["i_am_local_proxy_auth_keys"]}} asyncio_mode = auto diff --git a/ruff.toml b/ruff.toml index 4759ae7..fe30ebd 100644 --- a/ruff.toml +++ b/ruff.toml @@ -79,7 +79,7 @@ ignore = [ ] [lint.per-file-ignores] -"tests/**/*.py" = ["S101", "ANN201", "ANN001", "ANN002"] +"tests/**/*.py" = ["S101", "ANN201", "ANN001", "ANN002", "ANN003"] [lint.flake8-builtins] builtins-ignorelist = ["input", "id", "bytes", "type"] diff --git a/src/framex/config.py b/src/framex/config.py index 9c5a577..e453231 100644 --- a/src/framex/config.py +++ b/src/framex/config.py @@ -1,3 +1,4 @@ +import secrets from typing import Any, Literal from pydantic import BaseModel, Field @@ -45,24 +46,44 @@ class ServerConfig(BaseModel): excluded_log_paths: list[str] = Field(default_factory=list) ingress_config: dict[str, Any] = Field(default_factory=lambda: {"max_ongoing_requests": 60}) - # docs config - docs_user: str = "admin" - docs_password: str = "" - - def model_post_init(self, __context: Any) -> None: # pragma: no cover - if self.docs_password == "": - self.docs_password = "admin" # noqa: S105 - from framex.log import logger - - logger.warning("No docs_password set, fallback to default password: admin") - class TestConfig(BaseModel): disable_record_request: bool = False silent: bool = False +class OauthConfig(BaseModel): + client_id: str = "" + client_secret: str = "" + authorization_url: str = "" + redirect_uri: str = "" + base_url: str = "" + + token_url: str = "" + user_info_url: str = "" + app_url: str = "" + + jwt_secret: str = "" + jwt_algorithm: str = "HS256" + + @property + def call_back_url(self) -> str: + return f"{self.app_url}{self.redirect_uri}" + + def model_post_init(self, context: Any) -> None: + super().model_post_init(context) + if not self.authorization_url: + self.authorization_url = f"{self.base_url}/oauth/authorize" + if not self.token_url: + self.token_url = f"{self.base_url}/oauth/token" + if not self.user_info_url: + self.user_info_url = f"{self.base_url}/api/v4/user" + if not self.jwt_secret: + self.jwt_secret = secrets.token_urlsafe(32) + + class AuthConfig(BaseModel): + oauth: OauthConfig | None = Field(default=None) rules: dict[str, list[str]] = Field(default_factory=dict) def _is_url_protected(self, url: str) -> bool: diff --git a/src/framex/driver/application.py b/src/framex/driver/application.py index 0911b1a..01448c3 100644 --- a/src/framex/driver/application.py +++ b/src/framex/driver/application.py @@ -1,7 +1,6 @@ """Module containing FastAPI instance related functions and classes.""" import json -import secrets from collections.abc import Callable from contextlib import asynccontextmanager from datetime import UTC, datetime @@ -13,7 +12,6 @@ from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html from fastapi.openapi.utils import get_openapi from fastapi.responses import HTMLResponse -from fastapi.security import HTTPBasic, HTTPBasicCredentials from starlette import status from starlette.concurrency import iterate_in_threadpool from starlette.exceptions import HTTPException @@ -24,6 +22,7 @@ from framex.config import settings from framex.consts import API_STR, DOCS_URL, OPENAPI_URL, PROJECT_NAME, REDOC_URL, VERSION +from framex.driver.auth import authenticate, oauth_callback from framex.utils import format_uptime FRAME_START_TIME = datetime.now(tz=UTC) @@ -95,22 +94,12 @@ async def _on_start(deployment: DeploymentHandle) -> None: redirect_slashes=False, ) - security = HTTPBasic(realm="Swagger Docs") - - def authenticate(credentials: HTTPBasicCredentials = Depends(security)) -> str: - correct_username = secrets.compare_digest(credentials.username, settings.server.docs_user) - correct_password = secrets.compare_digest(credentials.password, settings.server.docs_password) - - if not (correct_username and correct_password): - from framex.log import logger - - logger.warning(f"Failed authentication attempt for user: {credentials.username}") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect user or password", - headers={"WWW-Authenticate": "Basic"}, - ) - return credentials.username + if settings.auth.oauth: + application.add_api_route( + settings.auth.oauth.redirect_uri, + oauth_callback, + methods=["GET"], + ) @application.get(DOCS_URL, include_in_schema=False) async def get_documentation(_: Annotated[str, Depends(authenticate)]) -> HTMLResponse: diff --git a/src/framex/driver/auth.py b/src/framex/driver/auth.py new file mode 100644 index 0000000..4c204b8 --- /dev/null +++ b/src/framex/driver/auth.py @@ -0,0 +1,128 @@ +from datetime import UTC, datetime, timedelta + +import httpx +import jwt +from fastapi import Depends, HTTPException, Response, status +from fastapi.responses import RedirectResponse +from fastapi.security import APIKeyHeader +from starlette.requests import Request + +from framex.config import settings +from framex.consts import DOCS_URL + +api_key_header = APIKeyHeader(name="Authorization", auto_error=False) + + +def create_jwt(payload: dict) -> str: + if not settings.auth.oauth: + raise RuntimeError("OAuth not configured") + + now_utc = datetime.now(UTC) + + payload.update( + { + "iat": int(now_utc.timestamp()), + "exp": int((now_utc + timedelta(hours=24)).timestamp()), + } + ) + return jwt.encode(payload, settings.auth.oauth.jwt_secret, algorithm=settings.auth.oauth.jwt_algorithm) + + +def auth_jwt(request: Request) -> bool: + if not settings.auth.oauth: + return False + + token = request.cookies.get("token") + if not token: + return False + + try: + jwt.decode( + token, + settings.auth.oauth.jwt_secret, + algorithms=[settings.auth.oauth.jwt_algorithm], + ) + return True + except (jwt.InvalidTokenError, jwt.ExpiredSignatureError): + return False + + +def authenticate(request: Request, api_key: str | None = Depends(api_key_header)) -> None: + if settings.auth.oauth: + if token := request.cookies.get("token"): + try: + jwt.decode( + token, + settings.auth.oauth.jwt_secret, + algorithms=[settings.auth.oauth.jwt_algorithm], + ) + return + + except Exception as e: + from framex.log import logger + + logger.warning(f"JWT decode failed: {e}") + + if api_key and api_key in (settings.auth.get_auth_keys(request.url.path) or []): + return + + raise HTTPException( + status_code=status.HTTP_302_FOUND, + headers={ + "Location": ( + f"{settings.auth.oauth.authorization_url}" + f"?client_id={settings.auth.oauth.client_id}" + "&response_type=code" + f"&redirect_uri={settings.auth.oauth.call_back_url}" + "&scope=read_user" + ) + }, + ) + + +async def oauth_callback(code: str) -> Response: + if not settings.auth.oauth: # pragma: no cover + raise RuntimeError("OAuth not configured") + + async with httpx.AsyncClient() as client: + resp = await client.post( + settings.auth.oauth.token_url, + data={ + "client_id": settings.auth.oauth.client_id, + "client_secret": settings.auth.oauth.client_secret, + "grant_type": "authorization_code", + "code": code, + "redirect_uri": settings.auth.oauth.call_back_url, + }, + ) + resp.raise_for_status() + token_resp = resp.json() + + if not (auth_token := token_resp.get("access_token")): # pragma: no cover + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="GitLab token exchange failed") + + resp = await client.get( + settings.auth.oauth.user_info_url, + headers={"Authorization": f"Bearer {auth_token}"}, + timeout=10, + ) + resp.raise_for_status() + user_resp = resp.json() + + if not (username := user_resp.get("username")): # pragma: no cover + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Failed to get GitLab user") + + user_info = { + "message": f"Welcome {username}", + "username": username, + "email": user_resp.get("email"), + } + + res = RedirectResponse(url=DOCS_URL, status_code=status.HTTP_302_FOUND) + res.set_cookie( + "token", + create_jwt(user_info), + httponly=True, + samesite="lax", + ) + return res diff --git a/src/framex/driver/ingress.py b/src/framex/driver/ingress.py index 1854583..1739184 100644 --- a/src/framex/driver/ingress.py +++ b/src/framex/driver/ingress.py @@ -2,10 +2,9 @@ from enum import Enum from typing import Any -from fastapi import Depends, HTTPException, Response, status +from fastapi import Depends, HTTPException, Request, Response, status from fastapi.responses import JSONResponse, StreamingResponse from fastapi.routing import APIRoute -from fastapi.security import APIKeyHeader from pydantic import create_model from ray.serve.handle import DeploymentHandle from starlette.routing import Route @@ -13,13 +12,13 @@ from framex.adapter import get_adapter from framex.consts import BACKEND_NAME from framex.driver.application import create_fastapi_application +from framex.driver.auth import api_key_header, auth_jwt from framex.driver.decorator import api_ingress from framex.log import setup_logger from framex.plugin.model import ApiType, PluginApi from framex.utils import escape_tag, safe_error_message app = create_fastapi_application() -api_key_header = APIKeyHeader(name="Authorization", auto_error=True) @app.get("/health") @@ -114,8 +113,8 @@ async def route_handler(response: Response, model: Model = Depends()) -> Any: # if auth_keys is not None: logger.debug(f"API({path}) with tags {tags} requires auth.") - def _verify_api_key(api_key: str = Depends(api_key_header)) -> None: - if api_key not in auth_keys: + def _verify_api_key(request: Request, api_key: str | None = Depends(api_key_header)) -> None: + if (api_key is None or api_key not in auth_keys) and (not auth_jwt(request)): logger.error(f"Unauthorized access attempt with API Key({api_key}) for API({path})") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, diff --git a/src/framex/plugins/proxy/__init__.py b/src/framex/plugins/proxy/__init__.py index 084d3d0..b63b6f0 100644 --- a/src/framex/plugins/proxy/__init__.py +++ b/src/framex/plugins/proxy/__init__.py @@ -295,7 +295,7 @@ async def call_proxy_function(self, func_name: str, data: str) -> str: else: kwargs = decode_kwargs tag = "remote" if proxy_func.is_remote else "local" - logger.info(f"Calling proxy function[{tag}]: {decode_func_name}, kwargs: {decode_kwargs}") + logger.info(f"Calling proxy function[{tag}]: {decode_func_name}") res = await proxy_func.func(**kwargs) return res if proxy_func.is_remote else cache_encode(res) raise RuntimeError(f"Proxy function({decode_func_name}) not registered") diff --git a/tests/api/test_echo.py b/tests/api/test_echo.py index e384776..c5dc8cb 100644 --- a/tests/api/test_echo.py +++ b/tests/api/test_echo.py @@ -16,8 +16,8 @@ def test_echo(client: TestClient): def test_echo_with_no_api_key(client: TestClient): params = {"message": "hello world"} res = client.get(f"{API_STR}/echo", params=params).json() - assert res["status"] == 403 - assert res["message"] == "Not authenticated" + assert res["status"] == 401 + assert res["message"] == "Invalid API Key(None) for API(/api/v1/echo)" def test_echo_with_error_api_key(client: TestClient): diff --git a/tests/driver/test_application.py b/tests/driver/test_application.py index f14263a..f1b0cff 100644 --- a/tests/driver/test_application.py +++ b/tests/driver/test_application.py @@ -8,7 +8,7 @@ from starlette import status from starlette.exceptions import HTTPException -from framex.consts import API_STR, DOCS_URL, OPENAPI_URL +from framex.consts import API_STR from framex.driver.application import create_fastapi_application @@ -33,45 +33,6 @@ def test_cors_middleware_configured(self): assert "CORSMiddleware" in middleware_classes -class TestAuthenticationEndpoints: - @pytest.fixture - def app(self): - return create_fastapi_application() - - @pytest.fixture - def client(self, app): - return TestClient(app) - - def test_docs_requires_auth(self, client): - response = client.get(DOCS_URL) - assert response.status_code == status.HTTP_401_UNAUTHORIZED - - def test_docs_wrong_credentials(self, client): - response = client.get(DOCS_URL, auth=("bad", "bad")) - assert response.status_code == status.HTTP_401_UNAUTHORIZED - - def test_docs_correct_credentials(self, client): - from framex.config import settings - - response = client.get( - DOCS_URL, - auth=(settings.server.docs_user, settings.server.docs_password), - ) - assert response.status_code == status.HTTP_200_OK - assert "text/html" in response.headers["content-type"] - - def test_openapi_correct_credentials(self, client): - from framex.config import settings - - response = client.get( - OPENAPI_URL, - auth=(settings.server.docs_user, settings.server.docs_password), - ) - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data["info"]["title"] == "FrameX API" - - class TestExceptionHandlers: @pytest.fixture def app(self): @@ -146,15 +107,6 @@ async def endpoint() -> Any: assert data["data"] == {"result": "ok"} assert "timestamp" in data - def test_docs_not_wrapped(self, client): - from framex.config import settings - - response = client.get( - DOCS_URL, - auth=(settings.server.docs_user, settings.server.docs_password), - ) - assert "text/html" in response.headers["content-type"] - class TestLifespanBehavior: @patch("framex.config.settings") diff --git a/tests/driver/test_auth.py b/tests/driver/test_auth.py new file mode 100644 index 0000000..a26ea25 --- /dev/null +++ b/tests/driver/test_auth.py @@ -0,0 +1,218 @@ +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock, patch + +import jwt +import pytest +from fastapi import HTTPException, status +from fastapi.testclient import TestClient +from starlette.requests import Request + +from framex.config import AuthConfig +from framex.consts import DOCS_URL +from framex.driver.application import create_fastapi_application +from framex.driver.auth import auth_jwt, authenticate, create_jwt, oauth_callback + +# ========================================================= +# helpers +# ========================================================= + + +def fake_oauth(**overrides): + data = dict( # noqa: C408 + authorization_url="https://oauth.example.com/authorize", + token_url="https://oauth.example.com/token", # noqa: S106 + user_info_url="https://oauth.example.com/user", + client_id="client", + client_secret="secret", # noqa: S106 + redirect_uri="/oauth/callback", + call_back_url="http://test/callback", + jwt_secret="secret", # noqa: S106 + jwt_algorithm="HS256", + ) + data.update(overrides) + return SimpleNamespace(**data) + + +# ========================================================= +# create_jwt +# ========================================================= + + +class TestCreateJWT: + def test_create_jwt_success(self): + with patch("framex.config.settings.auth.oauth", fake_oauth()): + token = create_jwt({"username": "test"}) + decoded = jwt.decode(token, "secret", algorithms=["HS256"]) + assert decoded["username"] == "test" + assert "iat" in decoded + assert "exp" in decoded + + def test_create_jwt_no_oauth(self): + with patch("framex.config.settings.auth.oauth", None), pytest.raises(RuntimeError): + create_jwt({"username": "test"}) + + +# ========================================================= +# auth_jwt +# ========================================================= + + +class TestAuthJWT: + def test_returns_false_when_oauth_not_configured(self): + with patch("framex.config.settings.auth.oauth", None): + req = Mock(spec=Request) + assert auth_jwt(req) is False + + def test_returns_false_when_no_token_cookie(self): + with patch("framex.config.settings.auth.oauth") as mock_oauth: + mock_oauth.jwt_secret = "secret" # noqa: S105 + mock_oauth.jwt_algorithm = "HS256" + + req = Mock(spec=Request) + req.cookies.get.return_value = None + + assert auth_jwt(req) is False + + def test_returns_true_when_token_is_valid(self): + with patch("framex.config.settings.auth.oauth") as mock_oauth: + mock_oauth.jwt_secret = "secret" # noqa: S105 + mock_oauth.jwt_algorithm = "HS256" + + now = datetime.now(UTC) + token = jwt.encode( + { + "username": "test", + "iat": int(now.timestamp()), + "exp": int((now + timedelta(hours=1)).timestamp()), + }, + "secret", + algorithm="HS256", + ) + + req = Mock(spec=Request) + req.cookies.get.return_value = token + + assert auth_jwt(req) is True + + def test_returns_false_when_token_is_invalid(self): + with patch("framex.config.settings.auth.oauth") as mock_oauth: + mock_oauth.jwt_secret = "secret" # noqa: S105 + mock_oauth.jwt_algorithm = "HS256" + + req = Mock(spec=Request) + req.cookies.get.return_value = "this.is.not.a.jwt" + + assert auth_jwt(req) is False + + def test_returns_false_when_token_is_expired(self): + with patch("framex.config.settings.auth.oauth") as mock_oauth: + mock_oauth.jwt_secret = "secret" # noqa: S105 + mock_oauth.jwt_algorithm = "HS256" + + now = datetime.now(UTC) + expired_token = jwt.encode( + { + "username": "test", + "iat": int((now - timedelta(days=2)).timestamp()), + "exp": int((now - timedelta(days=1)).timestamp()), + }, + "secret", + algorithm="HS256", + ) + + req = Mock(spec=Request) + req.cookies.get.return_value = expired_token + + assert auth_jwt(req) is False + + +# ========================================================= +# authenticate (unit) +# ========================================================= + + +class TestAuthenticate: + def test_redirect_when_no_credentials(self): + with patch("framex.config.settings.auth.oauth", fake_oauth()): + req = Mock(spec=Request) + req.cookies.get.return_value = None + req.url.path = "/docs" + with pytest.raises(HTTPException) as exc: + authenticate(req, api_key=None) + assert exc.value.status_code == status.HTTP_302_FOUND + + def test_valid_api_key(self): + with ( + patch("framex.config.settings.auth.oauth", fake_oauth()), + patch.object(AuthConfig, "get_auth_keys", return_value=["good-key"]), + ): + req = Mock(spec=Request) + req.cookies.get.return_value = None + req.url.path = "/docs" + authenticate(req, api_key="good-key") + + +# ========================================================= +# oauth_callback +# ========================================================= + + +class TestOAuthCallback: + @pytest.mark.asyncio + async def test_oauth_callback_success(self): + with patch("framex.config.settings.auth.oauth", fake_oauth()), patch("httpx.AsyncClient") as mock_client_cls: + client = AsyncMock() + mock_client_cls.return_value.__aenter__.return_value = client + + token_resp = Mock() + token_resp.json.return_value = {"access_token": "oauth-token"} + token_resp.raise_for_status = Mock() + + user_resp = Mock() + user_resp.json.return_value = {"username": "test", "email": "a@b.com"} + user_resp.raise_for_status = Mock() + + client.post.return_value = token_resp + client.get.return_value = user_resp + + res = await oauth_callback(code="abc") + assert res.status_code == status.HTTP_302_FOUND + assert res.headers["location"] == DOCS_URL + assert "token=" in res.headers.get("set-cookie", "") + + +# ========================================================= +# Integration +# ========================================================= + + +class TestAuthenticationIntegration: + def test_docs_redirects_when_not_authenticated(self): + with patch("framex.config.settings.auth.oauth", fake_oauth()): + app = create_fastapi_application() + client = TestClient(app) + + resp = client.get("/docs", follow_redirects=False) + + assert resp.status_code == status.HTTP_302_FOUND + assert "oauth.example.com" in resp.headers["location"] + + def test_docs_accessible_with_valid_jwt(self): + with patch("framex.config.settings.auth.oauth", fake_oauth()): + app = create_fastapi_application() + client = TestClient(app) + + now = datetime.now(UTC) + token = jwt.encode( + { + "username": "test", + "iat": int(now.timestamp()), + "exp": int((now + timedelta(hours=1)).timestamp()), + }, + "secret", + algorithm="HS256", + ) + + resp = client.get("/docs", cookies={"token": token}, follow_redirects=False) + assert resp.status_code == status.HTTP_200_OK diff --git a/tests/test_config.py b/tests/test_config.py index db86afb..6ba6dcf 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,4 +1,4 @@ -from framex.config import AuthConfig +from framex.config import OauthConfig def test_config(): @@ -10,21 +10,39 @@ def test_config(): assert cfg.proxy_urls is not None -def test_auth_config(): - AuthConfig( - general_auth_keys=["abcdefg"], - auth_urls=[ - "/api/v1/a/*", - "/api/b/call", - "/api/v1/c/*", - ], - special_auth_keys={"/api/v1/a/call": ["0123456789"], "/api/v1/c/*": ["0123456789a", "0123456789b"]}, +def test_oauth_config_callback_url_property(): + cfg = OauthConfig( + app_url="https://example.com", + redirect_uri="/auth/callback", ) + assert cfg.call_back_url == "https://example.com/auth/callback" - AuthConfig( - general_auth_keys=["abcdefg"], - auth_urls=[ - "/api/v1/a/*", - ], - special_auth_keys={"/api/v1/a/call": ["0123456789"]}, + +def test_oauth_config_generates_default_urls_from_base_url(): + cfg = OauthConfig( + base_url="https://gitlab.example.com", + ) + + assert cfg.authorization_url == "https://gitlab.example.com/oauth/authorize" + assert cfg.token_url == "https://gitlab.example.com/oauth/token" # noqa: S105 + assert cfg.user_info_url == "https://gitlab.example.com/api/v4/user" + + +def test_oauth_config_generates_jwt_secret_when_missing(): + cfg = OauthConfig() + assert cfg.jwt_secret + assert isinstance(cfg.jwt_secret, str) + assert len(cfg.jwt_secret) >= 32 + + +def test_oauth_config_does_not_override_custom_urls(): + cfg = OauthConfig( + base_url="https://gitlab.example.com", + authorization_url="https://custom.auth", + token_url="https://custom.token", # noqa: S106 + user_info_url="https://custom.user", ) + + assert cfg.authorization_url == "https://custom.auth" + assert cfg.token_url == "https://custom.token" # noqa: S105 + assert cfg.user_info_url == "https://custom.user" diff --git a/uv.lock b/uv.lock index ab5dfff..492cdc1 100644 --- a/uv.lock +++ b/uv.lock @@ -1,4 +1,5 @@ version = 1 +revision = 1 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.13' and platform_python_implementation == 'PyPy'", @@ -300,7 +301,7 @@ name = "click" version = "8.1.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "platform_system == 'Windows'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } wheels = [ @@ -333,7 +334,7 @@ name = "colorful" version = "0.5.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "platform_system == 'Windows'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/82/31/109ef4bedeb32b4202e02ddb133162457adc4eb890a9ed9c05c9dd126ed0/colorful-0.5.8.tar.gz", hash = "sha256:bb16502b198be2f1c42ba3c52c703d5f651d826076817185f0294c1a549a7445", size = 209361 } wheels = [ @@ -498,6 +499,7 @@ dependencies = [ { name = "loguru" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "pyjwt" }, { name = "pytz" }, { name = "ray", extra = ["serve"] }, { name = "sentry-sdk", extra = ["fastapi"] }, @@ -537,12 +539,14 @@ requires-dist = [ { name = "poethepoet", marker = "extra == 'release'", specifier = ">=0.36.0" }, { name = "pydantic", specifier = ">=2.11.7" }, { name = "pydantic-settings", specifier = ">=2.10.1" }, + { name = "pyjwt", specifier = ">=2.10.1" }, { name = "python-semantic-release", marker = "extra == 'release'", specifier = ">=10.2.0" }, { name = "pytz", specifier = ">=2025.2" }, { name = "ray", extras = ["serve"], specifier = "==2.53.0" }, { name = "sentry-sdk", extras = ["fastapi"], specifier = ">=2.33.0" }, { name = "tomli", specifier = ">=2.2.1" }, ] +provides-extras = ["release"] [package.metadata.requires-dev] dev = [ @@ -1742,6 +1746,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, ] +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997 }, +] + [[package]] name = "pytest" version = "8.4.2"