From 598453060e94c90799a5925eff7552f95646ec61 Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Tue, 22 Sep 2026 10:59:02 -0400 Subject: [PATCH 1/6] fix(b2b_learner_records): verify the bearer JWT instead of trusting X-Userinfo The tenant authorized from X-Userinfo, which APISIX rebuilds from a token it has already validated. That only says anything about traffic that went through APISIX, and nothing forces a caller to: 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. A compromised in-cluster workload could post a forged X-Userinfo carrying learner-records:read and any organization UUID and read identifiable learner records. This verifies the token in the app instead. token.py checks RS256 against the olapps realm JWKS, with issuer, audience and lifetime, and require_organization_grant reads scope, learner_records_organizations and azp from the verified payload. X-Userinfo is not read by this tenant at all. Anything that fails verification gets one 401 that does not say which check failed. The key set is cached for a TTL and refetched once on an unseen kid, so a realm key rotation costs a fetch rather than a TTL of refusals; a cooldown keeps forged kids from turning the endpoint into an amplifier pointed at Keycloak. An unreachable JWKS refuses rather than falling back. Config derives the token, JWKS and issuer URLs from one issuer setting, so moving a deployment to another realm is one environment variable. b2b_dashboard still reads X-Userinfo. Its exposure is aggregate and k-anonymized and it has a browser session flow, so that is a separate call. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Qoz9pfQxUU8VLsRxr1tTnm --- ...-learner-records-provider-authorization.md | 38 ++- pyproject.toml | 3 +- .../tenants/b2b_learner_records/auth.py | 32 ++- .../tenants/b2b_learner_records/config.py | 39 ++- .../tenants/b2b_learner_records/token.py | 186 +++++++++++++ tests/conftest.py | 106 ++++++++ tests/test_learner_records.py | 69 ++--- tests/test_learner_records_token.py | 254 ++++++++++++++++++ uv.lock | 160 +++++++++++ 9 files changed, 831 insertions(+), 56 deletions(-) create mode 100644 src/ol_analytics_api/tenants/b2b_learner_records/token.py create mode 100644 tests/conftest.py create mode 100644 tests/test_learner_records_token.py 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/pyproject.toml b/pyproject.toml index 0600442..e123ca6 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] @@ -127,7 +128,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/auth.py b/src/ol_analytics_api/tenants/b2b_learner_records/auth.py index 9458ad1..9ce9af1 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 @@ -19,15 +24,16 @@ 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.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 +51,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 +69,21 @@ 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( status_code=status.HTTP_403_FORBIDDEN, detail=f"Token lacks the {READ_SCOPE} scope", ) - if organization_id not in _granted_organizations(userinfo): + if organization_id not in _granted_organizations(claims): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, 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..dbafff8 100644 --- a/src/ol_analytics_api/tenants/b2b_learner_records/config.py +++ b/src/ol_analytics_api/tenants/b2b_learner_records/config.py @@ -2,7 +2,7 @@ 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.db.identifiers import validate_sql_identifier @@ -21,9 +21,33 @@ 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. + issuer: str = "https://sso.ol.mit.edu/realms/olapps" + + # 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 +59,14 @@ 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 _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/token.py b/src/ol_analytics_api/tenants/b2b_learner_records/token.py new file mode 100644 index 0000000..ae8f0d1 --- /dev/null +++ b/src/ol_analytics_api/tenants/b2b_learner_records/token.py @@ -0,0 +1,186 @@ +"""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 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. +""" + +from __future__ import annotations + +import asyncio +import time +from typing import Any + +import httpx +import jwt +import structlog +from fastapi import HTTPException, Request, status +from jwt import PyJWKSet + +from ol_analytics_api.tenants.b2b_learner_records.config import settings + +log = structlog.get_logger(__name__) + +ALGORITHMS = ["RS256"] + +# 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. +_REFETCH_COOLDOWN_SECONDS = 60.0 + + +def _unauthorized() -> HTTPException: + return HTTPException( + status_code=status.HTTP_401_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._last_refetch_attempt = 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._last_refetch_attempt = 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: + 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()) + 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] + async with self._lock: + # Whoever held the lock may have just fetched, in which case this + # caller rides on their result. + if not force and self._is_fresh(): + return self._keys # type: ignore[return-value] + return await self._fetch() + + async def signing_key(self, kid: str) -> jwt.PyJWK: + """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. + """ + keys = await self._load() + try: + return keys[kid] + except KeyError: + pass + + now = time.monotonic() + if now - self._last_refetch_attempt < _REFETCH_COOLDOWN_SECONDS: + raise _unauthorized() from None + self._last_refetch_attempt = now + + log.info("Refetching JWKS for an unknown key id", kid=kid) + keys = await self._load(force=True) + try: + return keys[kid] + except KeyError as exc: + raise _unauthorized() from exc + + +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", "") + 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 HTTPException: + raise + except (httpx.HTTPError, ValueError, KeyError) 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), jwks_url=settings.jwks_url) + raise _unauthorized() from exc + + try: + claims: dict[str, Any] = jwt.decode( + token, + key=key, + algorithms=ALGORITHMS, + audience=settings.audience, + issuer=settings.issuer, + leeway=settings.token_leeway_seconds, + options={"require": ["exp", "iat", "iss", "aud"]}, + ) + except jwt.PyJWTError as exc: + log.info("Refused a bearer token", reason=type(exc).__name__) + raise _unauthorized() from exc + return claims diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8021a03 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,106 @@ +"""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 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, + "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..882dc07 100644 --- a/tests/test_learner_records.py +++ b/tests/test_learner_records.py @@ -1,14 +1,13 @@ """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 @@ -21,6 +20,7 @@ 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.models import CourseRun, Enrollment, Learner +from tests.conftest import bearer, mint BASE = "/api/v1/learner-records" ORG_ID = "8f14e45f-ceea-467a-9c1b-2f4b9c0a3d21" @@ -29,13 +29,16 @@ OTHER_LEARNER_ID = "c04e8a17-3d62-4b95-a7e8-51fb2c8d9042" _AS_OF = datetime.datetime(2026, 8, 13, 6, 15) # noqa: DTZ001 - StarRocks returns naive UTC +# 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 +147,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 +164,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,7 +172,7 @@ 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} @@ -177,14 +180,14 @@ async def test_user_token_without_the_grant_claim_is_refused(app, monkeypatch): 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 @@ -195,7 +198,7 @@ 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 @@ -205,8 +208,8 @@ async def test_missing_scope_is_refused(app, monkeypatch): 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 +217,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 +226,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 +249,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 +262,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 @@ -380,7 +383,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 +398,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 +426,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, ) @@ -450,7 +453,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 +469,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, ) @@ -543,7 +546,7 @@ 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 @@ -606,7 +609,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 +640,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 +664,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, ) diff --git a/tests/test_learner_records_token.py b/tests/test_learner_records_token.py new file mode 100644 index 0000000..a90ad53 --- /dev/null +++ b/tests/test_learner_records_token.py @@ -0,0 +1,254 @@ +"""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 json +import time + +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa +from httpx import ASGITransport, AsyncClient + +from ol_analytics_api.main import create_app +from ol_analytics_api.tenants.b2b_learner_records.config import settings +from ol_analytics_api.tenants.b2b_learner_records.token import ( + 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() == {"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() == {"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, "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(app, realm_keys): # noqa: ARG001 + token = jwt.encode( + {"iss": ISSUER, "aud": AUDIENCE, "iat": int(time.time()), "exp": int(time.time()) + 300}, + signing_key(), + algorithm="RS256", + ) + response = await _get(app, bearer(token)) + assert response.status_code == 401 + + +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, "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_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() == {"detail": INVALID_TOKEN_DETAIL} + + +async def test_a_failed_fetch_is_not_cached(app, httpx_mock): + 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_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): + httpx_mock.add_response(url=JWKS_URL, json=jwks(), 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 len(httpx_mock.get_requests(url=JWKS_URL)) == 1 + + +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" + ) diff --git a/uv.lock b/uv.lock index 861692a..44190f4 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" }, @@ -950,6 +1086,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" }, @@ -1166,6 +1303,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 +1425,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" From 8e45b8865f9bef5f5445ac404bb11a84b22d635b Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Tue, 22 Sep 2026 11:13:26 -0400 Subject: [PATCH 2/6] docs(openapi): say the service verifies the token, not only the gateway The security scheme said the credential is 'validated at the gateway' and the 401 example named a detail string the tenant no longer returns. Both describe the behaviour before the tenant started verifying the bearer JWT itself. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Qoz9pfQxUU8VLsRxr1tTnm --- docs/openapi/b2b-learner-records-v1.yaml | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/openapi/b2b-learner-records-v1.yaml b/docs/openapi/b2b-learner-records-v1.yaml index d82392e..470a082 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: >- From 3fca6e9a1d752c89e0365a29b1956f3d99d4d5d5 Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Tue, 22 Sep 2026 11:33:43 -0400 Subject: [PATCH 3/6] fix(b2b_learner_records): harden the JWKS cache and check the token class Review findings on the verification added in the previous commit. A JWKS body that parses as JSON but holds no usable key (an empty set, an error document served as 200, a bare list) raised out of the dependency as a 500 rather than the 401 it exists to return, and since the fetch never populated the cache it recurred on every request. _fetch now turns every way the endpoint can go wrong into one JWKSUnavailableError. A fetch failure was neither held off nor backed by the key set already in hand, so once the TTL lapsed with Keycloak unreachable every request took the lock and paid the timeout in turn while a still-valid key set sat unused. Failures now hold off further attempts briefly, and a stale key set is served through an outage: realm keys turn over on the order of months, so refusing every request because a refresh failed is an outage this service inflicts on itself. An ID token minted for ol-analytics-api-client carries the realm's signature, this issuer and this audience, so it cleared verification and was stopped only by carrying no organization claim. The token class is now required and checked. Also: a forced load no longer refetches what another request just fetched; the unknown-kid cooldown starts only once a freshly fetched key set really lacked the kid, so a failed refetch doesn't lock every other kid out for a minute; and the tenant refuses to start when the issuer is left at the production realm in another deployed environment, since every failure mode of that setting is a silent 401. The concurrency test pinned the cache rather than the lock -- a mocked transport never suspends, so the gathered requests serialised themselves and it passed with the lock removed. It now fetches through a callback that awaits. Added cases for the token class, for HS256 signed with the public key out of the JWKS, for the multi-audience shape Keycloak actually emits, and for the unusable-key-set bodies above. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Qoz9pfQxUU8VLsRxr1tTnm --- README.md | 14 ++ pyproject.toml | 1 + .../tenants/b2b_learner_records/config.py | 35 ++- .../tenants/b2b_learner_records/token.py | 143 +++++++++---- tests/conftest.py | 5 +- tests/test_learner_records_token.py | 200 +++++++++++++++++- uv.lock | 2 + 7 files changed, 354 insertions(+), 46 deletions(-) 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/pyproject.toml b/pyproject.toml index e123ca6..3bb27db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,6 +87,7 @@ dev = [ "cyclopts>=4.22.5", "pyyaml>=6.0.3", "types-pyyaml>=6.0.12.20260724", + "cryptography>=44", ] [build-system] 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 dbafff8..1506373 100644 --- a/src/ol_analytics_api/tenants/b2b_learner_records/config.py +++ b/src/ol_analytics_api/tenants/b2b_learner_records/config.py @@ -5,8 +5,16 @@ 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_") @@ -24,7 +32,12 @@ def _validate_starrocks_schema(cls, value: str) -> str: # 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. - issuer: str = "https://sso.ol.mit.edu/realms/olapps" + # 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 @@ -59,6 +72,26 @@ 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("/") diff --git a/src/ol_analytics_api/tenants/b2b_learner_records/token.py b/src/ol_analytics_api/tenants/b2b_learner_records/token.py index ae8f0d1..92ea09e 100644 --- a/src/ol_analytics_api/tenants/b2b_learner_records/token.py +++ b/src/ol_analytics_api/tenants/b2b_learner_records/token.py @@ -13,8 +13,14 @@ 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 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. +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 @@ -27,13 +33,19 @@ import jwt import structlog from fastapi import HTTPException, Request, status -from jwt import PyJWKSet +from jwt import PyJWK, PyJWKSet from ol_analytics_api.tenants.b2b_learner_records.config import settings log = structlog.get_logger(__name__) -ALGORITHMS = ["RS256"] +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 @@ -43,7 +55,17 @@ # 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. -_REFETCH_COOLDOWN_SECONDS = 60.0 +_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() -> HTTPException: @@ -60,7 +82,8 @@ class JWKSCache: def __init__(self) -> None: self._keys: PyJWKSet | None = None self._fetched_at = 0.0 - self._last_refetch_attempt = 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() @@ -68,7 +91,8 @@ def __init__(self) -> None: def clear(self) -> None: self._keys = None self._fetched_at = 0.0 - self._last_refetch_attempt = 0.0 + self._retry_after = 0.0 + self._kid_refetch_after = 0.0 def _is_fresh(self) -> bool: return ( @@ -77,10 +101,22 @@ def _is_fresh(self) -> bool: ) async def _fetch(self) -> PyJWKSet: - 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()) + """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 @@ -88,14 +124,44 @@ async def _fetch(self) -> PyJWKSet: 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. - if not force and self._is_fresh(): + # 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] - return await self._fetch() + 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 - async def signing_key(self, kid: str) -> jwt.PyJWK: + @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 @@ -103,23 +169,25 @@ async def signing_key(self, kid: str) -> jwt.PyJWK: unknown id turns that into one extra request instead of a TTL's worth of refusals. """ - keys = await self._load() - try: - return keys[kid] - except KeyError: - pass + key = self._select(await self._load(), kid) + if key is not None: + return key - now = time.monotonic() - if now - self._last_refetch_attempt < _REFETCH_COOLDOWN_SECONDS: - raise _unauthorized() from None - self._last_refetch_attempt = now + # 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) - keys = await self._load(force=True) - try: - return keys[kid] - except KeyError as exc: - raise _unauthorized() from exc + key = self._select(await self._load(force=True), kid) + if key is None: + # Start the cooldown only once a freshly fetched key set really + # didn't have the kid. A refetch that failed is already held off + # by the fetch-failure cooldown, and shouldn't also lock out the + # kid that a later, working fetch would have found. + self._kid_refetch_after = time.monotonic() + _KID_REFETCH_COOLDOWN_SECONDS + return key jwks_cache = JWKSCache() @@ -141,7 +209,7 @@ def bearer_token(request: Request) -> str: if scheme.lower() != "bearer" or not token.strip(): raise _unauthorized() return token.strip() - token = request.headers.get("X-Access-Token", "") + token = request.headers.get("X-Access-Token", "").strip() if not token: raise _unauthorized() return token @@ -162,25 +230,28 @@ async def verified_claims(request: Request) -> dict[str, Any]: try: key = await jwks_cache.signing_key(kid) - except HTTPException: - raise - except (httpx.HTTPError, ValueError, KeyError) as exc: + 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), jwks_url=settings.jwks_url) + 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=ALGORITHMS, + algorithms=list(ALGORITHMS), audience=settings.audience, issuer=settings.issuer, leeway=settings.token_leeway_seconds, - options={"require": ["exp", "iat", "iss", "aud"]}, + 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 index 8021a03..32a9cf2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,7 +12,7 @@ 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 jwks_cache +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" @@ -62,6 +62,9 @@ def mint( 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 {}), diff --git a/tests/test_learner_records_token.py b/tests/test_learner_records_token.py index a90ad53..74d282f 100644 --- a/tests/test_learner_records_token.py +++ b/tests/test_learner_records_token.py @@ -7,17 +7,29 @@ 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.config import settings +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, ) @@ -115,7 +127,13 @@ async def test_a_token_signed_by_another_key_is_refused(app, realm_keys): # noq 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, "iat": int(time.time()), "exp": int(time.time()) + 300}, + { + "iss": ISSUER, + "aud": AUDIENCE, + "typ": ACCESS_TOKEN_TYPE, + "iat": int(time.time()), + "exp": int(time.time()) + 300, + }, key=None, algorithm="none", headers={"kid": KID}, @@ -124,14 +142,24 @@ async def test_an_unsigned_token_is_refused(app, realm_keys): # noqa: ARG001 assert response.status_code == 401 -async def test_a_token_with_no_kid_is_refused(app, realm_keys): # noqa: ARG001 +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, "iat": int(time.time()), "exp": int(time.time()) + 300}, + { + "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 @@ -164,7 +192,13 @@ 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, "iat": int(time.time()), **PARTNER_CLAIMS}, + { + "iss": ISSUER, + "aud": AUDIENCE, + "typ": ACCESS_TOKEN_TYPE, + "iat": int(time.time()), + **PARTNER_CLAIMS, + }, signing_key(), algorithm="RS256", headers={"kid": KID}, @@ -220,7 +254,18 @@ async def test_an_unreachable_key_set_refuses_rather_than_opening_up(app, httpx_ assert response.json() == {"detail": INVALID_TOKEN_DETAIL} -async def test_a_failed_fetch_is_not_cached(app, httpx_mock): +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 @@ -228,6 +273,22 @@ async def test_a_failed_fetch_is_not_cached(app, httpx_mock): 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) @@ -237,11 +298,118 @@ async def test_the_key_set_is_refetched_after_the_ttl(app, httpx_mock, monkeypat async def test_concurrent_cold_requests_fetch_the_key_set_once(app, httpx_mock): - httpx_mock.add_response(url=JWKS_URL, json=jwks(), is_reusable=True) + """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 len(httpx_mock.get_requests(url=JWKS_URL)) == 1 + 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() == {"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() == {"detail": INVALID_TOKEN_DETAIL}, case def test_settings_derive_the_sso_urls_from_the_issuer(): @@ -252,3 +420,19 @@ def test_settings_derive_the_sso_urls_from_the_issuer(): 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 44190f4..66b2e35 100644 --- a/uv.lock +++ b/uv.lock @@ -1059,6 +1059,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "asgi-lifespan" }, + { name = "cryptography" }, { name = "cyclopts" }, { name = "mypy" }, { name = "pytest" }, @@ -1095,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" }, From 6acce6b9fdd9f4dd2f2cdf76f2c15f79f81bebda Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Tue, 22 Sep 2026 12:44:06 -0400 Subject: [PATCH 4/6] fix(b2b_learner_records): don't start the kid cooldown on a failed refetch A forced refetch that fails hands back the stale key set rather than raising, so the unknown kid still isn't found and the cooldown started on evidence nobody gathered. The comment there already claimed this didn't happen. The case it breaks is a Keycloak blip during a key rotation: the refetch fails, the cooldown starts, and tokens carrying the new kid keep getting 401s for a minute after Keycloak comes back, with no refetch attempted. Now the cooldown starts only when _fetched_at advanced, which is what says a freshly fetched key set really lacked the kid. A failed fetch is already held off by _retry_after. The regression test fails against the previous code. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Qoz9pfQxUU8VLsRxr1tTnm --- .../tenants/b2b_learner_records/token.py | 12 +++++++---- tests/test_learner_records_token.py | 20 +++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/ol_analytics_api/tenants/b2b_learner_records/token.py b/src/ol_analytics_api/tenants/b2b_learner_records/token.py index 92ea09e..54bb173 100644 --- a/src/ol_analytics_api/tenants/b2b_learner_records/token.py +++ b/src/ol_analytics_api/tenants/b2b_learner_records/token.py @@ -180,12 +180,16 @@ async def signing_key(self, kid: str) -> PyJWK | None: 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: + 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. A refetch that failed is already held off - # by the fetch-failure cooldown, and shouldn't also lock out the - # kid that a later, working fetch would have found. + # 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 diff --git a/tests/test_learner_records_token.py b/tests/test_learner_records_token.py index 74d282f..659024d 100644 --- a/tests/test_learner_records_token.py +++ b/tests/test_learner_records_token.py @@ -235,6 +235,26 @@ async def test_an_unknown_key_id_refetches_the_key_set(app, httpx_mock): 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.""" From 852009332356f95ec531b20b3572e08a6992c68e Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Tue, 22 Sep 2026 12:55:02 -0400 Subject: [PATCH 5/6] fix(b2b_learner_records): return the error code the contract has always required The Error schema in docs/openapi/b2b-learner-records-v1.yaml declares every error response as {code, detail}, with code required and drawn from a fixed enum, and tells clients to branch on it because the wording of detail may change. Nothing produced it. FastAPI's HTTPException renders {"detail": ...}, so a client generated from the spec would reject every error this tenant returned, on all five documented statuses. errors.py adds ApiError, carrying the code, plus the handler that renders it. The code can't be derived from the status, which is why it rides on the exception: 403 is missing_scope when the token's scopes fall short and no_organization_access when the organization isn't granted, and those stay distinct to a client while remaining indistinguishable to an attacker, since both keep the same detail. 400 (invalid_parameter), 401 (unauthorized), both 403s and 503 (unavailable) now carry their code. Errors the contract doesn't document, a 404 for an unrouted path or a 405, keep FastAPI's own body rather than being dressed in a code that claims a contract covering them. 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. 429 is enforced at APISIX, not here, so nothing in this service produces that body. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Qoz9pfQxUU8VLsRxr1tTnm --- docs/openapi/b2b-learner-records-v1.yaml | 2 +- .../tenants/b2b_learner_records/app.py | 14 ++- .../tenants/b2b_learner_records/auth.py | 12 ++- .../tenants/b2b_learner_records/errors.py | 88 +++++++++++++++++++ .../tenants/b2b_learner_records/token.py | 8 +- tests/test_learner_records.py | 67 +++++++++++--- tests/test_learner_records_token.py | 10 +-- 7 files changed, 175 insertions(+), 26 deletions(-) create mode 100644 src/ol_analytics_api/tenants/b2b_learner_records/errors.py diff --git a/docs/openapi/b2b-learner-records-v1.yaml b/docs/openapi/b2b-learner-records-v1.yaml index 470a082..b70545e 100644 --- a/docs/openapi/b2b-learner-records-v1.yaml +++ b/docs/openapi/b2b-learner-records-v1.yaml @@ -953,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/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 9ce9af1..1656215 100644 --- a/src/ol_analytics_api/tenants/b2b_learner_records/auth.py +++ b/src/ol_analytics_api/tenants/b2b_learner_records/auth.py @@ -20,11 +20,12 @@ 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.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" @@ -74,12 +75,17 @@ def require_organization_grant( ) -> None: 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(claims): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=NO_GRANT_DETAIL) + 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( 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 index 54bb173..b236a80 100644 --- a/src/ol_analytics_api/tenants/b2b_learner_records/token.py +++ b/src/ol_analytics_api/tenants/b2b_learner_records/token.py @@ -32,10 +32,11 @@ import httpx import jwt import structlog -from fastapi import HTTPException, Request, status +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__) @@ -68,9 +69,10 @@ class JWKSUnavailableError(Exception): """The realm's key set could not be fetched or parsed.""" -def _unauthorized() -> HTTPException: - return HTTPException( +def _unauthorized() -> ApiError: + return ApiError( status_code=status.HTTP_401_UNAUTHORIZED, + code=ErrorCode.UNAUTHORIZED, detail=INVALID_TOKEN_DETAIL, headers={"WWW-Authenticate": "Bearer"}, ) diff --git a/tests/test_learner_records.py b/tests/test_learner_records.py index 882dc07..57684f1 100644 --- a/tests/test_learner_records.py +++ b/tests/test_learner_records.py @@ -13,12 +13,14 @@ import pytest 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 @@ -175,7 +177,7 @@ async def test_user_token_without_the_grant_claim_is_refused(app, 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 == [] @@ -191,7 +193,11 @@ async def test_ungranted_and_nonexistent_organizations_are_indistinguishable(app 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): @@ -202,6 +208,7 @@ async def test_missing_scope_is_refused(app, monkeypatch): 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]]) @@ -324,7 +331,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 @@ -335,7 +342,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 @@ -347,7 +354,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, ) @@ -369,7 +376,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, ) @@ -442,7 +449,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 @@ -500,7 +507,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, ) @@ -522,7 +529,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, ) @@ -550,7 +557,9 @@ async def test_malformed_parameters_are_400_with_a_string_detail(app, monkeypatc 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(): @@ -681,7 +690,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, ) @@ -700,3 +709,39 @@ async def test_courses_filters_are_bound_in_order(app, monkeypatch): 0, ) assert pool.count_call()[1] == params[:-2] + + +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 index 659024d..47a2c6c 100644 --- a/tests/test_learner_records_token.py +++ b/tests/test_learner_records_token.py @@ -90,7 +90,7 @@ async def test_a_forged_x_userinfo_alone_is_refused(app, realm_keys): # noqa: A skips APISIX and asserts its own claims.""" response = await _get(app, _userinfo(PARTNER_CLAIMS)) assert response.status_code == 401 - assert response.json() == {"detail": INVALID_TOKEN_DETAIL} + 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 @@ -114,7 +114,7 @@ async def test_a_forged_x_userinfo_cannot_widen_a_valid_token(app, realm_keys): 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() == {"detail": INVALID_TOKEN_DETAIL}, 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 @@ -271,7 +271,7 @@ async def test_an_unreachable_key_set_refuses_rather_than_opening_up(app, httpx_ 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() == {"detail": INVALID_TOKEN_DETAIL} + assert response.json() == {"code": "unauthorized", "detail": INVALID_TOKEN_DETAIL} async def test_a_cold_fetch_failure_is_held_off_before_retrying(app, httpx_mock): @@ -346,7 +346,7 @@ async def test_an_id_token_for_the_same_client_is_refused(app, realm_keys): # n apart from an access token.""" response = await _get(app, bearer(mint({**PARTNER_CLAIMS, "typ": "ID"}))) assert response.status_code == 401 - assert response.json() == {"detail": INVALID_TOKEN_DETAIL} + 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 @@ -429,7 +429,7 @@ async def test_an_unusable_key_set_refuses_rather_than_erroring(app, httpx_mock, 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() == {"detail": INVALID_TOKEN_DETAIL}, case + assert response.json() == {"code": "unauthorized", "detail": INVALID_TOKEN_DETAIL}, case def test_settings_derive_the_sso_urls_from_the_issuer(): From 544fdd512f09d5136c6e3c3551cbae8e355f06da Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Tue, 22 Sep 2026 14:24:58 -0400 Subject: [PATCH 6/6] test(b2b_learner_records): reconcile with the per-tenant spec work on main Two things #70 changed under this branch. _partner_header became _partner_token here while #70 added a test that calls it. Both sides touched different parts of the file, so the rebase merged them cleanly and the result referenced a helper that no longer exists. against the ErrorCode enum rather than the contract the enum exists to mirror. It now reads the enum out of docs/openapi/b2b-learner-records-v1.yaml, so a code added on one side and not the other fails. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Qoz9pfQxUU8VLsRxr1tTnm --- tests/test_learner_records.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_learner_records.py b/tests/test_learner_records.py index 57684f1..ce87c37 100644 --- a/tests/test_learner_records.py +++ b/tests/test_learner_records.py @@ -11,6 +11,7 @@ import pathlib import pytest +import yaml from httpx import ASGITransport, AsyncClient from ol_analytics_api.core.db.client import PoolAcquireTimeoutError @@ -30,6 +31,9 @@ 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") @@ -711,6 +715,15 @@ async def test_courses_filters_are_bound_in_order(app, monkeypatch): 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