From da1977ea73007ebf6377596d5a757b8e53311bc0 Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Tue, 22 Sep 2026 10:59:02 -0400 Subject: [PATCH 1/9] 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 4537986..bc55764 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 0b6a67b..485c2f3 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 @@ -299,7 +302,7 @@ def test_models_keep_outcomes_when_shared(): 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 @@ -314,7 +317,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, ) @@ -342,7 +345,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, ) @@ -358,7 +361,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, ) @@ -374,7 +377,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, ) @@ -435,7 +438,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 @@ -479,7 +482,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" @@ -510,7 +513,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 @@ -534,7 +537,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 00c23b9..99b67ad 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/b1/5a/234e8fadf85c3cc48cb31c247b9e8e0c7f06ece80f5b29f9b8c241f9da4c/coverage-7.16.0-py3-none-any.whl", hash = "sha256:245f7de6d023a5bba375dbec9f2e0869bfa26ac0cc639bbb7b4c814884000b73", size = 214977, upload-time = "2026-08-28T21:54:35.189Z" }, ] +[[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.22.5" @@ -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/39/ca/c47f91d3cab175b01fd8c4f0d80fdf8613be876cc616e66ad281a59c5ddf/protobuf-7.36.1-py3-none-any.whl", hash = "sha256:7d951e46b3f963d6c264c367c437921de9d5aedd9c3f9612b9077736b4e3ad5c", size = 179813, upload-time = "2026-08-31T22:40:03.54Z" }, ] +[[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.0" From c0ab78e67fb4a64e5a4941436f5ef4c53ff2024e Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Tue, 22 Sep 2026 11:13:26 -0400 Subject: [PATCH 2/9] 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 3f49106..38b18a5 100644 --- a/docs/openapi/b2b-learner-records-v1.yaml +++ b/docs/openapi/b2b-learner-records-v1.yaml @@ -361,11 +361,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 @@ -912,11 +913,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 419f14c78f3090a6dec7b23132fcf8d9f51817d0 Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Tue, 22 Sep 2026 11:33:43 -0400 Subject: [PATCH 3/9] 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 bc55764..8b85e89 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 99b67ad..45b24ab 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 f4a0eec3b7a91d6f90509df0b5d4c4332b799e40 Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Tue, 22 Sep 2026 12:44:06 -0400 Subject: [PATCH 4/9] 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 e379a2645f9dfef6ce4fefc4503d75782d78f248 Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Tue, 22 Sep 2026 12:55:02 -0400 Subject: [PATCH 5/9] 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 | 51 ++++++++++- tests/test_learner_records_token.py | 10 +-- 7 files changed, 167 insertions(+), 18 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 38b18a5..e4e103e 100644 --- a/docs/openapi/b2b-learner-records-v1.yaml +++ b/docs/openapi/b2b-learner-records-v1.yaml @@ -932,7 +932,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 485c2f3..48e520b 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]]) @@ -442,7 +449,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(): @@ -545,3 +554,39 @@ async def test_courses_contract_filter_is_bound(app, monkeypatch): assert "sso_organization_id = %s AND contract_id = %s" in query assert params == (ORG_ID, 42, 100, 0) assert pool.count_call()[1] == (ORG_ID, 42) + + +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 b267cd04f7e434eed6809239277ccd8f1eaccb39 Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Tue, 22 Sep 2026 14:24:58 -0400 Subject: [PATCH 6/9] 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. #70 also added pyyaml, which was the only reason the error-code test asserted 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 | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/test_learner_records.py b/tests/test_learner_records.py index 48e520b..e6f2643 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") @@ -421,7 +425,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, ) @@ -556,6 +560,15 @@ async def test_courses_contract_filter_is_bound(app, monkeypatch): assert pool.count_call()[1] == (ORG_ID, 42) +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 From a98efa48db066f5f8bf4e9b0a444d1a7bdfcd1a0 Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Fri, 25 Sep 2026 15:49:11 -0400 Subject: [PATCH 7/9] feat(b2b_learner_records): refuse tokens past the contract end date Learner-records clients carry learner_records_contract_end_date from their contract, but nothing checked it, so a partner whose contract ended kept reading identifiable records until someone deleted the Keycloak client. The tenant now refuses a verified token once its contract end date has passed, reading the date as inclusive and Anywhere on Earth so no partner is cut off early by its own clock. An absent claim stays open-ended, since existing clients were provisioned without one. A malformed claim refuses, because the alternative fails open. The access log now carries the end date so MIT can alert before a lapse. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GoWu48siDM16978y9JKmhr --- ...-learner-records-provider-authorization.md | 31 ++++++-- docs/openapi/b2b-learner-records-v1.yaml | 5 +- .../tenants/b2b_learner_records/auth.py | 8 +- .../tenants/b2b_learner_records/token.py | 66 +++++++++++++++++ tests/test_learner_records_token.py | 73 +++++++++++++++++++ 5 files changed, 174 insertions(+), 9 deletions(-) diff --git a/docs/b2b-learner-records-provider-authorization.md b/docs/b2b-learner-records-provider-authorization.md index c530aa6..9b8dafc 100644 --- a/docs/b2b-learner-records-provider-authorization.md +++ b/docs/b2b-learner-records-provider-authorization.md @@ -41,13 +41,27 @@ client listing the same organization keeps reading the same rows, so ending one provider's access means removing that provider's clients, not the organization's. -**Open, for pdpinch: does access expire with the contract?** Today it doesn't. -The per-request steps below check no end date, so a client whose cleanup PR is -forgotten keeps issuing tokens. Two ways to enforce it: - -- Carry the contract end date as a claim, and refuse tokens past it. -- Check the warehouse instead. `dim_contract` already has `contract_is_active` - and `contract_end_date`, and the API serves both on `/courses`. +**Access ends with the contract, as a backstop.** A client provisioned with +`contract_end_date` carries it as the `learner_records_contract_end_date` +claim, and the API refuses the client's tokens once that date has passed. The +date is inclusive and read as Anywhere on Earth (UTC-12): access runs through +the end of the end date wherever the partner is, and stops at 12:00 UTC the +following day. A client provisioned without an end date is open-ended. A claim +that is present but isn't a `YYYY-MM-DD` string refuses every token, since +reading it as "no end date" would fail open. + +This does not replace removing the client when the contract ends. It is what +stops a client whose cleanup PR was forgotten, and when it fires the API logs +`learner_records_contract_ended` at warning level, which means that cleanup is +overdue. Every authorized request also logs the client's `contract_end_date` +beside `learner_records_access`, so an alert can raise the renewal +conversation before a partner's sync breaks. + +Checking the warehouse instead (`dim_contract` has `contract_is_active` and +`contract_end_date`) was the other option. It would put a StarRocks read ahead +of every authorization decision, and a client covers every contract under +each of its organizations, so no single `dim_contract` row says when the +client's access ends. ## What the API does @@ -61,6 +75,9 @@ In `tenants/b2b_learner_records/auth.py`, per request: 3. Require the `learner-records:read` scope. Identity fields are always populated. +Before any of that, `token.py` verifies the token and refuses one whose +contract end date has passed (above). + No call to mitxonline or any other service, and no access store. The client definition is the only place access is recorded. diff --git a/docs/openapi/b2b-learner-records-v1.yaml b/docs/openapi/b2b-learner-records-v1.yaml index e4e103e..bed4838 100644 --- a/docs/openapi/b2b-learner-records-v1.yaml +++ b/docs/openapi/b2b-learner-records-v1.yaml @@ -916,7 +916,10 @@ components: 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. + The body does not say which, so it cannot be used to probe. The one + exception is a genuine token whose contract has ended (through the end + of the contract end date, Anywhere on Earth), which says so and names + the date. content: application/json: schema: { $ref: '#/components/schemas/Error' } 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 1656215..7565628 100644 --- a/src/ol_analytics_api/tenants/b2b_learner_records/auth.py +++ b/src/ol_analytics_api/tenants/b2b_learner_records/auth.py @@ -26,7 +26,10 @@ 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 +from ol_analytics_api.tenants.b2b_learner_records.token import ( + CONTRACT_END_DATE_CLAIM, + verified_claims, +) ORGANIZATIONS_CLAIM = "learner_records_organizations" READ_SCOPE = "learner-records:read" @@ -88,8 +91,11 @@ def require_organization_grant( ) # Every granted read discloses identifiable learner records, so record which # client read which organization. The access log has the path but not the client. + # The end date rides along so an alert can warn MIT ahead of a lapse; token.py + # refuses the credential once it passes. log.info( "learner_records_access", client_id=claims.get("azp") or claims.get("client_id"), organization_id=str(organization_id), + contract_end_date=claims.get(CONTRACT_END_DATE_CLAIM), ) 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 b236a80..4b0bae7 100644 --- a/src/ol_analytics_api/tenants/b2b_learner_records/token.py +++ b/src/ol_analytics_api/tenants/b2b_learner_records/token.py @@ -27,6 +27,7 @@ import asyncio import time +from datetime import UTC, date, datetime, timedelta, timezone from typing import Any import httpx @@ -48,11 +49,26 @@ # organization grant. That is one claim deep; this closes the class. ACCESS_TOKEN_TYPE = "Bearer" # noqa: S105 - a claim value, not a credential +# Hardcoded on the client from its contract when MIT provisions it +# (ol-infrastructure substructure/keycloak/learner_records.py), as an ISO date +# string. Absent on a client provisioned without an end date, which is then +# open-ended. +CONTRACT_END_DATE_CLAIM = "learner_records_contract_end_date" + +# The claim is a bare date and the contract behind it names no timezone. +# Holding access through the end of that date Anywhere on Earth (UTC-12) means +# no partner is cut off before its end date by its own clock. The cost is up +# to a day of access past the date for everyone east of UTC-12, which is +# small next to what this guards against: a client nobody deleted. +CONTRACT_END_TIMEZONE = timezone(timedelta(hours=-12), "AoE") + # 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 +CONTRACT_ENDED_DETAIL = "The contract this credential was issued under ended" + # 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. @@ -65,6 +81,29 @@ _FETCH_RETRY_COOLDOWN_SECONDS = 5.0 +def contract_access_ends(claims: dict[str, Any]) -> datetime | None: + """The instant the token's contract stops granting access, or None. + + Raises ValueError on a claim that is present but not a YYYY-MM-DD string. + Reading a malformed end date as "no end date" would fail open. + """ + if CONTRACT_END_DATE_CLAIM not in claims: + return None + value = claims[CONTRACT_END_DATE_CLAIM] + if not isinstance(value, str): + msg = f"{CONTRACT_END_DATE_CLAIM} is not a string: {value!r}" + raise ValueError(msg) # noqa: TRY004 - a malformed claim, not a caller's type error + end_date = date.fromisoformat(value) + # fromisoformat also takes 20270630 and 2027-W26-3. The mapper writes + # date.isoformat(), so anything else was not written by it. + if end_date.isoformat() != value: + msg = f"{CONTRACT_END_DATE_CLAIM} is not YYYY-MM-DD: {value!r}" + raise ValueError(msg) + return datetime.combine( + end_date + timedelta(days=1), datetime.min.time(), CONTRACT_END_TIMEZONE + ) + + class JWKSUnavailableError(Exception): """The realm's key set could not be fetched or parsed.""" @@ -260,4 +299,31 @@ async def verified_claims(request: Request) -> dict[str, Any]: if claims.get("typ") != ACCESS_TOKEN_TYPE: log.info("Refused a token that is not an access token", typ=claims.get("typ")) raise _unauthorized() + + client_id = claims.get("azp") or claims.get("client_id") + try: + access_ends = contract_access_ends(claims) + except ValueError as exc: + log.warning( + "Refused a token with a malformed contract end date", + client_id=client_id, + error=str(exc), + ) + raise _unauthorized() from exc + if access_ends is not None and datetime.now(UTC) >= access_ends: + end_date = claims[CONTRACT_END_DATE_CLAIM] + # Warning, not info: the backstop firing means the client outlived its + # contract and nobody removed it. + log.warning( + "learner_records_contract_ended", client_id=client_id, contract_end_date=end_date + ) + # A detail of its own, unlike the refusals above. Only a token with a + # valid signature gets this far, so it tells a forger nothing, and it + # tells a partner whose sync just broke why. + raise ApiError( + status_code=status.HTTP_401_UNAUTHORIZED, + code=ErrorCode.UNAUTHORIZED, + detail=f"{CONTRACT_ENDED_DETAIL} on {end_date}", + headers={"WWW-Authenticate": "Bearer"}, + ) return claims diff --git a/tests/test_learner_records_token.py b/tests/test_learner_records_token.py index 47a2c6c..6549329 100644 --- a/tests/test_learner_records_token.py +++ b/tests/test_learner_records_token.py @@ -11,6 +11,7 @@ import hmac import json import time +from datetime import UTC, date, datetime, timedelta import httpx import jwt @@ -30,7 +31,10 @@ ) from ol_analytics_api.tenants.b2b_learner_records.token import ( ACCESS_TOKEN_TYPE, + CONTRACT_END_DATE_CLAIM, + CONTRACT_ENDED_DETAIL, INVALID_TOKEN_DETAIL, + contract_access_ends, jwks_cache, ) from tests.conftest import ( @@ -456,3 +460,72 @@ def test_a_deployed_environment_must_name_its_own_realm(monkeypatch, environment 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 + + +def _with_contract_end(value: object) -> dict: + return {**PARTNER_CLAIMS, CONTRACT_END_DATE_CLAIM: value} + + +async def test_a_token_past_its_contract_end_is_refused(app, realm_keys): # noqa: ARG001 + ended = (datetime.now(UTC).date() - timedelta(days=2)).isoformat() + response = await _get(app, bearer(mint(_with_contract_end(ended)))) + assert response.status_code == 401 + assert response.json() == { + "code": "unauthorized", + "detail": f"{CONTRACT_ENDED_DETAIL} on {ended}", + } + + +async def test_a_token_before_its_contract_end_is_accepted(app, realm_keys): # noqa: ARG001 + ends = (datetime.now(UTC).date() + timedelta(days=2)).isoformat() + response = await _get(app, bearer(mint(_with_contract_end(ends)))) + assert response.status_code == 200 + + +async def test_a_token_with_no_contract_end_is_accepted(app, realm_keys): # noqa: ARG001 + """Clients provisioned without an end date are open-ended by design. + Reading absence as expired would revoke all of them on deploy.""" + response = await _get(app, bearer(mint(PARTNER_CLAIMS))) + assert response.status_code == 200 + + +@pytest.mark.parametrize( + "value", + [ + "2027-6-30", + "20270630", + "2027-W26-3", + "2027-06-30T00:00:00", + "", + "never", + None, + 20270630, + ["2027-06-30"], + ], +) +async def test_a_malformed_contract_end_is_refused(app, realm_keys, value): # noqa: ARG001 + """Anything other than the mapper's YYYY-MM-DD refuses, because reading it + as "no end date" is the direction that fails open.""" + response = await _get(app, bearer(mint(_with_contract_end(value)))) + assert response.status_code == 401 + assert response.json()["detail"] == INVALID_TOKEN_DETAIL + + +@pytest.mark.parametrize( + ("now", "ended"), + [ + (datetime(2027, 6, 30, 23, 59, 59, tzinfo=UTC), False), + (datetime(2027, 7, 1, 11, 59, 59, tzinfo=UTC), False), + (datetime(2027, 7, 1, 12, 0, 0, tzinfo=UTC), True), + ], +) +def test_access_runs_to_the_end_of_the_date_anywhere_on_earth(now, ended): + ends = contract_access_ends({CONTRACT_END_DATE_CLAIM: "2027-06-30"}) + assert ends is not None + assert (now >= ends) is ended + + +def test_the_end_date_matches_what_the_pulumi_mapper_writes(): + assert contract_access_ends( + {CONTRACT_END_DATE_CLAIM: date(2027, 6, 30).isoformat()} + ) == datetime(2027, 7, 1, 12, tzinfo=UTC) From de326ef33353f74ffc2902d4cddf6fe6612d54c0 Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Fri, 25 Sep 2026 15:52:12 -0400 Subject: [PATCH 8/9] fix(b2b_learner_records): don't overflow on a 9999-12-31 contract end Adding a day to date.max raised OverflowError, which the ValueError handler didn't catch, so a client provisioned with the obvious "never" sentinel got a 500 on every request. The check now compares against the end of the end date instead of the start of the next. Also drives the Anywhere-on-Earth boundary through the endpoint rather than the helper, covers the access log's contract_end_date field, and reuses _unauthorized for the contract-ended refusal. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GoWu48siDM16978y9JKmhr --- .../tenants/b2b_learner_records/token.py | 31 +++++++------- tests/test_learner_records_token.py | 42 +++++++++++++------ 2 files changed, 45 insertions(+), 28 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 4b0bae7..c44b804 100644 --- a/src/ol_analytics_api/tenants/b2b_learner_records/token.py +++ b/src/ol_analytics_api/tenants/b2b_learner_records/token.py @@ -28,6 +28,7 @@ import asyncio import time from datetime import UTC, date, datetime, timedelta, timezone +from datetime import time as clock_time from typing import Any import httpx @@ -81,8 +82,8 @@ _FETCH_RETRY_COOLDOWN_SECONDS = 5.0 -def contract_access_ends(claims: dict[str, Any]) -> datetime | None: - """The instant the token's contract stops granting access, or None. +def contract_access_through(claims: dict[str, Any]) -> datetime | None: + """The last instant the token's contract grants access, or None. Raises ValueError on a claim that is present but not a YYYY-MM-DD string. Reading a malformed end date as "no end date" would fail open. @@ -99,20 +100,24 @@ def contract_access_ends(claims: dict[str, Any]) -> datetime | None: if end_date.isoformat() != value: msg = f"{CONTRACT_END_DATE_CLAIM} is not YYYY-MM-DD: {value!r}" raise ValueError(msg) - return datetime.combine( - end_date + timedelta(days=1), datetime.min.time(), CONTRACT_END_TIMEZONE - ) + # The end of the day rather than the start of the next, which would + # overflow on 9999-12-31, the obvious sentinel for "no real end". + return datetime.combine(end_date, clock_time.max, CONTRACT_END_TIMEZONE) + + +def _now() -> datetime: + return datetime.now(UTC) class JWKSUnavailableError(Exception): """The realm's key set could not be fetched or parsed.""" -def _unauthorized() -> ApiError: +def _unauthorized(detail: str = INVALID_TOKEN_DETAIL) -> ApiError: return ApiError( status_code=status.HTTP_401_UNAUTHORIZED, code=ErrorCode.UNAUTHORIZED, - detail=INVALID_TOKEN_DETAIL, + detail=detail, headers={"WWW-Authenticate": "Bearer"}, ) @@ -302,7 +307,7 @@ async def verified_claims(request: Request) -> dict[str, Any]: client_id = claims.get("azp") or claims.get("client_id") try: - access_ends = contract_access_ends(claims) + access_through = contract_access_through(claims) except ValueError as exc: log.warning( "Refused a token with a malformed contract end date", @@ -310,7 +315,7 @@ async def verified_claims(request: Request) -> dict[str, Any]: error=str(exc), ) raise _unauthorized() from exc - if access_ends is not None and datetime.now(UTC) >= access_ends: + if access_through is not None and _now() > access_through: end_date = claims[CONTRACT_END_DATE_CLAIM] # Warning, not info: the backstop firing means the client outlived its # contract and nobody removed it. @@ -320,10 +325,6 @@ async def verified_claims(request: Request) -> dict[str, Any]: # A detail of its own, unlike the refusals above. Only a token with a # valid signature gets this far, so it tells a forger nothing, and it # tells a partner whose sync just broke why. - raise ApiError( - status_code=status.HTTP_401_UNAUTHORIZED, - code=ErrorCode.UNAUTHORIZED, - detail=f"{CONTRACT_ENDED_DETAIL} on {end_date}", - headers={"WWW-Authenticate": "Bearer"}, - ) + detail = f"{CONTRACT_ENDED_DETAIL} on {end_date}" + raise _unauthorized(detail) return claims diff --git a/tests/test_learner_records_token.py b/tests/test_learner_records_token.py index 6549329..372aa2a 100644 --- a/tests/test_learner_records_token.py +++ b/tests/test_learner_records_token.py @@ -16,6 +16,7 @@ import httpx import jwt import pytest +import structlog.testing from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa from httpx import ASGITransport, AsyncClient @@ -34,7 +35,7 @@ CONTRACT_END_DATE_CLAIM, CONTRACT_ENDED_DETAIL, INVALID_TOKEN_DETAIL, - contract_access_ends, + contract_access_through, jwks_cache, ) from tests.conftest import ( @@ -512,20 +513,35 @@ async def test_a_malformed_contract_end_is_refused(app, realm_keys, value): # n @pytest.mark.parametrize( - ("now", "ended"), + ("now", "accepted"), [ - (datetime(2027, 6, 30, 23, 59, 59, tzinfo=UTC), False), - (datetime(2027, 7, 1, 11, 59, 59, tzinfo=UTC), False), - (datetime(2027, 7, 1, 12, 0, 0, tzinfo=UTC), True), + (datetime(2027, 6, 30, 23, 59, 59, tzinfo=UTC), True), + (datetime(2027, 7, 1, 11, 59, 59, tzinfo=UTC), True), + (datetime(2027, 7, 1, 12, 0, 0, tzinfo=UTC), False), ], ) -def test_access_runs_to_the_end_of_the_date_anywhere_on_earth(now, ended): - ends = contract_access_ends({CONTRACT_END_DATE_CLAIM: "2027-06-30"}) - assert ends is not None - assert (now >= ends) is ended +async def test_access_runs_to_the_end_of_the_date_anywhere_on_earth( + app, + realm_keys, # noqa: ARG001 + monkeypatch, + now, + accepted, +): + monkeypatch.setattr(token_module, "_now", lambda: now) + response = await _get(app, bearer(mint(_with_contract_end("2027-06-30")))) + assert response.status_code == (200 if accepted else 401) + + +def test_the_last_day_representable_is_an_end_date_not_an_error(): + through = contract_access_through({CONTRACT_END_DATE_CLAIM: date.max.isoformat()}) + assert through is not None + assert datetime.now(UTC) < through -def test_the_end_date_matches_what_the_pulumi_mapper_writes(): - assert contract_access_ends( - {CONTRACT_END_DATE_CLAIM: date(2027, 6, 30).isoformat()} - ) == datetime(2027, 7, 1, 12, tzinfo=UTC) +async def test_the_access_log_carries_the_contract_end_date(app, realm_keys): # noqa: ARG001 + ends = (datetime.now(UTC).date() + timedelta(days=2)).isoformat() + with structlog.testing.capture_logs() as logs: + response = await _get(app, bearer(mint(_with_contract_end(ends)))) + assert response.status_code == 200 + access = [entry for entry in logs if entry["event"] == "learner_records_access"] + assert [entry["contract_end_date"] for entry in access] == [ends] From 351edcd361a37e47654c344c43d68d06e1079caa Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Fri, 25 Sep 2026 16:26:50 -0400 Subject: [PATCH 9/9] test(b2b_learner_records): assert the contract-ended alert event The expired-token test checked only the response, so demoting or dropping the learner_records_contract_ended warning would pass while silently disabling the alert that keys on it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GoWu48siDM16978y9JKmhr --- tests/test_learner_records_token.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/test_learner_records_token.py b/tests/test_learner_records_token.py index 372aa2a..6368ef1 100644 --- a/tests/test_learner_records_token.py +++ b/tests/test_learner_records_token.py @@ -469,12 +469,22 @@ def _with_contract_end(value: object) -> dict: async def test_a_token_past_its_contract_end_is_refused(app, realm_keys): # noqa: ARG001 ended = (datetime.now(UTC).date() - timedelta(days=2)).isoformat() - response = await _get(app, bearer(mint(_with_contract_end(ended)))) + with structlog.testing.capture_logs() as logs: + response = await _get(app, bearer(mint(_with_contract_end(ended)))) assert response.status_code == 401 assert response.json() == { "code": "unauthorized", "detail": f"{CONTRACT_ENDED_DETAIL} on {ended}", } + # The alert for a client that outlived its contract keys on this event. + assert [entry for entry in logs if entry["event"] == "learner_records_contract_ended"] == [ + { + "event": "learner_records_contract_ended", + "log_level": "warning", + "client_id": PARTNER_CLAIMS["azp"], + "contract_end_date": ended, + } + ] async def test_a_token_before_its_contract_end_is_accepted(app, realm_keys): # noqa: ARG001