diff --git a/api/app.py b/api/app.py index c245926..d14c09f 100644 --- a/api/app.py +++ b/api/app.py @@ -12,6 +12,11 @@ exposes the two endpoints every later task depends on: (service-token protected; see :mod:`api.routes_panel`, whose router carries the shared :func:`api.dependencies.require_panel_token` check). +- ``GET /api/v1/admin/ping`` — the admin plane's auth probe, protected + by the Authentik OIDC JWT check (see :mod:`api.routes_admin`, whose + router carries the shared :func:`api.auth.require_admin_jwt` + dependency; the JWKS document it verifies against is cached on + ``app.state.jwks_cache``). - ``GET /docs`` — FastAPI's auto-served OpenAPI document (Swagger UI); ``/openapi.json`` is the raw schema. No extra wiring is needed; FastAPI serves both by default. @@ -37,9 +42,11 @@ from collections.abc import AsyncIterator from fastapi import FastAPI +from api.auth import JwksCache from api.config import ApiConfig from api.database import DatabaseState, make_database from api.events import TransitionPublisher +from api.routes_admin import router as admin_router from api.routes_panel import router as panel_router @@ -90,10 +97,16 @@ def create_app(config: ApiConfig | None = None) -> FastAPI: ) # The resolved config rides on app.state so the reusable route - # dependencies (the panel service-token check) read the configured - # values from the app rather than module state. + # dependencies (the panel service-token check, the admin JWT check) + # read the configured values from the app rather than module state. app.state.config = cfg + # The admin plane's ONE per-app JWKS cache: fetched from the + # configured provider on first use, then reused until its TTL + # lapses (see api.auth.JwksCache). Tests replace it with a + # stub-backed cache so no request ever leaves the process. + app.state.jwks_cache = JwksCache(cfg.oidc_jwks_url) app.include_router(panel_router) + app.include_router(admin_router) @app.get("/health", summary="Service health") async def health() -> dict[str, str]: diff --git a/api/auth.py b/api/auth.py new file mode 100644 index 0000000..156d738 --- /dev/null +++ b/api/auth.py @@ -0,0 +1,261 @@ +"""Admin-plane authentication for the NestQuest API service. + +The admin plane (``/api/v1/admin/*``, D-012) is authenticated by +Authentik-issued OIDC access tokens: the admin PWA presents its JWT as +a Bearer credential and :func:`require_admin_jwt` — the ONE +implementation of that check — verifies it before any admin route runs. +Verification enforces, without exception: + +- the signature, against the RSA signing key published in the + provider's JWKS document (fetched from the configured + ``NESTQUEST_OIDC_JWKS_URL`` and cached per app — never per request); +- the issuer (``iss`` must equal the configured + ``NESTQUEST_OIDC_ISSUER``); +- the audience (``aud`` must carry the configured + ``NESTQUEST_OIDC_AUDIENCE``); +- expiry (``exp`` is required and enforced, and ``nbf`` is enforced + when the token carries it). + +Every rejection raises the SAME :class:`~fastapi.HTTPException` with +the same detail — nothing in the response distinguishes which check +failed. The check FAILS CLOSED: a missing app config, a malformed or +missing ``Authorization`` header, a non-RS256 algorithm, an unknown +``kid``, and any PyJWT verification error all yield the one 401. A +JWKS FETCH failure is deliberately NOT mapped to 401: the provider +being unreachable is a server-side fault (an HTTP 500 surfaces) and +never poisons the cache, so the plane stays closed while the real +cause is visible in the server log instead of disguised as a bad +credential. + +This module validates the TOKEN only — it is authentication, not +authorization. Membership in the ``nestquest-admins`` group is the +NEXT task, which extends :func:`require_admin_jwt` (the verified +claims are already its return value, so no rewrite is needed). +""" +from __future__ import annotations + +import asyncio +import json +import time +from collections.abc import Awaitable, Callable +from typing import Annotated + +import httpx +import jwt +from fastapi import Header, HTTPException, Request + +#: The authorization scheme the admin plane expects. Matched +#: case-insensitively, as RFC 9110 requires for scheme names. +_BEARER_SCHEME = "bearer" + +#: The ONLY JWT signing algorithm accepted. Pinned to one asymmetric +#: algorithm (Authentik's RS256 default) so neither the header's ``alg`` +#: nor any JWKS key can talk the verifier into a weaker or symmetric +#: scheme; PyJWT re-enforces it at decode time. +_ALLOWED_ALGORITHM = "RS256" + +#: The 401 detail shared by every rejection — deliberately uniform so +#: the response never reveals which part of the check failed. +_REJECTION_DETAIL = "Missing or invalid admin credentials" + +#: Challenge header returned with a 401, per the Bearer auth scheme. +_WWW_AUTHENTICATE = {"WWW-Authenticate": "Bearer"} + +#: Seconds a fetched JWKS document stays trusted before the next +#: fetch. Five minutes bounds how long a rotated-away key keeps +#: verifying while staying far above a per-request fetch. +_JWKS_TTL_SECONDS = 300.0 + +#: Seconds before the real JWKS fetch gives up on the provider. +_JWKS_TIMEOUT_SECONDS = 5.0 + +#: An async callable that fetches the JWKS document from a URL. The +#: real one (:func:`_fetch_jwks`) uses httpx; tests inject a stub so +#: the suite stays hermetic (no network access). +JwksFetcher = Callable[[str], Awaitable[dict[str, object]]] + + +async def _fetch_jwks(url: str) -> dict[str, object]: + """Fetch the provider's JWKS document over HTTP (the real fetch). + + Uses httpx with a short timeout; a non-2xx response or a body that + is not a ``{"keys": [...]}`` object raises — the cache never stores + a failed or malformed fetch. + """ + async with httpx.AsyncClient(timeout=_JWKS_TIMEOUT_SECONDS) as client: + response = await client.get(url) + response.raise_for_status() + document = response.json() + if not isinstance(document, dict) or not isinstance( + document.get("keys"), list + ): + raise ValueError("JWKS document must be an object with a keys list") + return document + + +class JwksCache: + """One app's cache of the OIDC provider's JWKS document. + + The document is fetched through ``fetcher`` (the real one uses + httpx against the configured JWKS URL) and held for ``ttl_seconds`` + — the dependency NEVER fetches per request. A single fetch is + shared between concurrent requests via an :class:`asyncio.Lock` + (double-checked around it), and a failed fetch is never cached, so + the next request retries rather than being locked out until the TTL + lapses. + + One instance lives on ``app.state.jwks_cache`` (installed by + :func:`api.app.create_app`); tests replace it with one built around + a stub fetcher, keeping the suite hermetic. + """ + + def __init__( + self, + url: str, + *, + ttl_seconds: float = _JWKS_TTL_SECONDS, + fetcher: JwksFetcher | None = None, + ) -> None: + self._url = url + self._ttl_seconds = ttl_seconds + self._fetcher: JwksFetcher = ( + fetcher if fetcher is not None else _fetch_jwks + ) + self._document: dict[str, object] | None = None + self._fetched_at: float | None = None + self._lock = asyncio.Lock() + + async def get(self) -> dict[str, object]: + """Return the JWKS document, refetching it if the TTL lapsed.""" + fresh = self._fresh_document() + if fresh is not None: + return fresh + async with self._lock: + fresh = self._fresh_document() + if fresh is not None: + return fresh + document = await self._fetcher(self._url) + self._document = document + self._fetched_at = time.monotonic() + return document + + def _fresh_document(self) -> dict[str, object] | None: + """The cached document if one exists and is inside its TTL.""" + document = self._document + fetched_at = self._fetched_at + if ( + document is not None + and fetched_at is not None + and (time.monotonic() - fetched_at) < self._ttl_seconds + ): + return document + return None + + +def _reject() -> HTTPException: + """Build the single 401 the dependency raises for every failure.""" + return HTTPException( + status_code=401, + detail=_REJECTION_DETAIL, + headers=_WWW_AUTHENTICATE, + ) + + +def _matching_key(jwks: dict[str, object], kid: str) -> object: + """Return the RSA signing key named ``kid`` from the JWKS. + + Only ``kty: RSA`` entries are considered (the pinned RS256 scheme + is asymmetric RSA); anything else — a missing ``keys`` list, no + entry with that ``kid`` — raises the one 401 rather than falling + back to "some" key. The key is built through PyJWT's JWKS decoder; + malformed key data raises there and is caught by the caller. + """ + keys = jwks.get("keys") + if not isinstance(keys, list): + raise _reject() + for entry in keys: + if not isinstance(entry, dict): + continue + if entry.get("kid") != kid or entry.get("kty") != "RSA": + continue + return jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(entry)) + raise _reject() + + +async def verify_admin_token( + request: Request, token: str +) -> dict[str, object]: + """Verify ONE admin bearer token and return its verified claims. + + Reads the OIDC settings off ``app.state.config`` and the JWKS cache + off ``app.state.jwks_cache`` (both installed by + :func:`api.app.create_app`) so the check carries no module state. + A missing config or cache, an EMPTY configured issuer/audience/JWKS + URL, a non-RS256 ``alg`` header, an absent ``kid``, an unknown + ``kid``, and any PyJWT verification failure (bad signature, + expired, wrong issuer, wrong audience, not-yet-valid, missing + ``exp``) raise the SAME 401 — the failure mode is never leaked. + ``exp`` is REQUIRED (a token that never expires is never accepted) + and PyJWT enforces ``nbf`` when the token carries it. + + A JWKS fetch failure propagates (see the module docstring): the + provider being down is a server fault, not a bad credential. + """ + config = getattr(request.app.state, "config", None) + if ( + config is None + or not config.oidc_issuer + or not config.oidc_audience + or not config.oidc_jwks_url + ): + raise _reject() + cache = getattr(request.app.state, "jwks_cache", None) + if cache is None: + raise _reject() + jwks = await cache.get() + try: + header = jwt.get_unverified_header(token) + if header.get("alg") != _ALLOWED_ALGORITHM: + raise _reject() + kid = header.get("kid") + if not isinstance(kid, str) or not kid: + raise _reject() + key = _matching_key(jwks, kid) + claims = jwt.decode( + token, + key=key, + algorithms=[_ALLOWED_ALGORITHM], + issuer=config.oidc_issuer, + audience=config.oidc_audience, + options={"require": ["exp"]}, + ) + except jwt.PyJWTError: + raise _reject() from None + return claims + + +async def require_admin_jwt( + request: Request, + authorization: Annotated[str | None, Header()] = None, +) -> dict[str, object]: + """Reject the request with 401 unless it carries a valid admin JWT. + + Extracts the Bearer credential — a missing or malformed + ``Authorization`` header (wrong scheme, empty credential) raises + the one 401 — and delegates the verification to + :func:`verify_admin_token`, returning the verified claims for the + route to use (the probe route surfaces the token's ``sub``). + + The nestquest-admins GROUP check is the NEXT task: it extends this + dependency (e.g. by inspecting the returned claims) so every admin + route inherits it — the 401 contract stays exactly this uniform. + """ + if authorization is None: + raise _reject() + scheme, _, credential = authorization.partition(" ") + if scheme.lower() != _BEARER_SCHEME or not credential.strip(): + raise _reject() + return await verify_admin_token(request, credential.strip()) + + +__all__ = ["JwksCache", "JwksFetcher", "require_admin_jwt", "verify_admin_token"] diff --git a/api/config.py b/api/config.py index 776e19b..6256f3a 100644 --- a/api/config.py +++ b/api/config.py @@ -8,15 +8,25 @@ The API reads its configuration knobs from environment variables: (D-012): the credential ``/api/v1/panel/*`` routes require via :func:`api.dependencies.require_panel_token`. It is stored outside the repo and handed to the service through the environment. +- ``NESTQUEST_OIDC_ISSUER`` — the Authentik OIDC issuer URL whose + ``iss`` claim admin tokens must carry (D-012 admin plane). +- ``NESTQUEST_OIDC_AUDIENCE`` — the OIDC client id the admin tokens + must carry as their ``aud`` claim (the API service's client id). +- ``NESTQUEST_OIDC_JWKS_URL`` — the Authentik endpoint serving the + signing keys (the JWKS document) the admin tokens' signatures are + verified against. Cached per app (see :class:`api.auth.JwksCache`). Unlike a dev convenience default, a missing or empty value is a configuration ERROR, not a silent fallback: a default database path -would silently drop data at reboot, and a default (or empty) service -token would either authenticate everyone or lock the panel plane out -for good. ``ApiConfig.from_env`` therefore RAISES when either variable -is unset or empty. This mirrors the integration's "explicit settings -object" discipline (Feature 15's ``core.settings``): the API never -reads HA config; callers pass the resolved values into the wiring. +would silently drop data at reboot, a default (or empty) service token +would either authenticate everyone or lock the panel plane out for +good, and a default (or empty) OIDC setting would silently leave the +admin plane trusting an issuer that was never configured. All five +variables are therefore REQUIRED — ``ApiConfig.from_env`` RAISES when +any of them is unset or empty. This mirrors the integration's +"explicit settings object" discipline (Feature 15's ``core.settings``): +the API never reads HA config; callers pass the resolved values into +the wiring. Serving: the app is built with the factory ``api.app:create_app`` — run it with ``uvicorn --factory api.app:create_app`` so the factory reads @@ -41,6 +51,28 @@ DB_PATH_ENV = "NESTQUEST_DB_PATH" #: lock the panel plane out permanently. PANEL_TOKEN_ENV = "NESTQUEST_PANEL_TOKEN" +#: Environment variable holding the Authentik OIDC issuer URL (D-012 +#: admin plane): every admin JWT's ``iss`` claim must equal it. Checked +#: by :func:`api.auth.require_admin_jwt`. REQUIRED — unset or empty +#: raises :class:`ConfigError` from :meth:`ApiConfig.from_env`; a default +#: issuer would silently make the admin plane trust tokens from an +#: issuer nobody configured. +OIDC_ISSUER_ENV = "NESTQUEST_OIDC_ISSUER" + +#: Environment variable holding the OIDC audience (the API service's +#: client id at Authentik): every admin JWT's ``aud`` claim must carry +#: it. Checked by :func:`api.auth.require_admin_jwt`. REQUIRED — unset +#: or empty raises :class:`ConfigError` from :meth:`ApiConfig.from_env`. +OIDC_AUDIENCE_ENV = "NESTQUEST_OIDC_AUDIENCE" + +#: Environment variable holding the Authentik JWKS URL: the endpoint +#: serving the signing keys admin JWT signatures are verified against. +#: Fetched and cached by :class:`api.auth.JwksCache`. REQUIRED — unset +#: or empty raises :class:`ConfigError` from :meth:`ApiConfig.from_env`; +#: without the provider's keys no token can be verified, so a silent +#: default would only move the failure to request time. +OIDC_JWKS_URL_ENV = "NESTQUEST_OIDC_JWKS_URL" + class ConfigError(RuntimeError): """Raised when required API configuration is missing or invalid.""" @@ -50,18 +82,25 @@ class ConfigError(RuntimeError): class ApiConfig: """Resolved API configuration. - Both fields are required (no defaults): ``from_env`` rejects an - unset or empty value for either, and a caller building the dataclass - directly must pass an explicit token — the token check itself fails - closed on an empty configured token, but expecting a caller to - discover that at request time is a wiring mistake, so the field is - mandatory. Kept as a dataclass so later tasks (admin plane, SSE, - settings persistence) can extend it without touching the call sites - that read ``db_path``. + Every field is required (no defaults): ``from_env`` rejects an + unset or empty value for each, and a caller building the dataclass + directly must pass explicit values — the token check and the JWT + checks themselves fail closed on empty configured values, but + expecting a caller to discover that at request time is a wiring + mistake, so every field is mandatory. Kept as a dataclass so later + tasks (SSE, settings persistence) can extend it without touching + the call sites that read ``db_path``. """ db_path: str panel_token: str + #: The Authentik OIDC issuer URL admin tokens' ``iss`` must equal. + oidc_issuer: str + #: The OIDC audience (the API service's client id) admin tokens' + #: ``aud`` must carry. + oidc_audience: str + #: The Authentik JWKS URL the signing keys are fetched from. + oidc_jwks_url: str @classmethod def from_env(cls, env: dict[str, str] | None = None) -> "ApiConfig": @@ -70,29 +109,66 @@ class ApiConfig: ``env`` defaults to ``os.environ``; tests pass an explicit dict so they are hermetic (no monkeypatch of the global environ). - Raises :class:`ConfigError` when ``NESTQUEST_DB_PATH`` is unset - or empty/whitespace-only — there is no silent default, because a - default in the temp directory would silently lose data at reboot - — and likewise when ``NESTQUEST_PANEL_TOKEN`` is unset or empty - (a default token is never invented: the panel plane is closed - until a real one is configured). + Raises :class:`ConfigError` when ANY of the five variables is + unset or empty/whitespace-only — there is no silent default: + + - ``NESTQUEST_DB_PATH``: a default in the temp directory would + silently lose data at reboot; + - ``NESTQUEST_PANEL_TOKEN``: a default token is never invented — + the panel plane is closed until a real one is configured; + - the three OIDC variables: an unconfigured issuer/audience/JWKS + URL must not silently leave the admin plane open (a default + JWKS URL or issuer would point at a provider nobody set up). """ source = env if env is not None else os.environ - raw_db_path = source.get(DB_PATH_ENV, "").strip() - if not raw_db_path: - raise ConfigError( - f"{DB_PATH_ENV} must be set to a writable SQLite file path " - f"(it is unset or empty — the API will not fall back to a " - f"temp directory and silently lose data)" - ) - raw_panel_token = source.get(PANEL_TOKEN_ENV, "").strip() - if not raw_panel_token: - raise ConfigError( - f"{PANEL_TOKEN_ENV} must be set to the panel service token " - f"(it is unset or empty — the API will not start without " - f"one, and no default token is invented)" - ) - return cls(db_path=raw_db_path, panel_token=raw_panel_token) + + def require(env_var: str, reason: str) -> str: + """Return the stripped env value or raise ConfigError.""" + value = source.get(env_var, "").strip() + if not value: + raise ConfigError(f"{env_var} {reason}") + return value + + return cls( + db_path=require( + DB_PATH_ENV, + "must be set to a writable SQLite file path (it is unset " + "or empty — the API will not fall back to a temp directory " + "and silently lose data)", + ), + panel_token=require( + PANEL_TOKEN_ENV, + "must be set to the panel service token (it is unset or " + "empty — the API will not start without one, and no " + "default token is invented)", + ), + oidc_issuer=require( + OIDC_ISSUER_ENV, + "must be set to the Authentik OIDC issuer URL (it is " + "unset or empty — the admin plane will not trust tokens " + "from an issuer nobody configured)", + ), + oidc_audience=require( + OIDC_AUDIENCE_ENV, + "must be set to the OIDC audience the admin tokens carry " + "(it is unset or empty — the admin plane will not accept " + "tokens without a configured audience)", + ), + oidc_jwks_url=require( + OIDC_JWKS_URL_ENV, + "must be set to the Authentik JWKS URL (it is unset or " + "empty — admin token signatures cannot be verified " + "without the provider's signing keys)", + ), + ) -__all__ = ["ApiConfig", "ConfigError", "DB_PATH_ENV", "PANEL_TOKEN_ENV"] +__all__ = [ + "ApiConfig", + "ConfigError", + "DB_PATH_ENV", + "OIDC_AUDIENCE_ENV", + "OIDC_ISSUER_ENV", + "OIDC_JWKS_URL_ENV", + "PANEL_TOKEN_ENV", +] diff --git a/api/routes_admin.py b/api/routes_admin.py new file mode 100644 index 0000000..7a2e2a2 --- /dev/null +++ b/api/routes_admin.py @@ -0,0 +1,63 @@ +"""Admin-plane routes for the NestQuest API service. + +The admin plane (``/api/v1/admin/*``, D-012) serves the admin PWA. +Every route on :data:`router` requires a valid Authentik OIDC JWT +through the ONE reusable dependency :func:`api.auth.require_admin_jwt` +(declared once at the router level, so each later admin route added to +this router inherits the check). + +This task is the AUTH probe only: the ping route is a thin adapter +that performs NO business logic and NO auth of its own — it reuses the +router's already-verified claims (FastAPI caches a dependency's result +per request, so the check runs exactly once) and returns the status +plus the token's subject. Authorization — membership in the +``nestquest-admins`` group — is the NEXT task, layered onto +:func:`api.auth.require_admin_jwt` so every admin route inherits it. +""" +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends +from pydantic import BaseModel + +from api.auth import require_admin_jwt + +#: All admin-plane routes share this router; the JWT check is attached +#: HERE so every admin route (current and later) requires it. +router = APIRouter( + prefix="/api/v1/admin", + dependencies=[Depends(require_admin_jwt)], +) + + +class AdminPingResponse(BaseModel): + """The probe body: the status plus the verified token's subject.""" + + status: str + #: The JWT's ``sub`` claim — the subject the admin PWA authenticated + #: as. ``None`` only for a verified token that carries no ``sub``. + sub: str | None + + +@router.get("/ping", summary="Admin plane probe") +async def admin_ping( + claims: Annotated[dict[str, object], Depends(require_admin_jwt)], +) -> AdminPingResponse: + """Return 200 for a verified admin JWT (the plane's smoke probe). + + Requires a valid Authentik JWT (checked by the router's shared + :func:`~api.auth.require_admin_jwt` dependency — the ONE token + check; this handler performs NO auth of its own). ``claims`` is + the SAME verified-claims dict that dependency produced (FastAPI's + per-request dependency cache), so the token is verified exactly + once per request. + """ + sub = claims.get("sub") + return AdminPingResponse( + status="ok", + sub=sub if isinstance(sub, str) else None, + ) + + +__all__ = ["router"] diff --git a/pyproject.toml b/pyproject.toml index 754ff79..b247a53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dev = [ "fastapi>=0.115", "httpx>=0.28", "jinja2>=3", + "pyjwt[crypto]>=2.9", "pytest>=8.0.0", "pytest-asyncio>=0.24", "pyyaml>=6", diff --git a/tests/test_api_admin_auth.py b/tests/test_api_admin_auth.py new file mode 100644 index 0000000..b573f38 --- /dev/null +++ b/tests/test_api_admin_auth.py @@ -0,0 +1,472 @@ +"""Admin-plane JWT auth tests: Authentik-style OIDC verification (task e70e6bfb). + +These tests exercise the NestQuest API's admin plane against the ASGI +app with httpx, using a locally generated RSA key pair and a stubbed +JWKS fetcher — NO network access: + +- ``GET /api/v1/admin/ping`` requires a valid Authentik OIDC JWT: a + request with NO ``Authorization`` header and a MALFORMED header + return 401; a garbage, expired, wrong-issuer, wrong-audience, + not-yet-valid (future ``nbf``), unknown-``kid``, or bad-signature + token returns 401; a token without ``exp`` is rejected; a VALID + token returns 200 with the verified subject. +- Every 401 carries the SAME detail — nothing in the response + distinguishes which check failed. +- The JWKS document is fetched ONCE through the app's + :class:`api.auth.JwksCache` and then reused across requests (the + stub fetcher counts calls); a zero TTL refetches. + +``ApiConfig.from_env`` requires the three OIDC variables +(``NESTQUEST_OIDC_ISSUER``, ``NESTQUEST_OIDC_AUDIENCE``, +``NESTQUEST_OIDC_JWKS_URL``) with the same fail-fast discipline as +``NESTQUEST_DB_PATH`` / ``NESTQUEST_PANEL_TOKEN``: tests build the app +with explicit ApiConfig values so they stay hermetic. + +The signature/expiry/audience proof is real, not stubbed: tokens are +signed with a locally generated RSA private key, the app verifies +against the JWKS built from its public key, and the wrong-signature +case signs with a SECOND locally generated key pair. +""" +from __future__ import annotations + +import base64 +import time +from pathlib import Path + +import httpx +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa + +from api.app import create_app +from api.auth import JwksCache +from api.config import ApiConfig, ConfigError + +#: The configured OIDC settings the test apps run with (values a real +#: deployment would get from the NESTQUEST_OIDC_* environment vars). +ISSUER = "https://authentik.example.com/application/o/nestquest/" +AUDIENCE = "nestquest-api" +JWKS_URL = "https://authentik.example.com/application/o/nestquest/jwks/" + +#: The ``kid`` the test tokens carry, matching the JWKS entry. +KID = "admin-test-key-1" + +#: The subject a valid token authenticates as. +SUBJECT = "admin-user-1" + +#: The uniform 401 detail every rejection must carry. +REJECTION_DETAIL = "Missing or invalid admin credentials" + + +def _generate_key() -> rsa.RSAPrivateKey: + """Generate one local RSA key pair (no network, no fixtures).""" + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +#: The key pair whose PUBLIC half the stubbed JWKS publishes — tokens +#: signed with it verify; the second pair below proves they don't when +#: signed otherwise. +ADMIN_KEY = _generate_key() +OTHER_KEY = _generate_key() + + +def _b64url_uint(value: int) -> str: + """Base64url-encode a non-negative integer (no padding), per JWK.""" + raw = value.to_bytes((value.bit_length() + 7) // 8, "big") + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def _jwks_for(private_key: rsa.RSAPrivateKey, kid: str) -> dict[str, object]: + """Build a JWKS document publishing ``private_key``'s public half.""" + numbers = private_key.public_key().public_numbers() + return { + "keys": [ + { + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": kid, + "n": _b64url_uint(numbers.n), + "e": _b64url_uint(numbers.e), + } + ] + } + + +class _StubbedFetch: + """A JWKS fetcher stub: returns a fixed document, counts calls.""" + + def __init__(self, document: dict[str, object]) -> None: + self.document = document + self.calls = 0 + + async def __call__(self, url: str) -> dict[str, object]: + self.calls += 1 + return self.document + + +def _make_token( + signing_key: rsa.RSAPrivateKey = ADMIN_KEY, + *, + kid: str | None = KID, + iss: str = ISSUER, + aud: object = AUDIENCE, + sub: str = SUBJECT, + expires_in: int | None = 300, + nbf_in: int | None = -1, +) -> str: + """Mint one RS256 JWT the way Authentik would (its claims, our key).""" + now = int(time.time()) + claims: dict[str, object] = {"iss": iss, "aud": aud, "sub": sub, "iat": now} + if nbf_in is not None: + claims["nbf"] = now + nbf_in + if expires_in is not None: + claims["exp"] = now + expires_in + headers: dict[str, str] = {} + if kid is not None: + headers["kid"] = kid + return jwt.encode( + claims, signing_key, algorithm="RS256", headers=headers + ) + + +class _AdminRunner: + """Run the app's lifespan around an httpx AsyncClient. + + Same pattern as test_api_health.py's ``_AppRunner``: httpx's ASGI + transport does not run the lifespan, so it is driven explicitly and + shut down in ``__aexit__``'s finally block even on failure. + + The app's JWKS cache is replaced (before the lifespan runs) with + one built around a counting stub fetcher, so verification goes + through the REAL cache logic but never the network. + """ + + def __init__( + self, + db_path: str, + jwks_document: dict[str, object], + *, + ttl_seconds: float | None = None, + ) -> None: + self.app = create_app( + ApiConfig( + db_path=db_path, + panel_token="admin-test-panel-token", + oidc_issuer=ISSUER, + oidc_audience=AUDIENCE, + oidc_jwks_url=JWKS_URL, + ) + ) + self.fetch = _StubbedFetch(jwks_document) + cache_kwargs: dict[str, float] = {} + if ttl_seconds is not None: + cache_kwargs["ttl_seconds"] = ttl_seconds + self.app.state.jwks_cache = JwksCache( + JWKS_URL, fetcher=self.fetch, **cache_kwargs + ) + + async def __aenter__(self) -> httpx.AsyncClient: + self._lifespan = self.app.router.lifespan_context(self.app) + await self._lifespan.__aenter__() + self._client = httpx.AsyncClient( + transport=httpx.ASGITransport(app=self.app), + base_url="http://testserver", + ) + return await self._client.__aenter__() + + async def __aexit__(self, *exc) -> None: + try: + await self._client.__aexit__(*exc) + finally: + await self._lifespan.__aexit__(*exc) + + +@pytest.fixture +def temp_db_path(tmp_path: Path) -> str: + """A fresh per-test SQLite path under pytest's tmp_path.""" + return str(tmp_path / "nestquest.db") + + +@pytest.fixture +async def admin_client( + temp_db_path: str, +) -> httpx.AsyncClient: + """An app client whose provider publishes the admin test key.""" + async with _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID)) as client: + yield client + + +# --- the happy path ------------------------------------------------------- + + +async def test_valid_token_returns_200_with_subject( + admin_client: httpx.AsyncClient, +) -> None: + """A valid token returns 200 with the status and verified subject.""" + response = await admin_client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {_make_token()}"}, + ) + assert response.status_code == 200 + assert response.json() == {"status": "ok", "sub": SUBJECT} + + +async def test_bearer_scheme_is_case_insensitive( + admin_client: httpx.AsyncClient, +) -> None: + """``bearer`` (lowercase) is accepted, as RFC 9110 requires.""" + response = await admin_client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"bearer {_make_token()}"}, + ) + assert response.status_code == 200 + + +# --- 401s: missing / malformed credentials -------------------------------- + + +async def test_missing_token_is_unauthorized( + admin_client: httpx.AsyncClient, +) -> None: + """A request with no Authorization header returns 401.""" + response = await admin_client.get("/api/v1/admin/ping") + assert response.status_code == 401 + assert response.json()["detail"] == REJECTION_DETAIL + assert "www-authenticate" in { + key.lower() for key in response.headers + } + + +@pytest.mark.parametrize( + "header", + [ + "Basic cGFuZWw6dG9rZW4=", # wrong scheme + "Bearer", # scheme, no credential + "Bearer ", # blank credential + "BearerNotAToken", # no scheme separator at all + ], +) +async def test_malformed_header_is_unauthorized( + admin_client: httpx.AsyncClient, header: str +) -> None: + """A malformed Authorization header returns 401.""" + response = await admin_client.get( + "/api/v1/admin/ping", headers={"Authorization": header} + ) + assert response.status_code == 401 + + +async def test_garbage_token_is_unauthorized( + admin_client: httpx.AsyncClient, +) -> None: + """A Bearer credential that is not a JWT at all returns 401.""" + response = await admin_client.get( + "/api/v1/admin/ping", + headers={"Authorization": "Bearer not-a-jwt"}, + ) + assert response.status_code == 401 + + +# --- 401s: token content the verifier must reject --------------------------- + + +async def test_expired_token_is_unauthorized( + admin_client: httpx.AsyncClient, +) -> None: + """A token whose exp has passed returns 401.""" + token = _make_token(expires_in=-60) + response = await admin_client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 401 + + +async def test_wrong_issuer_is_unauthorized( + admin_client: httpx.AsyncClient, +) -> None: + """A token issued by another issuer returns 401.""" + token = _make_token(iss="https://evil.example.com/application/o/nestquest/") + response = await admin_client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 401 + + +async def test_wrong_audience_is_unauthorized( + admin_client: httpx.AsyncClient, +) -> None: + """A token minted for another audience returns 401.""" + token = _make_token(aud="some-other-client") + response = await admin_client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 401 + + +async def test_future_nbf_is_unauthorized( + admin_client: httpx.AsyncClient, +) -> None: + """A token that is not yet valid (future nbf) returns 401.""" + token = _make_token(nbf_in=3600) + response = await admin_client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 401 + + +async def test_token_without_exp_is_unauthorized( + admin_client: httpx.AsyncClient, +) -> None: + """A token with no exp claim returns 401 (expiry is REQUIRED).""" + token = _make_token(expires_in=None) + response = await admin_client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 401 + + +async def test_unknown_kid_is_unauthorized( + admin_client: httpx.AsyncClient, +) -> None: + """A token naming a kid the JWKS does not publish returns 401.""" + token = _make_token(kid="no-such-key") + response = await admin_client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 401 + + +async def test_bad_signature_is_unauthorized( + admin_client: httpx.AsyncClient, +) -> None: + """A token signed by a key the provider never published returns 401. + + Signed with the SECOND local key pair (header kid still points at + the published key), so the signature check itself must fail. + """ + token = _make_token(signing_key=OTHER_KEY) + response = await admin_client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 401 + + +# --- the uniform 401 contract ----------------------------------------------- + + +async def test_every_rejection_carries_the_same_detail( + admin_client: httpx.AsyncClient, +) -> None: + """No failure mode is distinguishable in the response body. + + Collects the (status, detail) pair for every rejection — missing + and malformed headers, garbage, expired, wrong issuer, wrong + audience, future nbf, missing exp, unknown kid, bad signature — + and asserts they are ALL the same one 401. + """ + cases: list[dict[str, str]] = [ + {}, + {"Authorization": "Basic cGFuZWw6dG9rZW4="}, + {"Authorization": "Bearer"}, + {"Authorization": "Bearer not-a-jwt"}, + {"Authorization": f"Bearer {_make_token(expires_in=-60)}"}, + {"Authorization": f"Bearer {_make_token(iss='https://evil.example.com/')}"}, + {"Authorization": f"Bearer {_make_token(aud='other-client')}"}, + {"Authorization": f"Bearer {_make_token(nbf_in=3600)}"}, + {"Authorization": f"Bearer {_make_token(expires_in=None)}"}, + {"Authorization": f"Bearer {_make_token(kid='no-such-key')}"}, + {"Authorization": f"Bearer {_make_token(signing_key=OTHER_KEY)}"}, + ] + observed = set() + for headers in cases: + response = await admin_client.get("/api/v1/admin/ping", headers=headers) + observed.add((response.status_code, response.json()["detail"])) + assert observed == {(401, REJECTION_DETAIL)} + + +# --- the JWKS cache ---------------------------------------------------------- + + +async def test_jwks_is_fetched_once_across_requests( + temp_db_path: str, +) -> None: + """Multiple requests reuse ONE cached JWKS document. + + Three separately authenticated requests trigger exactly ONE stub + fetch — the cache is per app and never per request. + """ + runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID)) + token = _make_token() + async with runner as client: + for _ in range(3): + response = await client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 200 + assert runner.fetch.calls == 1 + + +async def test_expired_ttl_refetches_the_jwks(temp_db_path: str) -> None: + """A lapsed TTL triggers a fresh fetch instead of serving stale keys.""" + runner = _AdminRunner( + temp_db_path, _jwks_for(ADMIN_KEY, KID), ttl_seconds=0.0 + ) + token = _make_token() + async with runner as client: + for _ in range(2): + response = await client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 200 + assert runner.fetch.calls == 2 + + +# --- ApiConfig.from_env: the OIDC settings ----------------------------------- + + +def _full_env() -> dict[str, str]: + """A from_env dict with every required variable set to a valid value.""" + return { + "NESTQUEST_DB_PATH": "/data/nq.db", + "NESTQUEST_PANEL_TOKEN": "panel-token", + "NESTQUEST_OIDC_ISSUER": ISSUER, + "NESTQUEST_OIDC_AUDIENCE": AUDIENCE, + "NESTQUEST_OIDC_JWKS_URL": JWKS_URL, + } + + +def test_from_env_reads_oidc_settings() -> None: + """Set, non-empty OIDC env values are returned verbatim.""" + cfg = ApiConfig.from_env(_full_env()) + assert cfg.oidc_issuer == ISSUER + assert cfg.oidc_audience == AUDIENCE + assert cfg.oidc_jwks_url == JWKS_URL + + +@pytest.mark.parametrize( + "env_var", + [ + "NESTQUEST_OIDC_ISSUER", + "NESTQUEST_OIDC_AUDIENCE", + "NESTQUEST_OIDC_JWKS_URL", + ], +) +def test_from_env_requires_oidc_setting(env_var: str) -> None: + """A missing OR empty OIDC variable raises ConfigError (fail fast).""" + env = _full_env() + del env[env_var] + with pytest.raises(ConfigError): + ApiConfig.from_env(env) + env[env_var] = " " + with pytest.raises(ConfigError): + ApiConfig.from_env(env) diff --git a/tests/test_api_health.py b/tests/test_api_health.py index efc4cc4..a007d0d 100644 --- a/tests/test_api_health.py +++ b/tests/test_api_health.py @@ -69,11 +69,21 @@ class _AppRunner: """ def __init__(self, db_path: str) -> None: - # The token value is irrelevant to these tests (the health - # route is unauthenticated); it just satisfies the required - # field — the panel token's own tests live in test_api_panel.py. + # The token and OIDC values are irrelevant to these tests (the + # health route is unauthenticated); they just satisfy the + # required fields — the panel token's own tests live in + # test_api_panel.py and the OIDC settings' in + # test_api_admin_auth.py. self.app = create_app( - ApiConfig(db_path=db_path, panel_token="health-runner-token") + ApiConfig( + db_path=db_path, + panel_token="health-runner-token", + oidc_issuer="https://authentik.example.com/application/o/nestquest/", + oidc_audience="nestquest-api", + oidc_jwks_url=( + "https://authentik.example.com/application/o/nestquest/jwks/" + ), + ) ) async def __aenter__(self) -> httpx.AsyncClient: @@ -107,6 +117,9 @@ def test_from_env_returns_set_value() -> None: { "NESTQUEST_DB_PATH": "/data/nq.db", "NESTQUEST_PANEL_TOKEN": "panel-token", + "NESTQUEST_OIDC_ISSUER": "https://idp.example.com/issuer/", + "NESTQUEST_OIDC_AUDIENCE": "nestquest-api", + "NESTQUEST_OIDC_JWKS_URL": "https://idp.example.com/jwks/", } ) assert cfg.db_path == "/data/nq.db" @@ -295,10 +308,16 @@ _NO_HA_SCRIPT = textwrap.dedent( # Build the app (the lifespan is NOT run here, so the path is never # opened — it just needs to be a non-empty string to satisfy the # fail-fast config check). Use a temp path so nothing is created; - # the panel token value is irrelevant to this import-only check. - app = create_app(ApiConfig(db_path=str( - Path(tempfile.gettempdir()) / "nq-noha-buildonly.db" - ), panel_token="noha-import-token")) + # the token and OIDC values are irrelevant to this import-only check. + app = create_app(ApiConfig( + db_path=str(Path(tempfile.gettempdir()) / "nq-noha-buildonly.db"), + panel_token="noha-import-token", + oidc_issuer="https://authentik.example.com/application/o/nestquest/", + oidc_audience="nestquest-api", + oidc_jwks_url=( + "https://authentik.example.com/application/o/nestquest/jwks/" + ), + )) assert app is not None print("API NO-HA IMPORT OK") diff --git a/tests/test_api_panel.py b/tests/test_api_panel.py index 58aa414..5d8489b 100644 --- a/tests/test_api_panel.py +++ b/tests/test_api_panel.py @@ -236,7 +236,15 @@ class _PanelRunner: self, db_path: str, panel_token: str = PANEL_TOKEN ) -> None: self.app = create_app( - ApiConfig(db_path=db_path, panel_token=panel_token) + ApiConfig( + db_path=db_path, + panel_token=panel_token, + oidc_issuer="https://authentik.example.com/application/o/nestquest/", + oidc_audience="nestquest-api", + oidc_jwks_url=( + "https://authentik.example.com/application/o/nestquest/jwks/" + ), + ) ) async def __aenter__(self) -> tuple["_PanelRunner", httpx.AsyncClient]: @@ -311,6 +319,9 @@ def test_from_env_reads_panel_token() -> None: { "NESTQUEST_DB_PATH": "/data/nq.db", "NESTQUEST_PANEL_TOKEN": "panel-token", + "NESTQUEST_OIDC_ISSUER": "https://idp.example.com/issuer/", + "NESTQUEST_OIDC_AUDIENCE": "nestquest-api", + "NESTQUEST_OIDC_JWKS_URL": "https://idp.example.com/jwks/", } ) assert cfg.panel_token == "panel-token" @@ -319,7 +330,14 @@ def test_from_env_reads_panel_token() -> None: def test_from_env_requires_panel_token_when_missing() -> None: """A missing panel token raises ConfigError (no silent default).""" with pytest.raises(ConfigError): - ApiConfig.from_env({"NESTQUEST_DB_PATH": "/data/nq.db"}) + ApiConfig.from_env( + { + "NESTQUEST_DB_PATH": "/data/nq.db", + "NESTQUEST_OIDC_ISSUER": "https://idp.example.com/issuer/", + "NESTQUEST_OIDC_AUDIENCE": "nestquest-api", + "NESTQUEST_OIDC_JWKS_URL": "https://idp.example.com/jwks/", + } + ) def test_from_env_requires_panel_token_when_empty() -> None: @@ -329,6 +347,9 @@ def test_from_env_requires_panel_token_when_empty() -> None: { "NESTQUEST_DB_PATH": "/data/nq.db", "NESTQUEST_PANEL_TOKEN": " ", + "NESTQUEST_OIDC_ISSUER": "https://idp.example.com/issuer/", + "NESTQUEST_OIDC_AUDIENCE": "nestquest-api", + "NESTQUEST_OIDC_JWKS_URL": "https://idp.example.com/jwks/", } ) diff --git a/tests/test_api_sse.py b/tests/test_api_sse.py index c9cfcc0..aea8034 100644 --- a/tests/test_api_sse.py +++ b/tests/test_api_sse.py @@ -100,7 +100,17 @@ class _Server: def factory(): app = create_app( - ApiConfig(db_path=db_path, panel_token=PANEL_TOKEN) + ApiConfig( + db_path=db_path, + panel_token=PANEL_TOKEN, + oidc_issuer=( + "https://authentik.example.com/application/o/nestquest/" + ), + oidc_audience="nestquest-api", + oidc_jwks_url=( + "https://authentik.example.com/application/o/nestquest/jwks/" + ), + ) ) self._server_loop = asyncio.get_running_loop() self.app = app diff --git a/uv.lock b/uv.lock index 1f57235..8f50a83 100644 --- a/uv.lock +++ b/uv.lock @@ -42,6 +42,104 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + [[package]] name = "click" version = "8.5.0" @@ -60,6 +158,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, + { url = "https://files.pythonhosted.org/packages/c7/27/8d207af749c453ee17ea087340b3f2b4adef75aadd1d277b1b129bdda84e/cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94", size = 3974350, upload-time = "2026-08-25T19:45:26.551Z" }, + { url = "https://files.pythonhosted.org/packages/14/9a/6d3a4d7852e22d657438b7bf51f66102c7d71c0e1fafeec652281d0403e5/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f", size = 4698675, upload-time = "2026-08-25T19:45:28.658Z" }, + { url = "https://files.pythonhosted.org/packages/73/35/5c3717edf9e68a0550ce04e28eab493fe545eccd81742af03f6a75fe260b/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671", size = 4707410, upload-time = "2026-08-25T19:45:30.816Z" }, + { url = "https://files.pythonhosted.org/packages/1d/e0/e786934472e3ac4ecdecc7b129a0ca1a2a40dffdafcf2c3ea9d4397f8def/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e", size = 4698378, upload-time = "2026-08-25T19:45:33.043Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/5b3f53a0b74d122f023476ede40ba5d3e70d5cf475f73b899740d26a4fb2/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6", size = 4706889, upload-time = "2026-08-25T19:45:35.086Z" }, + { url = "https://files.pythonhosted.org/packages/71/44/711e61f7d014be825ef79b285b047292d1bf893732ac1bc030a351fb517f/cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b", size = 3824006, upload-time = "2026-08-25T19:45:37.281Z" }, +] + [[package]] name = "fastapi" version = "0.141.1" @@ -227,6 +381,7 @@ dev = [ { name = "fastapi" }, { name = "httpx" }, { name = "jinja2" }, + { name = "pyjwt", extra = ["crypto"] }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pyyaml" }, @@ -241,6 +396,7 @@ dev = [ { name = "fastapi", specifier = ">=0.115" }, { name = "httpx", specifier = ">=0.28" }, { name = "jinja2", specifier = ">=3" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.9" }, { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-asyncio", specifier = ">=0.24" }, { name = "pyyaml", specifier = ">=6" }, @@ -266,6 +422,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.13.5" @@ -392,6 +557,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] +[[package]] +name = "pyjwt" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pytest" version = "9.1.1"