diff --git a/README.md b/README.md index b3f8316..ec27f9e 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,20 @@ refuses identically whether the organization is ungranted or doesn't exist. There's no round-trip and no grant store; removing the client revokes the access. See `docs/b2b-learner-records-provider-authorization.md`. +That tenant does **not** read `X-Userinfo`. It verifies the bearer token +itself (`tenants/b2b_learner_records/token.py`): RS256 against the realm's +JWKS, checking issuer, audience, token class and lifetime, and it takes the +claims it authorizes on from the verified payload. The gateway rebuilds +`X-Userinfo` only for traffic that goes through the gateway, and the pod is +reachable without doing so — the pod security group admits the whole pod +subnet and the CNI runs with network policy disabled on both data clusters. +Aggregate k-anonymized figures can live with that; records naming individual +learners can't. `OL_ANALYTICS_API_B2B_LEARNER_RECORDS_ISSUER` and +`..._AUDIENCE` come from Vault via the Pulumi stack, out of the same entry +the gateway route reads, so the two can't check different realms. Both +default to production's, and the app refuses to start if the issuer is left +at that default in any other deployed environment. + The org-manager round-trip authenticates with this service's **own** OAuth2 client-credentials token and names the subject user explicitly (`?user_global_id=`), rather than forwarding the caller's diff --git a/docs/b2b-learner-records-provider-authorization.md b/docs/b2b-learner-records-provider-authorization.md index fdcc3ef..c530aa6 100644 --- a/docs/b2b-learner-records-provider-authorization.md +++ b/docs/b2b-learner-records-provider-authorization.md @@ -107,15 +107,41 @@ definition is the only place access is recorded. - Whether APISIX's `openid-connect` plugin passes the hardcoded claim and scopes through in `X-Userinfo` on a bearer-only route. The learner-records mount needs a bearer-only route, but today's routes use the redirect flow. - Check on QA. -- That APISIX *overwrites* a caller-supplied `X-Userinfo` rather than passing - it through. `core/auth/userinfo.py` decodes whatever header arrives without - validating a token, and the organization check reads from it. Also confirm - the pod can't be reached except through the gateway route: `k8s/` defines no - NetworkPolicy. Check both on QA. + Check on QA. This no longer gates the tenant (see below), but the claim + still has to arrive in the token. - The access-token lifespan these clients will get, since it is the revocation window. +## The app verifies the token; the gateway is not the trust boundary + +Settled 2026-09-18, after Copilot raised it on +[ol-infrastructure#5939](https://github.com/mitodl/ol-infrastructure/pull/5939). + +The question above was whether APISIX overwrites a caller-supplied +`X-Userinfo`. It does, at the start of its `rewrite` phase. That is beside the +point, because a caller does not have to go through APISIX. The pod security +group admits the whole pod subnet, and `aws-eks-nodeagent` runs with +`--enable-network-policy=false` on both data clusters, so the NetworkPolicies +that exist are no-ops. Any compromised in-cluster workload can post a forged +`X-Userinfo` naming `learner-records:read` and any organization UUID, straight +to the pod. + +For k-anonymized aggregates that is a risk worth arguing about. For records +that name individual learners it is not, so this tenant verifies the bearer +token itself (`tenants/b2b_learner_records/token.py`): RS256 against the +realm's JWKS, checking issuer, audience and lifetime, and taking `scope`, +`learner_records_organizations` and `azp` from the verified payload. +`X-Userinfo` is not read at all. The gateway route stays as it is; it is now +defence in depth rather than the only check. + +Rejected: locking down the pod security group, and enabling CNI network +policy cluster-wide. Both are larger changes that protect one tenant by +changing how every workload on the cluster is reached. + +`b2b_dashboard` still authorizes from `X-Userinfo`. Its exposure is aggregate +and k-anonymized and it has a browser session flow, so it is a separate +decision, not a follow-up to this one. + ## Follow-ups Not needed to write the tenant, but each needs an owner before partners diff --git a/docs/openapi/b2b-learner-records-v1.yaml b/docs/openapi/b2b-learner-records-v1.yaml index d82392e..b70545e 100644 --- a/docs/openapi/b2b-learner-records-v1.yaml +++ b/docs/openapi/b2b-learner-records-v1.yaml @@ -362,11 +362,12 @@ components: oauth2ClientCredentials: type: oauth2 description: >- - Keycloak client credentials, validated at the gateway. One client is - issued per contracted integration and lists the organizations it may - read, every contract under each included. Scopes bound to a client are - a contractual limit on that credential; they are not the consent - mechanism. + Keycloak client credentials. The token's signature, issuer, audience + and lifetime are verified by the service itself, not only at the + gateway. One client is issued per contracted integration and lists the + organizations it may read, every contract under each included. Scopes + bound to a client are a contractual limit on that credential; they are + not the consent mechanism. flows: clientCredentials: tokenUrl: https://sso.ol.mit.edu/realms/olapps/protocol/openid-connect/token @@ -933,11 +934,14 @@ components: example: { code: invalid_parameter, detail: 'limit: Input should be less than or equal to 1000' } Unauthorized: - description: Missing, malformed or expired token. + description: >- + No token, or one that failed verification: bad signature, unknown + signing key, wrong issuer or audience, or outside its validity window. + The body does not say which, so it cannot be used to probe. content: application/json: schema: { $ref: '#/components/schemas/Error' } - example: { code: unauthorized, detail: 'Invalid or expired access token' } + example: { code: unauthorized, detail: 'Invalid or missing bearer token' } Forbidden: description: >- @@ -949,7 +953,7 @@ components: content: application/json: schema: { $ref: '#/components/schemas/Error' } - example: { code: no_organization_access, detail: 'No access to the requested organization' } + example: { code: no_organization_access, detail: 'No grant for the requested organization' } TooManyRequests: description: Per-client rate limit exceeded. diff --git a/pyproject.toml b/pyproject.toml index 0600442..3bb27db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "opentelemetry-instrumentation-fastapi>=0.52b0", "opentelemetry-instrumentation-httpx>=0.52b0", "granian[reload,uvloop]>=2.7", + "pyjwt[crypto]>=2.10", ] [tool.uv] @@ -86,6 +87,7 @@ dev = [ "cyclopts>=4.22.5", "pyyaml>=6.0.3", "types-pyyaml>=6.0.12.20260724", + "cryptography>=44", ] [build-system] @@ -127,7 +129,7 @@ ignore = [ [tool.ruff.lint.per-file-ignores] # S105-S107: fixture values named `token` are fake OAuth2 tokens for the # MITx Online client tests, not real credentials. -"tests/*" = ["S101", "ANN", "PLR2004", "S105", "S106", "S107"] +"tests/*" = ["S101", "ANN", "PLR2004", "PLR0913", "S105", "S106", "S107"] # structlog's processor signature is conventionally (logger: Any, method_name: str, # event_dict: dict) — matches mitol-django-observability's own typing, ported verbatim. "src/ol_analytics_api/core/observability/processors.py" = ["ANN401"] diff --git a/src/ol_analytics_api/tenants/b2b_learner_records/app.py b/src/ol_analytics_api/tenants/b2b_learner_records/app.py index 5b0090d..7319bff 100644 --- a/src/ol_analytics_api/tenants/b2b_learner_records/app.py +++ b/src/ol_analytics_api/tenants/b2b_learner_records/app.py @@ -15,19 +15,24 @@ from ol_analytics_api.core.errors import add_shared_error_handlers from ol_analytics_api.core.health import register_readiness_check +from ol_analytics_api.tenants.b2b_learner_records.errors import ( + ErrorCode, + add_error_handlers, + error_body, +) from ol_analytics_api.tenants.b2b_learner_records.routers import organizations TENANT_NAME = "b2b_learner_records" async def _bad_request(_request: Request, exc: RequestValidationError) -> JSONResponse: - # The contract's error body is {"detail": ""} with a 400, not - # FastAPI's 422 carrying a list of error objects. + # The contract's error body is {"code": ..., "detail": ...} with a 400, + # not FastAPI's 422 carrying a list of error objects. error = exc.errors()[0] location = ".".join(str(part) for part in error["loc"][1:]) return JSONResponse( status_code=status.HTTP_400_BAD_REQUEST, - content={"detail": f"{location}: {error['msg']}"}, + content=error_body(ErrorCode.INVALID_PARAMETER, f"{location}: {error['msg']}"), ) @@ -41,6 +46,9 @@ def create_app() -> FastAPI: ) app.include_router(organizations.router) add_shared_error_handlers(app) + # After the shared handlers: this tenant overrides the 503 so it carries + # the code its contract documents. + add_error_handlers(app) app.add_exception_handler(RequestValidationError, _bad_request) # type: ignore[arg-type] register_readiness_check(TENANT_NAME) return app diff --git a/src/ol_analytics_api/tenants/b2b_learner_records/auth.py b/src/ol_analytics_api/tenants/b2b_learner_records/auth.py index 9458ad1..1656215 100644 --- a/src/ol_analytics_api/tenants/b2b_learner_records/auth.py +++ b/src/ol_analytics_api/tenants/b2b_learner_records/auth.py @@ -3,9 +3,14 @@ There is no user in this flow. MIT issues one Keycloak client-credentials client per contracted integration, and that client carries the organization UUIDs its contract covers as a hardcoded claim -(docs/b2b-learner-records-provider-authorization.md). APISIX validates the -token and forwards its claims in X-Userinfo, so authorization here is a set -membership test: no call to MITx Online, no grant store. +(docs/b2b-learner-records-provider-authorization.md). Authorization is +therefore a set membership test over the token's own claims: no call to MITx +Online, no grant store. + +Unlike b2b_dashboard, this tenant does not read X-Userinfo. It verifies the +bearer token's signature itself (token.py), because a header rebuilt by +APISIX only proves anything about traffic that went through APISIX, and +these records name individual learners. """ from __future__ import annotations @@ -15,19 +20,21 @@ from typing import Annotated, Any import structlog -from fastapi import Depends, HTTPException, Security, status +from fastapi import Depends, Security, status from fastapi.openapi.models import OAuthFlowClientCredentials, OAuthFlows from fastapi.security import OAuth2 -from ol_analytics_api.core.auth.userinfo import get_userinfo from ol_analytics_api.tenants.b2b_learner_records.config import settings +from ol_analytics_api.tenants.b2b_learner_records.errors import ApiError, ErrorCode +from ol_analytics_api.tenants.b2b_learner_records.token import verified_claims ORGANIZATIONS_CLAIM = "learner_records_organizations" READ_SCOPE = "learner-records:read" # Declares the contract's security scheme in this tenant's OpenAPI, so generated -# clients obtain and send a token. It enforces nothing: auto_error=False, and -# the checks below read the claims APISIX forwards after validating the token. +# clients obtain and send a token. It enforces nothing on its own +# (auto_error=False); the token is verified by the TokenClaims dependency and +# the checks below run against the verified payload. oauth2_client_credentials = OAuth2( flows=OAuthFlows( clientCredentials=OAuthFlowClientCredentials( @@ -45,13 +52,13 @@ log = structlog.get_logger(__name__) -UserInfo = Annotated[dict[str, Any], Depends(get_userinfo)] +TokenClaims = Annotated[dict[str, Any], Depends(verified_claims)] -def _granted_organizations(userinfo: dict[str, Any]) -> set[uuid.UUID]: +def _granted_organizations(claims: dict[str, Any]) -> set[uuid.UUID]: # Keycloak emits the claim as a JSON array only when the mapper's claim # type is JSON. Any other shape grants nothing rather than being guessed at. - claim = userinfo.get(ORGANIZATIONS_CLAIM) + claim = claims.get(ORGANIZATIONS_CLAIM) if not isinstance(claim, list): return set() granted = set() @@ -63,21 +70,26 @@ def _granted_organizations(userinfo: dict[str, Any]) -> set[uuid.UUID]: def require_organization_grant( organization_id: uuid.UUID, - userinfo: UserInfo, + claims: TokenClaims, _token: Annotated[str | None, Security(oauth2_client_credentials, scopes=[READ_SCOPE])], ) -> None: - scopes = userinfo.get("scope") + scopes = claims.get("scope") if not isinstance(scopes, str) or READ_SCOPE not in scopes.split(): - raise HTTPException( + raise ApiError( status_code=status.HTTP_403_FORBIDDEN, + code=ErrorCode.MISSING_SCOPE, detail=f"Token lacks the {READ_SCOPE} scope", ) - if organization_id not in _granted_organizations(userinfo): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=NO_GRANT_DETAIL) + if organization_id not in _granted_organizations(claims): + raise ApiError( + status_code=status.HTTP_403_FORBIDDEN, + code=ErrorCode.NO_ORGANIZATION_ACCESS, + detail=NO_GRANT_DETAIL, + ) # Every granted read discloses identifiable learner records, so record which # client read which organization. The access log has the path but not the client. log.info( "learner_records_access", - client_id=userinfo.get("azp") or userinfo.get("client_id"), + client_id=claims.get("azp") or claims.get("client_id"), organization_id=str(organization_id), ) diff --git a/src/ol_analytics_api/tenants/b2b_learner_records/config.py b/src/ol_analytics_api/tenants/b2b_learner_records/config.py index 9764b2c..1506373 100644 --- a/src/ol_analytics_api/tenants/b2b_learner_records/config.py +++ b/src/ol_analytics_api/tenants/b2b_learner_records/config.py @@ -2,11 +2,19 @@ from __future__ import annotations -from pydantic import field_validator +from pydantic import field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict +from ol_analytics_api.core.config import settings as core_settings from ol_analytics_api.core.db.identifiers import validate_sql_identifier +PRODUCTION_ISSUER = "https://sso.ol.mit.edu/realms/olapps" + +# Where leaving the issuer at its production default is not evidence of a +# misconfiguration: production itself, and a developer's machine, which has +# no partner tokens to verify either way. +_ISSUER_DEFAULT_IS_FINE = ("development", "production") + class B2BLearnerRecordsSettings(BaseSettings): model_config = SettingsConfigDict(env_prefix="OL_ANALYTICS_API_B2B_LEARNER_RECORDS_") @@ -21,9 +29,38 @@ class B2BLearnerRecordsSettings(BaseSettings): def _validate_starrocks_schema(cls, value: str) -> str: return validate_sql_identifier(value) + # The Keycloak realm that issues partner client-credentials tokens. Every + # other SSO URL below is derived from it, so pointing a deployment at a + # different realm is one setting, not four that can drift apart. + # ol-infrastructure templates it out of the same Vault entry the gateway + # route reads. If that ever fails to render, the default below would have + # a QA pod verifying against the production realm while APISIX in front of + # it used QA's -- every partner token refused, for a reason nothing in the + # refusal names. _reject_the_wrong_realm turns that into a failed start. + issuer: str = PRODUCTION_ISSUER + + # Tokens must name this service in `aud`. The learner-records client scope + # adds it through an audience mapper (ol-infrastructure + # substructure/keycloak/learner_records.py); a token minted for another + # olapps client is signed by the same realm key and is refused on this + # check alone. + audience: str = "ol-analytics-api-client" + # Advertised in this tenant's OpenAPI security scheme so generated clients - # know where to get a token. APISIX, not this service, validates tokens. - token_url: str = "https://sso.ol.mit.edu/realms/olapps/protocol/openid-connect/token" # noqa: S105 + # know where to get a token. + token_url: str = "" + + # Verification keys. Cached for the TTL and refetched early on a kid this + # service has not seen, so a realm key rotation costs one fetch rather + # than a TTL of 401s. + jwks_url: str = "" + jwks_cache_ttl_seconds: float = 3600.0 + jwks_timeout_seconds: float = 5.0 + + # Tolerance for clock skew between Keycloak and this pod when checking + # exp/nbf. The partner access token lifespan is 300s, so this stays well + # under it. + token_leeway_seconds: float = 30.0 default_page_size: int = 100 max_page_size: int = 1000 @@ -35,5 +72,34 @@ def _validate_starrocks_schema(cls, value: str) -> str: # discloses nothing; deployments opt in through ol-infrastructure. consent_fail_open: bool = False + @model_validator(mode="after") + def _reject_the_wrong_realm(self) -> B2BLearnerRecordsSettings: + """Refuse to start rather than verify against another environment. + + Every failure mode of this setting is silent: a pod that verifies + partner tokens against a realm that never issued them refuses every + one of them, and the 401 it returns looks like a bad credential. + """ + if self.issuer == PRODUCTION_ISSUER and core_settings.environment not in ( + _ISSUER_DEFAULT_IS_FINE + ): + msg = ( + f"{self.model_config['env_prefix']}ISSUER is unset in the " + f"{core_settings.environment!r} environment, so partner tokens would be " + f"verified against the production realm ({PRODUCTION_ISSUER}). Set it to " + "this environment's Keycloak realm URL." + ) + raise ValueError(msg) + return self + + @model_validator(mode="after") + def _derive_sso_urls(self) -> B2BLearnerRecordsSettings: + base = self.issuer.rstrip("/") + if not self.token_url: + self.token_url = f"{base}/protocol/openid-connect/token" + if not self.jwks_url: + self.jwks_url = f"{base}/protocol/openid-connect/certs" + return self + settings = B2BLearnerRecordsSettings() diff --git a/src/ol_analytics_api/tenants/b2b_learner_records/errors.py b/src/ol_analytics_api/tenants/b2b_learner_records/errors.py new file mode 100644 index 0000000..011132c --- /dev/null +++ b/src/ol_analytics_api/tenants/b2b_learner_records/errors.py @@ -0,0 +1,88 @@ +"""The contract's error body: a machine-readable code beside the detail. + +`docs/openapi/b2b-learner-records-v1.yaml` has always declared every error +response as `{code, detail}`, with `code` required and drawn from a fixed +enum, and told clients to branch on it because the wording of `detail` may +change. Nothing produced it: FastAPI's HTTPException renders `{"detail": +...}`, so a generated client validating against the spec would have rejected +every error this tenant returned. + +The code can't be derived from the status alone, which is why this is an +exception type rather than a lookup in the handler: 403 is `missing_scope` +when the token's scopes fall short and `no_organization_access` when the +organization isn't granted, and those must stay distinct to a client without +being distinguishable to an attacker (both carry the same `detail`). + +Scoped to this tenant. b2b_dashboard has no published contract to honour, so +adding a field to its error bodies would be a change with no reader. +""" + +from __future__ import annotations + +from enum import StrEnum + +from fastapi import FastAPI, HTTPException, Request, status +from fastapi.responses import JSONResponse + +from ol_analytics_api.core.db.client import PoolAcquireTimeoutError + + +class ErrorCode(StrEnum): + """The `code` enum in the contract's Error schema, verbatim.""" + + INVALID_PARAMETER = "invalid_parameter" + UNAUTHORIZED = "unauthorized" + MISSING_SCOPE = "missing_scope" + NO_ORGANIZATION_ACCESS = "no_organization_access" + RATE_LIMITED = "rate_limited" + UNAVAILABLE = "unavailable" + + +class ApiError(HTTPException): + """An error this tenant's contract documents, carrying its code.""" + + def __init__( + self, + status_code: int, + code: ErrorCode, + detail: str, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__(status_code=status_code, detail=detail, headers=headers) + self.code = code + + +def error_body(code: ErrorCode, detail: str) -> dict[str, str]: + return {"code": str(code), "detail": detail} + + +async def _api_error_handler(_request: Request, exc: Exception) -> JSONResponse: + error = exc if isinstance(exc, ApiError) else None + if error is None: # pragma: no cover - registered for ApiError only + raise exc + return JSONResponse( + status_code=error.status_code, + content=error_body(error.code, error.detail), + headers=error.headers, + ) + + +async def _pool_acquire_timeout_handler(_request: Request, exc: Exception) -> JSONResponse: + # Overrides the shared handler from core/errors.py, which returns the same + # 503 without a code. Register it after add_shared_error_handlers. + return JSONResponse( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + content=error_body(ErrorCode.UNAVAILABLE, str(exc)), + headers={"Retry-After": "1"}, + ) + + +def add_error_handlers(app: FastAPI) -> None: + """Render this tenant's documented errors in the contract's shape. + + Errors the contract doesn't document (a 404 for an unrouted path, a 405) + keep FastAPI's own body. Dressing them in a code from the enum would + claim a contract that doesn't cover them. + """ + app.add_exception_handler(ApiError, _api_error_handler) + app.add_exception_handler(PoolAcquireTimeoutError, _pool_acquire_timeout_handler) diff --git a/src/ol_analytics_api/tenants/b2b_learner_records/token.py b/src/ol_analytics_api/tenants/b2b_learner_records/token.py new file mode 100644 index 0000000..b236a80 --- /dev/null +++ b/src/ol_analytics_api/tenants/b2b_learner_records/token.py @@ -0,0 +1,263 @@ +"""Verify the partner's bearer token here, rather than trusting the gateway. + +Every other tenant reads its claims from X-Userinfo, which APISIX rebuilds +from a token it has already validated. That is only sound for traffic that +goes through APISIX, and nothing forces it to: the pod security group admits +the whole pod subnet, and the CNI is running with network policy disabled on +both data clusters, so the existing NetworkPolicies are no-ops. Any +compromised in-cluster workload can therefore post a forged X-Userinfo +naming a scope and an organization. For aggregate k-anonymized figures that +is a tolerable risk; these records name individual learners, so this tenant +checks the signature itself and ignores X-Userinfo entirely. + +The token is a Keycloak client-credentials access token from the olapps +realm, signed RS256 with a realm key published at the realm's JWKS endpoint. +Verification is local: fetch the key set, cache it, check signature, issuer, +audience, token class and lifetime. No call to Keycloak is on the request +path except the key fetch, which happens once per TTL or once per unseen key +id. + +PyJWT ships PyJWKClient, which caches and refetches much like JWKSCache +below. It fetches with urllib, which would block the event loop on every +cache miss, so the cache here is a small async reimplementation rather than +a wrapper around it. +""" + +from __future__ import annotations + +import asyncio +import time +from typing import Any + +import httpx +import jwt +import structlog +from fastapi import Request, status +from jwt import PyJWK, PyJWKSet + +from ol_analytics_api.tenants.b2b_learner_records.config import settings +from ol_analytics_api.tenants.b2b_learner_records.errors import ApiError, ErrorCode + +log = structlog.get_logger(__name__) + +ALGORITHMS = ("RS256",) + +# Keycloak's token class, in the payload. An ID token for this same client id +# carries the realm's signature, this issuer and this audience, so without +# this check it clears verification and is stopped only by carrying no +# organization grant. That is one claim deep; this closes the class. +ACCESS_TOKEN_TYPE = "Bearer" # noqa: S105 - a claim value, not a credential + +# One refusal for every verification failure. The caller is a machine holding +# a contract, not a person debugging a login, and naming which check failed +# tells an attacker probing with forged tokens which part they got right. +INVALID_TOKEN_DETAIL = "Invalid or missing bearer token" # noqa: S105 - a refusal message + +# Floor on how often an unknown key id may trigger a refetch. Without it, a +# stream of tokens carrying junk kids would pull the JWKS endpoint once per +# request. +_KID_REFETCH_COOLDOWN_SECONDS = 60.0 + +# How long to stop trying after a fetch fails. Without it, every request that +# arrives with the cache cold or expired and Keycloak unreachable takes the +# lock and pays the full timeout in turn, so callers queue up behind each +# other for as long as the outage lasts. +_FETCH_RETRY_COOLDOWN_SECONDS = 5.0 + + +class JWKSUnavailableError(Exception): + """The realm's key set could not be fetched or parsed.""" + + +def _unauthorized() -> ApiError: + return ApiError( + status_code=status.HTTP_401_UNAUTHORIZED, + code=ErrorCode.UNAUTHORIZED, + detail=INVALID_TOKEN_DETAIL, + headers={"WWW-Authenticate": "Bearer"}, + ) + + +class JWKSCache: + """The realm's signing keys, fetched on demand and held for a TTL.""" + + def __init__(self) -> None: + self._keys: PyJWKSet | None = None + self._fetched_at = 0.0 + self._retry_after = 0.0 + self._kid_refetch_after = 0.0 + # Serializes fetches so N concurrent requests on a cold or expired + # cache open one connection to Keycloak, not N. + self._lock = asyncio.Lock() + + def clear(self) -> None: + self._keys = None + self._fetched_at = 0.0 + self._retry_after = 0.0 + self._kid_refetch_after = 0.0 + + def _is_fresh(self) -> bool: + return ( + self._keys is not None + and time.monotonic() - self._fetched_at < settings.jwks_cache_ttl_seconds + ) + + async def _fetch(self) -> PyJWKSet: + """Pull the key set from the realm, or raise JWKSUnavailableError. + + Everything the endpoint can go wrong with ends up as one exception: + a transport error, a non-2xx, a body that isn't JSON, and a body that + is JSON but holds no key this service can verify with (an empty set, + an error document, a bare list). Left uncaught, the last few surface + as a 500 from a dependency whose whole contract is to answer 401. + """ + try: + async with httpx.AsyncClient(timeout=settings.jwks_timeout_seconds) as client: + response = await client.get(settings.jwks_url) + response.raise_for_status() + keys = PyJWKSet.from_dict(response.json()) + except (httpx.HTTPError, ValueError, TypeError, AttributeError, jwt.PyJWTError) as exc: + msg = f"Could not load the key set at {settings.jwks_url}: {exc}" + raise JWKSUnavailableError(msg) from exc + self._keys = keys + self._fetched_at = time.monotonic() + return keys + + async def _load(self, *, force: bool = False) -> PyJWKSet: + if not force and self._is_fresh(): + return self._keys # type: ignore[return-value] + fetched_at = self._fetched_at + async with self._lock: + # Whoever held the lock may have just fetched, in which case this + # caller rides on their result -- including a forced load, where + # freshness isn't the question but "did someone already refetch + # while I waited" is. + if self._is_fresh() if not force else self._fetched_at != fetched_at: + return self._keys # type: ignore[return-value] + if time.monotonic() < self._retry_after: + return self._stale_or_raise(JWKSUnavailableError("In the fetch-failure cooldown")) + try: + return await self._fetch() + except JWKSUnavailableError as exc: + self._retry_after = time.monotonic() + _FETCH_RETRY_COOLDOWN_SECONDS + return self._stale_or_raise(exc) + + def _stale_or_raise(self, exc: JWKSUnavailableError) -> PyJWKSet: + """Fall back on the last key set we did fetch, if there is one. + + Realm signing keys turn over on the order of months, so a key set + that is past its TTL is still almost certainly the right one. Serving + it through a Keycloak outage keeps partners working; refusing every + request because a refresh failed would be an outage this service + inflicted on itself. + """ + if self._keys is None: + raise exc + log.warning("Serving the last known realm key set", error=str(exc)) + return self._keys + + @staticmethod + def _select(keys: PyJWKSet, kid: str) -> PyJWK | None: + try: + return keys[kid] + except KeyError: + return None + + async def signing_key(self, kid: str) -> PyJWK | None: + """The key with this id, refetching once if it isn't in the cache. + + Keycloak rotates realm keys without warning, and the first token + signed by a new key arrives before the TTL expires. Refetching on an + unknown id turns that into one extra request instead of a TTL's worth + of refusals. + """ + key = self._select(await self._load(), kid) + if key is not None: + return key + + # No await between reading the cooldown and setting it below, so a + # burst of unknown kids can't all slip through the window. Keep it + # that way: an await in between reopens the amplifier this guards. + if time.monotonic() < self._kid_refetch_after: + return None + + log.info("Refetching JWKS for an unknown key id", kid=kid) + fetched_at = self._fetched_at + key = self._select(await self._load(force=True), kid) + if key is None and self._fetched_at != fetched_at: + # Start the cooldown only once a freshly fetched key set really + # didn't have the kid, which is what an advanced _fetched_at says. + # A refetch that failed hands back the stale set instead of + # raising, so without that test a Keycloak blip during a key + # rotation would start the cooldown on evidence nobody gathered, + # and keep refusing the new kid for a minute after Keycloak came + # back. A failed fetch is already held off by _retry_after. + self._kid_refetch_after = time.monotonic() + _KID_REFETCH_COOLDOWN_SECONDS + return key + + +jwks_cache = JWKSCache() + + +def bearer_token(request: Request) -> str: + """Pull the raw token out of the request. + + Authorization is where a client-credentials caller puts it and APISIX + passes it through untouched (it only ever overwrites it with the same + token). X-Access-Token is the gateway's own output header, and its + openid-connect plugin accepts a bearer there too; accepting both keeps + this service working whichever way the route is configured. Neither is + trusted: both end up at the same signature check. + """ + header = request.headers.get("Authorization") + if header: + scheme, _, token = header.partition(" ") + if scheme.lower() != "bearer" or not token.strip(): + raise _unauthorized() + return token.strip() + token = request.headers.get("X-Access-Token", "").strip() + if not token: + raise _unauthorized() + return token + + +async def verified_claims(request: Request) -> dict[str, Any]: + """The token's claims, or 401. This replaces get_userinfo for this tenant.""" + token = bearer_token(request) + try: + kid = jwt.get_unverified_header(token).get("kid") + except jwt.PyJWTError as exc: + raise _unauthorized() from exc + if not isinstance(kid, str): + # Keycloak always sets kid. Without one there is nothing to select a + # key by, and trying every key in the set is how you end up accepting + # a token signed by a key meant for something else. + raise _unauthorized() + + try: + key = await jwks_cache.signing_key(kid) + except JWKSUnavailableError as exc: + # The key set is unreachable or unusable. That is this service's + # problem, not the caller's, but it must not open the door: refuse. + log.warning("Could not load the realm JWKS", error=str(exc)) + raise _unauthorized() from exc + if key is None: + raise _unauthorized() + + try: + claims: dict[str, Any] = jwt.decode( + token, + key=key, + algorithms=list(ALGORITHMS), + audience=settings.audience, + issuer=settings.issuer, + leeway=settings.token_leeway_seconds, + options={"require": ["exp", "iat", "iss", "aud", "typ"]}, + ) + except jwt.PyJWTError as exc: + log.info("Refused a bearer token", reason=type(exc).__name__) + raise _unauthorized() from exc + if claims.get("typ") != ACCESS_TOKEN_TYPE: + log.info("Refused a token that is not an access token", typ=claims.get("typ")) + raise _unauthorized() + return claims diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..32a9cf2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,109 @@ +"""Shared fixtures. + +Mostly the signing key the b2b_learner_records tenant verifies bearer tokens +against. That tenant no longer trusts X-Userinfo, so its tests have to present +a real RS256 token from a realm whose JWKS the service can fetch. +""" + +import time + +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa + +from ol_analytics_api.tenants.b2b_learner_records.config import settings +from ol_analytics_api.tenants.b2b_learner_records.token import ACCESS_TOKEN_TYPE, jwks_cache + +ISSUER = "https://sso.test.example/realms/olapps" +JWKS_URL = f"{ISSUER}/protocol/openid-connect/certs" +AUDIENCE = "ol-analytics-api-client" +KID = "realm-key-1" +OTHER_KID = "realm-key-2" + +# One 2048-bit key generation per test session. RSA keygen is slow enough that +# doing it per test is noticeable, and nothing here depends on key freshness. +_KEYS = {KID: rsa.generate_private_key(public_exponent=65537, key_size=2048)} + + +def signing_key(kid: str = KID) -> rsa.RSAPrivateKey: + if kid not in _KEYS: + _KEYS[kid] = rsa.generate_private_key(public_exponent=65537, key_size=2048) + return _KEYS[kid] + + +def jwks(*kids: str) -> dict: + """The realm's published key set, as Keycloak's certs endpoint returns it.""" + return { + "keys": [ + { + **jwt.algorithms.RSAAlgorithm.to_jwk(signing_key(kid).public_key(), as_dict=True), + "kid": kid, + "alg": "RS256", + "use": "sig", + } + for kid in (kids or (KID,)) + ] + } + + +def mint( + claims: dict | None = None, + *, + kid: str = KID, + issuer: str = ISSUER, + audience: str | list[str] = AUDIENCE, + lifetime_seconds: int = 300, + issued_at: float | None = None, + algorithm: str = "RS256", + key: object | None = None, +) -> str: + """A Keycloak-shaped access token signed by the test realm.""" + now = time.time() if issued_at is None else issued_at + payload = { + "iss": issuer, + "aud": audience, + # Keycloak's token class. An ID token carries "ID" here, which is the + # difference the tenant checks, so it belongs in the default shape. + "typ": ACCESS_TOKEN_TYPE, + "iat": int(now), + "exp": int(now + lifetime_seconds), + **(claims or {}), + } + return jwt.encode( + payload, + key if key is not None else signing_key(kid), # type: ignore[arg-type] + algorithm=algorithm, + headers={"kid": kid}, + ) + + +def bearer(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +@pytest.fixture(autouse=True) +def _test_realm(monkeypatch): + """Point the tenant at the test realm and hand back a clean key cache. + + The cache is a process-wide singleton, so a key set left over from one + test would answer another test's fetch and make it pass for the wrong + reason. + """ + monkeypatch.setattr(settings, "issuer", ISSUER) + monkeypatch.setattr(settings, "jwks_url", JWKS_URL) + monkeypatch.setattr(settings, "audience", AUDIENCE) + jwks_cache.clear() + yield + jwks_cache.clear() + + +@pytest.fixture +def realm_keys(httpx_mock): + """Serve the realm's JWKS for as many fetches as a test makes. + + Optional because a test that is refused before verification (no bearer + token at all) never fetches, and reusable because the cache is cleared + between tests. + """ + httpx_mock.add_response(url=JWKS_URL, json=jwks(), is_reusable=True, is_optional=True) + return httpx_mock diff --git a/tests/test_learner_records.py b/tests/test_learner_records.py index ec79303..ce87c37 100644 --- a/tests/test_learner_records.py +++ b/tests/test_learner_records.py @@ -1,26 +1,29 @@ """End-to-end tests for the b2b_learner_records tenant. -Drives the mounted app over ASGITransport with an X-Userinfo header shaped like -a client-credentials token's claims, stubbing only the StarRocks pool. The SQL -itself isn't executed here; these tests pin what it filters on and what it binds. +Drives the mounted app over ASGITransport with a real RS256 client-credentials +token signed by the test realm in conftest, stubbing only the StarRocks pool +and the realm's JWKS endpoint. The SQL itself isn't executed here; these tests +pin what it filters on and what it binds. """ import ast -import base64 import datetime -import json import pathlib import pytest +import yaml from httpx import ASGITransport, AsyncClient +from ol_analytics_api.core.db.client import PoolAcquireTimeoutError from ol_analytics_api.core.db.refresh_metadata import _clear_cache from ol_analytics_api.main import create_app from ol_analytics_api.tenants import b2b_learner_records from ol_analytics_api.tenants.b2b_learner_records import queries from ol_analytics_api.tenants.b2b_learner_records.auth import NO_GRANT_DETAIL from ol_analytics_api.tenants.b2b_learner_records.config import settings +from ol_analytics_api.tenants.b2b_learner_records.errors import ErrorCode from ol_analytics_api.tenants.b2b_learner_records.models import CourseRun, Enrollment, Learner +from tests.conftest import bearer, mint BASE = "/api/v1/learner-records" ORG_ID = "8f14e45f-ceea-467a-9c1b-2f4b9c0a3d21" @@ -28,14 +31,20 @@ LEARNER_ID = "3e1a9c74-5b2d-4f88-9a01-7c6de2b4f019" OTHER_LEARNER_ID = "c04e8a17-3d62-4b95-a7e8-51fb2c8d9042" _AS_OF = datetime.datetime(2026, 8, 13, 6, 15) # noqa: DTZ001 - StarRocks returns naive UTC +_CONTRACT_PATH = ( + pathlib.Path(__file__).resolve().parents[1] / "docs/openapi/b2b-learner-records-v1.yaml" +) + +# Every request here carries a token, so every test may fetch the realm JWKS. +pytestmark = pytest.mark.usefixtures("realm_keys") -def _header(claims: dict) -> str: - return base64.b64encode(json.dumps(claims).encode()).decode() +def _token(claims: dict) -> str: + return mint(claims) -def _partner_header(*organization_ids, scope="learner-records:read"): - return _header( +def _partner_token(*organization_ids, scope="learner-records:read"): + return _token( { "azp": "contoso-lms", "scope": f"profile email {scope}", @@ -144,15 +153,15 @@ def _clear_as_of_cache(): _clear_cache() -async def _get(app, path, header=None, pool=None, monkeypatch=None): +async def _get(app, path, token=None, pool=None, monkeypatch=None): pool = pool or _FakePool() monkeypatch.setattr("ol_analytics_api.core.db.client.starrocks_pool.fetch_all", pool.fetch_all) - headers = {"X-Userinfo": header} if header else {} + headers = bearer(token) if token else {} async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: return await client.get(f"{BASE}{path}", headers=headers) -async def test_requires_forwarded_claims(app, monkeypatch): +async def test_requires_a_bearer_token(app, monkeypatch): response = await _get(app, f"/organizations/{ORG_ID}/learners", monkeypatch=monkeypatch) assert response.status_code == 401 @@ -161,7 +170,7 @@ async def test_user_token_without_the_grant_claim_is_refused(app, monkeypatch): # A logged-in org manager's token carries no learner_records_organizations # claim, so the b2b_dashboard audience can't reach individual records. pool = _FakePool() - header = _header( + token = _token( { "sub": "kc-user", "scope": "openid learner-records:read", @@ -169,44 +178,49 @@ async def test_user_token_without_the_grant_claim_is_refused(app, monkeypatch): } ) response = await _get( - app, f"/organizations/{ORG_ID}/learners", header, pool, monkeypatch=monkeypatch + app, f"/organizations/{ORG_ID}/learners", token, pool, monkeypatch=monkeypatch ) assert response.status_code == 403 - assert response.json() == {"detail": NO_GRANT_DETAIL} + assert response.json() == {"code": "no_organization_access", "detail": NO_GRANT_DETAIL} assert pool.calls == [] async def test_ungranted_and_nonexistent_organizations_are_indistinguishable(app, monkeypatch): - header = _partner_header(ORG_ID) + token = _partner_token(ORG_ID) ungranted = await _get( - app, f"/organizations/{OTHER_ORG_ID}/enrollments", header, monkeypatch=monkeypatch + app, f"/organizations/{OTHER_ORG_ID}/enrollments", token, monkeypatch=monkeypatch ) missing = await _get( app, "/organizations/99999999-9999-9999-9999-999999999999/enrollments", - header, + token, monkeypatch=monkeypatch, ) assert ungranted.status_code == missing.status_code == 403 - assert ungranted.json() == missing.json() == {"detail": NO_GRANT_DETAIL} + assert ( + ungranted.json() + == missing.json() + == {"code": "no_organization_access", "detail": NO_GRANT_DETAIL} + ) async def test_missing_scope_is_refused(app, monkeypatch): response = await _get( app, f"/organizations/{ORG_ID}/learners", - _partner_header(ORG_ID, scope="learner-records:write"), + _partner_token(ORG_ID, scope="learner-records:write"), monkeypatch=monkeypatch, ) assert response.status_code == 403 + assert response.json()["code"] == "missing_scope" @pytest.mark.parametrize("claim", [ORG_ID, f"{ORG_ID},{OTHER_ORG_ID}", {"id": ORG_ID}, None, [42]]) async def test_a_grant_claim_that_is_not_a_json_array_of_uuids_grants_nothing( app, monkeypatch, claim ): - header = _header({"scope": "learner-records:read", "learner_records_organizations": claim}) - response = await _get(app, f"/organizations/{ORG_ID}/learners", header, monkeypatch=monkeypatch) + token = _token({"scope": "learner-records:read", "learner_records_organizations": claim}) + response = await _get(app, f"/organizations/{ORG_ID}/learners", token, monkeypatch=monkeypatch) assert response.status_code == 403 @@ -214,7 +228,7 @@ async def test_grant_matches_the_uuid_not_its_spelling(app, monkeypatch): response = await _get( app, f"/organizations/{ORG_ID}/learners", - _partner_header(ORG_ID.upper()), + _partner_token(ORG_ID.upper()), monkeypatch=monkeypatch, ) assert response.status_code == 200 @@ -223,7 +237,7 @@ async def test_grant_matches_the_uuid_not_its_spelling(app, monkeypatch): async def test_learners_envelope_withholds_outcomes_and_counts_them(app, monkeypatch): pool = _FakePool(rows=[_learner_row()], total_count=47, withheld=47) response = await _get( - app, f"/organizations/{ORG_ID}/learners", _partner_header(ORG_ID), pool, monkeypatch + app, f"/organizations/{ORG_ID}/learners", _partner_token(ORG_ID), pool, monkeypatch ) assert response.status_code == 200 @@ -246,7 +260,7 @@ async def test_learners_envelope_withholds_outcomes_and_counts_them(app, monkeyp async def test_outcome_columns_read_null_while_consent_is_absent(app, monkeypatch): pool = _FakePool() await _get( - app, f"/organizations/{ORG_ID}/enrollments", _partner_header(ORG_ID), pool, monkeypatch + app, f"/organizations/{ORG_ID}/enrollments", _partner_token(ORG_ID), pool, monkeypatch ) page_query, _ = pool.page_call() assert "FALSE AS outcomes_shared" in page_query @@ -259,7 +273,7 @@ async def test_consent_fail_open_discloses_outcomes(app, monkeypatch): monkeypatch.setattr(settings, "consent_fail_open", True) pool = _FakePool() await _get( - app, f"/organizations/{ORG_ID}/enrollments", _partner_header(ORG_ID), pool, monkeypatch + app, f"/organizations/{ORG_ID}/enrollments", _partner_token(ORG_ID), pool, monkeypatch ) page_query, _ = pool.page_call() assert "TRUE AS outcomes_shared" in page_query @@ -321,7 +335,7 @@ def test_models_keep_activity_when_shared(): async def test_enrollments_project_the_activity_columns(app, monkeypatch): pool = _FakePool() await _get( - app, f"/organizations/{ORG_ID}/enrollments", _partner_header(ORG_ID), pool, monkeypatch + app, f"/organizations/{ORG_ID}/enrollments", _partner_token(ORG_ID), pool, monkeypatch ) query, _ = pool.page_call() assert "videos_played AS videos_watched" in query @@ -332,7 +346,7 @@ async def test_enrollments_project_the_activity_columns(app, monkeypatch): async def test_default_learners_project_the_activity_columns(app, monkeypatch): pool = _FakePool() - await _get(app, f"/organizations/{ORG_ID}/learners", _partner_header(ORG_ID), pool, monkeypatch) + await _get(app, f"/organizations/{ORG_ID}/learners", _partner_token(ORG_ID), pool, monkeypatch) query, _ = pool.page_call() assert "NULL AS last_active_on" not in query assert "NULL AS courses_in_progress" not in query @@ -344,7 +358,7 @@ async def test_recomputed_learners_count_in_progress_with_the_enrollment_status( await _get( app, f"/organizations/{ORG_ID}/learners?contract_id=42", - _partner_header(ORG_ID), + _partner_token(ORG_ID), pool, monkeypatch, ) @@ -366,7 +380,7 @@ async def test_include_inactive_learners_count_activity_on_every_enrollment(app, await _get( app, f"/organizations/{ORG_ID}/learners?include_inactive=true", - _partner_header(ORG_ID), + _partner_token(ORG_ID), pool, monkeypatch, ) @@ -380,7 +394,7 @@ async def test_include_inactive_learners_count_activity_on_every_enrollment(app, async def test_default_learners_read_the_precomputed_rollup(app, monkeypatch): pool = _FakePool() - await _get(app, f"/organizations/{ORG_ID}/learners", _partner_header(ORG_ID), pool, monkeypatch) + await _get(app, f"/organizations/{ORG_ID}/learners", _partner_token(ORG_ID), pool, monkeypatch) query, params = pool.page_call() assert f"FROM b2b_learner_records.{queries.LEARNER_MV} WHERE" in query assert queries.ENROLLMENT_MV not in query @@ -395,7 +409,7 @@ async def test_contract_filter_recomputes_learners_from_that_contracts_enrollmen await _get( app, f"/organizations/{ORG_ID}/learners?contract_id=42&limit=10&offset=20", - _partner_header(ORG_ID), + _partner_token(ORG_ID), pool, monkeypatch, ) @@ -423,7 +437,7 @@ async def test_include_inactive_learners_keep_every_roster_member(app, monkeypat await _get( app, f"/organizations/{ORG_ID}/learners?include_inactive=true", - _partner_header(ORG_ID), + _partner_token(ORG_ID), pool, monkeypatch, ) @@ -439,7 +453,7 @@ async def test_include_inactive_learners_keep_every_roster_member(app, monkeypat ) async def test_blank_names_read_as_null(app, monkeypatch, path): pool = _FakePool() - await _get(app, f"/organizations/{ORG_ID}/{path}", _partner_header(ORG_ID), pool, monkeypatch) + await _get(app, f"/organizations/{ORG_ID}/{path}", _partner_token(ORG_ID), pool, monkeypatch) query, _ = pool.page_call() assert "NULLIF(TRIM(full_name), '')" in query @@ -450,7 +464,7 @@ async def test_recomputed_learners_report_the_staler_view(app, monkeypatch): response = await _get( app, f"/organizations/{ORG_ID}/learners?contract_id=42", - _partner_header(ORG_ID), + _partner_token(ORG_ID), pool, monkeypatch, ) @@ -466,7 +480,7 @@ async def test_enrollment_filters_are_bound_in_order(app, monkeypatch): f"&learner_id={LEARNER_ID}&learner_id={OTHER_LEARNER_ID}" "&completion_status=passed&completion_status=unknown" "&updated_since=2026-08-12T06:15:00.5%2B02:00", - _partner_header(ORG_ID), + _partner_token(ORG_ID), pool, monkeypatch, ) @@ -497,7 +511,7 @@ async def test_updated_before_bounds_the_window_exclusively(app, monkeypatch): app, f"/organizations/{ORG_ID}/enrollments" "?updated_since=2026-08-12T00:00:00Z&updated_before=2026-08-13T00:00:00Z", - _partner_header(ORG_ID), + _partner_token(ORG_ID), pool, monkeypatch, ) @@ -519,7 +533,7 @@ async def test_omitted_list_filters_add_no_predicate(app, monkeypatch): response = await _get( app, f"/organizations/{ORG_ID}/enrollments", - _partner_header(ORG_ID), + _partner_token(ORG_ID), pool, monkeypatch, ) @@ -543,11 +557,13 @@ async def test_malformed_parameters_are_400_with_a_string_detail(app, monkeypatc response = await _get( app, f"/organizations/{ORG_ID}/enrollments?{query}", - _partner_header(ORG_ID), + _partner_token(ORG_ID), monkeypatch=monkeypatch, ) assert response.status_code == 400 - assert isinstance(response.json()["detail"], str) + body = response.json() + assert isinstance(body["detail"], str) + assert body["code"] == "invalid_parameter" def test_cursor_value_is_a_prefix_of_stored_values_in_the_same_second(): @@ -606,7 +622,7 @@ async def test_as_of_is_read_before_the_records(app, monkeypatch): # refresh time, and a client syncing from that as_of would skip the new rows. pool = _FakePool() await _get( - app, f"/organizations/{ORG_ID}/enrollments", _partner_header(ORG_ID), pool, monkeypatch + app, f"/organizations/{ORG_ID}/enrollments", _partner_token(ORG_ID), pool, monkeypatch ) kinds = [ "as_of" if "information_schema" in query else "count" if "COUNT(*)" in query else "page" @@ -637,7 +653,7 @@ def _course_row(**overrides): async def test_courses_read_the_contract_courserun_view(app, monkeypatch): pool = _FakePool(rows=[_course_row()], total_count=1) response = await _get( - app, f"/organizations/{ORG_ID}/courses", _partner_header(ORG_ID), pool, monkeypatch + app, f"/organizations/{ORG_ID}/courses", _partner_token(ORG_ID), pool, monkeypatch ) assert response.status_code == 200 @@ -661,7 +677,7 @@ async def test_courses_contract_filter_is_bound(app, monkeypatch): await _get( app, f"/organizations/{ORG_ID}/courses?contract_id=42", - _partner_header(ORG_ID), + _partner_token(ORG_ID), pool, monkeypatch, ) @@ -678,7 +694,7 @@ async def test_courses_filters_are_bound_in_order(app, monkeypatch): f"/organizations/{ORG_ID}/courses" "?contract_is_active=true&courserun_id=course-v1:MITxT%2B14.310x%2B2T2026" "&courserun_starts_after=2026-01-01T00:00:00Z&courserun_starts_before=2026-04-01T00:00:00Z", - _partner_header(ORG_ID), + _partner_token(ORG_ID), pool, monkeypatch, ) @@ -697,3 +713,48 @@ async def test_courses_filters_are_bound_in_order(app, monkeypatch): 0, ) assert pool.count_call()[1] == params[:-2] + + +def test_the_error_code_enum_matches_the_published_contract(): + """ErrorCode exists to mirror the contract's enum. Read it from the + contract rather than trusting the copy, so the two can't drift.""" + spec = yaml.safe_load(_CONTRACT_PATH.read_text()) + documented = spec["components"]["schemas"]["Error"]["properties"]["code"]["enum"] + assert set(ErrorCode) == set(documented) + assert spec["components"]["schemas"]["Error"]["required"] == ["code", "detail"] + + +async def test_every_error_carries_the_code_its_contract_documents(app, monkeypatch): + """The Error schema requires `code` and tells clients to branch on it + rather than on `detail`, whose wording may change. A body without one + fails validation in a client generated from the spec.""" + + async def saturated(*_args, **_kwargs): + msg = "pool saturated" + raise PoolAcquireTimeoutError(msg) + + cases = [ + (f"/organizations/{ORG_ID}/enrollments?limit=0", _partner_token(ORG_ID), 400), + (f"/organizations/{ORG_ID}/learners", None, 401), + ( + f"/organizations/{ORG_ID}/learners", + _partner_token(ORG_ID, scope="learner-records:write"), + 403, + ), + (f"/organizations/{OTHER_ORG_ID}/learners", _partner_token(ORG_ID), 403), + ] + for path, token, expected in cases: + response = await _get(app, path, token, monkeypatch=monkeypatch) + assert response.status_code == expected, path + assert set(response.json()) == {"code", "detail"}, path + assert response.json()["code"] in set(ErrorCode), path + + monkeypatch.setattr("ol_analytics_api.core.db.client.starrocks_pool.fetch_all", saturated) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + unavailable = await client.get( + f"{BASE}/organizations/{ORG_ID}/learners", + headers=bearer(_partner_token(ORG_ID)), + ) + assert unavailable.status_code == 503 + assert unavailable.json()["code"] == "unavailable" + assert unavailable.headers["Retry-After"] == "1" diff --git a/tests/test_learner_records_token.py b/tests/test_learner_records_token.py new file mode 100644 index 0000000..47a2c6c --- /dev/null +++ b/tests/test_learner_records_token.py @@ -0,0 +1,458 @@ +"""What the learner-records tenant accepts as proof of identity. + +The point of these tests is that the gateway is not in the trust path. A +request that reaches the pod directly, carrying whatever headers its sender +chose, gets exactly as far as its token's signature takes it. +""" + +import asyncio +import base64 +import hashlib +import hmac +import json +import time + +import httpx +import jwt +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from httpx import ASGITransport, AsyncClient +from pydantic import ValidationError + +from ol_analytics_api.core.config import settings as core_settings +from ol_analytics_api.main import create_app +from ol_analytics_api.tenants.b2b_learner_records import token as token_module +from ol_analytics_api.tenants.b2b_learner_records.config import ( + PRODUCTION_ISSUER, + B2BLearnerRecordsSettings, + settings, +) +from ol_analytics_api.tenants.b2b_learner_records.token import ( + ACCESS_TOKEN_TYPE, + INVALID_TOKEN_DETAIL, + jwks_cache, +) +from tests.conftest import ( + AUDIENCE, + ISSUER, + JWKS_URL, + KID, + OTHER_KID, + bearer, + jwks, + mint, + signing_key, +) + +BASE = "/api/v1/learner-records" +ORG_ID = "8f14e45f-ceea-467a-9c1b-2f4b9c0a3d21" +PATH = f"{BASE}/organizations/{ORG_ID}/learners" + +PARTNER_CLAIMS = { + "azp": "contoso-lms", + "scope": "basic learner-records:read", + "learner_records_organizations": [ORG_ID], +} + + +@pytest.fixture +def app(): + return create_app() + + +@pytest.fixture(autouse=True) +def _stub_pool(monkeypatch): + async def fetch_all(query, params=()): # noqa: ARG001 + if "COUNT(*)" in query: + return [{"total_count": 0, "outcomes_withheld_count": 0}] + return [] + + monkeypatch.setattr("ol_analytics_api.core.db.client.starrocks_pool.fetch_all", fetch_all) + + +async def _get(app, headers): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + return await client.get(PATH, headers=headers) + + +def _userinfo(claims: dict) -> dict[str, str]: + return {"X-Userinfo": base64.b64encode(json.dumps(claims).encode()).decode()} + + +async def test_a_valid_token_is_accepted(app, realm_keys): # noqa: ARG001 + response = await _get(app, bearer(mint(PARTNER_CLAIMS))) + assert response.status_code == 200 + + +async def test_a_forged_x_userinfo_alone_is_refused(app, realm_keys): # noqa: ARG001 + """The attack this tenant exists to close: a pod-to-pod request that + skips APISIX and asserts its own claims.""" + response = await _get(app, _userinfo(PARTNER_CLAIMS)) + assert response.status_code == 401 + assert response.json() == {"code": "unauthorized", "detail": INVALID_TOKEN_DETAIL} + + +async def test_a_forged_x_userinfo_cannot_widen_a_valid_token(app, realm_keys): # noqa: ARG001 + """A token granting nothing plus an X-Userinfo granting everything is + still a token granting nothing: the header is never read.""" + narrow = mint({**PARTNER_CLAIMS, "learner_records_organizations": []}) + wide = _userinfo(PARTNER_CLAIMS) + response = await _get(app, {**bearer(narrow), **wide}) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + ("headers", "case"), + [ + ({}, "no headers at all"), + ({"Authorization": "Bearer "}, "an empty bearer"), + ({"Authorization": "Basic Zm9vOmJhcg=="}, "the wrong scheme"), + ({"Authorization": "Bearer not-a-jwt"}, "a token that isn't a JWT"), + ], +) +async def test_malformed_credentials_are_refused(app, realm_keys, headers, case): # noqa: ARG001 + response = await _get(app, headers) + assert response.status_code == 401, case + assert response.json() == {"code": "unauthorized", "detail": INVALID_TOKEN_DETAIL}, case + + +async def test_a_token_signed_by_another_key_is_refused(app, realm_keys): # noqa: ARG001 + """Right kid, wrong key: what an attacker who read the JWKS would try.""" + impostor = rsa.generate_private_key(public_exponent=65537, key_size=2048) + response = await _get(app, bearer(mint(PARTNER_CLAIMS, key=impostor))) + assert response.status_code == 401 + + +async def test_an_unsigned_token_is_refused(app, realm_keys): # noqa: ARG001 + """alg=none, the oldest JWT hole. Only RS256 is accepted.""" + token = jwt.encode( + { + "iss": ISSUER, + "aud": AUDIENCE, + "typ": ACCESS_TOKEN_TYPE, + "iat": int(time.time()), + "exp": int(time.time()) + 300, + }, + key=None, + algorithm="none", + headers={"kid": KID}, + ) + response = await _get(app, bearer(token)) + assert response.status_code == 401 + + +async def test_a_token_with_no_kid_is_refused_without_touching_the_key_set(app, realm_keys): + """Refused on the missing kid itself. Without that guard it would still + be refused, but only after a pointless fetch, and one bad header would be + enough to make a caller reach Keycloak.""" + token = jwt.encode( + { + "iss": ISSUER, + "aud": AUDIENCE, + "typ": ACCESS_TOKEN_TYPE, + "iat": int(time.time()), + "exp": int(time.time()) + 300, + }, + signing_key(), + algorithm="RS256", + ) + response = await _get(app, bearer(token)) + assert response.status_code == 401 + assert realm_keys.get_requests(url=JWKS_URL) == [] + + +async def test_a_token_for_another_audience_is_refused(app, realm_keys): # noqa: ARG001 + """Every olapps client's token is signed by the same realm key, so the + audience is what separates this service's callers from everyone else's.""" + response = await _get(app, bearer(mint(PARTNER_CLAIMS, audience="mitxonline-client"))) + assert response.status_code == 401 + + +async def test_a_token_from_another_issuer_is_refused(app, realm_keys): # noqa: ARG001 + response = await _get( + app, bearer(mint(PARTNER_CLAIMS, issuer="https://sso.test.example/realms/other")) + ) + assert response.status_code == 401 + + +async def test_an_expired_token_is_refused(app, realm_keys): # noqa: ARG001 + stale = mint(PARTNER_CLAIMS, issued_at=time.time() - 3600, lifetime_seconds=300) + response = await _get(app, bearer(stale)) + assert response.status_code == 401 + + +async def test_a_token_that_is_not_valid_yet_is_refused(app, realm_keys): # noqa: ARG001 + future = mint({**PARTNER_CLAIMS, "nbf": int(time.time() + 3600)}) + response = await _get(app, bearer(future)) + assert response.status_code == 401 + + +async def test_a_token_missing_exp_is_refused(app, realm_keys): # noqa: ARG001 + """A token with no expiry can never be aged out, which is the only + revocation this design has.""" + token = jwt.encode( + { + "iss": ISSUER, + "aud": AUDIENCE, + "typ": ACCESS_TOKEN_TYPE, + "iat": int(time.time()), + **PARTNER_CLAIMS, + }, + signing_key(), + algorithm="RS256", + headers={"kid": KID}, + ) + response = await _get(app, bearer(token)) + assert response.status_code == 401 + + +async def test_the_gateway_access_token_header_is_accepted_and_verified(app, realm_keys): # noqa: ARG001 + """APISIX puts the token it validated in X-Access-Token. Reading it is + safe because it is verified like any other.""" + accepted = await _get(app, {"X-Access-Token": mint(PARTNER_CLAIMS)}) + assert accepted.status_code == 200 + + impostor = rsa.generate_private_key(public_exponent=65537, key_size=2048) + forged = await _get(app, {"X-Access-Token": mint(PARTNER_CLAIMS, key=impostor)}) + assert forged.status_code == 401 + + +async def test_the_key_set_is_fetched_once_and_reused(app, realm_keys): + for _ in range(3): + assert (await _get(app, bearer(mint(PARTNER_CLAIMS)))).status_code == 200 + assert len(realm_keys.get_requests(url=JWKS_URL)) == 1 + + +async def test_an_unknown_key_id_refetches_the_key_set(app, httpx_mock): + """A realm key rotation costs one extra fetch, not a cache TTL of 401s.""" + httpx_mock.add_response(url=JWKS_URL, json=jwks(KID)) + assert (await _get(app, bearer(mint(PARTNER_CLAIMS)))).status_code == 200 + + httpx_mock.add_response(url=JWKS_URL, json=jwks(KID, OTHER_KID)) + rotated = await _get(app, bearer(mint(PARTNER_CLAIMS, kid=OTHER_KID))) + assert rotated.status_code == 200 + assert len(httpx_mock.get_requests(url=JWKS_URL)) == 2 + + +async def test_a_rotation_that_lands_during_an_outage_recovers(app, httpx_mock, monkeypatch): + """Keycloak blips while a new key is being rotated in. + + The forced refetch fails and falls back on the stale key set, so the new + kid still isn't there. That is not evidence the realm lacks the kid, so + it must not start the unknown-kid cooldown: doing so would keep refusing + the rotated key for a minute after Keycloak came back. + """ + monkeypatch.setattr(token_module, "_FETCH_RETRY_COOLDOWN_SECONDS", 0.0) + httpx_mock.add_response(url=JWKS_URL, json=jwks(KID)) + assert (await _get(app, bearer(mint(PARTNER_CLAIMS)))).status_code == 200 + + httpx_mock.add_response(url=JWKS_URL, status_code=503) + assert (await _get(app, bearer(mint(PARTNER_CLAIMS, kid=OTHER_KID)))).status_code == 401 + + httpx_mock.add_response(url=JWKS_URL, json=jwks(KID, OTHER_KID)) + recovered = await _get(app, bearer(mint(PARTNER_CLAIMS, kid=OTHER_KID))) + assert recovered.status_code == 200 + + +async def test_a_junk_key_id_does_not_refetch_on_every_request(app, httpx_mock): + """Otherwise a stream of forged tokens is a request amplifier pointed at + Keycloak.""" + httpx_mock.add_response(url=JWKS_URL, json=jwks(), is_reusable=True) + for _ in range(5): + response = await _get(app, bearer(mint(PARTNER_CLAIMS, kid="no-such-key"))) + assert response.status_code == 401 + # One cold fetch, then one refetch for the first unknown kid; the cooldown + # absorbs the rest. + assert len(httpx_mock.get_requests(url=JWKS_URL)) == 2 + + +async def test_an_unreachable_key_set_refuses_rather_than_opening_up(app, httpx_mock): + httpx_mock.add_response(url=JWKS_URL, status_code=503, is_reusable=True) + response = await _get(app, bearer(mint(PARTNER_CLAIMS))) + assert response.status_code == 401 + assert response.json() == {"code": "unauthorized", "detail": INVALID_TOKEN_DETAIL} + + +async def test_a_cold_fetch_failure_is_held_off_before_retrying(app, httpx_mock): + """With no key set to fall back on, refuse; but don't let every arriving + request pay the timeout in turn for as long as Keycloak is down.""" + httpx_mock.add_response(url=JWKS_URL, status_code=503, is_reusable=True) + for _ in range(4): + assert (await _get(app, bearer(mint(PARTNER_CLAIMS)))).status_code == 401 + assert len(httpx_mock.get_requests(url=JWKS_URL)) == 1 + + +async def test_the_tenant_recovers_once_the_key_set_is_reachable(app, httpx_mock, monkeypatch): + """The hold-off above delays a retry; it must not prevent one.""" + monkeypatch.setattr(token_module, "_FETCH_RETRY_COOLDOWN_SECONDS", 0.0) + httpx_mock.add_response(url=JWKS_URL, status_code=503) + assert (await _get(app, bearer(mint(PARTNER_CLAIMS)))).status_code == 401 + + httpx_mock.add_response(url=JWKS_URL, json=jwks()) + assert (await _get(app, bearer(mint(PARTNER_CLAIMS)))).status_code == 200 + + +async def test_a_stale_key_set_carries_the_tenant_through_a_keycloak_outage( + app, httpx_mock, monkeypatch +): + """Realm keys turn over on the order of months, so a key set past its TTL + is still the right one. Refusing every request because a refresh failed + would be an outage this service inflicted on itself.""" + httpx_mock.add_response(url=JWKS_URL, json=jwks()) + assert (await _get(app, bearer(mint(PARTNER_CLAIMS)))).status_code == 200 + + monkeypatch.setattr(settings, "jwks_cache_ttl_seconds", 0.0) + monkeypatch.setattr(token_module, "_FETCH_RETRY_COOLDOWN_SECONDS", 0.0) + httpx_mock.add_response(url=JWKS_URL, status_code=503, is_reusable=True) + assert (await _get(app, bearer(mint(PARTNER_CLAIMS)))).status_code == 200 + assert len(httpx_mock.get_requests(url=JWKS_URL)) > 1 + + +async def test_the_key_set_is_refetched_after_the_ttl(app, httpx_mock, monkeypatch): + monkeypatch.setattr(settings, "jwks_cache_ttl_seconds", 0.0) + httpx_mock.add_response(url=JWKS_URL, json=jwks(), is_reusable=True) + for _ in range(2): + assert (await _get(app, bearer(mint(PARTNER_CLAIMS)))).status_code == 200 + assert len(httpx_mock.get_requests(url=JWKS_URL)) == 2 + + +async def test_concurrent_cold_requests_fetch_the_key_set_once(app, httpx_mock): + """Pins the lock, not the cache. + + A mocked transport returns without ever suspending, so eight gathered + requests would serialise themselves and pass this even with no lock at + all. The sleep makes the fetch yield the way a real one does, so the + other seven arrive while the first is still in flight. + """ + fetches = 0 + + async def slow_jwks(request): # noqa: ARG001 + nonlocal fetches + fetches += 1 + await asyncio.sleep(0.05) + return httpx.Response(200, json=jwks()) + + httpx_mock.add_callback(slow_jwks, url=JWKS_URL, is_reusable=True) + jwks_cache.clear() + responses = await asyncio.gather(*(_get(app, bearer(mint(PARTNER_CLAIMS))) for _ in range(8))) + assert [r.status_code for r in responses] == [200] * 8 + assert fetches == 1 + + +async def test_an_id_token_for_the_same_client_is_refused(app, realm_keys): # noqa: ARG001 + """An ID token minted for ol-analytics-api-client carries the realm's + signature, this issuer and this audience. Only its token class tells it + apart from an access token.""" + response = await _get(app, bearer(mint({**PARTNER_CLAIMS, "typ": "ID"}))) + assert response.status_code == 401 + assert response.json() == {"code": "unauthorized", "detail": INVALID_TOKEN_DETAIL} + + +async def test_a_token_with_no_token_class_is_refused(app, realm_keys): # noqa: ARG001 + token = jwt.encode( + { + "iss": ISSUER, + "aud": AUDIENCE, + "iat": int(time.time()), + "exp": int(time.time()) + 300, + **PARTNER_CLAIMS, + }, + signing_key(), + algorithm="RS256", + headers={"kid": KID}, + ) + assert (await _get(app, bearer(token))).status_code == 401 + + +async def test_a_symmetric_token_keyed_with_the_public_key_is_refused(app, realm_keys): # noqa: ARG001 + """The other half of algorithm confusion: not an unsigned token but one + signed with HS256, using the public key everyone can read out of the JWKS + as the shared secret. + + Assembled by hand rather than with jwt.encode, which refuses to key HMAC + with an asymmetric key. An attacker has no such scruples, so the test + can't have them either. + """ + public_pem = ( + signing_key() + .public_key() + .public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + ) + + def segment(payload: dict) -> bytes: + return base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b"=") + + signing_input = b".".join( + ( + segment({"alg": "HS256", "typ": "JWT", "kid": KID}), + segment( + { + "iss": ISSUER, + "aud": AUDIENCE, + "typ": ACCESS_TOKEN_TYPE, + "iat": int(time.time()), + "exp": int(time.time()) + 300, + **PARTNER_CLAIMS, + } + ), + ) + ) + signature = hmac.new(public_pem, signing_input, hashlib.sha256).digest() + token = b".".join((signing_input, base64.urlsafe_b64encode(signature).rstrip(b"="))) + assert (await _get(app, bearer(token.decode()))).status_code == 401 + + +async def test_the_audience_may_be_a_list(app, realm_keys): # noqa: ARG001 + """The shape Keycloak actually emits once a token carries more than one + audience. The single-string form the other tests use is the simpler case, + not the real one.""" + token = mint(PARTNER_CLAIMS, audience=[AUDIENCE, "account"]) + assert (await _get(app, bearer(token))).status_code == 200 + + +@pytest.mark.parametrize( + ("body", "case"), + [ + ({"keys": []}, "a key set with no keys"), + ({"error": "realm not found"}, "an error document served as 200"), + (["not", "a", "key", "set"], "a bare list"), + ], +) +async def test_an_unusable_key_set_refuses_rather_than_erroring(app, httpx_mock, body, case): + """A proxy or a mid-import realm can answer 200 with a body that parses + as JSON but holds no usable key. This dependency's contract is to answer + 401; letting the parse error escape would make it a 500 instead.""" + httpx_mock.add_response(url=JWKS_URL, json=body, is_reusable=True) + response = await _get(app, bearer(mint(PARTNER_CLAIMS))) + assert response.status_code == 401, case + assert response.json() == {"code": "unauthorized", "detail": INVALID_TOKEN_DETAIL}, case + + +def test_settings_derive_the_sso_urls_from_the_issuer(): + """One setting moves a deployment to another realm; four can drift.""" + assert settings.__class__(issuer="https://sso.example/realms/r").jwks_url == ( + "https://sso.example/realms/r/protocol/openid-connect/certs" + ) + assert settings.__class__(issuer="https://sso.example/realms/r/").token_url == ( + "https://sso.example/realms/r/protocol/openid-connect/token" + ) + + +@pytest.mark.parametrize("environment", ["qa", "ci", "rc"]) +def test_a_deployed_environment_must_name_its_own_realm(monkeypatch, environment): + """Leaving the issuer unset outside production would have the pod verify + against a realm that never issued the token, and the 401 that follows + reads like a bad credential.""" + monkeypatch.setattr(core_settings, "environment", environment) + with pytest.raises(ValidationError, match="ISSUER is unset"): + B2BLearnerRecordsSettings(issuer=PRODUCTION_ISSUER) + + +@pytest.mark.parametrize("environment", ["production", "development"]) +def test_the_default_realm_is_accepted_where_it_is_the_right_one(monkeypatch, environment): + monkeypatch.setattr(core_settings, "environment", environment) + assert B2BLearnerRecordsSettings(issuer=PRODUCTION_ISSUER).issuer == PRODUCTION_ISSUER diff --git a/uv.lock b/uv.lock index 861692a..66b2e35 100644 --- a/uv.lock +++ b/uv.lock @@ -212,6 +212,91 @@ 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/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 = "charset-normalizer" version = "3.5.1" @@ -460,6 +545,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/1a/d6d16babd0a5fe4c3fae40702158c570351694e74516d8d81b86c5637448/coverage-7.16.1-py3-none-any.whl", hash = "sha256:3d8bd4e58b6a5c2018d808f297905393c6c61da466a48c3f0596a76a4900ebe4", size = 215264, upload-time = "2026-09-13T19:12:18.895Z" }, ] +[[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" }, +] + [[package]] name = "cyclopts" version = "4.25.3" @@ -915,6 +1050,7 @@ dependencies = [ { name = "opentelemetry-instrumentation-httpx" }, { name = "opentelemetry-sdk" }, { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, { name = "sentry-sdk", extra = ["fastapi"] }, { name = "sqlmodel" }, { name = "structlog" }, @@ -923,6 +1059,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "asgi-lifespan" }, + { name = "cryptography" }, { name = "cyclopts" }, { name = "mypy" }, { name = "pytest" }, @@ -950,6 +1087,7 @@ requires-dist = [ { name = "opentelemetry-instrumentation-httpx", specifier = ">=0.52b0" }, { name = "opentelemetry-sdk", specifier = ">=1.31.0" }, { name = "pydantic-settings", specifier = ">=2.6" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10" }, { name = "sentry-sdk", extras = ["fastapi"], specifier = ">=2.0" }, { name = "sqlmodel", specifier = ">=0.0.22" }, { name = "structlog", specifier = ">=24.0.0" }, @@ -958,6 +1096,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "asgi-lifespan", specifier = ">=2.1.0" }, + { name = "cryptography", specifier = ">=44" }, { name = "cyclopts", specifier = ">=4.22.5" }, { name = "mypy", specifier = ">=1.13" }, { name = "pytest", specifier = ">=8.3" }, @@ -1166,6 +1305,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/04/d52c7016b04b6c5108f26691f9d33ec82a9b65d041f1a9c771137693d618/protobuf-7.36.2-py3-none-any.whl", hash = "sha256:bdb3a345d48db958e6ce1f18e508beb0cc981d64f24088427549c866cd039f1e", size = 179806, upload-time = "2026-09-17T20:07:58.211Z" }, ] +[[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" @@ -1279,6 +1427,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 = "pymysql" version = "1.2.3"