diff --git a/.env.example b/.env.example index 94e1379004..0f15b2ee1b 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,28 @@ ENVIRONMENT=development # API port (default: 8000) PORT=8000 +# ============================================================================ +# Admin Auth (for /debug/* endpoints) +# ============================================================================ + +# GET /debug/status and GET /debug/ping can be authorized either by a valid +# Cloudflare Access JWT (production browser flow) or by ADMIN_TOKEN. +# Leave ADMIN_TOKEN unset if you only want Cloudflare Access auth. +# Set it for local development, CI, or as a break-glass fallback when +# Cloudflare Access is unavailable. Locally, any non-empty string works +# because these endpoints are only reachable on localhost. +# ADMIN_TOKEN= + +# Cloudflare Access (Zero Trust) settings — only needed when verifying the +# Cf-Access-Jwt-Assertion header in production. Leave unset locally. +# CF_ACCESS_TEAM_DOMAIN=anyplot.cloudflareaccess.com +# CF_ACCESS_AUD= + +# Comma-separated list of allowed Google account email addresses for the +# Cloudflare Access JWT path. Required for the JWT path to authorize anyone; +# requests with a valid JWT but an unlisted email return 403. +# ADMIN_ALLOWED_EMAILS=alice@example.com,bob@example.com + # ============================================================================ # AI Services (optional) # ============================================================================ diff --git a/api/cloudbuild.yaml b/api/cloudbuild.yaml index fed0bf7bd2..fa0c50f56a 100644 --- a/api/cloudbuild.yaml +++ b/api/cloudbuild.yaml @@ -6,6 +6,17 @@ substitutions: _CPU: "1" _MIN_INSTANCES: "1" _MAX_INSTANCES: "3" + # Cloudflare Access (Zero Trust) for /debug/* — the team domain is stable; + # the AUD must be filled in once the Self-hosted Application is created in + # the Cloudflare Zero Trust dashboard. With AUD unset (or with no email + # in _ADMIN_ALLOWED_EMAILS), the JWT path in require_admin denies all and + # only the X-Admin-Token fallback is active. + _CF_ACCESS_TEAM_DOMAIN: "anyplot.cloudflareaccess.com" + _CF_ACCESS_AUD: "" + # Comma-separated. Browser users with a valid Cloudflare Access JWT but an + # email not in this list get 403. Required for the JWT path to authorize + # anyone. + _ADMIN_ALLOWED_EMAILS: "" steps: # Build the container image @@ -53,11 +64,14 @@ steps: - "--port=8000" - "--allow-unauthenticated" - "--add-cloudsql-instances=anyplot:europe-west4:anyplot-db" - - "--set-secrets=DATABASE_URL=DATABASE_URL:latest,CACHE_INVALIDATE_TOKEN=CACHE_INVALIDATE_TOKEN:latest" + - "--set-secrets=DATABASE_URL=DATABASE_URL:latest,CACHE_INVALIDATE_TOKEN=CACHE_INVALIDATE_TOKEN:latest,ADMIN_TOKEN=ADMIN_TOKEN:latest" - "--set-env-vars=ENVIRONMENT=production" - "--execution-environment=gen2" - "--set-env-vars=GOOGLE_CLOUD_PROJECT=$PROJECT_ID" - "--set-env-vars=GCS_BUCKET=anyplot-images" + - "--set-env-vars=CF_ACCESS_TEAM_DOMAIN=${_CF_ACCESS_TEAM_DOMAIN}" + - "--set-env-vars=CF_ACCESS_AUD=${_CF_ACCESS_AUD}" + - "--set-env-vars=^@^ADMIN_ALLOWED_EMAILS=${_ADMIN_ALLOWED_EMAILS}" - "--cpu-throttling" - "--concurrency=15" - "--timeout=600" diff --git a/api/routers/debug.py b/api/routers/debug.py index a07cc4418f..6932b6174a 100644 --- a/api/routers/debug.py +++ b/api/routers/debug.py @@ -6,7 +6,9 @@ import time from collections import Counter from datetime import datetime, timedelta, timezone +from functools import lru_cache +import jwt as pyjwt from fastapi import APIRouter, Depends, Header, HTTPException, Request, status from pydantic import BaseModel from sqlalchemy import text @@ -22,14 +24,69 @@ router = APIRouter(prefix="/debug", tags=["debug"]) -def require_admin(x_admin_token: str | None = Header(default=None)) -> None: - """Gate sensitive /debug/* endpoints behind a shared secret. +@lru_cache(maxsize=1) +def _jwks_client() -> pyjwt.PyJWKClient | None: + """Lazy, process-wide JWKS client for Cloudflare Access JWT verification. - Without this gate, /debug/status and /debug/ping reflect quality scores, - weakness aggregates, and DB latency to the public internet. When - settings.admin_token is unset the endpoint is disabled (503), so a - misconfigured prod deploy fails closed instead of fails open. + PyJWKClient caches keys per instance; we cache the instance itself so a + single Cloud Run worker only fetches the JWKS endpoint once at first + /debug request after cold start. """ + if not settings.cf_access_team_domain: + return None + return pyjwt.PyJWKClient(f"https://{settings.cf_access_team_domain}/cdn-cgi/access/certs") + + +def _verify_cf_access_jwt(token: str) -> str | None: + """Verify a Cloudflare Access JWT and return the authenticated email. + + Returns None when the JWT path is unconfigured, the signature is invalid, + the token is expired, or the aud/iss claims don't match. + """ + client = _jwks_client() + if client is None or not settings.cf_access_aud: + return None + try: + signing_key = client.get_signing_key_from_jwt(token) + claims = pyjwt.decode( + token, + signing_key.key, + algorithms=["RS256"], + audience=settings.cf_access_aud, + issuer=f"https://{settings.cf_access_team_domain}", + ) + except pyjwt.PyJWTError: + return None + email = claims.get("email") + return email if isinstance(email, str) else None + + +def require_admin( + x_admin_token: str | None = Header(default=None), + cf_access_jwt: str | None = Header(default=None, alias="Cf-Access-Jwt-Assertion"), +) -> None: + """Gate sensitive /debug/* endpoints behind Cloudflare Access OR a shared secret. + + Two paths: + 1. Browser path — Cloudflare Access verifies a Google identity at the edge, + forwards the request with `Cf-Access-Jwt-Assertion`. We verify the JWT + against Cloudflare's JWKS and check the email is on the allow-list. + 2. Token path — `X-Admin-Token` against `settings.admin_token`. Used by CI, + local dev, and break-glass access via the Cloud Run direct URL (which + bypasses Cloudflare). + + Without `settings.admin_token` configured the token path is disabled (503), + so a misconfigured prod deploy without Cloudflare Access still fails closed. + """ + if cf_access_jwt: + email = _verify_cf_access_jwt(cf_access_jwt) + if email and email in settings.admin_allowed_emails: + return + if email: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"User {email} not authorized") + # Invalid JWT (signature/aud/iss/expiry) — fall through to token path so + # a misconfigured edge never strands the operator without break-glass access. + expected = settings.admin_token if not expected: raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Debug endpoints not configured") diff --git a/app/src/pages/DebugPage.test.tsx b/app/src/pages/DebugPage.test.tsx index e25ddb4f0e..5b8262700a 100644 --- a/app/src/pages/DebugPage.test.tsx +++ b/app/src/pages/DebugPage.test.tsx @@ -95,7 +95,7 @@ describe('DebugPage', () => { render(); await waitFor(() => { - expect(screen.getByPlaceholderText('X-Admin-Token')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Admin token (fallback)')).toBeInTheDocument(); }); expect(screen.getByRole('button', { name: /unlock/i })).toBeInTheDocument(); expect(screen.getByText(/admin token required/i)).toBeInTheDocument(); @@ -112,6 +112,31 @@ describe('DebugPage', () => { }); }); + it('surfaces the server message on 403 (Cloudflare Access denial)', async () => { + // Cloudflare Access path: signed-in Google account not on the + // admin_allowed_emails allow-list. Backend returns 403 with the email + // in the detail; the page should switch to the auth-required screen and + // show that message instead of falling through to "failed to load: 403". + sessionStorage.clear(); + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve({ + ok: false, + status: 403, + json: () => Promise.resolve({ status: 403, message: 'User stranger@example.com not authorized', path: '/debug/status' }), + }), + ), + ); + + render(); + + await waitFor(() => { + expect(screen.getByText(/stranger@example\.com not authorized/i)).toBeInTheDocument(); + }); + expect(screen.getByPlaceholderText('Admin token (fallback)')).toBeInTheDocument(); + }); + it('submits the token, sends X-Admin-Token, and persists to sessionStorage', async () => { sessionStorage.clear(); let callIndex = 0; @@ -136,7 +161,7 @@ describe('DebugPage', () => { render(); - const input = await screen.findByPlaceholderText('X-Admin-Token'); + const input = await screen.findByPlaceholderText('Admin token (fallback)'); fireEvent.change(input, { target: { value: 'secret-xyz' } }); fireEvent.click(screen.getByRole('button', { name: /unlock/i })); @@ -159,7 +184,7 @@ describe('DebugPage', () => { expect(sessionStorage.getItem('anyplot.adminToken')).toBeNull(); }); // Form is still on screen because fetch still returns 401. - expect(screen.getByPlaceholderText('X-Admin-Token')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Admin token (fallback)')).toBeInTheDocument(); }); }); diff --git a/app/src/pages/DebugPage.tsx b/app/src/pages/DebugPage.tsx index 59ee549e4f..5a1f408dfd 100644 --- a/app/src/pages/DebugPage.tsx +++ b/app/src/pages/DebugPage.tsx @@ -160,10 +160,14 @@ function pingColor(ms: number): string { return colors.error; } -// Admin-token storage. /debug endpoints require X-Admin-Token in prod -// (require_admin gate, api/routers/debug.py); we keep the token in -// sessionStorage so it survives reloads of the same tab without persisting -// across browser sessions. +// Admin auth for /debug endpoints (require_admin in api/routers/debug.py). +// Two paths: +// - Cloudflare Access cookie set on .anyplot.ai → forwarded as Cf-Access-Jwt-Assertion +// to api.anyplot.ai. credentials: 'include' is required for the cookie to +// travel cross-origin to the API. +// - X-Admin-Token header as a fallback (CI, break-glass, local dev). Stored +// in sessionStorage so it survives reloads of the same tab without +// persisting across browser sessions. const ADMIN_TOKEN_KEY = 'anyplot.adminToken'; const readAdminToken = (): string => { try { return sessionStorage.getItem(ADMIN_TOKEN_KEY) ?? ''; } catch { return ''; } @@ -176,7 +180,10 @@ const clearAdminToken = (): void => { }; const adminFetch = (url: string, token: string): Promise => - fetch(url, token ? { headers: { 'X-Admin-Token': token } } : undefined); + fetch(url, { + credentials: 'include', + headers: token ? { 'X-Admin-Token': token } : undefined, + }); export function DebugPage() { const [data, setData] = useState(null); @@ -200,9 +207,17 @@ export function DebugPage() { setLoading(true); setError(null); adminFetch(`${API_URL}/debug/status`, adminToken) - .then(r => { - if (r.status === 401 || r.status === 503) { + .then(async r => { + // 403 is the Cloudflare Access JWT path's denial: a signed-in Google + // account that isn't on the admin_allowed_emails allow-list. Surface + // it on the auth-required screen with the server's message so the + // user knows to sign in with a different account or ask for access. + if (r.status === 401 || r.status === 403 || r.status === 503) { setAuthRequired(true); + if (r.status === 403) { + const body = await r.json().catch(() => ({})); + throw new Error(body?.message || 'this account is not authorized for /debug'); + } throw new Error(r.status === 503 ? 'admin auth not configured on server' : 'admin token required'); } if (!r.ok) throw new Error(`${r.status}`); @@ -301,14 +316,14 @@ export function DebugPage() { debug · admin auth} /> - {error || 'admin token required'} + {error || 'sign in via your browser session, or paste an admin token as a fallback.'} ) => setTokenInput(e.target.value)} sx={nativeControlSx} diff --git a/core/config.py b/core/config.py index a46a3d57ed..e27e655f99 100644 --- a/core/config.py +++ b/core/config.py @@ -5,8 +5,9 @@ defaults, and documentation. """ -from typing import Optional +from typing import Any, Optional +from pydantic import field_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -156,6 +157,39 @@ class Settings(BaseSettings): weakness aggregates, and DB latency to the public internet. Set via Secret Manager in Cloud Run; pass via the `X-Admin-Token` request header.""" + cf_access_team_domain: Optional[str] = None + """Cloudflare Access team domain, e.g. `anyplot.cloudflareaccess.com`. + Used as the JWT issuer and for fetching JWKS keys at + `https://{team_domain}/cdn-cgi/access/certs`. When unset, the JWT path in + require_admin is skipped (token-only mode).""" + + cf_access_aud: Optional[str] = None + """Cloudflare Access Application AUD tag (UUID from the Zero Trust + dashboard). Validated as the JWT `aud` claim.""" + + admin_allowed_emails: list[str] = [] + """Email addresses allowed to authenticate via Cloudflare Access for + /debug/* endpoints. Defaults to an empty list — must be set explicitly + in production. Accepts either a JSON array (`["a@b.com","c@d.com"]`) + or a comma-separated string (`a@b.com,c@d.com`) when set via env var.""" + + @field_validator("admin_allowed_emails", mode="before") + @classmethod + def _parse_admin_allowed_emails(cls, value: Any) -> Any: + """Allow `ADMIN_ALLOWED_EMAILS=foo@bar.com,baz@qux.com` in addition to + the JSON-array form pydantic-settings expects by default. A bare + string with no commas is treated as a single-element list.""" + import json + + if isinstance(value, str): + stripped = value.strip() + if not stripped: + return [] + if stripped.startswith("["): + return json.loads(stripped) + return [item.strip() for item in stripped.split(",") if item.strip()] + return value + # ============================================================================= # CORS # ============================================================================= diff --git a/pyproject.toml b/pyproject.toml index 105106ef7b..0827c14269 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,8 @@ dependencies = [ "pandas-stubs>=2.3.0", # Caching "cachetools>=7.0.6", + # Auth — Cloudflare Access JWT verification on /debug/* + "pyjwt[crypto]>=2.10.0", ] [project.optional-dependencies] diff --git a/tests/unit/api/test_debug.py b/tests/unit/api/test_debug.py index f94ccece17..755353dcee 100644 --- a/tests/unit/api/test_debug.py +++ b/tests/unit/api/test_debug.py @@ -424,3 +424,209 @@ def test_cache_invalidate_unaffected_by_admin_token(self, auth_client) -> None: # Without X-Admin-Token, cache invalidate still works given correct X-Cache-Token. response = auth_client.post("/debug/cache/invalidate", headers={"X-Cache-Token": "cachesecret"}) assert response.status_code == 200 + + +class TestRequireAdminCfAccess: + """Tests for the Cloudflare Access JWT path of `require_admin`. + + The browser path forwards `Cf-Access-Jwt-Assertion` from the Cloudflare + edge. We mock `_verify_cf_access_jwt` so these tests don't need a real + RS256 signature or JWKS network round-trip. + """ + + _ALLOWED = "meakeiok@gmail.com" + _DENIED = "stranger@example.com" + + def test_status_200_when_jwt_email_allowed(self, auth_client) -> None: + """Valid JWT + email on allow-list → 200, no admin token needed.""" + mock_repo = MagicMock() + mock_repo.get_all = AsyncMock(return_value=[]) + with ( + patch.object(settings, "admin_token", None), + patch.object(settings, "admin_allowed_emails", [self._ALLOWED]), + patch("api.routers.debug._verify_cf_access_jwt", return_value=self._ALLOWED), + patch("api.routers.debug.SpecRepository", return_value=mock_repo), + ): + response = auth_client.get("/debug/status", headers={"Cf-Access-Jwt-Assertion": "any.jwt.here"}) + assert response.status_code == 200 + + def test_status_403_when_jwt_email_not_allowed(self, auth_client) -> None: + """Valid JWT but email not on allow-list → 403, no fall-through.""" + with ( + patch.object(settings, "admin_token", "supersecret"), + patch.object(settings, "admin_allowed_emails", [self._ALLOWED]), + patch("api.routers.debug._verify_cf_access_jwt", return_value=self._DENIED), + ): + response = auth_client.get("/debug/status", headers={"Cf-Access-Jwt-Assertion": "any.jwt.here"}) + assert response.status_code == 403 + assert self._DENIED in response.json()["message"] + + def test_status_503_when_jwt_invalid_and_token_unset(self, auth_client) -> None: + """Invalid JWT + admin_token unset → falls through to 503 (fail-closed).""" + with ( + patch.object(settings, "admin_token", None), + patch("api.routers.debug._verify_cf_access_jwt", return_value=None), + ): + response = auth_client.get("/debug/status", headers={"Cf-Access-Jwt-Assertion": "garbage"}) + assert response.status_code == 503 + + def test_status_200_when_jwt_invalid_but_token_correct(self, auth_client) -> None: + """Invalid JWT + correct X-Admin-Token → 200 (break-glass fall-through).""" + mock_repo = MagicMock() + mock_repo.get_all = AsyncMock(return_value=[]) + with ( + patch.object(settings, "admin_token", "supersecret"), + patch("api.routers.debug._verify_cf_access_jwt", return_value=None), + patch("api.routers.debug.SpecRepository", return_value=mock_repo), + ): + response = auth_client.get( + "/debug/status", headers={"Cf-Access-Jwt-Assertion": "garbage", "X-Admin-Token": "supersecret"} + ) + assert response.status_code == 200 + + +class TestJwksHelpers: + """Direct tests for the JWKS helpers — `_jwks_client` and `_verify_cf_access_jwt`. + + The integration tests above mock `_verify_cf_access_jwt` wholesale, which + is the right shape there but leaves the helpers themselves uncovered. These + tests stub `pyjwt` so we can exercise every branch without needing a real + Cloudflare Access deployment or RS256 key material. + """ + + @staticmethod + def _reset_jwks_cache(): + from api.routers.debug import _jwks_client + + _jwks_client.cache_clear() + + def test_jwks_client_returns_none_when_team_domain_unset(self) -> None: + """No team domain → no client (token-only mode).""" + from api.routers.debug import _jwks_client + + self._reset_jwks_cache() + with patch.object(settings, "cf_access_team_domain", None): + assert _jwks_client() is None + + def test_jwks_client_constructs_with_team_domain(self) -> None: + """Team domain set → returns a PyJWKClient pointed at /cdn-cgi/access/certs.""" + from api.routers.debug import _jwks_client + + self._reset_jwks_cache() + sentinel = MagicMock() + with ( + patch.object(settings, "cf_access_team_domain", "anyplot.cloudflareaccess.com"), + patch("api.routers.debug.pyjwt.PyJWKClient", return_value=sentinel) as mock_ctor, + ): + result = _jwks_client() + assert result is sentinel + mock_ctor.assert_called_once_with("https://anyplot.cloudflareaccess.com/cdn-cgi/access/certs") + self._reset_jwks_cache() + + def test_verify_cf_access_jwt_returns_none_when_unconfigured(self) -> None: + """No JWKS client (team domain unset) → None, no exception.""" + from api.routers.debug import _verify_cf_access_jwt + + self._reset_jwks_cache() + with patch.object(settings, "cf_access_team_domain", None): + assert _verify_cf_access_jwt("some.jwt.token") is None + + def test_verify_cf_access_jwt_returns_none_when_aud_unset(self) -> None: + """Team domain set but AUD unset → None (don't verify against missing aud).""" + from api.routers.debug import _verify_cf_access_jwt + + self._reset_jwks_cache() + with ( + patch.object(settings, "cf_access_team_domain", "anyplot.cloudflareaccess.com"), + patch.object(settings, "cf_access_aud", None), + patch("api.routers.debug.pyjwt.PyJWKClient", return_value=MagicMock()), + ): + assert _verify_cf_access_jwt("some.jwt.token") is None + self._reset_jwks_cache() + + def test_verify_cf_access_jwt_returns_email_on_valid_token(self) -> None: + """Valid signature + claims → email from `email` claim.""" + from api.routers.debug import _verify_cf_access_jwt + + self._reset_jwks_cache() + mock_client = MagicMock() + mock_client.get_signing_key_from_jwt.return_value = MagicMock(key="fake-key") + with ( + patch.object(settings, "cf_access_team_domain", "anyplot.cloudflareaccess.com"), + patch.object(settings, "cf_access_aud", "the-aud-uuid"), + patch("api.routers.debug.pyjwt.PyJWKClient", return_value=mock_client), + patch("api.routers.debug.pyjwt.decode", return_value={"email": "alice@example.com"}) as mock_decode, + ): + email = _verify_cf_access_jwt("good.jwt.token") + assert email == "alice@example.com" + # Confirm we passed the right audience and issuer to pyjwt.decode. + kwargs = mock_decode.call_args.kwargs + assert kwargs["audience"] == "the-aud-uuid" + assert kwargs["issuer"] == "https://anyplot.cloudflareaccess.com" + assert kwargs["algorithms"] == ["RS256"] + self._reset_jwks_cache() + + def test_verify_cf_access_jwt_returns_none_on_pyjwt_error(self) -> None: + """pyjwt raises (bad signature, expired, wrong aud, …) → None.""" + import jwt as pyjwt + + from api.routers.debug import _verify_cf_access_jwt + + self._reset_jwks_cache() + mock_client = MagicMock() + mock_client.get_signing_key_from_jwt.side_effect = pyjwt.PyJWTError("bad sig") + with ( + patch.object(settings, "cf_access_team_domain", "anyplot.cloudflareaccess.com"), + patch.object(settings, "cf_access_aud", "the-aud-uuid"), + patch("api.routers.debug.pyjwt.PyJWKClient", return_value=mock_client), + ): + assert _verify_cf_access_jwt("bad.jwt.token") is None + self._reset_jwks_cache() + + def test_verify_cf_access_jwt_returns_none_on_non_string_email_claim(self) -> None: + """Defensive: claims with no email or non-str email → None.""" + from api.routers.debug import _verify_cf_access_jwt + + self._reset_jwks_cache() + mock_client = MagicMock() + mock_client.get_signing_key_from_jwt.return_value = MagicMock(key="fake-key") + with ( + patch.object(settings, "cf_access_team_domain", "anyplot.cloudflareaccess.com"), + patch.object(settings, "cf_access_aud", "the-aud-uuid"), + patch("api.routers.debug.pyjwt.PyJWKClient", return_value=mock_client), + patch("api.routers.debug.pyjwt.decode", return_value={"email": None}), + ): + assert _verify_cf_access_jwt("token") is None + self._reset_jwks_cache() + + +class TestAdminAllowedEmailsParsing: + """The admin_allowed_emails field accepts both comma-separated strings + (operator-friendly for `.env` files) and JSON arrays (pydantic-settings + default for list fields).""" + + def test_parses_comma_separated_string(self) -> None: + from core.config import Settings + + s = Settings(admin_allowed_emails="a@x.com,b@y.com, c@z.com ") + assert s.admin_allowed_emails == ["a@x.com", "b@y.com", "c@z.com"] + + def test_parses_json_array(self) -> None: + from core.config import Settings + + s = Settings(admin_allowed_emails='["a@x.com","b@y.com"]') + assert s.admin_allowed_emails == ["a@x.com", "b@y.com"] + + def test_empty_string_yields_empty_list(self) -> None: + from core.config import Settings + + s = Settings(admin_allowed_emails="") + assert s.admin_allowed_emails == [] + + def test_default_is_empty_list(self) -> None: + """Defaulting to an empty list prevents accidental authorization if the + operator forgets to configure ADMIN_ALLOWED_EMAILS in production.""" + from core.config import Settings + + s = Settings() + assert s.admin_allowed_emails == [] diff --git a/uv.lock b/uv.lock index be8bab5731..23be4a6dd9 100644 --- a/uv.lock +++ b/uv.lock @@ -235,6 +235,7 @@ dependencies = [ { name = "pillow" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, { name = "python-dotenv" }, { name = "pyyaml" }, { name = "scikit-learn" }, @@ -443,6 +444,7 @@ requires-dist = [ { name = "pydantic-settings", specifier = ">=2.14.0" }, { name = "pygal", marker = "extra == 'lib-pygal'", specifier = ">=3.0.0" }, { name = "pygal", marker = "extra == 'plotting'", specifier = ">=3.0.0" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10.0" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8.4.0" }, { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=1.0.0" }, { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.2.1" },