diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17dd873..1b8b223 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,7 +111,10 @@ jobs: run: uv sync --all-extras - name: Run tests with coverage - run: uv run pytest tests/ -v --cov=introspection_sdk --cov-report=term-missing --cov-report=xml + run: | + echo "## introspection-sdk coverage" >> "$GITHUB_STEP_SUMMARY" + uv run pytest tests/ -v --cov=introspection_sdk --cov-report=term-missing --cov-report=xml \ + --cov-report="markdown-append:$GITHUB_STEP_SUMMARY" - name: Upload coverage report if: always() @@ -205,3 +208,53 @@ jobs: node-version: "24" - name: Duplicate code stays under the threshold run: npx --yes jscpd@5.4.1 + + aauth: + # packages/introspection-aauth is its own distribution, with its own + # lock, coverage floor and duplicate-code check (its pyproject.toml and + # .jscpd.json). + name: introspection-aauth + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/introspection-aauth + steps: + - uses: actions/checkout@v7 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + version: "latest" + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.11" + + - uses: actions/setup-node@v7 + with: + node-version: "24" + + - name: Install dependencies + run: uv sync --all-extras + + - name: Check formatting + run: uv run ruff format --check . + + - name: Run linting + run: uv run ruff check . + + - name: Run type checking + run: uv run ty check introspection_aauth tests + + - name: Run tests with coverage + run: | + echo "## introspection-aauth coverage" >> "$GITHUB_STEP_SUMMARY" + uv run pytest tests/ -v --cov=introspection_aauth --cov-report=term-missing \ + --cov-report="markdown-append:$GITHUB_STEP_SUMMARY" + + - name: Check dependencies + run: uv run deptry . + + - name: Duplicate code stays at zero + run: npx --yes jscpd@5.4.1 diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index 2522ca3..5d8c4bb 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -48,6 +48,12 @@ jobs: restore-keys: | ${{ runner.os }}-uv-examples- + - name: Run the AAuth example + # Every party runs in process; it needs no secrets. + if: inputs.run_examples || startsWith(github.ref, 'refs/tags/v') + working-directory: examples + run: uv run -m introspection_examples.aauth.four_party + - name: Run pattern examples # The docs' pattern pages show these modules' snippet regions. # patterns.add_to_your_app and patterns.web_chat also need a service diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 712789e..e2a6ac1 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,4 @@ { - ".": "0.29.1" + ".": "0.29.1", + "packages/introspection-aauth": "0.0.0" } diff --git a/AGENTS.md b/AGENTS.md index 6e94fe2..c760c65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,6 +122,36 @@ uv run ty check These are enforced by `.pre-commit-config.yaml`. Don't disable hooks with `--no-verify` — fix the underlying issue. +**No leading underscore on a class, constant or type variable.** Write +`class RouteDocs`, `JSON_OBJECT`, `AnyServer = TypeVar("AnyServer", ...)`, +never `class _Docs` or `_object`. A module keeps such a name private by +leaving it out of its `__all__`, so a module that has private names defines +`__all__`. Private methods and module functions keep their underscore. This +is the rule for new code: about 76 underscore classes and constants remain in +`introspection_sdk/`, `tests/` and `examples/`; rename one when you touch +its module. + +**One file, one concept, named for it.** A module holds one class, or one +family of closely related ones, and its name says which: `access_server_client.py` +holds `AccessServerClient`, `directory.py` the `Directory` protocol and its +default, `store.py` the `Store` protocol and its `MemoryStore`. A `models.py` +holds a package's models, and `hooks.py` the protocols a host implements. +Aim for 100 to 300 lines; a module far past that is a sign to split it, the +exceptions being a `models.py` and a class that is legitimately that long, in +which case its module holds that class alone. + +**Log and trace as the platform does.** A module that does meaningful work +declares `logger = logging.getLogger(__name__)` and, where it has an +operation worth a span, `tracer = trace.get_tracer(__name__)`, once, after its +imports. A record has a fixed message and its values as `extra` fields: +identifiers and enumerated values, never a token, personal data or a request +body. A library never configures logging or a tracer provider; its host does. + +**Imports are absolute.** `from introspection_aauth.protocol.errors import +InvalidToken`, never `from ..protocol.errors import InvalidToken`; ruff's +TID252 enforces it. A test suite imports its own helpers relatively +(`from .world import World`). + ## Sync code generation Every class or function with an `Async*` twin is written once, as async @@ -282,9 +312,9 @@ It needs the release tags locally (`git fetch --tags`). What counts as public: a module's `__all__` when it has one, otherwise every name without a leading underscore. Imported names are public only through `__all__`. `_`-prefixed modules (`_http.py`, `_errors.py`, -`otel/_provider.py`, ...) and `_`-prefixed classes, functions and attributes -are internal and free to change. To keep a new helper out of the contract, -give it a leading underscore or leave it out of `__all__`. +`otel/_provider.py`, ...) and `_`-prefixed functions and attributes are +internal and free to change. To keep a new class or constant out of the +contract, leave it out of `__all__`, never prefix it with an underscore. `-e scripts/griffe_reexports.py` loads a griffe extension that replaces each public alias whose target lives in `introspection_sdk` with the target diff --git a/README.md b/README.md index ae70e3a..2eaf123 100644 --- a/README.md +++ b/README.md @@ -511,6 +511,14 @@ caller who is not a project administrator only ever gets their own connections. The routes need `connections:read`, `connections:write` and `connections:delete`. +## Run an AAuth Person Server or Access Server + +[`introspection-aauth`](packages/introspection-aauth) is a separate package +in this repository: an AAuth Person Server and Access Server +(draft-hardt-oauth-aauth-protocol -11) over a store you provide, with +FastAPI routers. `examples/introspection_examples/aauth/four_party.py` runs +the four-party flow in one process. + ## Environment variables ```shell diff --git a/examples/introspection_examples/__init__.py b/examples/introspection_examples/__init__.py index d2b29d8..7670051 100644 --- a/examples/introspection_examples/__init__.py +++ b/examples/introspection_examples/__init__.py @@ -7,7 +7,7 @@ from dotenv import load_dotenv -from . import api, otel +from introspection_examples import api, otel load_dotenv() diff --git a/examples/introspection_examples/aauth/__init__.py b/examples/introspection_examples/aauth/__init__.py new file mode 100644 index 0000000..3b152ff --- /dev/null +++ b/examples/introspection_examples/aauth/__init__.py @@ -0,0 +1 @@ +"""AAuth Person Server and Access Server examples (introspection-aauth).""" diff --git a/examples/introspection_examples/aauth/four_party.py b/examples/introspection_examples/aauth/four_party.py new file mode 100644 index 0000000..0b89cd4 --- /dev/null +++ b/examples/introspection_examples/aauth/four_party.py @@ -0,0 +1,320 @@ +"""A Person Server and an Access Server on FastAPI, and AAuth's four-party +flow through them (draft-hardt-oauth-aauth-protocol -11). + +An agent proposes a trip, the traveller's manager approves it with a +budget, and the agent books with the airline. The airline's resource tokens +name its own Access Server, so the Person Server weighs the booking against +the trip's budget, then redeems it there; the airline's policy books only +for companies with an account. Every party runs in this process on +in-memory stores, joined by one in-process transport; no network, no keys +to configure, nothing to install beyond the examples project. + +Run the whole flow: + + uv run -m introspection_examples.aauth.four_party + +Or serve the two servers for your own agents, each on its own https origin +(a tunnel in front of each), with the agent provider you trust: + + PERSON_SERVER=https://ps.example AGENT_PROVIDER=https://agents.example \\ + uv run --with uvicorn uvicorn --factory introspection_examples.aauth.four_party:serve_person_server --port 8001 + ACCESS_SERVER=https://as.example RESOURCE=https://booking.example \\ + PERSON_SERVER=https://ps.example AGENT_PROVIDER=https://agents.example \\ + uv run --with uvicorn uvicorn --factory introspection_examples.aauth.four_party:serve_access_server --port 8002 +""" + +from __future__ import annotations + +import asyncio +import os +from collections.abc import AsyncIterator, Callable +from contextlib import AbstractAsyncContextManager, asynccontextmanager + +import httpx2 as httpx +from fastapi import FastAPI +from introspection_aauth.access_server import ( + AccessRequest, + AccessServer, + Verdict, +) +from introspection_aauth.fastapi import ( + access_server_router, + person_server_router, +) +from introspection_aauth.person_server import ( + ApprovalStatus, + MemoryStore, + PersonServer, +) +from introspection_aauth.person_server.models import ( + AuthTokenRequest, + MissionAnswer, + MissionProposal, +) +from introspection_aauth.protocol import Budget, SigningKey, jwks +from introspection_aauth.protocol.claims import ( + AGENT_DWK, + RESOURCE_DWK, + AgentTokenClaims, + AuthTokenClaims, + Confirmation, + PersonTokenClaims, + ResourceTokenClaims, +) +from introspection_aauth.protocol.httpsig import jwt_key, sign +from introspection_aauth.protocol.jwt import ( + AGENT_TOKEN, + RESOURCE_TOKEN, + decode, + encode, +) +from introspection_aauth.protocol.wire import ( + AuthTokenAnswer, + ServerMetadata, +) +from pydantic import BaseModel + +AP = os.environ.get("AGENT_PROVIDER", "https://agents.example") +PS = os.environ.get("PERSON_SERVER", "https://ps.example") +AS = os.environ.get("ACCESS_SERVER", "https://as.flightsector.example") +RESOURCE = os.environ.get("RESOURCE", "https://booking.flightsector.example") +TENANT = "acme" + + +def usd(amount: int) -> Budget: + return Budget(amount=amount, unit="USD", decimals=2) + + +# #region docs:aauth-servers +def flight_sector(request: AccessRequest) -> Verdict: + """The airline's policy: it books only for companies with an account.""" + if (request.tenant, request.ps) == (TENANT, PS): + return Verdict.allow() + return Verdict.deny("no Flight Sector account") + + +def person_server(http: httpx.AsyncClient) -> PersonServer: + return PersonServer( + PS, + SigningKey.generate(), + MemoryStore(), + subject_secret=os.urandom(32), + agent_providers=[AP], + http=http, + endpoint_base=f"{PS}/aauth", + default_tenant=TENANT, + ) + + +def access_server(http: httpx.AsyncClient) -> AccessServer: + return AccessServer( + AS, + SigningKey.generate(), + flight_sector, + resources=[RESOURCE], + person_servers=[PS], + agent_providers=[AP], + http=http, + ) + + +# #endregion docs:aauth-servers + + +def closing( + http: httpx.AsyncClient, +) -> Callable[[FastAPI], AbstractAsyncContextManager[None]]: + """A lifespan that closes the server's http client when the app stops.""" + + @asynccontextmanager + async def lifespan(_: FastAPI) -> AsyncIterator[None]: + yield + await http.aclose() + + return lifespan + + +# #region docs:aauth-apps +def person_server_app(ps: PersonServer) -> FastAPI: + app = FastAPI(lifespan=closing(ps.http)) + app.include_router(person_server_router(ps)) + return app + + +def access_server_app(server: AccessServer) -> FastAPI: + app = FastAPI(lifespan=closing(server.http)) + app.include_router(access_server_router(server)) + return app + + +# #endregion docs:aauth-apps + + +def serve_person_server() -> FastAPI: + """For `uvicorn --factory`: a Person Server with its own http client.""" + return person_server_app(person_server(httpx.AsyncClient())) + + +def serve_access_server() -> FastAPI: + """For `uvicorn --factory`: an Access Server with its own http client.""" + return access_server_app(access_server(httpx.AsyncClient())) + + +class InProcessTransport(httpx.AsyncBaseTransport): + """Routes each origin to its party's app in this process, and serves the + fixed documents of the parties that run no app.""" + + def __init__(self) -> None: + self.parties: dict[str, httpx.AsyncBaseTransport] = {} + self.documents: dict[str, BaseModel] = {} + + async def handle_async_request( + self, request: httpx.Request + ) -> httpx.Response: + document = self.documents.get(str(request.url)) + if document is not None: + return httpx.Response( + 200, + content=document.model_dump_json(exclude_none=True), + headers={"Content-Type": "application/json"}, + ) + origin = f"{request.url.scheme}://{request.url.host}" + print(f" → {request.method} {request.url}") + return await self.parties[origin].handle_async_request(request) + + +async def main() -> None: + transport = InProcessTransport() + async with httpx.AsyncClient(transport=transport) as http: + await run(transport, http) + + +async def run(transport: InProcessTransport, http: httpx.AsyncClient) -> None: + ps = person_server(http) + transport.parties[PS] = httpx.ASGITransport(app=person_server_app(ps)) + transport.parties[AS] = httpx.ASGITransport( + app=access_server_app(access_server(http)) + ) + + # The agent provider and the airline's booking API, as fixed documents. + provider, airline, agent = (SigningKey.generate() for _ in range(3)) + transport.documents |= { + f"{AP}/.well-known/aauth-agent.json": ServerMetadata( + issuer=AP, jwks_uri=f"{AP}/jwks.json" + ), + f"{AP}/jwks.json": jwks([provider]), + f"{RESOURCE}/.well-known/aauth-resource.json": ServerMetadata( + issuer=RESOURCE, jwks_uri=f"{RESOURCE}/jwks.json" + ), + f"{RESOURCE}/jwks.json": jwks([airline]), + } + now = ps.clock() + agent_token = encode( + AgentTokenClaims( + dwk=AGENT_DWK, + iss=AP, + sub="aauth:travel@agents.example", + jti="agent-1", + iat=now, + exp=now + 3_600, + cnf=Confirmation(jwk=agent.public_jwk), + ), + provider, + AGENT_TOKEN, + ) + + async def call( + method: str, url: str, body: BaseModel | None = None + ) -> httpx.Response: + content = ( + body.model_dump_json(exclude_none=True).encode() + if body is not None + else None + ) + headers = sign( + agent, + jwt_key(agent_token), + method, + url, + body=content, + created=ps.clock(), + ) + return await http.request( + method, url, content=content, headers=headers + ) + + print("1. The agent proposes the trip") + proposed = await call( + "POST", + f"{PS}/aauth/mission", + MissionProposal( + description="Sam to Sydney, up to $2,000", + resources=[RESOURCE], + login_hint="sam@acme.example", + ), + ) + print(f" {proposed.status_code}: it waits on Sam's manager") + approval_id = proposed.headers["location"].rsplit("/", 1)[1] + await ps.decide( + TENANT, + approval_id, + ApprovalStatus.APPROVED, + decided_by="dana@acme.example", + budget=usd(200_000), + ) + collected = await call("GET", proposed.headers["location"]) + mission = MissionAnswer.model_validate_json(collected.content) + print("2. Dana approves it with a $2,000 budget; the agent collects it") + + person_token = mission.person_tokens[RESOURCE] + presented = PersonTokenClaims.model_validate(decode(person_token).claims) + resource_token = encode( + ResourceTokenClaims( + dwk=RESOURCE_DWK, + jti="quote-1", + iss=RESOURCE, + aud=AS, + ps=PS, + agent_jkt=agent.kid, + presented_jti=presented.jti, + sub=presented.sub, + mission_s256=mission.s256, + tenant=TENANT, + scope="book", + budget=usd(145_000), + iat=now, + exp=now + 300, + ), + airline, + RESOURCE_TOKEN, + ) + print("3. The airline quotes $1,450 and asks for an auth token") + asked = await call( + "POST", + f"{PS}/aauth/token", + AuthTokenRequest( + resource_token=resource_token, presented_token=person_token + ), + ) + print(f" {asked.status_code}: within budget, so off to the airline's AS") + for _ in range(50): + answered = await call("GET", asked.headers["location"]) + if answered.status_code != 202: + break + await asyncio.sleep(0.05) + auth_token = AuthTokenAnswer.model_validate_json(answered.content) + claims = AuthTokenClaims.model_validate( + decode(auth_token.auth_token).claims + ) + amount = claims.budget.amount if claims.budget else 0 + print( + f"4. {answered.status_code}: an auth token from {claims.iss}" + f" for {claims.aud}, ${amount / 100:,.2f}" + ) + stored = await ps.store.select_mission(TENANT, mission.s256) + assert stored is not None + print(f"5. The trip has spent ${stored.spent / 100:,.2f} of its budget") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/pyproject.toml b/examples/pyproject.toml index ddc7cfe..2ece2f7 100644 --- a/examples/pyproject.toml +++ b/examples/pyproject.toml @@ -11,6 +11,7 @@ requires-python = ">=3.11" license = "Apache-2.0" dependencies = [ + "introspection-aauth[fastapi]", "introspection-sdk[otel]", "python-dotenv>=1.0.0", ] @@ -19,6 +20,7 @@ dependencies = [ packages = ["introspection_examples"] [tool.uv.sources] +introspection-aauth = { path = "../packages/introspection-aauth", editable = true } introspection-sdk = { path = "..", editable = true } [tool.ruff] diff --git a/examples/uv.lock b/examples/uv.lock index 1da8a41..b9ed1ff 100644 --- a/examples/uv.lock +++ b/examples/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 5 +revision = 3 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -23,6 +23,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4c/0a/bcad8116eb058e4b4a305e3fc37ebd7efc879deeb86b854f1c5b8b6e97dd/ag_ui_protocol-0.1.19-py3-none-any.whl", hash = "sha256:898843b1410d378824da0c6a776486288b9c5828689d0bf563118868e37f390f", size = 13490, upload-time = "2026-06-02T17:26:16.313Z" }, ] +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -54,6 +63,104 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + [[package]] name = "charset-normalizer" version = "3.5.2" @@ -201,6 +308,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" }, ] +[[package]] +name = "cryptography" +version = "50.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" }, + { url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" }, + { url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" }, + { url = "https://files.pythonhosted.org/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" }, + { url = "https://files.pythonhosted.org/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" }, + { url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" }, + { url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" }, + { url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" }, + { url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" }, + { url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" }, + { url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" }, + { url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" }, + { url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" }, + { url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" }, + { url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, + { url = "https://files.pythonhosted.org/packages/1d/7a/f08d34ce09d60f89ebd391e2ebc6ba2b995e6dd7552f41820f8085f94e53/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:92e665960f25fcdc73725b9cec7a3824f279ba97a98653afe9ffac2e43668f67", size = 4716035, upload-time = "2026-09-30T15:29:48.681Z" }, + { url = "https://files.pythonhosted.org/packages/45/67/e18fb65592451a2acb76e9f2fbe14e0f47a8318b4c5430f1633851d03daa/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:eef4c2f3423810b3070ab391f85436d2f8bbfcb286ac15cbc73190b3563b1f1a", size = 4726917, upload-time = "2026-09-30T15:29:50.608Z" }, + { url = "https://files.pythonhosted.org/packages/83/28/38fdce17e60f6b825e69fc3b7f75e70a6612759980704697e1de4cbfaf6e/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7c6d0330c472d96f6a6afe24d80dfdf15176c33096f0a4397ae4c60f3dd3be48", size = 4715341, upload-time = "2026-09-30T15:29:52.522Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b1/d9121a717e0f893c64bd6ca7702614778d7df2a5c309128a002421788516/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:1ba34f04897fcdaa73f74145c25f3ec146fbd56593853e88adc2e811303c5f42", size = 4726322, upload-time = "2026-09-30T15:29:54.263Z" }, + { url = "https://files.pythonhosted.org/packages/36/8b/e6d153808bf353e152abd2fd4d8f09670d956ac78379ac46e60d7efbf04c/cryptography-50.0.2-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:3dc4fd8058cea1644971207d530e1a03a184a805ffc8ebdddf0599d78a331b81", size = 3873097, upload-time = "2026-09-30T15:29:56.097Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1d/1271f287ff7170ddafc2aad36260c4eec20ccd2fea70f38455e9d56d427b/cryptography-50.0.2-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:7b75de3c8b3be1cdb1052747c929440c3eea46c1bc2cb8a6e3a48388e9b7b452", size = 3805376, upload-time = "2026-09-30T15:29:58.729Z" }, +] + +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + [[package]] name = "googleapis-common-protos" version = "1.75.5" @@ -222,6 +414,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "http-sfv" +version = "0.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/ae/7684925d335eb4182d95b2d179c81fc05dbc89dc93e42b239c13f800525c/http_sfv-0.9.9.tar.gz", hash = "sha256:e132dc9bef990832bc01824f5fa9d4efc7d0f4271e4b227db35c8ef38540c739", size = 15973, upload-time = "2024-01-25T05:16:26.969Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/f7/f5adcd777e7e641c7824f4e45875a14cc7e0c6cd30b0f9db0c3698fd8e16/http_sfv-0.9.9-py3-none-any.whl", hash = "sha256:5feed51c90e9a1dc797701662d044d936923cf0027255c75452b8240e33d6c82", size = 16846, upload-time = "2024-01-25T05:16:24.072Z" }, +] + [[package]] name = "httpcore2" version = "2.12.0" @@ -270,24 +474,66 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, ] +[[package]] +name = "introspection-aauth" +version = "0.0.0" +source = { editable = "../packages/introspection-aauth" } +dependencies = [ + { name = "cryptography" }, + { name = "http-sfv" }, + { name = "httpx2" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, +] + +[package.optional-dependencies] +fastapi = [ + { name = "fastapi" }, +] + +[package.metadata] +requires-dist = [ + { name = "aauth-signing", marker = "extra == 'test'", specifier = "==0.2.0" }, + { name = "cryptography", specifier = ">=44.0.0" }, + { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.115.0" }, + { name = "http-sfv", specifier = ">=0.9.9" }, + { name = "httpx2", specifier = ">=2.12.0" }, + { name = "opentelemetry-api", specifier = ">=1.39.0" }, + { name = "opentelemetry-sdk", marker = "extra == 'test'", specifier = ">=1.39.0" }, + { name = "pydantic", specifier = ">=2.10.0" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=9.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=1.3.0" }, + { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=7.1.0" }, +] +provides-extras = ["fastapi", "test"] + +[package.metadata.requires-dev] +dev = [ + { name = "deptry", specifier = ">=0.25.1" }, + { name = "ruff", specifier = "==0.14.14" }, + { name = "ty", specifier = ">=0.0.7" }, +] + [[package]] name = "introspection-examples" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "introspection-aauth", extra = ["fastapi"] }, { name = "introspection-sdk", extra = ["otel"] }, { name = "python-dotenv" }, ] [package.metadata] requires-dist = [ + { name = "introspection-aauth", extras = ["fastapi"], editable = "../packages/introspection-aauth" }, { name = "introspection-sdk", extras = ["otel"], editable = "../" }, { name = "python-dotenv", specifier = ">=1.0.0" }, ] [[package]] name = "introspection-sdk" -version = "0.21.0" +version = "0.29.1" source = { editable = "../" } dependencies = [ { name = "ag-ui-protocol" }, @@ -305,9 +551,12 @@ otel = [ requires-dist = [ { name = "ag-ui-protocol", specifier = ">=0.1.19,<0.2.0" }, { name = "dirty-equals", marker = "extra == 'test'", specifier = ">=0.11.0" }, + { name = "griffe", marker = "extra == 'dev'", specifier = ">=2.3.2" }, { name = "httpx", marker = "extra == 'proxy-httpx'", specifier = ">=0.27.0,<1" }, { name = "httpx2", specifier = ">=2.12.0" }, { name = "inline-snapshot", marker = "extra == 'test'", specifier = ">=0.31.0" }, + { name = "jsonschema", marker = "extra == 'test'", specifier = ">=4.23.0" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=2.4.0" }, { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'otel'", specifier = ">=1.39.0,<1.43.0" }, { name = "opentelemetry-sdk", marker = "extra == 'otel'", specifier = ">=1.39.0,<1.43.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.5.0" }, @@ -319,12 +568,17 @@ requires-dist = [ { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=7.1.0" }, { name = "responses", marker = "extra == 'test'", specifier = ">=0.25.8" }, { name = "ruff", marker = "extra == 'dev'", specifier = "==0.14.14" }, + { name = "trustme", marker = "extra == 'test'", specifier = ">=1.2.0" }, { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.7" }, + { name = "unasync", marker = "extra == 'dev'", specifier = "==0.6.0" }, ] provides-extras = ["arrow", "proxy-httpx", "otel", "dev", "test"] [package.metadata.requires-dev] -dev = [{ name = "inline-snapshot", extras = ["black"], specifier = ">=0.32.5" }] +dev = [ + { name = "deptry", specifier = ">=0.25.1" }, + { name = "inline-snapshot", extras = ["black"], specifier = ">=0.32.5" }, +] [[package]] name = "opentelemetry-api" @@ -422,6 +676,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, ] +[[package]] +name = "pycparser" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/a8/c5fdbeee588bb8ada9458774f43adf1bdd30bd59157055142183e769a024/pycparser-3.11.tar.gz", hash = "sha256:d875f09c3507d00e1aba0eecc6dcadc1352f30fff09dc6bff2f1c2935e97c2bc", size = 113796, upload-time = "2026-10-09T12:56:59.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl", hash = "sha256:51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80", size = 51178, upload-time = "2026-10-09T12:56:58.131Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -558,6 +821,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "starlette" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/eb/4e197bf26b023abafc5e9043ec8710d7de0fcb032d0969045236d72aa0b1/starlette-1.9.0.tar.gz", hash = "sha256:3d931de246ed870e35ce605974440816ef895e592f255d1396cfdf191d4c4318", size = 2732466, upload-time = "2026-10-18T15:50:33.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/93/181df62699d9b5aca1d87e5cfda131dac87e3cdaebf668662f7206ca3470/starlette-1.9.0-py3-none-any.whl", hash = "sha256:fd3f159936e882d06c1108f84688f7cda2cf85fc6a592a50edbd175e882c2ddc", size = 80335, upload-time = "2026-10-18T15:50:31.526Z" }, +] + [[package]] name = "truststore" version = "0.10.4" diff --git a/packages/introspection-aauth/.jscpd.json b/packages/introspection-aauth/.jscpd.json new file mode 100644 index 0000000..d0e4a07 --- /dev/null +++ b/packages/introspection-aauth/.jscpd.json @@ -0,0 +1,31 @@ +{ + "path": [ + "introspection_aauth" + ], + "pattern": "**/*.py", + "format": [ + "python" + ], + "ignore": [ + "**/.venv/**", + "**/__pycache__/**" + ], + "gitignore": true, + "minTokens": 50, + "minLines": 5, + "mode": "weak", + "kind": [ + "exact", + "renamed" + ], + "ignoreIdentifiers": true, + "threshold": 0, + "reporters": [ + "console" + ], + "ignorePattern": [ + "from [\\w.]+ import \\([^)]*\\)", + "from [\\w.]+ import [^\\n(]+", + "import [\\w.]+( as \\w+)?" + ] +} diff --git a/packages/introspection-aauth/.python-version b/packages/introspection-aauth/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/packages/introspection-aauth/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/packages/introspection-aauth/LICENSE b/packages/introspection-aauth/LICENSE new file mode 100644 index 0000000..c4bc470 --- /dev/null +++ b/packages/introspection-aauth/LICENSE @@ -0,0 +1,191 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 Introspection AI Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/introspection-aauth/README.md b/packages/introspection-aauth/README.md new file mode 100644 index 0000000..d2956e5 --- /dev/null +++ b/packages/introspection-aauth/README.md @@ -0,0 +1,158 @@ +# introspection-aauth + +An AAuth Person Server and Access Server for Python, implementing +[draft-hardt-oauth-aauth-protocol -11](https://github.com/dickhardt/AAuth): +missions, person and auth tokens, budgets, approvals, and four-party +federation (`#access-server-federation`). You provide the store; nothing +here names a database, a queue or a web framework. + +```bash +pip install introspection-aauth # the servers +pip install 'introspection-aauth[fastapi]' # and FastAPI routers for them +``` + +## Layout + +| Module | Holds | +| --- | --- | +| `introspection_aauth.protocol` | What both servers share, written once: `AAuthError`, a subclass per AAuth error code (`InvalidRequest`, `InvalidToken`, `InvalidCode`, …), and their problem+json; Ed25519 signing keys (current and previous) and Ed25519/ES256 verification; RFC 9421 signing and verification with `Signature-Key`; AAuth's tokens; discovery of metadata and keys, cached for a bounded time; budgets; and the server plumbing both use: the `Server` base, verifying an agent's or a server's signed request, the `202` pending answer, reading an R3 proposal. | +| `introspection_aauth.person_server` | `PersonServer`, its `Store` and `Directory` protocols, a `MemoryStore`, and `AccessServerClient`, its client for a resource's own Access Server. | +| `introspection_aauth.access_server` | `AccessServer`, its policy `Verdict`s, its `Store` protocol and a `MemoryStore`. | +| `introspection_aauth.fastapi` | `person_server_router` and `access_server_router`, and `*_well_known_router` for the documents alone (the `fastapi` extra). Each takes an `APIRouter` of yours for its tags, dependencies, responses or `include_in_schema`. | + +Neither server imports the other, and `protocol` imports neither. + +Every body either server sends or reads is a pydantic model in +`protocol.wire`, and every token's claims a model in `protocol.claims` +that checks what the token must be on its own: `exp` after `iat`, a +lifetime of at most an hour where the protocol caps it, a `cnf` that is a +public key, a budget of non-negative integers. Whether a token that +verified is the one a caller expected is an expectation's `failures`, +which names every check it fails (`["sub", "tenant"]`). Discovery reads +only AAuth server identifiers (`https://` and a lowercase host, nothing +more), requires an https `jwks_uri`, and follows no redirect. An agent's +request is signed `sig=jwt` and a server's `sig=jwks_uri`; any other scheme, +server or `dwk` is refused before anything is fetched. An R3 proposal is +read only from its resource's own https origin. An approved auth token is +collected once; a second poll is `410 invalid_code`. + +## Dependencies + +``` +introspection-aauth +├── cryptography Ed25519 and ES256, JWK thumbprints, PEM keys +├── http-sfv RFC 9651 structured fields: Signature-Input, Signature, Signature-Key, AAuth-Requirement +├── httpx2 discovery, federation and R3 fetches +├── pydantic models and request bodies +└── fastapi (extra: fastapi) the routers +``` + +JWS is done here, on `cryptography`: AAuth's tokens use fully-specified +algorithms (`alg: Ed25519`, RFC 9864), and every claim check is the +protocol's own, so a JWT library would add nothing but a dependency. + +## A Person Server + +```python +import httpx2 as httpx +from introspection_aauth.fastapi import person_server_router +from introspection_aauth.person_server import MemoryStore, PersonServer +from introspection_aauth.protocol import SigningKey + +person_server = PersonServer( + "https://ps.example", + SigningKey.from_pem(open("ps.pem").read()), + MemoryStore(), + subject_secret=b"...", # keys each person's directed `sub` + agent_providers=["https://agents.example"], + http=httpx.AsyncClient(), + endpoint_base="https://ps.example/aauth", + default_tenant="acme", +) +app.include_router(person_server_router(person_server)) +# Or with your own router: its prefix is the start of the endpoint base's +# path, and the well-known documents are mounted at the root on their own. +# app.include_router(person_server_router(person_server, APIRouter(prefix="/aauth", tags=["AAuth"]))) +# app.include_router(person_server_well_known_router(person_server)) + +# Your approval app answers what it asks: +await person_server.decide(tenant, approval_id, ApprovalStatus.APPROVED, + decided_by="dana", budget=Budget(amount=200_000, unit="USD", decimals=2)) +``` + +`subject_secret` is separate from the signing key, so rotating the key +changes no one's identifier. Pass `previous_keys` to keep a rotated key +published until what it signed has expired. + +## An Access Server + +```python +def policy(request: AccessRequest) -> Verdict: + if request.tenant == "acme": + return Verdict.allow() # or allow(scope=..., budget=...) for less + return Verdict.approval() # wait; settle it with decide() + +access_server = AccessServer("https://as.example", key, policy, + resources=["https://booking.example"], + person_servers=["https://ps.example"], + agent_providers=["https://agents.example"], + http=httpx.AsyncClient()) +app.include_router(access_server_router(access_server)) +``` + +## Bring your own store + +`person_server.Store` is the Person Server's state in logical operations, +named by their CRUD verb: `select_binding` and `insert_binding` (bind a key +to the first person it names), `select_mission`, `insert_mission`, +`update_spend` (spend only if it fits, atomically), `update_mission_log`, +`update_mission_end`, `select_approval`, `insert_approval`, and +`transition_approval`, which replaces an approval only if its status and +version have not changed. `transaction()` groups the writes that commit together: a decision +with the mission it creates or spends, and a federated auth token with the +spend it pays for. `transaction()` yields a store scoped to it; pass it as +`decide(..., store=tx)` to make a decision part of a unit of work of your +own, and call `decided` once that commits. Whether a store encrypts what it keeps is its own +choice. `access_server.Store` is `select_pending`, `insert_pending` and `transition_pending`. + +## Durable federation + +`PersonServer(on_federate=...)` is told of each approved request a +resource's Access Server must decide. Unset, `PersonServer.federate` drives +it in this process. A host with a durable executor journals the steps +itself: `federation_due`, `AccessServerClient.discover`, `.request`, +`record_federation`, wait `Outcome.poll_delay()`, `.poll`, +`record_federation`, and so on until `record_federation` returns False. + +The server makes every call through the `httpx.AsyncClient` it is given, so +retrying a dropped connection is that client's: pass one with +`transport=httpx.AsyncHTTPTransport(retries=3)` to retry, or a mock transport +in tests. + +## Logging and tracing + +The package configures no logging and no tracer provider; it writes through +`logging.getLogger(__name__)` and the OpenTelemetry API, so your formatter +and exporter carry its records and spans. Each record has a fixed message and +its values as `extra` fields: identifiers (`tenant`, `approval_id`, +`resource`, `ps`) and enumerated values (`step`, `decision`, `error`), never a +token, a person's identity or a request body. Federation is traced as +`person_server.federate`, with a child span for each exchange with the Access +Server (`.metadata`, `.request`, `.poll`, `.delivery`); reading an R3 +proposal is `aauth.r3.read`. + +## Not served + +As in the hosted Person Server: `requirement=claims` and `clarification`, +payment (`402`, answered `403 denied`), step-up with an auth token, +sub-agents and call chaining (`subagent_token`, `upstream_token`), and +token revocation. + +## Example + +`examples/introspection_examples/aauth/four_party.py` runs the four-party +flow in one process: + +```bash +cd examples && uv run -m introspection_examples.aauth.four_party +``` diff --git a/packages/introspection-aauth/introspection_aauth/__init__.py b/packages/introspection-aauth/introspection_aauth/__init__.py new file mode 100644 index 0000000..b44c9a7 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/__init__.py @@ -0,0 +1,7 @@ +"""An AAuth Person Server and Access Server, over a store you provide. + +- `introspection_aauth.protocol`: what both servers share. +- `introspection_aauth.person_server`: `PersonServer`. +- `introspection_aauth.access_server`: `AccessServer`. +- `introspection_aauth.fastapi`: routers for either (the `fastapi` extra). +""" diff --git a/packages/introspection-aauth/introspection_aauth/access_server/__init__.py b/packages/introspection-aauth/introspection_aauth/access_server/__init__.py new file mode 100644 index 0000000..a70dfea --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/access_server/__init__.py @@ -0,0 +1,23 @@ +"""An AAuth Access Server: a resource's own policy over the auth tokens a +Person Server redeems for it.""" + +from introspection_aauth.access_server.hooks import Policy +from introspection_aauth.access_server.models import ( + AccessRequest, + PendingAccess, + PendingStatus, + Verdict, +) +from introspection_aauth.access_server.server import AccessServer +from introspection_aauth.access_server.store import MemoryStore, Store + +__all__ = [ + "AccessRequest", + "AccessServer", + "MemoryStore", + "PendingAccess", + "PendingStatus", + "Policy", + "Store", + "Verdict", +] diff --git a/packages/introspection-aauth/introspection_aauth/access_server/hooks.py b/packages/introspection-aauth/introspection_aauth/access_server/hooks.py new file mode 100644 index 0000000..25d4a46 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/access_server/hooks.py @@ -0,0 +1,25 @@ +"""What an Access Server calls back into its host: the policy that decides a +request, and a hook for each one left pending.""" + +from collections.abc import Awaitable +from typing import Protocol + +from introspection_aauth.access_server.models import ( + AccessRequest, + PendingAccess, + Verdict, +) + + +class Policy(Protocol): + """A resource's policy: its verdict on one request, now or awaited.""" + + def __call__( + self, request: AccessRequest, / + ) -> Verdict | Awaitable[Verdict]: ... + + +class PendingHook(Protocol): + """Told of each request the policy waits on.""" + + def __call__(self, pending: PendingAccess, /) -> Awaitable[None]: ... diff --git a/packages/introspection-aauth/introspection_aauth/access_server/models.py b/packages/introspection-aauth/introspection_aauth/access_server/models.py new file mode 100644 index 0000000..f180af0 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/access_server/models.py @@ -0,0 +1,131 @@ +"""What an Access Server decides on, and what it keeps while it waits.""" + +from enum import StrEnum +from typing import Literal, Self + +from pydantic import BaseModel, ConfigDict + +from introspection_aauth.protocol.claims import ( + AgentTokenClaims, + AuthGrant, + PersonTokenClaims, + ResourceTokenClaims, +) +from introspection_aauth.protocol.errors import ServerError +from introspection_aauth.protocol.keys import Jwk +from introspection_aauth.protocol.models import Budget + + +class VerifiedTokens(BaseModel): + """The verified claims of the three tokens a Person Server sends.""" + + model_config = ConfigDict(frozen=True) + + resource_token: ResourceTokenClaims + agent_token: AgentTokenClaims + presented_token: PersonTokenClaims + + +class AccessRequest(BaseModel): + """A Person Server's token request, once its signature and all three + tokens verify: what the resource's policy is asked.""" + + #: The resource the auth token would be for: the resource token's `iss`. + resource: str + #: The Person Server that sent it, in whose namespace `sub` is. + ps: str + #: The person's directed identifier at the resource. + sub: str + #: The person's organisation at their Person Server; `(ps, tenant)` names it. + tenant: str | None = None + scope: str | None = None + mission_s256: str | None = None + #: What the call costs: the budget the Person Server sent, else the resource token's. + budget: Budget | None = None + #: The resource's one-line description of the call, from its R3 proposal. + summary: str | None = None + agent_id: str + agent_provider: str + agent_jkt: str + #: The verified claims of each token, for anything else a policy reads. + claims: VerifiedTokens | None = None + + def narrowed(self, scope: str | None) -> str | None: + """`scope` as a policy granted it, which may be no broader than asked.""" + if scope is None: + return self.scope + if not set(scope.split()) <= set((self.scope or "").split()): + raise ServerError( + "the policy granted a scope that was not asked for" + ) + return scope + + def lowered(self, budget: Budget | None) -> Budget | None: + """`budget` as a policy granted it, which may be no more than asked.""" + if budget is None: + return self.budget + if self.budget is None or not budget.at_most(self.budget): + raise ServerError("the policy granted more than was asked") + return budget + + +class Verdict(BaseModel): + """The policy's answer. `allow` may narrow the scope or lower the budget; + `approval` and `interaction` wait until `AccessServer.decide`.""" + + model_config = ConfigDict(frozen=True) + + decision: Literal["allow", "deny", "approval", "interaction"] + scope: str | None = None + budget: Budget | None = None + detail: str | None = None + url: str | None = None + code: str | None = None + + @classmethod + def allow( + cls, scope: str | None = None, budget: Budget | None = None + ) -> Self: + return cls(decision="allow", scope=scope, budget=budget) + + @classmethod + def deny(cls, detail: str | None = None) -> Self: + return cls(decision="deny", detail=detail) + + @classmethod + def approval(cls) -> Self: + return cls(decision="approval") + + @classmethod + def interaction(cls, url: str, code: str) -> Self: + return cls(decision="interaction", url=url, code=code) + + +class PendingStatus(StrEnum): + PENDING = "pending" + APPROVED = "approved" + DENIED = "denied" + + +class PendingAccess(BaseModel): + """A request the policy is waiting on.""" + + id: str + status: PendingStatus = PendingStatus.PENDING + version: int = 0 + verdict: Verdict + request: AccessRequest + #: What the auth token grants, once approved, before the policy narrows it. + grant: AuthGrant + #: The agent's key the auth token binds. + jwk: Jwk + #: The latest the auth token may expire: its agent and presented tokens' `exp`. + not_after: int + scope: str | None = None + budget: Budget | None = None + decided_by: str | None = None + #: Set once its terminal answer is given (an auth token, a refusal or an + #: expiry); its status stays the decision. + collected: bool = False + created_at: int + expires_at: int diff --git a/packages/introspection-aauth/introspection_aauth/access_server/server.py b/packages/introspection-aauth/introspection_aauth/access_server/server.py new file mode 100644 index 0000000..bc4592b --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/access_server/server.py @@ -0,0 +1,377 @@ +"""An AAuth Access Server (`#access-server-federation`): a resource's own +policy over the auth tokens a Person Server redeems for it. A resource names +this server as the `aud` of its resource tokens; the person's Person Server, +having weighed the call against the person's mission, asks it for the auth +token here, and this server issues it or makes the Person Server wait.""" + +import logging +from collections.abc import Sequence +from typing import Unpack + +import httpx2 as httpx + +from introspection_aauth.access_server.hooks import ( + PendingHook, + Policy, +) +from introspection_aauth.access_server.models import ( + AccessRequest, + PendingAccess, + PendingStatus, + Verdict, + VerifiedTokens, +) +from introspection_aauth.access_server.store import MemoryStore, Store +from introspection_aauth.protocol import jwt +from introspection_aauth.protocol.answer import Answer +from introspection_aauth.protocol.claims import ( + ACCESS_DWK, + PERSON_DWK, + AgentTokenClaims, + AuthGrant, + AuthTokenClaims, + PersonTokenClaims, + ResourceTokenClaims, +) +from introspection_aauth.protocol.errors import ( + Denied, + Expired, + InvalidCode, + InvalidRequest, + InvalidToken, + NotFound, + VerificationError, +) +from introspection_aauth.protocol.expectations import ( + AgentExpectation, + PresentedExpectation, + ResourceTokenExpectation, +) +from introspection_aauth.protocol.httpsig import SignedRequest +from introspection_aauth.protocol.keys import SigningKey +from introspection_aauth.protocol.models import Budget +from introspection_aauth.protocol.pending import ( + pending, + requirement, +) +from introspection_aauth.protocol.server import ( + Server, + ServerOptions, + parse, +) +from introspection_aauth.protocol.verification import ( + verify_agent_token, + verify_resource_token, + verify_server, +) +from introspection_aauth.protocol.wire import ( + AccessServerMetadata, + AuthTokenAnswer, + TokenRequest, +) + +logger = logging.getLogger(__name__) + + +class AccessServer(Server): + """Decides, by `policy`, for the `resources` it serves, taking requests + only from `person_servers` for agents of `agent_providers`.""" + + def __init__( + self, + issuer: str, + signing_key: SigningKey, + policy: Policy, + *, + resources: Sequence[str], + person_servers: Sequence[str], + agent_providers: Sequence[str], + http: httpx.AsyncClient, + store: Store | None = None, + token_ttl: int = jwt.MAX_TOKEN_LIFETIME_SECONDS, + pending_ttl: int = 86_400, + on_pending: PendingHook | None = None, + **options: Unpack[ServerOptions], + ): + super().__init__(issuer, ACCESS_DWK, signing_key, http, **options) + self.policy = policy + self.store: Store = store or MemoryStore() + self._resources = {r.rstrip("/") for r in resources} + self._person_servers = {p.rstrip("/") for p in person_servers} + self._providers = {p.rstrip("/") for p in agent_providers} + self._token_ttl = min(token_ttl, jwt.MAX_TOKEN_LIFETIME_SECONDS) + self._pending_ttl = pending_ttl + self._on_pending = on_pending + + def metadata(self) -> AccessServerMetadata: + return AccessServerMetadata( + issuer=self.issuer, + name=self.name or None, + jwks_uri=f"{self.issuer}/.well-known/aauth-access-jwks.json", + auth_token_endpoint=f"{self.endpoint_base}/token", + ) + + def pending_url(self, pending_id: str) -> str: + return f"{self.endpoint_base}/pending/{pending_id}" + + async def token(self, request: SignedRequest) -> Answer: + """`POST /token`: a Person Server's signed request for an auth token.""" + ps = await verify_server( + request, + self.discovery, + self._person_servers, + PERSON_DWK, + self.clock(), + ) + body = parse(TokenRequest, request.body) + if body.upstream_token is not None or body.subagent_token is not None: + raise InvalidRequest( + "call chaining and sub-agents are not served here" + ) + rt = await self._resource_token(body.resource_token, ps) + agent = await self._agent_token(body.agent_token, rt, ps) + presented = await self._presented_token(body.presented_token, rt, ps) + sent, asked_budget = body.budget, rt.budget + if ( + sent is not None + and asked_budget is not None + and not sent.at_most(asked_budget) + ): + raise InvalidRequest("budget is above the resource token's") + # Enforced by the resource token's expectation. + sub, agent_jkt = rt.sub or "", rt.agent_jkt or "" + asked = AccessRequest( + resource=rt.iss, + ps=ps, + sub=sub, + tenant=rt.tenant, + scope=rt.scope, + mission_s256=rt.mission_s256, + budget=sent or asked_budget, + summary=await self._summary(rt), + agent_id=agent.sub, + agent_provider=agent.iss, + agent_jkt=agent_jkt, + claims=VerifiedTokens( + resource_token=rt, + agent_token=agent, + presented_token=presented, + ), + ) + verdict = self.policy(asked) + verdict = verdict if isinstance(verdict, Verdict) else await verdict + logger.info( + "access request decided", + extra={ + "resource": asked.resource, + "ps": asked.ps, + "tenant": asked.tenant, + "decision": verdict.decision, + }, + ) + record = PendingAccess( + id=self.new_id(), + verdict=verdict, + request=asked, + grant=AuthGrant.requested(rt, sub), + jwk=agent.cnf.jwk, + not_after=min(agent.exp, presented.exp), + created_at=self.clock(), + expires_at=min( + self.clock() + self._pending_ttl, agent.exp, presented.exp + ), + ) + if verdict.decision == "allow": + return self._issue( + record, + asked.narrowed(verdict.scope), + asked.lowered(verdict.budget), + ) + if verdict.decision == "deny": + raise Denied(verdict.detail or "this resource's policy refuses it") + await self.store.insert_pending(record) + if self._on_pending is not None: + await self._on_pending(record) + return self._waiting(record) + + async def poll(self, request: SignedRequest, pending_id: str) -> Answer: + """`GET /pending/{id}`: only the Person Server that asked may poll.""" + ps = await verify_server( + request, + self.discovery, + self._person_servers, + PERSON_DWK, + self.clock(), + ) + record = await self.store.select_pending(pending_id) + if record is None or record.request.ps != ps: + raise NotFound("no such pending request") + if record.collected: + raise InvalidCode("this request was already answered") + expired = self.clock() >= record.expires_at + if record.status is PendingStatus.PENDING and not expired: + return self._waiting(record) + # A terminal answer is given once; the pending URL then answers 410. + answered = record.model_copy( + update={ + "collected": True, + "version": record.version + 1, + } + ) + if not await self.store.transition_pending(record, answered): + raise InvalidCode("this request was already answered") + if record.status is PendingStatus.DENIED: + raise Denied("this resource's policy refuses it") + if expired: + raise Expired("nobody decided in time") + return self._issue(record, record.scope, record.budget) + + async def decide( + self, + pending_id: str, + approved: bool, + *, + scope: str | None = None, + budget: Budget | None = None, + decided_by: str | None = None, + ) -> PendingAccess: + """Settle a request the policy waited on, once; approving may narrow + the scope or lower the budget.""" + record = await self.store.select_pending(pending_id) + if record is None: + raise NotFound("no such request") + if record.status is not PendingStatus.PENDING: + raise InvalidRequest("already decided", status=409) + if self.clock() >= record.expires_at: + raise Expired("this request has expired", status=410) + decided = record.model_copy( + update={ + "status": PendingStatus.APPROVED + if approved + else PendingStatus.DENIED, + "version": record.version + 1, + "scope": record.request.narrowed(scope), + "budget": record.request.lowered(budget), + "decided_by": decided_by, + } + ) + if not await self.store.transition_pending(record, decided): + raise InvalidRequest("already decided", status=409) + return decided + + # region private + + async def _resource_token( + self, token: str, ps: str + ) -> ResourceTokenClaims: + try: + _, unverified = jwt.peek(token) + except VerificationError: + raise InvalidToken("resource", "not a JWT") from None + resource = (unverified.iss or "").rstrip("/") + # Checked before verifying, since verifying fetches its keys. + if resource not in self._resources: + raise InvalidToken( + "resource", + f"this Access Server does not decide for {resource}", + ) + return await verify_resource_token( + token, + self.discovery, + resource, + self.clock(), + ResourceTokenExpectation( + ps=ps, aud=self.issuer, requires_presented=True + ), + ) + + async def _agent_token( + self, token: str, rt: ResourceTokenClaims, ps: str + ) -> AgentTokenClaims: + try: + claims = await verify_agent_token( + token, self.discovery, self._providers, self.clock() + ) + except VerificationError as exc: + raise InvalidRequest(f"agent_token: {exc.detail}") from None + wrong = AgentExpectation(agent_jkt=rt.agent_jkt or "", ps=ps).failures( + claims + ) + if "cnf" in wrong: + raise InvalidToken( + "resource", "agent_jkt is not the agent token's key" + ) + if wrong: + raise InvalidRequest("agent_token names another Person Server") + return claims + + async def _presented_token( + self, token: str, rt: ResourceTokenClaims, ps: str + ) -> PersonTokenClaims: + """The person token the resource saw, from the Person Server that sent + it, matching the resource token exactly. Step-up with an auth token this + server issued is not served.""" + try: + presented = await self.discovery.verified( + ps, + PERSON_DWK, + lambda keys: jwt.verify( + token, + keys, + jwt.PERSON_TOKEN, + self.clock(), + PersonTokenClaims, + ), + ) + except VerificationError as exc: + raise InvalidToken.rejected("presented", exc) from None + wrong = PresentedExpectation(ps=ps, rt=rt).failures(presented) + if wrong: + raise InvalidToken( + "resource", + f"it does not match the presented token: {', '.join(wrong)}", + ) + return presented + + async def _summary(self, rt: ResourceTokenClaims) -> str | None: + if rt.r3_uri is None or rt.r3_s256 is None: + return None + return await self.identity.summary( + self.http, rt.iss, rt.r3_uri, rt.r3_s256, self.clock() + ) + + def _waiting(self, record: PendingAccess) -> Answer: + verdict = record.verdict + waiting_on = ( + requirement("interaction", url=verdict.url, code=verdict.code) + if verdict.decision == "interaction" + else None + ) + return pending( + self.pending_url(record.id), self.retry_after, waiting_on + ) + + def _issue( + self, record: PendingAccess, scope: str | None, budget: Budget | None + ) -> Answer: + """An auth token, for no longer than an hour or the tokens it was asked with.""" + iat = self.clock() + exp = min(iat + self._token_ttl, record.not_after) + claims = AuthTokenClaims.issue( + record.grant.model_copy( + update={"scope": scope or None, "budget": budget} + ), + iss=self.issuer, + dwk=ACCESS_DWK, + ps=record.request.ps, + jwk=record.jwk, + jti=self.new_id(), + iat=iat, + exp=exp, + ) + token = self.identity.token(claims, jwt.AUTH_TOKEN) + return Answer( + 200, AuthTokenAnswer(auth_token=token, expires_in=exp - iat) + ) + + # endregion diff --git a/packages/introspection-aauth/introspection_aauth/access_server/store.py b/packages/introspection-aauth/introspection_aauth/access_server/store.py new file mode 100644 index 0000000..9e3230d --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/access_server/store.py @@ -0,0 +1,39 @@ +"""What an Access Server keeps: the requests its policy is waiting on.""" + +from typing import Protocol + +from introspection_aauth.access_server.models import PendingAccess +from introspection_aauth.protocol.memory import compare_and_set + + +class Store(Protocol): + async def select_pending( + self, pending_id: str + ) -> PendingAccess | None: ... + + async def insert_pending(self, pending: PendingAccess) -> None: ... + + async def transition_pending( + self, current: PendingAccess, changed: PendingAccess + ) -> bool: + """Replace `current` with `changed` only if what is stored still has + `current`'s status and version; whether it did.""" + ... + + +class MemoryStore: + """A `Store` in this process's memory.""" + + def __init__(self) -> None: + self.requests: dict[str, PendingAccess] = {} + + async def select_pending(self, pending_id: str) -> PendingAccess | None: + return self.requests.get(pending_id) + + async def insert_pending(self, pending: PendingAccess) -> None: + self.requests[pending.id] = pending + + async def transition_pending( + self, current: PendingAccess, changed: PendingAccess + ) -> bool: + return compare_and_set(self.requests, current.id, current, changed) diff --git a/packages/introspection-aauth/introspection_aauth/fastapi/__init__.py b/packages/introspection-aauth/introspection_aauth/fastapi/__init__.py new file mode 100644 index 0000000..0e6ea76 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/fastapi/__init__.py @@ -0,0 +1,38 @@ +"""FastAPI routers for either server (`pip install introspection-aauth[fastapi]`). + +Each server has two routers: its protocol routes, at the path of its +`endpoint_base`, and its well-known documents, at the root. Pass an +`APIRouter` of your own to either, to set its tags, dependencies, responses or +`include_in_schema`. A protocol router may carry a prefix, which must be the +start of the endpoint base's path: its routes are then the rest of it. A +router without a prefix gets the well-known documents too, as one router for +the whole server. + +A host that builds its server after the app (at startup, or only when a +feature is on) passes a resolver instead of the server, with the endpoint +base's path: the resolver finds the server for each request, and a request it +finds none for is answered `404`. + +A request is verified as its signer addressed it, at `endpoint_base`, +whatever proxy delivered it. +""" + +from introspection_aauth.fastapi.access_server import ( + AccessServerResolver, + access_server_router, + access_server_well_known_router, +) +from introspection_aauth.fastapi.person_server import ( + PersonServerResolver, + person_server_router, + person_server_well_known_router, +) + +__all__ = [ + "AccessServerResolver", + "PersonServerResolver", + "access_server_router", + "access_server_well_known_router", + "person_server_router", + "person_server_well_known_router", +] diff --git a/packages/introspection-aauth/introspection_aauth/fastapi/access_server.py b/packages/introspection-aauth/introspection_aauth/fastapi/access_server.py new file mode 100644 index 0000000..728c09d --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/fastapi/access_server.py @@ -0,0 +1,87 @@ +"""FastAPI routers for an Access Server's protocol routes and its well-known +documents.""" + +from fastapi import APIRouter + +from introspection_aauth.access_server.server import AccessServer +from introspection_aauth.fastapi.routes import ( + PENDING_RESPONSE, + SIGNED, + Resolver, + endpoint_base_path, + finder, + fixed, + relative_path, + signed_route, + well_known_router, +) +from introspection_aauth.protocol.wire import ( + AccessServerMetadata, + AuthTokenAnswer, + TokenRequest, +) + +#: Finds the AccessServer that serves a request, or None when there is none. +AccessServerResolver = Resolver[AccessServer] + + +def access_server_well_known_router( + server: AccessServer | AccessServerResolver, + router: APIRouter | None = None, + *, + cache_control: str | None = None, +) -> APIRouter: + """The Access Server's metadata and keys, to mount at the root.""" + return well_known_router( + APIRouter() if router is None else router, + finder(fixed(server) if isinstance(server, AccessServer) else server), + role="access", + title="Access Server", + metadata_model=AccessServerMetadata, + cache_control=cache_control, + ) + + +def access_server_router( + server: AccessServer | AccessServerResolver, + router: APIRouter | None = None, + *, + endpoint_path: str | None = None, +) -> APIRouter: + """As `person_server_router`, for an Access Server.""" + find = finder( + fixed(server) if isinstance(server, AccessServer) else server + ) + router = APIRouter() if router is None else router + if not router.prefix: + access_server_well_known_router(server, router) + prefix = relative_path(router, endpoint_base_path(server, endpoint_path)) + signed_route( + router, + "POST", + f"{prefix}/token", + find, + lambda found, signed, _: found.token(signed), + body=TokenRequest, + summary="Request an auth token, as a Person Server", + description=( + "A Person Server's signed request with the resource, agent and " + "presented tokens; issued, refused, or `202` while the resource's " + f"policy waits on someone. {SIGNED}" + ), + operation_id="aauth_access_server_token", + response_model=AuthTokenAnswer, + responses=PENDING_RESPONSE, + ) + signed_route( + router, + "GET", + prefix + "/pending/{pending_id}", + find, + lambda found, signed, path: found.poll(signed, path["pending_id"]), + summary="Poll a pending request, as the Person Server that asked", + operation_id="aauth_access_server_poll", + response_model=AuthTokenAnswer, + responses=PENDING_RESPONSE, + ) + return router diff --git a/packages/introspection-aauth/introspection_aauth/fastapi/person_server.py b/packages/introspection-aauth/introspection_aauth/fastapi/person_server.py new file mode 100644 index 0000000..68f579d --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/fastapi/person_server.py @@ -0,0 +1,169 @@ +"""FastAPI routers for a Person Server's protocol routes and its well-known +documents.""" + +from http import HTTPStatus +from urllib.parse import urlsplit + +from fastapi import APIRouter + +from introspection_aauth.fastapi.routes import ( + PENDING_RESPONSE, + PROBLEM_RESPONSES, + SIGNED, + Resolver, + endpoint_base_path, + finder, + fixed, + relative_path, + signed_route, + well_known_router, +) +from introspection_aauth.person_server.models import ( + AuthTokenRequest, + MissionAction, + MissionAnswer, + MissionLogged, + MissionProposal, + PersonTokenRequest, +) +from introspection_aauth.person_server.server import PersonServer +from introspection_aauth.protocol.answer import Answer +from introspection_aauth.protocol.errors import ( + NotFound, +) +from introspection_aauth.protocol.httpsig import SignedRequest +from introspection_aauth.protocol.wire import ( + AuthTokenAnswer, + Empty, + Pending, + PersonServerMetadata, + PersonTokenAnswer, +) + +#: Finds the PersonServer that serves a request, or None when there is none. +PersonServerResolver = Resolver[PersonServer] + + +def person_server_well_known_router( + ps: PersonServer | PersonServerResolver, + router: APIRouter | None = None, + *, + cache_control: str | None = None, +) -> APIRouter: + """The Person Server's metadata and keys, to mount at the root.""" + return well_known_router( + APIRouter() if router is None else router, + finder(fixed(ps) if isinstance(ps, PersonServer) else ps), + role="person", + title="Person Server", + metadata_model=PersonServerMetadata, + cache_control=cache_control, + ) + + +async def poll_person_server( + server: PersonServer, signed: SignedRequest, path: dict[str, str] +) -> Answer: + # Read from the signed URL, still encoded, so it is decoded once. + under = f"{urlsplit(server.endpoint_base).path}/pending/" + parts = server.pending_parts(urlsplit(signed.url).path.removeprefix(under)) + if parts is None: + raise NotFound("no such pending request") + return await server.poll(signed, *parts) + + +def person_server_router( + ps: PersonServer | PersonServerResolver, + router: APIRouter | None = None, + *, + endpoint_path: str | None = None, +) -> APIRouter: + """The Person Server's protocol routes, and its well-known documents + when `router` has no prefix. With a resolver, `endpoint_path` is the + path of its servers' endpoint base.""" + router = APIRouter() if router is None else router + prefix = relative_path(router, endpoint_base_path(ps, endpoint_path)) + find = finder(fixed(ps) if isinstance(ps, PersonServer) else ps) + if not router.prefix: + person_server_well_known_router(ps, router) + signed_route( + router, + "POST", + f"{prefix}/mission", + find, + lambda server, signed, _: server.propose_mission(signed), + body=MissionProposal, + summary="Propose a mission", + description=f"Always put to an approver, who may set its budget. {SIGNED}", + operation_id="aauth_person_server_propose_mission", + status_code=HTTPStatus.ACCEPTED, + response_model=Pending, + responses=PROBLEM_RESPONSES, + ) + signed_route( + router, + "POST", + prefix + "/mission/{s256}", + find, + lambda server, signed, path: server.mission_action( + signed, path["s256"] + ), + body=MissionAction, + summary="Log an update, or propose the mission complete", + description=( + "`action` is `update` or `completion`; a completion is put to " + f"the mission's person. {SIGNED}" + ), + operation_id="aauth_person_server_mission_action", + response_model=MissionLogged, + responses=PENDING_RESPONSE, + ) + signed_route( + router, + "POST", + f"{prefix}/person-token", + find, + lambda server, signed, _: server.person_token(signed), + body=PersonTokenRequest, + summary="Get a person token", + description=( + "Who the agent acts for, at one resource, under a mission it " + f"holds. {SIGNED}" + ), + operation_id="aauth_person_server_person_token", + response_model=PersonTokenAnswer, + responses=PROBLEM_RESPONSES, + ) + signed_route( + router, + "POST", + f"{prefix}/token", + find, + lambda server, signed, _: server.auth_token(signed), + body=AuthTokenRequest, + summary="Exchange a resource token for an auth token", + description=( + "Issued at once within the mission's budget and spent; over what " + f"is left, put to an approver. {SIGNED}" + ), + operation_id="aauth_person_server_auth_token", + response_model=AuthTokenAnswer, + responses=PENDING_RESPONSE, + ) + signed_route( + router, + "GET", + prefix + "/pending/{pending_path:path}", + find, + poll_person_server, + summary="Poll a pending request", + description=( + "Only the key that asked may read it: `202` while pending, `200` " + "with the result once decided, `403` when declined, `408` once it " + f"expires, `410` once collected. {SIGNED}" + ), + operation_id="aauth_person_server_poll", + response_model=AuthTokenAnswer | MissionAnswer | Empty, + responses=PENDING_RESPONSE, + ) + return router diff --git a/packages/introspection-aauth/introspection_aauth/fastapi/routes.py b/packages/introspection-aauth/introspection_aauth/fastapi/routes.py new file mode 100644 index 0000000..d487686 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/fastapi/routes.py @@ -0,0 +1,272 @@ +"""The plumbing every AAuth route shares: serving a signed request through a +server found per request, its documented answers, and the well-known +documents router.""" + +from collections.abc import Awaitable +from http import HTTPStatus +from types import UnionType +from typing import Protocol, TypedDict, TypeVar, Unpack +from urllib.parse import urlsplit + +from fastapi import APIRouter, HTTPException, Request, Response +from pydantic import BaseModel, JsonValue + +from introspection_aauth.protocol.answer import Answer +from introspection_aauth.protocol.errors import ( + AAuthError, + Problem, +) +from introspection_aauth.protocol.httpsig import SignedRequest +from introspection_aauth.protocol.json import JsonObject +from introspection_aauth.protocol.keys import Jwks +from introspection_aauth.protocol.server import Server +from introspection_aauth.protocol.wire import ( + Pending, +) + +FoundServer = TypeVar("FoundServer", bound=Server, covariant=True) +AnyServer = TypeVar("AnyServer", bound=Server) +ActingServer = TypeVar("ActingServer", bound=Server, contravariant=True) + + +class DocumentedResponse(TypedDict, total=False): + """One of a route's documented responses.""" + + model: type[BaseModel] + description: str + content: JsonObject + + +Responses = dict[int | str, DocumentedResponse] + + +class RouteDocs(TypedDict, total=False): + summary: str + description: str + operation_id: str + status_code: int + response_model: type[BaseModel] | UnionType + + +class Handler(Protocol): + """A server's answer to one signed request.""" + + def __call__(self, request: SignedRequest, /) -> Awaitable[Answer]: ... + + +SIGNED = ( + "Signed: the request carries an RFC 9421 signature with `Signature-Key`, " + "and errors are AAuth problem+json." +) +PROBLEM_RESPONSES: Responses = { + "4XX": { + "model": Problem, + "description": "Refused", + "content": {"application/problem+json": {}}, + }, +} +PENDING_RESPONSE: Responses = { + HTTPStatus.ACCEPTED.value: { + "model": Pending, + "description": "Put to whoever decides; poll the `Location` it names", + }, + **PROBLEM_RESPONSES, +} + + +def body_schema(model: type[BaseModel]) -> JsonObject: + """The signed JSON body, read raw so the signature covers its bytes.""" + return { + "requestBody": { + "required": True, + "content": { + "application/json": {"schema": model.model_json_schema()} + }, + } + } + + +def json_response( + model: BaseModel, cache_control: str | None = None +) -> Response: + response = Response( + model.model_dump_json(exclude_none=True), + media_type="application/json", + ) + if cache_control: + response.headers["Cache-Control"] = cache_control + return response + + +def respond(answer: Answer) -> Response: + headers = dict(answer.headers) + media_type = headers.pop("Content-Type", "application/json") + return Response(answer.content(), answer.status, headers, media_type) + + +def raw_target(request: Request) -> tuple[str, str]: + """The path and query as the signer sent them, still percent-encoded: + what `@path` and `@query` cover, which the decoded path is not.""" + raw_path = request.scope.get("raw_path") + path = ( + raw_path.decode("latin-1") + if isinstance(raw_path, bytes) + else request.url.path + ) + query = request.scope.get("query_string", b"") + return path, query.decode("latin-1") if isinstance(query, bytes) else "" + + +async def serve(request: Request, base: str, handle: Handler) -> Response: + raw_path, raw_query = raw_target(request) + path = raw_path.removeprefix(urlsplit(base).path) + query = f"?{raw_query}" if raw_query else "" + signed = SignedRequest( + request.method, + f"{base}{path}{query}", + dict(request.headers), + (await request.body()) or None, + ) + try: + return respond(await handle(signed)) + except AAuthError as exc: + return respond(exc.answer()) + + +def relative_path(router: APIRouter, path: str) -> str: + """The endpoint base's path, less the router's prefix.""" + if not path.startswith(router.prefix): + raise ValueError( + f"the router's prefix {router.prefix!r} is not the start of " + f"the endpoint base's path {path!r}" + ) + return path.removeprefix(router.prefix) + + +class Resolver(Protocol[FoundServer]): + def __call__(self, request: Request) -> FoundServer | None: ... + + +class Finder(Protocol[FoundServer]): + def __call__(self, request: Request) -> FoundServer: ... + + +def fixed(server: AnyServer) -> Resolver[AnyServer]: + return lambda request: server + + +def finder(resolve: Resolver[AnyServer]) -> Finder[AnyServer]: + def find(request: Request) -> AnyServer: + server = resolve(request) + if server is None: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND) + return server + + return find + + +def well_known_router( + router: APIRouter, + find: Finder[Server], + *, + role: str, + title: str, + metadata_model: type[BaseModel], + cache_control: str | None, +) -> APIRouter: + """`/.well-known/aauth-{role}.json` and its keys.""" + + @router.get( + f"/.well-known/aauth-{role}.json", + summary=f"{title} metadata", + description="Its AAuth discovery document: issuer, keys, endpoints.", + operation_id=f"aauth_{role}_server_metadata", + response_model=metadata_model, + ) + async def metadata(request: Request) -> Response: + return json_response(find(request).metadata(), cache_control) + + @router.get( + f"/.well-known/aauth-{role}-jwks.json", + summary=f"{title} signing keys", + operation_id=f"aauth_{role}_server_jwks", + response_model=Jwks, + ) + async def jwks(request: Request) -> Response: + return json_response(find(request).jwks(), cache_control) + + return router + + +def endpoint_base_path( + source: Server | Resolver[Server], + endpoint_path: str | None, +) -> str: + if isinstance(source, Server): + return urlsplit(source.endpoint_base).path + if endpoint_path is None: + raise ValueError("a resolver needs the endpoint base's path") + return endpoint_path.rstrip("/") + + +class Action(Protocol[ActingServer]): + """A server's answer to one signed request, given its path parameters.""" + + def __call__( + self, + server: ActingServer, + signed: SignedRequest, + path: dict[str, str], + /, + ) -> Awaitable[Answer]: ... + + +def path_parameters(path: str) -> list[JsonValue]: + names = [ + segment.strip("{}").split(":")[0] + for segment in path.split("/") + if segment.startswith("{") + ] + return [ + { + "name": n, + "in": "path", + "required": True, + "schema": {"type": "string"}, + } + for n in names + ] + + +def signed_route( + router: APIRouter, + method: str, + path: str, + find: Finder[AnyServer], + act: Action[AnyServer], + *, + body: type[BaseModel] | None = None, + responses: Responses | None = None, + **docs: Unpack[RouteDocs], +) -> None: + """A signed protocol route: the server `find` resolves answers it by `act`.""" + extra: JsonObject = body_schema(body) if body is not None else {} + if parameters := path_parameters(path): + extra["parameters"] = parameters + + async def endpoint(request: Request) -> Response: + server = find(request) + + async def handle(signed: SignedRequest) -> Answer: + return await act(server, signed, dict(request.path_params)) + + return await serve(request, server.endpoint_base, handle) + + router.add_api_route( + path, + endpoint, + methods=[method], + openapi_extra=extra or None, + responses={code: {**r} for code, r in (responses or {}).items()}, + **docs, + ) diff --git a/packages/introspection-aauth/introspection_aauth/person_server/__init__.py b/packages/introspection-aauth/introspection_aauth/person_server/__init__.py new file mode 100644 index 0000000..ae6b170 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/person_server/__init__.py @@ -0,0 +1,44 @@ +"""An AAuth Person Server: missions, person and auth tokens, budgets, +approvals, and federation with a resource's own Access Server.""" + +from introspection_aauth.person_server.access_server_client import ( + AccessServerClient, +) +from introspection_aauth.person_server.directory import ( + AnyoneDirectory, + Directory, +) +from introspection_aauth.person_server.models import ( + Approval, + ApprovalKind, + ApprovalStatus, + Federation, + LogEntry, + Mission, + MissionGrant, + Outcome, + Step, +) +from introspection_aauth.person_server.server import PersonServer +from introspection_aauth.person_server.store import ( + MemoryStore, + Store, +) + +__all__ = [ + "AccessServerClient", + "AnyoneDirectory", + "Approval", + "ApprovalKind", + "ApprovalStatus", + "Directory", + "Federation", + "LogEntry", + "MemoryStore", + "Mission", + "MissionGrant", + "Outcome", + "PersonServer", + "Step", + "Store", +] diff --git a/packages/introspection-aauth/introspection_aauth/person_server/access_server_client.py b/packages/introspection-aauth/introspection_aauth/person_server/access_server_client.py new file mode 100644 index 0000000..d02f377 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/person_server/access_server_client.py @@ -0,0 +1,277 @@ +"""The Person Server's client for a resource's own Access Server +(`#access-server-federation`): it discovers the Access Server, redeems a +resource token there signed as the Person Server (`#ps-to-as-token-request`), +polls while it decides (`#deferred-responses`), and checks the auth token it +delivers (`#auth-token-delivery`). `PersonServer.federate` drives it.""" + +import logging +from urllib.parse import urljoin + +import httpx2 as httpx +from opentelemetry import trace +from pydantic import ValidationError + +from introspection_aauth.person_server.models import Federation, Outcome, Step +from introspection_aauth.protocol import jwt +from introspection_aauth.protocol.claims import ( + ACCESS_DWK, + AgentTokenClaims, + AuthTokenClaims, + PersonTokenClaims, + ResourceTokenClaims, +) +from introspection_aauth.protocol.discovery import Discovery +from introspection_aauth.protocol.errors import Problem, VerificationError +from introspection_aauth.protocol.expectations import DeliveryExpectation +from introspection_aauth.protocol.identity import Identity +from introspection_aauth.protocol.keys import Jwks +from introspection_aauth.protocol.pending import ( + requirement_name, + retry_after, +) +from introspection_aauth.protocol.server import Clock +from introspection_aauth.protocol.urls import ( + https_endpoint, + same_origin, +) +from introspection_aauth.protocol.wire import ( + AccessServerMetadata, + AuthTokenAnswer, + TokenRequest, +) + +logger = logging.getLogger(__name__) +tracer = trace.get_tracer(__name__) + +SERVED = frozenset({"approval", "interaction"}) +#: Requirements draft -11 defines that this Person Server does not serve. +NOT_SERVED = frozenset({"claims", "clarification"}) + + +class AccessServerClient: + """Talks to the Access Server a verified resource token names, signed as the Person Server.""" + + def __init__( + self, + identity: Identity, + discovery: Discovery, + http: httpx.AsyncClient, + clock: Clock, + ): + self._identity = identity + self._discovery = discovery + self._http = http + self._clock = clock + + async def discover( + self, access_server: str + ) -> AccessServerMetadata | Outcome: + """`{aud}/.well-known/aauth-access.json`, whose `issuer` is the `aud`.""" + with tracer.start_as_current_span( + "person_server.federate.metadata", + attributes={"aauth.access_server": access_server}, + ): + try: + document = await self._discovery.get( + f"{access_server}/.well-known/{ACCESS_DWK}", + AccessServerMetadata, + ) + except VerificationError as exc: + return Outcome.unreachable( + f"its metadata could not be read: {exc.detail}" + ) + if document.issuer != access_server: + return Outcome.unreachable("its metadata names another issuer") + if not ( + https_endpoint(document.auth_token_endpoint) + and same_origin(document.auth_token_endpoint, access_server) + and same_origin(document.jwks_uri, access_server) + ): + return Outcome.unreachable( + "its metadata keeps its auth_token_endpoint and jwks_uri " + "off its own https origin" + ) + return document + + async def request( + self, + federation: Federation, + agent_jkt: str, + metadata: AccessServerMetadata, + ) -> Outcome: + """The token request, with the budget the Person Server allows.""" + with tracer.start_as_current_span( + "person_server.federate.request", + attributes={"aauth.access_server": metadata.issuer}, + ): + body = TokenRequest( + resource_token=federation.resource_token, + agent_token=federation.agent_token, + presented_token=federation.presented_token, + budget=federation.budget, + ).to_json() + url = metadata.auth_token_endpoint + headers = self._identity.signed("POST", url, body, self._clock()) + response = await self._http.post( + url, content=body, headers=headers, follow_redirects=False + ) + return await self._outcome( + response, url, federation, agent_jkt, metadata + ) + + async def poll( + self, + location: str, + federation: Federation, + agent_jkt: str, + metadata: AccessServerMetadata, + ) -> Outcome: + with tracer.start_as_current_span( + "person_server.federate.poll", + attributes={"aauth.access_server": metadata.issuer}, + ): + headers = self._identity.signed( + "GET", location, None, self._clock() + ) + response = await self._http.get( + location, headers=headers, follow_redirects=False + ) + return await self._outcome( + response, location, federation, agent_jkt, metadata + ) + + async def _outcome( # noqa: PLR0911 + self, + response: httpx.Response, + requested: str, + federation: Federation, + agent_jkt: str, + metadata: AccessServerMetadata, + ) -> Outcome: + status = response.status_code + if status == 200: + try: + answer = AuthTokenAnswer.model_validate_json(response.content) + except ValidationError: + return Outcome.unreachable( + "it answered 200 without an auth_token" + ) + return await self._delivered( + answer.auth_token, federation, agent_jkt, metadata + ) + if status == 202: + return self._waiting(response, requested) + if status == 402: + return Outcome.refused( + 403, + "denied", + "the Access Server asks for payment, which this Person Server does not make", + ) + if status in (429, 503): + # Slow down and ask again: poll again, or send the request again. + retry = retry_after(response.headers) or 0 + return Outcome( + step=Step.PENDING, + location=None + if requested == metadata.auth_token_endpoint + else requested, + requirement=federation.requirement or "requirement=approval", + retry_after=retry + 5 if status == 429 else retry, + ) + try: + problem = Problem.model_validate_json(response.content) + except ValidationError: + problem = None + if 400 <= status < 500 and problem is not None and problem.error: + return Outcome.refused( + status, + problem.error, + f"the Access Server refused: {problem.detail or problem.error}", + ) + return Outcome.unreachable(f"it answered {status}") + + @staticmethod + def _waiting(response: httpx.Response, requested: str) -> Outcome: + header = response.headers.get("aauth-requirement") + requirement = requirement_name(header) if header else "approval" + if requirement is None: + return Outcome.unreachable("its AAuth-Requirement is malformed") + if requirement in NOT_SERVED: + return Outcome.refused( + 403, + "denied", + f"the Access Server asks for requirement={requirement}, not served here", + ) + location = response.headers.get("location") + polled = urljoin(requested, location) if location else None + if polled is None or not same_origin(polled, requested): + return Outcome.unreachable( + "its pending Location is not on its own origin" + ) + return Outcome( + step=Step.PENDING, + location=polled, + # An unrecognised requirement is polled through, and relayed as approval. + requirement=header + if header and requirement in SERVED + else "requirement=approval", + retry_after=retry_after(response.headers), + ) + + async def _delivered( + self, + auth_token: str, + federation: Federation, + agent_jkt: str, + metadata: AccessServerMetadata, + ) -> Outcome: + """`#auth-token-delivery`, and the budget granted no more than was sent.""" + with tracer.start_as_current_span( + "person_server.federate.delivery", + attributes={"aauth.access_server": metadata.issuer}, + ): + try: + rt = ResourceTokenClaims.model_validate( + jwt.decode(federation.resource_token).claims + ) + presented = PersonTokenClaims.model_validate( + jwt.decode(federation.presented_token).claims + ) + agent = AgentTokenClaims.model_validate( + jwt.decode(federation.agent_token).claims + ) + except (VerificationError, ValidationError): + return Outcome.unreachable( + "its request's tokens cannot be read back" + ) + try: + keys = await self._discovery.get(metadata.jwks_uri, Jwks) + claims = jwt.verify( + auth_token, + keys, + jwt.AUTH_TOKEN, + self._clock(), + AuthTokenClaims, + ) + except VerificationError as exc: + return Outcome.unreachable( + f"its auth token does not verify: {exc.detail}" + ) + wrong = DeliveryExpectation( + issuer=metadata.issuer, + ps=self._identity.issuer, + rt=rt, + agent_jkt=agent_jkt, + not_after=min(presented.exp, agent.exp), + sent=federation.budget, + ).failures(claims) + if wrong: + return Outcome.unreachable( + f"its auth token has the wrong {', '.join(wrong)}" + ) + return Outcome( + step=Step.ISSUED, + auth_token=auth_token, + auth_token_expires_at=claims.exp, + granted=claims.budget, + ) diff --git a/packages/introspection-aauth/introspection_aauth/person_server/directory.py b/packages/introspection-aauth/introspection_aauth/person_server/directory.py new file mode 100644 index 0000000..696765f --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/person_server/directory.py @@ -0,0 +1,19 @@ +"""Who a Person Server speaks for: the person a `login_hint` names in a tenant.""" + +from __future__ import annotations + +from typing import Protocol + + +class Directory(Protocol): + def person(self, login_hint: str, tenant: str) -> str | None: + """The person a `login_hint` names in `tenant`, or None if this Person + Server does not speak for them.""" + ... + + +class AnyoneDirectory: + """Speaks for whoever the hint names: for samples and tests.""" + + def person(self, login_hint: str, tenant: str) -> str | None: + return login_hint or None diff --git a/packages/introspection-aauth/introspection_aauth/person_server/hooks.py b/packages/introspection-aauth/introspection_aauth/person_server/hooks.py new file mode 100644 index 0000000..19da917 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/person_server/hooks.py @@ -0,0 +1,31 @@ +"""What a Person Server calls back into its host: hooks for the approvals it +raises, and the waits and exchanges of a federation a host may journal.""" + +from collections.abc import Awaitable +from typing import Protocol + +from introspection_aauth.person_server.models import ( + Approval, + Outcome, +) + + +class Hook(Protocol): + """Told of an approval in `tenant`: one a person must decide, or one + its resource's Access Server must.""" + + def __call__( + self, tenant: str, approval: Approval, / + ) -> Awaitable[None]: ... + + +class Sleep(Protocol): + """Waits `seconds`; a durable host passes its own.""" + + def __call__(self, seconds: float, /) -> Awaitable[None]: ... + + +class Exchange(Protocol): + """One exchange with the Access Server.""" + + def __call__(self) -> Awaitable[Outcome]: ... diff --git a/packages/introspection-aauth/introspection_aauth/person_server/models.py b/packages/introspection-aauth/introspection_aauth/person_server/models.py new file mode 100644 index 0000000..e5f20a3 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/person_server/models.py @@ -0,0 +1,253 @@ +"""What a Person Server keeps, and the requests it takes.""" + +from enum import StrEnum +from typing import Literal, Self + +from pydantic import BaseModel, ConfigDict, Field + +from introspection_aauth.protocol.claims import AuthGrant +from introspection_aauth.protocol.keys import Jwk +from introspection_aauth.protocol.models import Budget +from introspection_aauth.protocol.wire import Wire + + +class ApprovalKind(StrEnum): + MISSION = "mission" + AUTH_TOKEN = "auth_token" + COMPLETION = "completion" + + +class ApprovalStatus(StrEnum): + PENDING = "pending" + APPROVED = "approved" + DENIED = "denied" + + +class LogEntry(BaseModel): + """An update the agent logged under a mission.""" + + at: int + entry: str + + +class Mission(BaseModel): + """What a person approved, keyed by the hash of its bytes.""" + + s256: str + #: The mission's bytes, as hashed and returned to the agent. + mission: str + agent_id: str + #: Who the mission is for, as the `Directory` names them. + person: str + resources: list[str] + budget: Budget | None = None + spent: int = 0 + log: list[LogEntry] = Field(default_factory=list) + expires_at: int + completed_at: int | None = None + + def fits(self, budget: Budget | None) -> bool: + """Whether a request costing `budget` is within what is left.""" + if budget is None: + return True + if self.budget is None: + return False + left = self.budget.model_copy( + update={"amount": self.budget.amount - self.spent} + ) + return budget.at_most(left) + + +class Refusal(BaseModel): + """How a federated request ended without an auth token.""" + + status: int + error: str + detail: str + + +class Federation(BaseModel): + """An auth token request redeemed at the resource's Access Server + (`#ps-as-federation`).""" + + access_server: str + resource_token: str + agent_token: str + presented_token: str + #: The `budget` sent to the Access Server. + budget: Budget | None = None + #: Whether the spend must fit what is left; False once a person allowed it over. + within_budget: bool = True + #: The Access Server's `AAuth-Requirement` while it decides, relayed to the agent. + requirement: str | None = None + auth_token: str | None = None + auth_token_expires_at: int | None = None + collected: bool = False + refusal: Refusal | None = None + + @property + def settled(self) -> bool: + return ( + self.auth_token is not None + or self.collected + or self.refusal is not None + ) + + +class MissionGrant(BaseModel): + """What a mission approval would grant: the resources it covers. A + completion's grant is empty.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + resources: list[str] = Field(default_factory=list) + + +class Approval(BaseModel): + """A decision this Person Server waits on; the host's app makes it.""" + + id: str + kind: ApprovalKind + status: ApprovalStatus = ApprovalStatus.PENDING + #: Bumped on each write, so a store can refuse a stale one. + version: int = 0 + agent_id: str + agent_jkt: str + agent_jwk: Jwk + #: When the agent token the request came with expires. + agent_expires_at: int + person: str + mission_s256: str | None = None + #: The mission's description, the request's scope, or the completion summary. + description: str + #: The resource's own description of the request, from its R3 proposal. + summary: str | None = None + resource: str | None = None + budget: Budget | None = None + #: For an auth token, what it is issued with; for a mission, its resources. + grant: AuthGrant | MissionGrant = Field( + default_factory=MissionGrant, union_mode="left_to_right" + ) + federation: Federation | None = None + #: Set once the agent collects the auth token it issued; a second collect is refused. + collected: bool = False + decided_budget: Budget | None = None + decided_by: str | None = None + decided_at: int | None = None + expires_at: int + created_at: int + + +class MissionProposal(BaseModel): + model_config = ConfigDict(extra="ignore") + + description: str = Field(min_length=1, max_length=8_000) + resources: list[str] = Field(default_factory=list, max_length=16) + tenant: str | None = None + login_hint: str | None = Field(None, max_length=512) + + +class MissionAction(BaseModel): + model_config = ConfigDict(extra="ignore") + + action: Literal["update", "completion"] + description: str | None = Field(None, max_length=8_000) + summary: str | None = Field(None, max_length=8_000) + tenant: str | None = None + + +class PersonTokenRequest(BaseModel): + model_config = ConfigDict(extra="ignore") + + resource: str = Field(min_length=1, max_length=2_048) + tenant: str | None = None + login_hint: str | None = Field(None, max_length=512) + mission_s256: str | None = None + + +class AuthTokenRequest(BaseModel): + model_config = ConfigDict(extra="ignore") + + resource_token: str + presented_token: str + tenant: str | None = None + login_hint: str | None = Field(None, max_length=512) + upstream_token: str | None = None + subagent_token: str | None = None + + +class MissionDocument(BaseModel): + """The mission as approved: its bytes are what `s256` hashes and what the + agent is handed (`#mission`). Its approval makes it, and so its hash, the + mission's own; it names no person, since the agent and an audit read it.""" + + model_config = ConfigDict(frozen=True) + + agent: str + approval: str + approved_at: str + expires_at: str + description: str + approved_resources: list[str] + budget: Budget | None = None + + def to_json(self) -> str: + return self.model_dump_json(exclude_none=True) + + +class MissionAnswer(Wire): + """An approved mission: its hash, its bytes and a person token for each + resource it covers.""" + + s256: str + mission: str + person_tokens: dict[str, str] + + +class MissionLogged(Wire): + """A logged update: the hash of the entry.""" + + s256: str + + +#: Seconds between polls when the Access Server sends no usable `Retry-After`, and the bounds on it. +DEFAULT_DELAY, MIN_DELAY, MAX_DELAY = 5, 1, 300 + + +class Step(StrEnum): + ISSUED = "issued" + PENDING = "pending" + REFUSED = "refused" + + +class Outcome(BaseModel): + """What one exchange with an Access Server came to.""" + + step: Step + auth_token: str | None = None + auth_token_expires_at: int | None = None + #: The budget the Access Server granted, from its auth token. + granted: Budget | None = None + #: Where to poll next; on a pending outcome, None sends the request again. + location: str | None = None + requirement: str | None = None + retry_after: int | None = None + refusal: Refusal | None = None + + @classmethod + def refused(cls, status: int, error: str, detail: str) -> Self: + return cls( + step=Step.REFUSED, + refusal=Refusal(status=status, error=error, detail=detail), + ) + + @classmethod + def unreachable(cls, detail: str) -> Self: + """`as_unreachable`: no verifiable answer from the Access Server.""" + return cls.refused(502, "as_unreachable", detail) + + def poll_delay(self) -> int: + """`Retry-After`, 5 seconds when absent; never a hot loop, never a stall.""" + if self.retry_after is None: + return DEFAULT_DELAY + return min(max(self.retry_after, MIN_DELAY), MAX_DELAY) diff --git a/packages/introspection-aauth/introspection_aauth/person_server/server.py b/packages/introspection-aauth/introspection_aauth/person_server/server.py new file mode 100644 index 0000000..8214c69 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/person_server/server.py @@ -0,0 +1,1091 @@ +"""An AAuth Person Server (draft-hardt-oauth-aauth-protocol -11): missions, +person and auth tokens, budgets, approvals, and federation with a resource's +own Access Server. Framework-free: each method takes a `SignedRequest` and +returns an `Answer`, or raises `AAuthError`.""" + +import asyncio +import hashlib +import hmac +import logging +from collections.abc import Sequence +from datetime import UTC, datetime +from functools import partial +from typing import Unpack +from urllib.parse import quote, unquote + +import httpx2 as httpx +from opentelemetry import trace + +from introspection_aauth.person_server.access_server_client import ( + AccessServerClient, +) +from introspection_aauth.person_server.directory import ( + AnyoneDirectory, + Directory, +) +from introspection_aauth.person_server.hooks import ( + Exchange, + Hook, + Sleep, +) +from introspection_aauth.person_server.models import ( + Approval, + ApprovalKind, + ApprovalStatus, + AuthTokenRequest, + Federation, + LogEntry, + Mission, + MissionAction, + MissionAnswer, + MissionDocument, + MissionGrant, + MissionLogged, + MissionProposal, + Outcome, + PersonTokenRequest, + Refusal, + Step, +) +from introspection_aauth.person_server.store import ( + StaleRecord, + Store, +) +from introspection_aauth.protocol import jwt +from introspection_aauth.protocol.answer import Answer +from introspection_aauth.protocol.claims import ( + PERSON_DWK, + RESOURCE_DWK, + AuthGrant, + AuthTokenClaims, + Confirmation, + PersonTokenClaims, + ResourceTokenClaims, +) +from introspection_aauth.protocol.errors import ( + AAuthError, + AccessDenied, + Denied, + Expired, + InvalidCode, + InvalidRequest, + InvalidToken, + MissionNotFound, + MissionTerminated, + NotFound, + ServerError, + VerificationError, +) +from introspection_aauth.protocol.expectations import ( + PresentedExpectation, + ResourceTokenExpectation, +) +from introspection_aauth.protocol.httpsig import SignedRequest +from introspection_aauth.protocol.keys import SigningKey, b64url, s256 +from introspection_aauth.protocol.models import Agent, Budget +from introspection_aauth.protocol.pending import pending +from introspection_aauth.protocol.server import ( + Server, + ServerOptions, + parse, +) +from introspection_aauth.protocol.urls import https_origin +from introspection_aauth.protocol.verification import ( + verify_agent, + verify_resource_token, +) +from introspection_aauth.protocol.wire import ( + AuthTokenAnswer, + Empty, + PersonServerMetadata, + PersonTokenAnswer, +) + +__all__ = [ + "PersonServer", +] + +logger = logging.getLogger(__name__) +tracer = trace.get_tracer(__name__) + + +class PersonServer(Server): + """Speaks for the people its `Directory` names, keeping its state in a `Store`. + + `subject_secret` keys each person's directed `sub` and is kept apart from + the signing key, so rotating the key changes no one's identifier. + `on_approval` is told of each approval a person must decide; `on_federate` + of each approved request its resource's Access Server must decide, which + the host runs with `federate` or journals step by step. Unset, it is + run in this process. + """ + + def __init__( + self, + issuer: str, + signing_key: SigningKey, + store: Store, + *, + subject_secret: bytes, + agent_providers: Sequence[str], + http: httpx.AsyncClient, + directory: Directory | None = None, + default_tenant: str | None = None, + token_ttl: int = 3_600, + mission_ttl: int = 14 * 86_400, + approval_ttl: int = 2 * 86_400, + on_approval: Hook | None = None, + on_federate: Hook | None = None, + **options: Unpack[ServerOptions], + ): + super().__init__(issuer, PERSON_DWK, signing_key, http, **options) + self.store = store + self.directory = directory or AnyoneDirectory() + self._subject_secret = subject_secret + self._providers = {p.rstrip("/") for p in agent_providers} + self._default_tenant = default_tenant + self._token_ttl = min(token_ttl, jwt.MAX_TOKEN_LIFETIME_SECONDS) + self._mission_ttl = mission_ttl + self._approval_ttl = approval_ttl + self._on_approval, self._on_federate = on_approval, on_federate + self._running: set[asyncio.Task[Step | None]] = set() + + # region documents + def metadata(self) -> PersonServerMetadata: + return PersonServerMetadata( + issuer=self.issuer, + name=self.name, + jwks_uri=f"{self.issuer}/.well-known/aauth-person-jwks.json", + person_token_endpoint=f"{self.endpoint_base}/person-token", + auth_token_endpoint=f"{self.endpoint_base}/token", + mission_endpoint=f"{self.endpoint_base}/mission", + claims_supported=["sub", "tenant"], + ) + + def access_server_client(self) -> AccessServerClient: + """A client for the Access Servers it federates with, signing as it.""" + return AccessServerClient( + self.identity, self.discovery, self.http, self.clock + ) + + def pending_url(self, tenant: str, approval_id: str) -> str: + return f"{self.endpoint_base}/pending/{quote(tenant, safe='')}/{approval_id}" + + def pending_parts(self, path: str) -> tuple[str, str] | None: + """The tenant and approval a pending URL's path after `/pending/` + names, or None: the inverse of `pending_url`, so a host overriding + one overrides both.""" + tenant, _, approval_id = path.rpartition("/") + if not tenant or not approval_id or "/" in tenant: + return None + return unquote(tenant), approval_id + + # endregion + + # region people and tokens + def directed_subject(self, person: str, audience: str) -> str: + """The person's identifier at one audience; no two audiences can correlate it.""" + message = f"{person}\n{audience}".encode() + return b64url( + hmac.new(self._subject_secret, message, hashlib.sha256).digest() + ) + + # endregion + + # region missions + async def propose_mission(self, request: SignedRequest) -> Answer: + """`POST /mission`: always put to a person, who sets the budget.""" + agent = await self._agent(request) + proposal = parse(MissionProposal, request.body) + tenant = self._tenant(proposal.tenant) + resources = [ + r.rstrip("/") for r in proposal.resources if https_origin(r) + ] + person = await self._person(tenant, agent, proposal.login_hint) + approval = self._new_approval( + ApprovalKind.MISSION, + agent, + person, + proposal.description, + grant=MissionGrant(resources=resources), + ) + await self.store.insert_approval(tenant, approval) + return await self._ask(tenant, approval) + + async def mission_action( + self, request: SignedRequest, mission_s256: str + ) -> Answer: + """`POST /mission/{s256}`: log an update, or propose the mission complete.""" + agent = await self._agent(request) + action = parse(MissionAction, request.body) + tenant = self._tenant(action.tenant) + mission = self._live( + await self.store.select_mission(tenant, mission_s256), agent.id + ) + if action.action == "update": + entry = action.description or "" + if not await self.store.update_mission_log( + tenant, + mission.s256, + LogEntry(at=self.clock(), entry=entry), + self.clock(), + ): + raise MissionTerminated("the mission has ended") + return Answer(200, MissionLogged(s256=s256(entry.encode()))) + approval = self._new_approval( + ApprovalKind.COMPLETION, + agent, + mission.person, + action.summary or "The agent says the mission is done", + mission_s256=mission.s256, + ) + await self.store.insert_approval(tenant, approval) + return await self._ask(tenant, approval) + + async def person_token(self, request: SignedRequest) -> Answer: + """`POST /person-token`: who the agent acts for, at one resource.""" + agent = await self._agent(request) + body = parse(PersonTokenRequest, request.body) + tenant = self._tenant(body.tenant) + resource = body.resource.rstrip("/") + if not https_origin(resource): + raise InvalidRequest("resource is an https origin") + person = await self._person(tenant, agent, body.login_hint) + mission = None + if body.mission_s256 is not None: + mission = self._live( + await self.store.select_mission(tenant, body.mission_s256), + agent.id, + ) + if mission.person != person: + raise MissionNotFound("no such mission") + return Answer( + 200, self._person_token(tenant, agent, person, resource, mission) + ) + + # endregion + + # region auth tokens + async def auth_token(self, request: SignedRequest) -> Answer: + """`POST /token`: a call to a resource, weighed against the mission's budget.""" + agent = await self._agent(request) + body = parse(AuthTokenRequest, request.body) + tenant = self._tenant(body.tenant) + presented = self._presented(body.presented_token) + if presented.tenant != tenant: + raise InvalidToken("resource", "tenant does not match") + rt, mission = await self._resource_token( + body.resource_token, tenant, agent + ) + resource = rt.iss.rstrip("/") + if PresentedExpectation(ps=self.issuer, rt=rt).failures(presented): + raise InvalidToken( + "resource", "does not match the presented token" + ) + if rt.sub is None: + raise InvalidToken("resource", "sub is required") + budget = rt.budget + grant = AuthGrant.requested(rt, rt.sub) + access_server = self._access_server(rt) + if access_server is not None: + if ( + body.upstream_token is not None + or body.subagent_token is not None + ): + raise InvalidRequest( + "call chaining and sub-agents are not federated here" + ) + federation = Federation( + access_server=access_server, + resource_token=body.resource_token, + agent_token=agent.token, + presented_token=body.presented_token, + budget=budget, + within_budget=mission.fits(budget), + ) + return await self._federated( + tenant, agent, mission, grant, federation + ) + if budget is None: + return Answer(200, self._auth_token(tenant, agent, grant, mission)) + if mission.budget is not None and mission.budget.same_scale(budget): + spent = await self.store.update_spend( + tenant, mission.s256, budget.amount, True, self.clock() + ) + if spent is not None: + return Answer( + 200, self._auth_token(tenant, agent, grant, spent) + ) + approval = self._new_approval( + ApprovalKind.AUTH_TOKEN, + agent, + mission.person, + rt.scope or "a request", + mission_s256=mission.s256, + resource=resource, + budget=budget, + grant=grant, + ) + await self.store.insert_approval(tenant, approval) + return await self._ask(tenant, approval) + + # endregion + + # region approvals + async def poll( + self, request: SignedRequest, tenant: str, approval_id: str + ) -> Answer: + """`GET /pending/{tenant}/{id}`: only the key that asked may read it.""" + agent = await self._agent(request) + approval = await self.store.select_approval(tenant, approval_id) + if approval is None or approval.agent_jkt != agent.jkt: + raise NotFound("no such pending request") + mission = ( + await self.store.select_mission(tenant, approval.mission_s256) + if approval.mission_s256 + else None + ) + if approval.collected: + raise InvalidCode("this request was already answered") + federation = approval.federation + if approval.status is ApprovalStatus.DENIED: + await self._answered(tenant, approval) + refusal = federation.refusal if federation else None + if refusal is not None: + raise AAuthError(refusal.status, refusal.error, refusal.detail) + raise Denied("the approver declined") + if approval.status is ApprovalStatus.PENDING: + if approval.expires_at <= self.clock(): + await self._answered(tenant, approval) + raise Expired("nobody answered in time") + return pending( + self.pending_url(tenant, approval.id), self.retry_after + ) + if federation is not None: + return await self._collect( + tenant, agent, approval, federation, mission + ) + if approval.expires_at <= self.clock(): + await self._answered(tenant, approval) + raise Expired("it was not collected in time") + if approval.kind is ApprovalKind.COMPLETION: + await self._answered(tenant, approval) + return Answer(200, Empty()) + mission = self._live(mission, agent.id) + if approval.kind is ApprovalKind.MISSION: + await self._answered(tenant, approval) + person_tokens = { + r: self._person_token( + tenant, agent, mission.person, r, mission + ).person_token + for r in mission.resources + } + return Answer( + 200, + MissionAnswer( + s256=mission.s256, + mission=b64url(mission.mission.encode()), + person_tokens=person_tokens, + ), + ) + if not isinstance(approval.grant, AuthGrant): + raise ServerError("the approval grants nothing") + await self._answered(tenant, approval) + return Answer( + 200, self._auth_token(tenant, agent, approval.grant, mission) + ) + + async def decide( + self, + tenant: str, + approval_id: str, + status: ApprovalStatus, + *, + decided_by: str, + budget: Budget | None = None, + store: Store | None = None, + ) -> Approval | None: + """Record a person's answer, and do what was asked: create the mission, + spend the call, or complete the mission. None if there is no such approval. + + With `store`, the decision is made in that unit of work, which the + caller commits and then calls `decided`; otherwise in a transaction of + its own, which `decided` follows.""" + if status is ApprovalStatus.PENDING: + raise InvalidRequest("status is approved or denied", status=422) + if store is not None: + return await self._decide( + store, + tenant, + approval_id, + status, + decided_by=decided_by, + budget=budget, + ) + async with self.store.transaction() as tx: + decided = await self._decide( + tx, + tenant, + approval_id, + status, + decided_by=decided_by, + budget=budget, + ) + if decided is not None: + await self.decided(tenant, decided) + return decided + + async def decided(self, tenant: str, approval: Approval) -> None: + """After an approval is approved: a federated request goes to its Access Server.""" + if ( + approval.federation is None + or approval.federation.settled + or approval.status is not ApprovalStatus.APPROVED + ): + return + if self._on_federate is not None: + await self._on_federate(tenant, approval) + return + task = asyncio.create_task(self.federate(tenant, approval.id)) + self._running.add(task) + task.add_done_callback(self._running.discard) + + async def describe(self, tenant: str, approval_id: str) -> Approval | None: + """Read a call's R3 proposal into its approval's `summary`, for whoever decides.""" + approval = await self.store.select_approval(tenant, approval_id) + grant = approval.grant if approval is not None else None + if ( + approval is None + or not isinstance(grant, AuthGrant) + or grant.r3_uri is None + or grant.r3_s256 is None + ): + return approval + summary = await self.identity.summary( + self.http, grant.aud, grant.r3_uri, grant.r3_s256, self.clock() + ) + if summary is None or not await self.store.update_approval_summary( + tenant, approval.id, summary + ): + return approval + return approval.model_copy(update={"summary": summary}) + + # endregion + + # region federation + + async def federation_due( + self, tenant: str, approval_id: str + ) -> tuple[Federation, str] | None: + """The federation still to run for an approval, and the agent's key thumbprint.""" + approval = await self.store.select_approval(tenant, approval_id) + if ( + approval is None + or approval.federation is None + or approval.federation.settled + or approval.status is not ApprovalStatus.APPROVED + ): + return None + return approval.federation, approval.agent_jkt + + async def record_federation( + self, tenant: str, approval_id: str, outcome: Outcome + ) -> bool: + """Record what the Access Server answered; whether to keep polling. The + spend lands only when it issues, in the same transaction as its token.""" + try: + waiting = await self._record_federation( + tenant, approval_id, outcome + ) + except StaleRecord: + logger.info( + "federation outcome not recorded: the approval changed", + extra={"tenant": tenant, "approval_id": approval_id}, + ) + return False + logger.info( + "federation outcome recorded", + extra={ + "tenant": tenant, + "approval_id": approval_id, + "step": outcome.step.value, + "error": outcome.refusal.error if outcome.refusal else None, + }, + ) + return waiting + + async def federate( + self, + tenant: str, + approval_id: str, + sleep: Sleep = asyncio.sleep, + ) -> Step | None: + """Redeem an approved request at its Access Server, polling while it decides.""" + with tracer.start_as_current_span( + "person_server.federate", + attributes={"aauth.approval_id": approval_id}, + ): + found = await self.federation_due(tenant, approval_id) + if found is None: + return None + federation, agent_jkt = found + client = self.access_server_client() + metadata = await client.discover(federation.access_server) + + async def attempt(call: Exchange) -> Outcome: + """`call`, or `as_unreachable` when the Access Server cannot be + reached; retrying a connection is the http client's transport's.""" + try: + return await call() + except httpx.TransportError as exc: + return Outcome.unreachable( + f"it could not be reached: {exc}" + ) + + outcome = ( + metadata + if isinstance(metadata, Outcome) + else await attempt( + partial(client.request, federation, agent_jkt, metadata) + ) + ) + while True: + waiting = await self.record_federation( + tenant, approval_id, outcome + ) + if not waiting or isinstance(metadata, Outcome): + return outcome.step + await sleep(outcome.poll_delay()) + federation = federation.model_copy( + update={"requirement": outcome.requirement} + ) + outcome = await attempt( + partial(client.request, federation, agent_jkt, metadata) + if outcome.location is None + else partial( + client.poll, + outcome.location, + federation, + agent_jkt, + metadata, + ) + ) + + # endregion + + # region private + + def _tenant(self, named: str | None) -> str: + tenant = named or self._default_tenant + if not tenant: + raise InvalidRequest("name the tenant") + return tenant + + async def _agent(self, request: SignedRequest) -> Agent: + agent = await verify_agent( + request, self.discovery, self._providers, self.clock() + ) + if ( + agent.person_server is not None + and agent.person_server != self.issuer + ): + raise InvalidRequest( + "this agent's Person Server is another", status=403 + ) + return agent + + async def _person( + self, tenant: str, agent: Agent, login_hint: str | None + ) -> str: + """Who the agent acts for. A key is bound to the first person it names.""" + if login_hint is None: + bound = await self.store.select_binding(tenant, agent.jkt) + if bound is None: + raise InvalidRequest("name the person: login_hint") + return bound + person = self.directory.person(login_hint, tenant) + if person is None: + raise AccessDenied("this Person Server does not speak for them") + if ( + await self.store.insert_binding(tenant, agent.jkt, person) + != person + ): + raise InvalidRequest( + "this agent acts for another person", status=403 + ) + return person + + def _expiry(self, agent: Agent, not_after: int | None = None) -> int: + bounds = [self.clock() + self._token_ttl, agent.expires_at] + return min([*bounds, not_after] if not_after is not None else bounds) + + def _person_token( + self, + tenant: str, + agent: Agent, + person: str, + resource: str, + mission: Mission | None, + ) -> PersonTokenAnswer: + now = self.clock() + expires_at = self._expiry( + agent, mission.expires_at if mission else None + ) + claims = PersonTokenClaims( + iss=self.issuer, + dwk=PERSON_DWK, + jti=self.new_id(), + iat=now, + exp=expires_at, + aud=resource, + sub=self.directed_subject(person, resource), + cnf=Confirmation(jwk=agent.jwk), + tenant=tenant, + mission_s256=mission.s256 if mission else None, + ) + return PersonTokenAnswer( + person_token=self.identity.token(claims, jwt.PERSON_TOKEN), + expires_in=expires_at - now, + ) + + def _auth_token( + self, + tenant: str, + agent: Agent, + grant: AuthGrant, + mission: Mission, + ) -> AuthTokenAnswer: + now = self.clock() + expires_at = self._expiry(agent, mission.expires_at) + claims = AuthTokenClaims.issue( + grant.model_copy( + update={"tenant": tenant, "mission_s256": mission.s256} + ), + iss=self.issuer, + dwk=PERSON_DWK, + ps=self.issuer, + jwk=agent.jwk, + jti=self.new_id(), + iat=now, + exp=expires_at, + ) + return AuthTokenAnswer( + auth_token=self.identity.token(claims, jwt.AUTH_TOKEN), + expires_in=expires_at - now, + ) + + def _live(self, mission: Mission | None, agent_id: str) -> Mission: + """The mission, if it is this agent's and live; the same 404 otherwise.""" + if mission is None or mission.agent_id != agent_id: + raise MissionNotFound("no such mission") + if mission.completed_at is not None: + raise MissionTerminated("mission completed") + if mission.expires_at <= self.clock(): + raise MissionTerminated("mission expired") + return mission + + def _presented(self, token: str) -> PersonTokenClaims: + """The presented person token; an expired one is refused before anything is federated.""" + try: + return self.identity.verify_own( + token, jwt.PERSON_TOKEN, self.clock(), PersonTokenClaims + ) + except VerificationError as exc: + raise InvalidToken.rejected("presented", exc) from None + + async def _resource_token( + self, token: str, tenant: str, agent: Agent + ) -> tuple[ResourceTokenClaims, Mission]: + """The resource token, verified against its resource's keys, and the + live mission it falls under; only a mission's resources are fetched.""" + try: + header, unverified = jwt.peek(token) + except VerificationError: + raise InvalidToken("resource", "not a JWT") from None + if header.typ != jwt.RESOURCE_TOKEN or unverified.dwk != RESOURCE_DWK: + raise InvalidToken("resource", "not a resource token") + if unverified.tenant != tenant: + raise InvalidToken("resource", "tenant does not match") + if unverified.mission_s256 is None: + raise AccessDenied("an auth token is issued only within a mission") + mission = self._live( + await self.store.select_mission(tenant, unverified.mission_s256), + agent.id, + ) + resource = (unverified.iss or "").rstrip("/") + if resource not in mission.resources: + raise AccessDenied("this mission does not cover that resource") + rt = await verify_resource_token( + token, + self.discovery, + resource, + self.clock(), + ResourceTokenExpectation(ps=self.issuer, agent_jkt=agent.jkt), + ) + return rt, mission + + def _access_server(self, rt: ResourceTokenClaims) -> str | None: + """The resource's own Access Server, when `aud` is not this Person Server.""" + if rt.aud == self.issuer: + return None + if not https_origin(rt.aud): + raise InvalidToken( + "resource", + "aud is neither this Person Server nor an Access Server", + ) + return rt.aud.rstrip("/") + + async def _federated( + self, + tenant: str, + agent: Agent, + mission: Mission, + grant: AuthGrant, + federation: Federation, + ) -> Answer: + """The mission and budget are weighed first, as for any resource: a + request they cover is approved here and redeemed at the Access Server; + one over what is left goes to a person first.""" + approval = self._new_approval( + ApprovalKind.AUTH_TOKEN, + agent, + mission.person, + grant.scope or "a request", + mission_s256=mission.s256, + resource=grant.aud, + budget=federation.budget, + grant=grant, + federation=federation, + ) + if not federation.within_budget: + await self.store.insert_approval(tenant, approval) + return await self._ask(tenant, approval) + approval = approval.model_copy( + update={ + "status": ApprovalStatus.APPROVED, + "decided_at": self.clock(), + } + ) + await self.store.insert_approval(tenant, approval) + await self.decided(tenant, approval) + return pending(self.pending_url(tenant, approval.id), self.retry_after) + + def _new_approval( + self, + kind: ApprovalKind, + agent: Agent, + person: str, + description: str, + *, + mission_s256: str | None = None, + resource: str | None = None, + budget: Budget | None = None, + grant: AuthGrant | MissionGrant | None = None, + federation: Federation | None = None, + ) -> Approval: + now = self.clock() + return Approval( + id=self.new_id(), + kind=kind, + agent_id=agent.id, + agent_jkt=agent.jkt, + agent_jwk=agent.jwk, + agent_expires_at=agent.expires_at, + person=person, + description=description, + mission_s256=mission_s256, + resource=resource, + budget=budget, + grant=grant or MissionGrant(), + federation=federation, + expires_at=now + self._approval_ttl, + created_at=now, + ) + + async def _ask(self, tenant: str, approval: Approval) -> Answer: + if self._on_approval is not None: + await self._on_approval(tenant, approval) + return pending(self.pending_url(tenant, approval.id), self.retry_after) + + async def _answered(self, tenant: str, approval: Approval) -> None: + """Record that a pending request got its terminal answer, once: its + pending URL then answers `410` (`#pending-url-security`).""" + answered = approval.model_copy( + update={"collected": True, "version": approval.version + 1} + ) + if not await self.store.transition_approval( + tenant, approval, answered + ): + raise InvalidCode("this request was already answered") + + async def _collect( + self, + tenant: str, + agent: Agent, + approval: Approval, + federation: Federation, + mission: Mission | None, + ) -> Answer: + """The Access Server's auth token, collected once; else still waiting on it.""" + now = self.clock() + if federation.collected: + raise InvalidCode( + "this request's auth token was already collected" + ) + if federation.auth_token is None: + if approval.expires_at <= now: + await self._answered(tenant, approval) + raise Expired("the Access Server did not answer in time") + return pending( + self.pending_url(tenant, approval.id), + self.retry_after, + federation.requirement, + ) + self._live(mission, agent.id) + expires_in = (federation.auth_token_expires_at or 0) - now + if expires_in <= 0: + await self._answered(tenant, approval) + raise Expired("the auth token expired before it was collected") + collected = federation.model_copy( + update={ + "auth_token": None, + "auth_token_expires_at": None, + "collected": True, + } + ) + changed = approval.model_copy( + update={"federation": collected, "version": approval.version + 1} + ) + if not await self.store.transition_approval(tenant, approval, changed): + raise InvalidCode( + "this request's auth token was already collected" + ) + return Answer( + 200, + AuthTokenAnswer( + auth_token=federation.auth_token, expires_in=expires_in + ), + ) + + async def _decide( + self, + tx: Store, + tenant: str, + approval_id: str, + status: ApprovalStatus, + *, + decided_by: str, + budget: Budget | None, + ) -> Approval | None: + approval = await tx.select_approval(tenant, approval_id) + if approval is None: + return None + if approval.kind is not ApprovalKind.MISSION and budget is not None: + raise InvalidRequest("only a mission takes a budget", status=422) + if ( + approval.kind is ApprovalKind.COMPLETION + and decided_by != approval.person + ): + raise AccessDenied( + "a completion is accepted by the person the mission is for" + ) + now = self.clock() + if ( + approval.status is not ApprovalStatus.PENDING + or approval.expires_at <= now + ): + raise InvalidRequest( + "this approval is already decided or has expired", status=409 + ) + mission_s256 = approval.mission_s256 + if status is ApprovalStatus.APPROVED: + mission_s256 = await self._carry_out( + tx, tenant, approval, budget, now + ) + decided = approval.model_copy( + update={ + "status": status, + "version": approval.version + 1, + "mission_s256": mission_s256, + "decided_budget": budget, + "decided_by": decided_by, + "decided_at": now, + } + ) + if not await tx.transition_approval(tenant, approval, decided): + raise InvalidRequest( + "this approval is already decided or has expired", status=409 + ) + return decided + + async def _carry_out( + self, + tx: Store, + tenant: str, + approval: Approval, + budget: Budget | None, + now: int, + ) -> str | None: + if approval.kind is ApprovalKind.MISSION: + expires_at = now + self._mission_ttl + resources = ( + list(approval.grant.resources) + if isinstance(approval.grant, MissionGrant) + else [] + ) + blob = MissionDocument( + agent=approval.agent_id, + approval=approval.id, + approved_at=datetime.fromtimestamp(now, UTC).isoformat(), + expires_at=datetime.fromtimestamp(expires_at, UTC).isoformat(), + description=approval.description, + approved_resources=resources, + budget=budget, + ).to_json() + mission = Mission( + s256=s256(blob.encode()), + mission=blob, + agent_id=approval.agent_id, + person=approval.person, + resources=resources, + budget=budget, + expires_at=expires_at, + ) + if not await tx.insert_mission(tenant, mission): + raise InvalidRequest( + "a mission with this hash exists already", status=409 + ) + return mission.s256 + if approval.mission_s256 is None: + return None + current = await tx.select_mission(tenant, approval.mission_s256) + if approval.kind is ApprovalKind.COMPLETION: + ended = await tx.update_mission_end( + tenant, approval.mission_s256, now + ) + else: + # A call in another unit than the mission's budget is approved without + # spending; a federated one is spent once its Access Server issues. + spend = ( + approval.budget.amount + if approval.federation is None + and approval.budget is not None + and current is not None + and current.budget is not None + and current.budget.same_scale(approval.budget) + else None + ) + ended = await tx.update_spend( + tenant, approval.mission_s256, spend, False, now + ) + if ended is None: + raise MissionTerminated("the mission has ended", status=409) + return approval.mission_s256 + + async def _record_federation( + self, tenant: str, approval_id: str, outcome: Outcome + ) -> bool: + async with self.store.transaction() as tx: + approval = await tx.select_approval(tenant, approval_id) + federation = approval.federation if approval else None + if ( + approval is None + or federation is None + or federation.settled + or approval.status is not ApprovalStatus.APPROVED + ): + return False + now = self.clock() + status: ApprovalStatus = approval.status + if outcome.step is Step.PENDING: + if outcome.requirement == federation.requirement: + return approval.expires_at > now + federation = federation.model_copy( + update={"requirement": outcome.requirement} + ) + elif outcome.step is Step.ISSUED: + refusal = await self._spend_granted( + tx, tenant, approval, outcome, now + ) + if refusal is None: + federation = federation.model_copy( + update={ + "auth_token": outcome.auth_token, + "auth_token_expires_at": outcome.auth_token_expires_at, + } + ) + else: + status, federation = ( + ApprovalStatus.DENIED, + federation.model_copy(update={"refusal": refusal}), + ) + else: + status, federation = ( + ApprovalStatus.DENIED, + federation.model_copy(update={"refusal": outcome.refusal}), + ) + changed = approval.model_copy( + update={ + "status": status, + "federation": federation, + "version": approval.version + 1, + } + ) + if approval.expires_at <= now or not await tx.transition_approval( + tenant, approval, changed + ): + raise StaleRecord + return outcome.step is Step.PENDING + + async def _spend_granted( + self, + tx: Store, + tenant: str, + approval: Approval, + outcome: Outcome, + now: int, + ) -> Refusal | None: + """Spend what the Access Server granted, which may be less than was asked.""" + mission_s256 = approval.mission_s256 + within_budget = bool( + approval.federation and approval.federation.within_budget + ) + if mission_s256 is None: + return Refusal( + status=404, error="mission_not_found", detail="no such mission" + ) + mission = await tx.select_mission(tenant, mission_s256) + granted = outcome.granted + spend = ( + granted.amount + if granted is not None + and mission is not None + and mission.budget is not None + and mission.budget.same_scale(granted) + else None + ) + if await tx.update_spend( + tenant, + mission_s256, + spend, + within_budget and spend is not None, + now, + ): + return None + if ( + mission is None + or mission.completed_at is not None + or mission.expires_at <= now + ): + return Refusal( + status=403, + error="mission_terminated", + detail="the mission has ended", + ) + return Refusal( + status=403, + error="denied", + detail="the mission's budget no longer covers this request; ask again", + ) + + # endregion diff --git a/packages/introspection-aauth/introspection_aauth/person_server/store.py b/packages/introspection-aauth/introspection_aauth/person_server/store.py new file mode 100644 index 0000000..c464636 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/person_server/store.py @@ -0,0 +1,214 @@ +"""What a Person Server asks of its host: a `Store` for its state and a +`Directory` for who it speaks for. Neither names a database.""" + +from __future__ import annotations + +import asyncio +import copy +from collections.abc import AsyncGenerator +from contextlib import AbstractAsyncContextManager, asynccontextmanager +from typing import Protocol + +from introspection_aauth.person_server.models import ( + Approval, + LogEntry, + Mission, +) +from introspection_aauth.protocol.memory import compare_and_set + + +class Store(Protocol): + """A Person Server's state, in logical operations, per tenant. + + `transaction()` yields a `Store` for one unit of work: every read and + write through it commits together when the block ends, and anything raised + inside it rolls them all back. It groups a decision that creates or spends + a mission with the approval it decides, and a federated auth token with the + spend it pays for. + """ + + async def select_binding(self, tenant: str, jkt: str) -> str | None: + """The person an agent key is bound to.""" + ... + + async def insert_binding(self, tenant: str, jkt: str, person: str) -> str: + """Bind a key to a person unless it is bound; whoever it is bound to.""" + ... + + async def select_mission( + self, tenant: str, s256: str + ) -> Mission | None: ... + + async def insert_mission(self, tenant: str, mission: Mission) -> bool: + """Save an approved mission unless one with its hash exists.""" + ... + + async def update_spend( + self, + tenant: str, + s256: str, + amount: int | None, + within_budget: bool, + now: int, + ) -> Mission | None: + """Add `amount` to a live mission's spend, with `within_budget` only if + it fits what is left. The mission, or None if it is not live or it does + not fit. `amount` None checks only that it is live.""" + ... + + async def update_mission_log( + self, tenant: str, s256: str, entry: LogEntry, now: int + ) -> bool: + """Append to a live mission's log; whether it was live.""" + ... + + async def update_mission_end( + self, tenant: str, s256: str, at: int + ) -> Mission | None: + """Complete a live mission at `at`; it, or None if it was not live.""" + ... + + async def select_approval( + self, tenant: str, approval_id: str + ) -> Approval | None: ... + + async def insert_approval(self, tenant: str, approval: Approval) -> None: + """Save a new approval.""" + ... + + async def transition_approval( + self, tenant: str, current: Approval, changed: Approval + ) -> bool: + """Replace `current` with `changed` only if what is stored still has + `current`'s status and version; whether it did. The stored `summary` + is kept: only `update_approval_summary` writes it.""" + ... + + async def update_approval_summary( + self, tenant: str, approval_id: str, summary: str + ) -> bool: + """Set an approval's summary, leaving its version as it is, so a + decision or federation in flight is not made stale; whether it did.""" + ... + + def transaction(self) -> AbstractAsyncContextManager[Store]: ... + + +def _live(mission: Mission | None, now: int) -> Mission | None: + if ( + mission is None + or mission.completed_at is not None + or mission.expires_at <= now + ): + return None + return mission + + +class MemoryStore: + """A `Store` in this process's memory.""" + + def __init__(self) -> None: + self.bindings: dict[tuple[str, str], str] = {} + self.missions: dict[tuple[str, str], Mission] = {} + self.approvals: dict[tuple[str, str], Approval] = {} + self._lock = asyncio.Lock() + + async def select_binding(self, tenant: str, jkt: str) -> str | None: + return self.bindings.get((tenant, jkt)) + + async def insert_binding(self, tenant: str, jkt: str, person: str) -> str: + return self.bindings.setdefault((tenant, jkt), person) + + async def select_mission(self, tenant: str, s256: str) -> Mission | None: + return self.missions.get((tenant, s256)) + + async def insert_mission(self, tenant: str, mission: Mission) -> bool: + if (tenant, mission.s256) in self.missions: + return False + self.missions[(tenant, mission.s256)] = mission + return True + + async def update_spend( + self, + tenant: str, + s256: str, + amount: int | None, + within_budget: bool, + now: int, + ) -> Mission | None: + mission = _live(self.missions.get((tenant, s256)), now) + if mission is None: + return None + if amount is None: + return mission + if within_budget and ( + mission.budget is None + or mission.budget.amount - mission.spent < amount + ): + return None + mission.spent += amount + return mission + + async def update_mission_log( + self, tenant: str, s256: str, entry: LogEntry, now: int + ) -> bool: + mission = _live(self.missions.get((tenant, s256)), now) + if mission is not None: + mission.log.append(entry) + return mission is not None + + async def update_mission_end( + self, tenant: str, s256: str, at: int + ) -> Mission | None: + mission = _live(self.missions.get((tenant, s256)), at) + if mission is not None: + mission.completed_at = at + return mission + + async def select_approval( + self, tenant: str, approval_id: str + ) -> Approval | None: + return self.approvals.get((tenant, approval_id)) + + async def insert_approval(self, tenant: str, approval: Approval) -> None: + self.approvals[(tenant, approval.id)] = approval + + async def transition_approval( + self, tenant: str, current: Approval, changed: Approval + ) -> bool: + stored = self.approvals.get((tenant, current.id)) + if stored is not None: + changed = changed.model_copy(update={"summary": stored.summary}) + return compare_and_set( + self.approvals, (tenant, current.id), current, changed + ) + + async def update_approval_summary( + self, tenant: str, approval_id: str, summary: str + ) -> bool: + stored = self.approvals.get((tenant, approval_id)) + if stored is None: + return False + self.approvals[(tenant, approval_id)] = stored.model_copy( + update={"summary": summary} + ) + return True + + def transaction(self) -> AbstractAsyncContextManager[Store]: + return self._transaction() + + @asynccontextmanager + async def _transaction(self) -> AsyncGenerator[Store, None]: + async with self._lock: + saved = copy.deepcopy( + (self.bindings, self.missions, self.approvals) + ) + try: + yield self + except BaseException: + self.bindings, self.missions, self.approvals = saved + raise + + +class StaleRecord(Exception): + """Rolls a `record` back: the approval changed or lapsed meanwhile.""" diff --git a/packages/introspection-aauth/introspection_aauth/protocol/__init__.py b/packages/introspection-aauth/introspection_aauth/protocol/__init__.py new file mode 100644 index 0000000..1763f5f --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/protocol/__init__.py @@ -0,0 +1,106 @@ +"""What a Person Server and an Access Server share: errors, keys, RFC 9421 +signatures, tokens and their claims, discovery, budgets, the bodies they +exchange, and the plumbing of a server.""" + +from introspection_aauth.protocol.answer import Answer +from introspection_aauth.protocol.claims import ( + AgentTokenClaims, + AuthGrant, + AuthTokenClaims, + Claims, + Confirmation, + PersonTokenClaims, + ResourceTokenClaims, +) +from introspection_aauth.protocol.discovery import Discovery +from introspection_aauth.protocol.errors import ( + AAuthError, + AccessDenied, + Denied, + Expired, + InvalidCode, + InvalidRequest, + InvalidToken, + MissionNotFound, + MissionTerminated, + NotFound, + Problem, + ServerError, + Unauthorized, + VerificationError, +) +from introspection_aauth.protocol.expectations import ( + AgentExpectation, + DeliveryExpectation, + PresentedExpectation, + ResourceTokenExpectation, +) +from introspection_aauth.protocol.httpsig import SignedRequest, Verified +from introspection_aauth.protocol.identity import Identity +from introspection_aauth.protocol.json import JsonObject, JsonValue +from introspection_aauth.protocol.keys import ( + Jwk, + Jwks, + SigningKey, + jwks, + s256, + thumbprint, +) +from introspection_aauth.protocol.models import Agent, Budget +from introspection_aauth.protocol.pending import pending +from introspection_aauth.protocol.server import ( + Clock, + IdFactory, +) +from introspection_aauth.protocol.verification import ( + verify_agent, + verify_server, +) + +__all__ = [ + "AAuthError", + "AccessDenied", + "Agent", + "AgentExpectation", + "AgentTokenClaims", + "Answer", + "AuthGrant", + "AuthTokenClaims", + "Budget", + "Claims", + "Clock", + "Confirmation", + "DeliveryExpectation", + "Denied", + "Discovery", + "Expired", + "IdFactory", + "Identity", + "InvalidCode", + "InvalidRequest", + "InvalidToken", + "JsonObject", + "JsonValue", + "Jwk", + "Jwks", + "MissionNotFound", + "MissionTerminated", + "NotFound", + "PersonTokenClaims", + "PresentedExpectation", + "Problem", + "ResourceTokenClaims", + "ResourceTokenExpectation", + "ServerError", + "SignedRequest", + "SigningKey", + "Unauthorized", + "VerificationError", + "Verified", + "jwks", + "pending", + "s256", + "thumbprint", + "verify_agent", + "verify_server", +] diff --git a/packages/introspection-aauth/introspection_aauth/protocol/answer.py b/packages/introspection-aauth/introspection_aauth/protocol/answer.py new file mode 100644 index 0000000..6eb3945 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/protocol/answer.py @@ -0,0 +1,19 @@ +"""What a protocol request is answered with, for any web framework.""" + +from dataclasses import dataclass, field + +from pydantic import BaseModel + + +@dataclass(frozen=True) +class Answer: + """What a protocol request is answered with, for any web framework: its + status, its body, serialised with members that are None left out, and + its headers.""" + + status: int + body: BaseModel + headers: dict[str, str] = field(default_factory=dict) + + def content(self) -> bytes: + return self.body.model_dump_json(exclude_none=True).encode() diff --git a/packages/introspection-aauth/introspection_aauth/protocol/claims.py b/packages/introspection-aauth/introspection_aauth/protocol/claims.py new file mode 100644 index 0000000..b052dbd --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/protocol/claims.py @@ -0,0 +1,194 @@ +"""AAuth's tokens' claims (`#tokens`): one model per token type, used both to +issue a token and to read one that verified. Each model checks what a token +must be on its own; whether it is the token a caller expected is the job of +an expectation's `failures`, which names every check that failed.""" + +from typing import ClassVar, Final, Literal, Self + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from introspection_aauth.protocol.errors import VerificationError +from introspection_aauth.protocol.keys import Jwk, thumbprint +from introspection_aauth.protocol.models import Budget + +AGENT_DWK = "aauth-agent.json" +#: The well-known document a Person Server or Access Server signs under. +ServerDwk = Literal["aauth-person.json", "aauth-access.json"] +PERSON_DWK: Final = "aauth-person.json" +ACCESS_DWK: Final = "aauth-access.json" +RESOURCE_DWK = "aauth-resource.json" +#: The longest a person or auth token may live (`#person-token`, `#auth-token`). +MAX_TOKEN_LIFETIME_SECONDS = 3_600 +#: The longest an agent token should live (`#agent-token`). +MAX_AGENT_TOKEN_LIFETIME_SECONDS = 86_400 + + +class Confirmation(BaseModel): + """`cnf`: the key a token is bound to (RFC 7800).""" + + model_config = ConfigDict(frozen=True, extra="ignore") + + jwk: Jwk + + @model_validator(mode="after") + def _a_public_key(self) -> Self: + try: + thumbprint(self.jwk) + except VerificationError as exc: + raise ValueError(exc.detail) from None + return self + + @property + def thumbprint(self) -> str: + return thumbprint(self.jwk) + + +class Claims(BaseModel): + """What every AAuth token carries; a token type that caps its lifetime + sets `max_lifetime`.""" + + model_config = ConfigDict(frozen=True, extra="ignore") + max_lifetime: ClassVar[int | None] = None + + iss: str + iat: int + exp: int + jti: str = Field(min_length=1) + + @model_validator(mode="after") + def _a_bounded_life(self) -> Self: + if self.exp <= self.iat: + raise ValueError("exp is not after iat") + cap = type(self).max_lifetime + if cap is not None and self.exp - self.iat > cap: + raise ValueError(f"the token's lifetime is over {cap} seconds") + return self + + +class AgentTokenClaims(Claims): + """`aa-agent+jwt` (`#agent-token`): the agent, and the key it signs with.""" + + max_lifetime: ClassVar[int | None] = MAX_AGENT_TOKEN_LIFETIME_SECONDS + + dwk: Literal["aauth-agent.json"] + #: `aauth:local@domain`. + sub: str = Field(min_length=1) + cnf: Confirmation + #: The Person Server the agent's provider names for it, when it names one. + ps: str | None = None + + +class PersonTokenClaims(Claims): + """`aa-person+jwt` (`#person-token`): who the agent acts for, at one resource.""" + + max_lifetime: ClassVar[int | None] = MAX_TOKEN_LIFETIME_SECONDS + + dwk: Literal["aauth-person.json"] + aud: str + #: The person's directed identifier at `aud`. + sub: str = Field(min_length=1) + cnf: Confirmation + tenant: str | None = None + mission_s256: str | None = None + + +class Carried(BaseModel): + """The members a resource token asks for and an auth token carries over.""" + + model_config = ConfigDict(frozen=True) + + scope: str | None = None + budget: Budget | None = None + mission_s256: str | None = None + tenant: str | None = None + account: str | None = None + r3_uri: str | None = None + r3_s256: str | None = None + + def scopes(self) -> set[str]: + return set((self.scope or "").split()) + + +class ResourceTokenClaims(Claims, Carried): + """`aa-resource+jwt` (`#resource-token`): a call a resource asks to be + authorized, for the server named in `aud`. It should live five minutes + at most; a longer one is still read, since its resource bounds it.""" + + dwk: Literal["aauth-resource.json"] + aud: str + ps: str | None = None + sub: str | None = None + agent_jkt: str | None = None + #: The `jti` of the person token the resource saw. + presented_jti: str | None = None + + +class AuthGrant(Carried): + """What an auth token grants, read from the resource token that asked + for it: the members an issuer carries over, and nothing else.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + aud: str + sub: str = Field(min_length=1) + + @classmethod + def requested(cls, rt: ResourceTokenClaims, sub: str) -> Self: + """What `rt` asks for, for the person `sub`.""" + return cls( + aud=rt.iss.rstrip("/"), + sub=sub, + scope=rt.scope, + budget=rt.budget, + mission_s256=rt.mission_s256, + tenant=rt.tenant, + account=rt.account, + r3_uri=rt.r3_uri, + r3_s256=rt.r3_s256, + ) + + +class AuthTokenClaims(Claims, Carried): + """`aa-auth+jwt` (`#auth-token`): a grant for one call, from a Person + Server (`dwk` its document) or a resource's Access Server.""" + + max_lifetime: ClassVar[int | None] = MAX_TOKEN_LIFETIME_SECONDS + + dwk: ServerDwk + aud: str + sub: str = Field(min_length=1) + #: The Person Server the person is at. + ps: str + cnf: Confirmation + + @classmethod + def issue( + cls, + grant: AuthGrant, + *, + iss: str, + dwk: ServerDwk, + ps: str, + jwk: Jwk, + jti: str, + iat: int, + exp: int, + ) -> Self: + return cls( + iss=iss, + dwk=dwk, + jti=jti, + iat=iat, + exp=exp, + ps=ps, + cnf=Confirmation(jwk=jwk), + aud=grant.aud, + sub=grant.sub, + scope=grant.scope, + budget=grant.budget, + mission_s256=grant.mission_s256, + tenant=grant.tenant, + account=grant.account, + r3_uri=grant.r3_uri, + r3_s256=grant.r3_s256, + ) diff --git a/packages/introspection-aauth/introspection_aauth/protocol/discovery.py b/packages/introspection-aauth/introspection_aauth/protocol/discovery.py new file mode 100644 index 0000000..e094308 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/protocol/discovery.py @@ -0,0 +1,146 @@ +"""Metadata and keys: `{issuer}/.well-known/{dwk}` → `jwks_uri` → JWKS +(`#metadata-documents`), cached for a bounded time.""" + +import time +from typing import Protocol, TypeVar +from urllib.parse import urlsplit + +import httpx2 as httpx +from pydantic import BaseModel, ValidationError + +from introspection_aauth.protocol.errors import VerificationError +from introspection_aauth.protocol.keys import Jwk, Jwks +from introspection_aauth.protocol.wire import ServerMetadata + +T = TypeVar("T") +T_co = TypeVar("T_co", covariant=True) +Document = TypeVar("Document", bound=BaseModel) +#: How long an issuer's keys are trusted before they are fetched again. +KEYS_TTL_SECONDS = 300 +#: The least time between refetches forced by a token that failed to verify. +REFRESH_MIN_SECONDS = 30 + + +def is_identifier(value: str) -> bool: + """Whether `value` is an AAuth server identifier (`#server-identifiers`): + `https://` and a lowercase host, with no port, path, userinfo, query, + fragment or trailing slash.""" + try: + parts = urlsplit(value) + port = parts.port + except ValueError: + return False + host = parts.hostname or "" + return ( + parts.scheme == "https" + and bool(host) + and parts.netloc == host == host.lower() + and port is None + and not (parts.path or parts.query or parts.fragment) + and "#" not in value + and "?" not in value + ) + + +def is_https(value: str) -> bool: + parts = urlsplit(value) + return parts.scheme == "https" and bool(parts.hostname) + + +class Check(Protocol[T_co]): + """What a document must pass, against an issuer's keys.""" + + def __call__(self, keys: Jwks, /) -> T_co: ... + + +class Monotonic(Protocol): + """Seconds on a clock that never goes back, for timing the cache.""" + + def __call__(self) -> float: ... + + +class Discovery: + """Fetches issuers' metadata and keys over `http`.""" + + def __init__( + self, + http: httpx.AsyncClient, + *, + ttl_seconds: int = KEYS_TTL_SECONDS, + refresh_min_seconds: int = REFRESH_MIN_SECONDS, + clock: Monotonic = time.monotonic, + ): + self.http = http + self._ttl = ttl_seconds + self._refresh_min = refresh_min_seconds + self._clock = clock + self._cache: dict[tuple[str, str], tuple[float, Jwks]] = {} + + async def metadata(self, issuer: str, dwk: str) -> ServerMetadata: + """The issuer's metadata document, which must name it as `issuer` + and publish its keys over https.""" + if not is_identifier(issuer): + raise VerificationError( + "invalid_issuer", f"{issuer} is not an AAuth server identifier" + ) + document = await self.get( + f"{issuer}/.well-known/{dwk}", ServerMetadata + ) + if document.issuer != issuer: + raise VerificationError( + "issuer_mismatch", "metadata names another issuer" + ) + if document.jwks_uri is not None and not is_https(document.jwks_uri): + raise VerificationError( + "invalid_metadata", "its jwks_uri is not https" + ) + return document + + async def jwks( + self, issuer: str, dwk: str, *, refresh: bool = False + ) -> Jwks: + """The issuer's keys; `refresh` fetches again keys that failed, since an + issuer may rotate a key, but no more often than `refresh_min_seconds`.""" + cached = self._cache.get((issuer, dwk)) + now = self._clock() + if cached and cached[0] > now: + fetched_at = cached[0] - self._ttl + if not refresh or now - fetched_at < self._refresh_min: + return cached[1] + document = await self.metadata(issuer, dwk) + if document.jwks is not None: + keys = document.jwks + elif document.jwks_uri is not None: + keys = await self.get(document.jwks_uri, Jwks) + else: + raise VerificationError( + "unknown_key", f"{issuer} publishes no keys" + ) + self._cache[(issuer, dwk)] = (self._clock() + self._ttl, keys) + return keys + + async def verified(self, issuer: str, dwk: str, check: Check[T]) -> T: + """`check` against the issuer's keys, and once more against fresh ones.""" + try: + return check(await self.jwks(issuer, dwk)) + except VerificationError: + return check(await self.jwks(issuer, dwk, refresh=True)) + + async def key(self, issuer: str, dwk: str, kid: str) -> Jwk: + """One published key, by `kid`: how a `jwks_uri` signature is resolved.""" + for refresh in (False, True): + keys = await self.jwks(issuer, dwk, refresh=refresh) + for key in keys.keys: + if key.kid == kid: + return key + raise VerificationError("unknown_key", f"no key {kid} for {issuer}") + + async def get(self, url: str, model: type[Document]) -> Document: + """The JSON document at `url`, read into `model`; a redirect is not + followed.""" + try: + response = await self.http.get(url, follow_redirects=False) + response.raise_for_status() + return model.model_validate_json(response.content) + except (httpx.HTTPError, ValidationError, ValueError) as exc: + raise VerificationError("unknown_key", f"{url}: {exc}") from None diff --git a/packages/introspection-aauth/introspection_aauth/protocol/errors.py b/packages/introspection-aauth/introspection_aauth/protocol/errors.py new file mode 100644 index 0000000..f16fe75 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/protocol/errors.py @@ -0,0 +1,154 @@ +"""AAuth errors, answered as `application/problem+json` (`#error-responses`).""" + +from collections.abc import Mapping +from typing import Self + +from pydantic import BaseModel, ConfigDict + +from introspection_aauth.protocol.answer import Answer + +__all__ = [ + "AAuthError", + "AccessDenied", + "Denied", + "Expired", + "InvalidCode", + "InvalidRequest", + "InvalidToken", + "MissionNotFound", + "MissionTerminated", + "NotFound", + "Problem", + "ServerError", + "Unauthorized", + "VerificationError", +] + + +class Problem(BaseModel): + """`application/problem+json` (`#error-responses`).""" + + model_config = ConfigDict(frozen=True, extra="ignore") + + error: str + detail: str | None = None + + +class AAuthError(Exception): + """A refusal: `status`, an AAuth `error` code and a `detail`.""" + + def __init__( + self, + status: int, + error: str, + detail: str = "", + headers: Mapping[str, str] | None = None, + ): + super().__init__(detail or error) + self.status = status + self.error = error + self.detail = detail + self.headers = dict(headers or {}) + + def answer(self) -> Answer: + return Answer( + self.status, + Problem(error=self.error, detail=self.detail or None), + {"Content-Type": "application/problem+json", **self.headers}, + ) + + +class VerificationError(Exception): + """A token or signature that failed a check, with the AAuth code for it. + + Callers decide the status: the same failure is a `401` on an agent's + request and a `400` on a token it presents. + """ + + def __init__(self, code: str, detail: str): + super().__init__(detail) + self.code = code + self.detail = detail + + +class CodedError(AAuthError): + """An `AAuthError` whose code is fixed by its class.""" + + code: str + default_status: int + + def __init__( + self, + detail: str = "", + *, + status: int | None = None, + headers: Mapping[str, str] | None = None, + ): + super().__init__( + status or self.default_status, self.code, detail, headers + ) + + +class InvalidRequest(CodedError): + code, default_status = "invalid_request", 400 + + +class InvalidCode(CodedError): + """A pending URL that has already answered, or never existed (`#pending-url-security`).""" + + code, default_status = "invalid_code", 410 + + +class Expired(CodedError): + code, default_status = "expired", 408 + + +class NotFound(CodedError): + code, default_status = "not_found", 404 + + +class MissionNotFound(CodedError): + code, default_status = "mission_not_found", 404 + + +class MissionTerminated(CodedError): + code, default_status = "mission_terminated", 403 + + +class AccessDenied(CodedError): + code, default_status = "access_denied", 403 + + +class Denied(CodedError): + code, default_status = "denied", 403 + + +class ServerError(CodedError): + code, default_status = "server_error", 500 + + +class Unauthorized(AAuthError): + """`401`: the request's signature or agent does not verify.""" + + def __init__( + self, error: str, detail: str, headers: Mapping[str, str] | None = None + ): + super().__init__(401, error, detail, headers) + + @classmethod + def rejected( + cls, exc: VerificationError, headers: Mapping[str, str] | None = None + ) -> Self: + return cls(exc.code, exc.detail, headers) + + +class InvalidToken(AAuthError): + """`400 invalid__token`, for a token the request carries.""" + + def __init__(self, token: str, detail: str, *, expired: bool = False): + prefix = "expired" if expired else "invalid" + super().__init__(400, f"{prefix}_{token}_token", detail) + + @classmethod + def rejected(cls, token: str, exc: VerificationError) -> Self: + return cls(token, exc.detail, expired=exc.code == "expired_jwt") diff --git a/packages/introspection-aauth/introspection_aauth/protocol/expectations.py b/packages/introspection-aauth/introspection_aauth/protocol/expectations.py new file mode 100644 index 0000000..39c8e83 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/protocol/expectations.py @@ -0,0 +1,128 @@ +"""What a caller expects of a token beyond its being valid on its own: each +expectation names every check a token fails.""" + +from pydantic import BaseModel, ConfigDict + +from introspection_aauth.protocol.claims import ( + ACCESS_DWK, + AgentTokenClaims, + AuthTokenClaims, + PersonTokenClaims, + ResourceTokenClaims, +) +from introspection_aauth.protocol.models import Budget + + +class DeliveryExpectation(BaseModel): + """What a Person Server expects of the auth token a resource's Access + Server delivers (`#auth-token-delivery`), and the budget it sent: an + auth token for exactly the call it asked for, granting no more.""" + + model_config = ConfigDict(frozen=True) + + issuer: str + ps: str + rt: ResourceTokenClaims + agent_jkt: str + #: The earlier of the presented person token's and the agent token's + #: `exp`, which the auth token may not outlive. + not_after: int + sent: Budget | None = None + + def failures(self, claims: AuthTokenClaims) -> list[str]: + """The name of every check `claims` fails.""" + rt, sent, granted = self.rt, self.sent, claims.budget + checks = { + "dwk": claims.dwk != ACCESS_DWK, + "iss": claims.iss != self.issuer, + "aud": claims.aud.rstrip("/") != rt.iss.rstrip("/"), + "cnf": claims.cnf.thumbprint != self.agent_jkt, + "sub": claims.sub != rt.sub, + "ps": claims.ps != self.ps, + "mission_s256": claims.mission_s256 != rt.mission_s256, + "tenant": claims.tenant != rt.tenant, + "account": claims.account != rt.account, + "r3_uri": claims.r3_uri != rt.r3_uri, + "r3_s256": claims.r3_s256 != rt.r3_s256, + "scope": not claims.scopes() <= rt.scopes(), + "exp": claims.exp > self.not_after, + "budget": (sent is None) != (granted is None) + or ( + sent is not None + and granted is not None + and ( + not sent.same_scale(granted) + or granted.amount > sent.amount + ) + ), + } + return [name for name, failed in checks.items() if failed] + + +class PresentedExpectation(BaseModel): + """What the person token a resource saw must be, read from the resource + token that names it: issued by `ps` for that resource, bound to the + agent's key, and the same person, mission and tenant.""" + + model_config = ConfigDict(frozen=True) + + ps: str + rt: ResourceTokenClaims + + def failures(self, presented: PersonTokenClaims) -> list[str]: + rt = self.rt + checks = { + "iss": presented.iss != self.ps, + "aud": presented.aud != rt.iss.rstrip("/"), + "cnf": presented.cnf.thumbprint != rt.agent_jkt, + "jti": presented.jti != rt.presented_jti, + "sub": presented.sub != rt.sub, + "mission_s256": presented.mission_s256 != rt.mission_s256, + "tenant": presented.tenant != rt.tenant, + } + return [name for name, failed in checks.items() if failed] + + +class ResourceTokenExpectation(BaseModel): + """What a resource token must say for the server it reached: that + server as its `aud`, the Person Server it came through as its `ps`, the + agent that presents it, and the members the server needs.""" + + model_config = ConfigDict(frozen=True) + + ps: str + aud: str | None = None + agent_jkt: str | None = None + tenant: str | None = None + #: Whether `sub`, `presented_jti` and `agent_jkt` must be present. + requires_presented: bool = False + + def failures(self, rt: ResourceTokenClaims) -> list[str]: + checks = { + "ps": (rt.ps or "").rstrip("/") != self.ps, + "aud": self.aud is not None and rt.aud != self.aud, + "agent_jkt": self.agent_jkt is not None + and rt.agent_jkt != self.agent_jkt, + "tenant": self.tenant is not None and rt.tenant != self.tenant, + "sub": self.requires_presented and not rt.sub, + "presented_jti": self.requires_presented and not rt.presented_jti, + "agent_jkt_present": self.requires_presented and not rt.agent_jkt, + } + return [name for name, failed in checks.items() if failed] + + +class AgentExpectation(BaseModel): + """What the agent token a Person Server forwards must be: bound to the + key the resource token names, and for that Person Server if it names one.""" + + model_config = ConfigDict(frozen=True) + + agent_jkt: str + ps: str + + def failures(self, agent: AgentTokenClaims) -> list[str]: + checks = { + "cnf": agent.cnf.thumbprint != self.agent_jkt, + "ps": agent.ps is not None and agent.ps.rstrip("/") != self.ps, + } + return [name for name, failed in checks.items() if failed] diff --git a/packages/introspection-aauth/introspection_aauth/protocol/httpsig/__init__.py b/packages/introspection-aauth/introspection_aauth/protocol/httpsig/__init__.py new file mode 100644 index 0000000..0ef7708 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/protocol/httpsig/__init__.py @@ -0,0 +1,47 @@ +"""RFC 9421 HTTP Message Signatures under the AAuth profile, with the +signer's key named in `Signature-Key` (draft-hardt-httpbis-signature-key). + +`sign` covers `@method`, `@authority`, `@path`, `@query` when there is one, +`signature-key`, and for a request with a body `content-digest` and +`content-type`. `verify` checks a signature and returns the key that made +it; a token in the `jwt` scheme is decoded but not verified, which is the +caller's job against its issuer's keys. +""" + +from introspection_aauth.protocol.httpsig.signing import ( + KNOWN_DWKS, + LABEL, + MAX_CLOCK_SKEW_SECONDS, + REQUIRED, + authority, + content_digest, + jwks_uri_key, + jwt_key, + named_server, + sign, + signature_scheme, +) +from introspection_aauth.protocol.httpsig.verification import ( + KeyResolver, + SignedRequest, + Verified, + verify, +) + +__all__ = [ + "KNOWN_DWKS", + "LABEL", + "MAX_CLOCK_SKEW_SECONDS", + "REQUIRED", + "KeyResolver", + "SignedRequest", + "Verified", + "authority", + "content_digest", + "jwks_uri_key", + "jwt_key", + "named_server", + "sign", + "signature_scheme", + "verify", +] diff --git a/packages/introspection-aauth/introspection_aauth/protocol/httpsig/signing.py b/packages/introspection-aauth/introspection_aauth/protocol/httpsig/signing.py new file mode 100644 index 0000000..e057ae1 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/protocol/httpsig/signing.py @@ -0,0 +1,132 @@ +"""Signing a request under the AAuth profile, and the `Signature-Key` values +that name its key.""" + +import base64 +import hashlib +from urllib.parse import urlsplit + +import http_sfv + +from introspection_aauth.protocol.keys import SigningKey + +LABEL = "sig" +REQUIRED = ("@method", "@authority", "@path", "signature-key") +MAX_CLOCK_SKEW_SECONDS = 60 +#: Metadata documents a `jwks_uri` key may be named under (`#metadata-documents`). +KNOWN_DWKS = frozenset( + { + "aauth-agent.json", + "aauth-person.json", + "aauth-access.json", + "aauth-resource.json", + } +) + + +def _quoted(value: str) -> str: + if '"' in value or "\\" in value or not value.isascii(): + raise ValueError("value cannot travel in Signature-Key") + return f'"{value}"' + + +def jwt_key(token: str) -> str: + """`Signature-Key` presenting a token whose `cnf.jwk` is the key.""" + return f"{LABEL}=jwt;jwt={_quoted(token)}" + + +def jwks_uri_key(server: str, dwk: str, kid: str) -> str: + """`Signature-Key` naming a server's published key.""" + return ( + f"{LABEL}=jwks_uri;id={_quoted(server)};dwk={_quoted(dwk)};" + f"kid={_quoted(kid)}" + ) + + +def _signature_key(signature_key: str) -> http_sfv.Item | None: + parsed = http_sfv.Dictionary() + try: + parsed.parse(signature_key.encode("ascii")) + except (ValueError, UnicodeEncodeError): + return None + if len(parsed) != 1: + return None + item = next(iter(parsed.values())) + return item if isinstance(item, http_sfv.Item) else None + + +def signature_scheme(signature_key: str) -> str | None: + """The scheme a Signature-Key names, read before anything is fetched.""" + item = _signature_key(signature_key) + return str(item.value) if item is not None else None + + +def named_server(signature_key: str) -> str | None: + """The server a `jwks_uri` Signature-Key names, read before any key is + fetched, so a verifier can refuse a server it does not take requests from.""" + item = _signature_key(signature_key) + if item is None or str(item.value) != "jwks_uri": + return None + server = item.params.get("id") + return server if isinstance(server, str) else None + + +def content_digest(body: bytes) -> str: + """`sha-256=:…:`, the RFC 9530 `Content-Digest` of `body`.""" + return ( + f"sha-256=:{base64.b64encode(hashlib.sha256(body).digest()).decode()}:" + ) + + +def authority(url: str) -> str: + parts = urlsplit(url) + host = (parts.hostname or "").lower() + if not host: + raise ValueError(f"{url} has no host") + return f"{host}:{parts.port}" if parts.port else host + + +def sign( + key: SigningKey, + signature_key: str, + method: str, + url: str, + *, + body: bytes | None = None, + content_type: str = "application/json", + authorization: str | None = None, + created: int, +) -> dict[str, str]: + """The headers that sign a request to `url` with `key`, covering its + `Authorization` when it carries one.""" + parts = urlsplit(url) + covered = [ + ("@method", method.upper()), + ("@authority", authority(url)), + ("@path", parts.path or "/"), + ] + if parts.query: + covered.append(("@query", f"?{parts.query}")) + covered.append(("signature-key", signature_key)) + headers: dict[str, str] = {} + if body: + digest = content_digest(body) + covered += [("content-digest", digest), ("content-type", content_type)] + headers |= {"content-digest": digest, "content-type": content_type} + if authorization is not None: + covered.append(("authorization", authorization)) + headers["authorization"] = authorization + params = ( + "(" + + " ".join(f'"{name}"' for name, _ in covered) + + f");created={created}" + ) + base = "\n".join( + [f'"{name}": {value}' for name, value in covered] + + [f'"@signature-params": {params}'] + ) + signature = base64.b64encode(key.sign(base.encode())).decode() + return headers | { + "signature-key": signature_key, + "signature-input": f"{LABEL}={params}", + "signature": f"{LABEL}=:{signature}:", + } diff --git a/packages/introspection-aauth/introspection_aauth/protocol/httpsig/verification.py b/packages/introspection-aauth/introspection_aauth/protocol/httpsig/verification.py new file mode 100644 index 0000000..a775288 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/protocol/httpsig/verification.py @@ -0,0 +1,234 @@ +"""Verifying an RFC 9421 signed request with `Signature-Key`: which key signed +it, whether every required component is covered, and whether the signature +holds.""" + +from collections.abc import Awaitable, Mapping +from dataclasses import dataclass +from typing import Protocol +from urllib.parse import urlsplit + +import http_sfv +from http_sfv.item import Parameters +from pydantic import ValidationError + +from introspection_aauth.protocol import jwt +from introspection_aauth.protocol.claims import Confirmation +from introspection_aauth.protocol.errors import VerificationError +from introspection_aauth.protocol.httpsig.signing import ( + KNOWN_DWKS, + MAX_CLOCK_SKEW_SECONDS, + REQUIRED, + authority, + content_digest, +) +from introspection_aauth.protocol.keys import Jwk, thumbprint +from introspection_aauth.protocol.keys import verify as verify_with + + +class KeyResolver(Protocol): + """Finds a published key: a server's `id`, its `dwk` and the key's `kid`.""" + + def __call__( + self, server: str, dwk: str, kid: str, / + ) -> Awaitable[Jwk]: ... + + +@dataclass(frozen=True) +class KeyNaming: + """How a key was named: the token presented, or the server and document.""" + + token: str | None = None + server: str | None = None + dwk: str | None = None + + +@dataclass(frozen=True) +class SignedRequest: + """A request as its signer addressed it: `url` is the one it signed, + whatever proxy or rewrite delivered it.""" + + method: str + url: str + headers: Mapping[str, str] + body: bytes | None = None + + def __post_init__(self) -> None: + lowered = {k.lower(): v for k, v in self.headers.items()} + object.__setattr__(self, "headers", lowered) + + +@dataclass(frozen=True) +class Verified: + """A signature that verified, and how its key was named.""" + + scheme: str + jwk: Jwk + thumbprint: str + created: int + #: The presented token, for the `jwt` scheme: decoded, not verified. + token: str | None = None + #: The server and document, for the `jwks_uri` scheme. + server: str | None = None + dwk: str | None = None + + +def _dictionary(headers: Mapping[str, str], name: str) -> http_sfv.Dictionary: + value = headers.get(name) + if not value: + raise VerificationError("invalid_signature", f"missing {name}") + parsed = http_sfv.Dictionary() + try: + parsed.parse(value.encode("ascii")) + except (ValueError, UnicodeEncodeError): + raise VerificationError( + "invalid_signature", f"{name} is malformed" + ) from None + return parsed + + +def _covered(signature_input: http_sfv.InnerList) -> list[str]: + names: list[str] = [] + for item in signature_input: + if ( + not isinstance(item.value, str) + or item.params + or item.value in names + ): + raise VerificationError( + "invalid_input", "only plain, distinct components" + ) + names.append(item.value) + missing = [c for c in REQUIRED if c not in names] + if missing: + raise VerificationError( + "invalid_input", f"{missing[0]} must be covered" + ) + return names + + +async def _key( + scheme: str, params: Parameters, resolve: KeyResolver | None +) -> tuple[Jwk, KeyNaming]: + if scheme == "jwt": + token = params.get("jwt") + if not isinstance(token, str): + raise VerificationError( + "invalid_key", "the jwt scheme carries a jwt" + ) + cnf = jwt.decode(token).claims.get("cnf") + try: + jwk = Confirmation.model_validate(cnf).jwk + except ValidationError: + raise VerificationError( + "invalid_jwt", "the token has no cnf.jwk" + ) from None + return jwk, KeyNaming(token=token) + if scheme == "jwks_uri": + server, dwk, kid = (params.get(n) for n in ("id", "dwk", "kid")) + if not ( + isinstance(server, str) + and isinstance(dwk, str) + and isinstance(kid, str) + ): + raise VerificationError( + "invalid_key", "jwks_uri needs id, dwk and kid" + ) + if dwk not in KNOWN_DWKS or resolve is None: + raise VerificationError( + "invalid_key", "not a server this verifier reads" + ) + return await resolve(server, dwk, kid), KeyNaming( + server=server, dwk=dwk + ) + raise VerificationError("unsupported_scheme", f"the {scheme} scheme") + + +def _component(name: str, request: SignedRequest) -> str: + parts = urlsplit(request.url) + derived = { + "@method": request.method.upper(), + "@authority": authority(request.url), + "@path": parts.path or "/", + "@query": f"?{parts.query}", + } + if name in derived: + return derived[name] + if name.startswith("@") or name not in request.headers: + raise VerificationError("invalid_input", f"{name} cannot be covered") + return request.headers[name].strip() + + +async def verify( + request: SignedRequest, + now: int, + resolve: KeyResolver | None = None, +) -> Verified: + """Verify `request`'s signature; `resolve` finds a `jwks_uri` key.""" + headers, body = request.headers, request.body + keys = _dictionary(headers, "signature-key") + if len(keys) != 1: + raise VerificationError("invalid_key", "Signature-Key has one member") + label, item = next(iter(keys.items())) + if not isinstance(item, http_sfv.Item) or not isinstance( + item.value, http_sfv.Token + ): + raise VerificationError("invalid_key", "Signature-Key names a scheme") + inputs = _dictionary(headers, "signature-input") + signature_input = inputs.get(label) + if not isinstance(signature_input, http_sfv.InnerList): + raise VerificationError( + "invalid_signature", "no Signature-Input for the key" + ) + names = _covered(signature_input) + created = signature_input.params.get("created") + if not isinstance(created, int): + raise VerificationError( + "invalid_signature", "Signature-Input has no created" + ) + if created > now + MAX_CLOCK_SKEW_SECONDS: + raise VerificationError("clock_skew", "created is ahead of this clock") + if now - created > MAX_CLOCK_SKEW_SECONDS: + raise VerificationError("invalid_signature", "the signature is stale") + jwk, named = await _key(str(item.value), item.params, resolve) + if body: + for required in ("content-digest", "content-type"): + if required not in names: + raise VerificationError( + "invalid_input", f"{required} must be covered" + ) + # Required when present, per the AAuth-Access rule; this library does + # not issue AAuth-Access yet. + if "authorization" in headers and "authorization" not in names: + raise VerificationError( + "invalid_input", "authorization must be covered" + ) + if "content-digest" in names and headers.get( + "content-digest" + ) != content_digest(body or b""): + raise VerificationError( + "invalid_input", "content-digest is not the body's" + ) + base = "\n".join( + [f'"{n}": {_component(n, request)}' for n in names] + + [f'"@signature-params": {signature_input}'] + ) + signature = _dictionary(headers, "signature").get(label) + if not isinstance(signature, http_sfv.Item) or not isinstance( + signature.value, bytes + ): + raise VerificationError( + "invalid_signature", "no Signature for the key" + ) + if not verify_with(jwk, base.encode(), signature.value): + raise VerificationError( + "invalid_signature", "the signature does not verify" + ) + return Verified( + str(item.value), + jwk, + thumbprint(jwk), + created, + token=named.token, + server=named.server, + dwk=named.dwk, + ) diff --git a/packages/introspection-aauth/introspection_aauth/protocol/identity.py b/packages/introspection-aauth/introspection_aauth/protocol/identity.py new file mode 100644 index 0000000..28de655 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/protocol/identity.py @@ -0,0 +1,120 @@ +"""A server's own identity: its issuer, signing keys and published keys, the +tokens and requests it signs, and the R3 proposals it reads.""" + +import logging +from collections.abc import Sequence + +import httpx2 as httpx +from opentelemetry import trace + +from introspection_aauth.protocol import httpsig, jwt +from introspection_aauth.protocol.claims import ( + Claims, + ServerDwk, +) +from introspection_aauth.protocol.errors import VerificationError +from introspection_aauth.protocol.keys import Jwks, SigningKey, jwks, s256 +from introspection_aauth.protocol.urls import ( + https_origin, + same_origin, +) +from introspection_aauth.protocol.verification import ClaimsModel +from introspection_aauth.protocol.wire import R3Proposal + +logger = logging.getLogger(__name__) +tracer = trace.get_tracer(__name__) + + +class Identity: + """A server's identifier and keys: the current key signs, and the + previous ones stay published until what they signed has expired.""" + + def __init__( + self, + issuer: str, + dwk: ServerDwk, + signing_key: SigningKey, + previous_keys: Sequence[SigningKey] = (), + ): + if not https_origin(issuer): + raise ValueError("an issuer is an https origin with no path") + self.issuer = issuer.rstrip("/") + self.dwk = dwk + self.key = signing_key + self.keys = [signing_key, *previous_keys] + + def jwks(self) -> Jwks: + return jwks(self.keys) + + def token(self, claims: Claims, typ: str) -> str: + return jwt.encode(claims, self.key, typ) + + def verify_own( + self, token: str, typ: str, now: int, model: type[ClaimsModel] + ) -> ClaimsModel: + claims = jwt.verify(token, self.jwks(), typ, now, model) + if claims.iss != self.issuer: + raise VerificationError("invalid_jwt", "not issued here") + return claims + + def signed( + self, method: str, url: str, body: bytes | None, now: int + ) -> dict[str, str]: + """Headers signing a request as this server (`sig=jwks_uri`).""" + key = httpsig.jwks_uri_key(self.issuer, self.dwk, self.key.kid) + return httpsig.sign(self.key, key, method, url, body=body, created=now) + + async def read_proposal( + self, + http: httpx.AsyncClient, + resource: str, + r3_uri: str, + r3_s256: str, + now: int, + ) -> R3Proposal: + """A resource's R3 proposal, fetched signed and checked against its + hash; only from the resource's own https origin, so a resource token + cannot send this server anywhere else.""" + with tracer.start_as_current_span( + "aauth.r3.read", attributes={"aauth.resource": resource} + ): + if not same_origin(r3_uri, resource): + raise ValueError( + "the proposal is not on the resource's origin" + ) + response = await http.get( + r3_uri, + headers=self.signed("GET", r3_uri, None, now), + follow_redirects=False, + ) + response.raise_for_status() + if s256(response.content) != r3_s256: + raise ValueError("the proposal does not match its hash") + return R3Proposal.model_validate_json(response.content) + + async def summary( + self, + http: httpx.AsyncClient, + resource: str, + r3_uri: str, + r3_s256: str, + now: int, + ) -> str | None: + """A proposal's one-line summary, or None when there is none or it + cannot be read: a request stands without one.""" + try: + proposal = await self.read_proposal( + http, resource, r3_uri, r3_s256, now + ) + except (httpx.HTTPError, ValueError) as exc: + logger.warning( + "r3 proposal not read", + extra={"resource": resource, "error": type(exc).__name__}, + ) + return None + summary = proposal.display.summary if proposal.display else None + return summary[:MAX_SUMMARY_CHARS] if summary else None + + +#: The longest summary of a proposal shown to whoever decides. +MAX_SUMMARY_CHARS = 2_000 diff --git a/packages/introspection-aauth/introspection_aauth/protocol/json.py b/packages/introspection-aauth/introspection_aauth/protocol/json.py new file mode 100644 index 0000000..10bfeef --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/protocol/json.py @@ -0,0 +1,8 @@ +"""JSON whose shape AAuth leaves open, such as an unverified token's claims, +before it is read into a model.""" + +from pydantic import JsonValue + +JsonObject = dict[str, JsonValue] + +__all__ = ["JsonObject", "JsonValue"] diff --git a/packages/introspection-aauth/introspection_aauth/protocol/jwt.py b/packages/introspection-aauth/introspection_aauth/protocol/jwt.py new file mode 100644 index 0000000..14ea0fd --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/protocol/jwt.py @@ -0,0 +1,162 @@ +"""Compact JWS for AAuth's tokens, signed and verified under fully-specified +algorithms (`#token-format`).""" + +from dataclasses import dataclass +from typing import TypeVar + +from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError + +from introspection_aauth.protocol.claims import ( + MAX_TOKEN_LIFETIME_SECONDS, + Claims, +) +from introspection_aauth.protocol.errors import VerificationError +from introspection_aauth.protocol.json import JsonObject +from introspection_aauth.protocol.keys import ( + Jwk, + Jwks, + SigningKey, + algorithm, + b64url, + b64url_decode, +) +from introspection_aauth.protocol.keys import verify as verify_signature + +AGENT_TOKEN = "aa-agent+jwt" +PERSON_TOKEN = "aa-person+jwt" +RESOURCE_TOKEN = "aa-resource+jwt" +AUTH_TOKEN = "aa-auth+jwt" +#: How far a token's `iat` may be ahead of this clock. +CLOCK_SKEW_SECONDS = 60 + +__all__ = [ + "AGENT_TOKEN", + "AUTH_TOKEN", + "CLOCK_SKEW_SECONDS", + "MAX_TOKEN_LIFETIME_SECONDS", + "PERSON_TOKEN", + "RESOURCE_TOKEN", + "Decoded", + "Header", + "Unverified", + "decode", + "encode", + "peek", + "verify", +] + +ClaimsModel = TypeVar("ClaimsModel", bound=Claims) +JSON_OBJECT = TypeAdapter(JsonObject) + + +class Header(BaseModel): + """A JWS header, as AAuth's tokens use it.""" + + model_config = ConfigDict(frozen=True, extra="ignore") + + alg: str | None = None + typ: str | None = None + kid: str | None = None + + +class Unverified(BaseModel): + """What may be read of a token before it is verified, to decide whose keys + to fetch: nothing here is to be trusted.""" + + model_config = ConfigDict(frozen=True, extra="ignore") + + iss: str | None = None + dwk: str | None = None + tenant: str | None = None + mission_s256: str | None = None + + +def _algorithm(key: Jwk) -> str | None: + """A key's algorithm, or None for one this library does not verify with.""" + try: + return algorithm(key) + except VerificationError: + return None + + +def encode(claims: Claims, key: SigningKey, typ: str) -> str: + header = Header(alg="Ed25519", typ=typ, kid=key.kid) + signing_input = ".".join( + b64url(part.model_dump_json(exclude_none=True).encode()) + for part in (header, claims) + ) + return f"{signing_input}.{b64url(key.sign(signing_input.encode()))}" + + +@dataclass(frozen=True) +class Decoded: + """A JWT split and decoded; nothing about it is verified yet.""" + + header: Header + claims: JsonObject + signing_input: bytes + signature: bytes + + +def decode(token: str) -> Decoded: + parts = token.split(".") + if len(parts) != 3: + raise VerificationError("invalid_jwt", "not a compact JWT") + try: + header = Header.model_validate_json(b64url_decode(parts[0])) + claims = JSON_OBJECT.validate_json(b64url_decode(parts[1])) + signature = b64url_decode(parts[2]) + except (ValueError, ValidationError): + raise VerificationError("invalid_jwt", "not a JWT") from None + return Decoded( + header, claims, f"{parts[0]}.{parts[1]}".encode(), signature + ) + + +def peek(token: str) -> tuple[Header, Unverified]: + """A token's header and what it says of itself, unverified.""" + decoded = decode(token) + try: + return decoded.header, Unverified.model_validate(decoded.claims) + except ValidationError: + raise VerificationError("invalid_jwt", "not a JWT") from None + + +def verify( + token: str, + keys: Jwks, + typ: str, + now: int, + model: type[ClaimsModel], +) -> ClaimsModel: + """The claims of `token`, read into `model`: signed by one of `keys`, of + type `typ`, unexpired at `now`, and valid as that token on its own.""" + decoded = decode(token) + if decoded.header.typ != typ: + raise VerificationError("invalid_jwt", f"not an {typ}") + kid = decoded.header.kid + candidates = [k for k in keys.keys if kid is None or k.kid == kid] + if not candidates: + raise VerificationError("unknown_key", "signed by a key not published") + if not any( + _algorithm(k) == decoded.header.alg + and verify_signature(k, decoded.signing_input, decoded.signature) + for k in candidates + ): + raise VerificationError("invalid_jwt", "the signature does not verify") + try: + claims = model.model_validate(decoded.claims) + except ValidationError as exc: + error = exc.errors()[0] + where = ".".join(str(part) for part in error["loc"]) + raise VerificationError( + "invalid_jwt", + f"{where}: {error['msg']}" if where else error["msg"], + ) from None + if claims.exp <= now: + raise VerificationError("expired_jwt", "the token has expired") + if claims.iat > now + CLOCK_SKEW_SECONDS: + raise VerificationError( + "clock_skew", "the token's iat is in the future" + ) + return claims diff --git a/packages/introspection-aauth/introspection_aauth/protocol/keys.py b/packages/introspection-aauth/introspection_aauth/protocol/keys.py new file mode 100644 index 0000000..606df12 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/protocol/keys.py @@ -0,0 +1,169 @@ +"""Keys: signing with Ed25519, verifying Ed25519 and ES256, RFC 7638 thumbprints. + +Algorithms are the fully-specified ones (RFC 9864): a JWK's `alg` and a JWS +header say `Ed25519` or `ES256`, never `EdDSA`. +""" + +import base64 +import hashlib +import json +from collections.abc import Sequence +from typing import Self + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, + Ed25519PublicKey, +) +from cryptography.hazmat.primitives.asymmetric.utils import ( + encode_dss_signature, +) +from pydantic import BaseModel, ConfigDict + +from introspection_aauth.protocol.errors import VerificationError + +ALGORITHMS = {"Ed25519": ("OKP", "Ed25519"), "ES256": ("EC", "P-256")} + + +def b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode() + + +def b64url_decode(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +class Jwk(BaseModel): + """A public JSON Web Key (RFC 7517), as AAuth's keys are: OKP or EC.""" + + model_config = ConfigDict(frozen=True, extra="ignore") + + kty: str + crv: str | None = None + x: str | None = None + y: str | None = None + alg: str | None = None + kid: str | None = None + use: str | None = None + + +class Jwks(BaseModel): + """A JSON Web Key Set.""" + + model_config = ConfigDict(frozen=True, extra="ignore") + + keys: list[Jwk] + + +def s256(data: bytes) -> str: + """The base64url SHA-256 of `data`, as AAuth hashes missions and proposals.""" + return b64url(hashlib.sha256(data).digest()) + + +def thumbprint(jwk: Jwk) -> str: + """RFC 7638 thumbprint of an OKP or EC public key: SHA-256 over its + required members only, as JSON with sorted keys and no whitespace.""" + if jwk.kty == "OKP" and jwk.crv and jwk.x: + members = {"crv": jwk.crv, "kty": jwk.kty, "x": jwk.x} + elif jwk.kty == "EC" and jwk.crv and jwk.x and jwk.y: + members = {"crv": jwk.crv, "kty": jwk.kty, "x": jwk.x, "y": jwk.y} + else: + raise VerificationError("invalid_key", "not an OKP or EC public key") + canonical = json.dumps(members, separators=(",", ":"), sort_keys=True) + return s256(canonical.encode()) + + +def algorithm(jwk: Jwk) -> str: + """The key's algorithm: its `alg`, which must match its type, or, with + no `alg` (RFC 7800 does not require one), the one its type names.""" + named = jwk.alg or next( + ( + alg + for alg, kind in ALGORITHMS.items() + if kind == (jwk.kty, jwk.crv) + ), + None, + ) + if named is None or named not in ALGORITHMS: + raise VerificationError( + "unsupported_algorithm", "the key is not Ed25519 or ES256" + ) + if (jwk.kty, jwk.crv) != ALGORITHMS[named]: + raise VerificationError( + "invalid_key", "the key's type is not its alg's" + ) + return named + + +def verify(jwk: Jwk, data: bytes, signature: bytes) -> bool: + """Whether `jwk` made `signature` over `data`.""" + try: + alg = algorithm(jwk) + if jwk.x is None: + return False + if alg == "Ed25519": + Ed25519PublicKey.from_public_bytes(b64url_decode(jwk.x)).verify( + signature, data + ) + return True + if len(signature) != 64 or jwk.y is None: + return False + public = ec.EllipticCurvePublicNumbers( + int.from_bytes(b64url_decode(jwk.x), "big"), + int.from_bytes(b64url_decode(jwk.y), "big"), + ec.SECP256R1(), + ).public_key() + der = encode_dss_signature( + int.from_bytes(signature[:32], "big"), + int.from_bytes(signature[32:], "big"), + ) + public.verify(der, data, ec.ECDSA(hashes.SHA256())) + return True + except (InvalidSignature, ValueError, VerificationError): + return False + + +class SigningKey: + """An Ed25519 signing key; its `kid` is its thumbprint.""" + + def __init__(self, private_key: Ed25519PrivateKey): + self._private = private_key + raw = private_key.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw + ) + self.kid = thumbprint(Jwk(kty="OKP", crv="Ed25519", x=b64url(raw))) + self.public_jwk = Jwk( + kty="OKP", + crv="Ed25519", + x=b64url(raw), + alg="Ed25519", + kid=self.kid, + use="sig", + ) + + @classmethod + def generate(cls) -> Self: + return cls(Ed25519PrivateKey.generate()) + + @classmethod + def from_pem(cls, pem: str | bytes) -> Self: + key = serialization.load_pem_private_key( + pem.encode() if isinstance(pem, str) else pem, password=None + ) + if not isinstance(key, Ed25519PrivateKey): + raise ValueError("an AAuth signing key is an Ed25519 PKCS#8 PEM") + return cls(key) + + @classmethod + def from_seed(cls, seed: bytes) -> Self: + return cls(Ed25519PrivateKey.from_private_bytes(seed)) + + def sign(self, data: bytes) -> bytes: + return self._private.sign(data) + + +def jwks(keys: Sequence[SigningKey]) -> Jwks: + """A JWKS publishing `keys`: the current key and any previous ones.""" + return Jwks(keys=[k.public_jwk for k in keys]) diff --git a/packages/introspection-aauth/introspection_aauth/protocol/memory.py b/packages/introspection-aauth/introspection_aauth/protocol/memory.py new file mode 100644 index 0000000..917c96b --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/protocol/memory.py @@ -0,0 +1,32 @@ +"""What both servers' in-memory stores share.""" + +from collections.abc import Hashable +from enum import StrEnum +from typing import Protocol, TypeVar + + +class Versioned(Protocol): + @property + def status(self) -> StrEnum: ... + + @property + def version(self) -> int: ... + + +Record = TypeVar("Record", bound=Versioned) +Key = TypeVar("Key", bound=Hashable) + + +def compare_and_set( + records: dict[Key, Record], key: Key, current: Record, changed: Record +) -> bool: + """Replace `records[key]` with `changed` only if it still has `current`'s + status and version: how a store refuses a write made on a stale read.""" + stored = records.get(key) + if stored is None or (stored.status, stored.version) != ( + current.status, + current.version, + ): + return False + records[key] = changed + return True diff --git a/packages/introspection-aauth/introspection_aauth/protocol/models.py b/packages/introspection-aauth/introspection_aauth/protocol/models.py new file mode 100644 index 0000000..a0d134e --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/protocol/models.py @@ -0,0 +1,46 @@ +"""Values both servers exchange.""" + +from typing import Self + +from pydantic import BaseModel, ConfigDict, Field + +from introspection_aauth.protocol.keys import Jwk + +#: draft-hardt-aauth-budgets caps an amount at 15 digits. +MAX_BUDGET_AMOUNT = 999_999_999_999_999 + + +class Budget(BaseModel): + """A budget: `amount / 10**decimals` in `unit` (draft-hardt-aauth-budgets).""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + amount: int = Field(ge=0, le=MAX_BUDGET_AMOUNT) + unit: str = Field(min_length=1, max_length=32) + decimals: int = Field(ge=0, le=18) + + def same_scale(self, other: Self) -> bool: + return self.unit == other.unit and self.decimals == other.decimals + + def at_most(self, limit: Self) -> bool: + """Whether this is no more than `limit`, in the same unit.""" + return self.same_scale(limit) and self.amount <= limit.amount + + +class Agent(BaseModel): + """The agent a request came from, once its signature and token verify.""" + + model_config = ConfigDict(frozen=True) + + #: `aauth:local@domain`. + id: str + #: Its agent provider. + provider: str + #: RFC 7638 thumbprint of the key it signs with. + jkt: str + jwk: Jwk + expires_at: int + #: The Person Server its agent token names, when it names one. + person_server: str | None = None + #: The agent token as presented. + token: str = Field(repr=False) diff --git a/packages/introspection-aauth/introspection_aauth/protocol/pending.py b/packages/introspection-aauth/introspection_aauth/protocol/pending.py new file mode 100644 index 0000000..e88327e --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/protocol/pending.py @@ -0,0 +1,63 @@ +"""Deferred responses (`#deferred-responses`): the `202` pending answer, its +`AAuth-Requirement` header and `Retry-After`.""" + +from collections.abc import Mapping + +import http_sfv + +from introspection_aauth.protocol.answer import Answer +from introspection_aauth.protocol.wire import Pending + + +def requirement( + name: str, *, url: str | None = None, code: str | None = None +) -> str: + """An `AAuth-Requirement` header (`#requirement-values`): an RFC 9651 + Dictionary whose `requirement` carries its parameters, strings escaped.""" + item = http_sfv.Item(http_sfv.Token(name)) + if url is not None: + item.params["url"] = url + if code is not None: + item.params["code"] = code + header = http_sfv.Dictionary() + header["requirement"] = item + return str(header) + + +def requirement_name(header: str) -> str | None: + """The `requirement` an `AAuth-Requirement` header names, or None when + it is not an RFC 9651 Dictionary that names one.""" + parsed = http_sfv.Dictionary() + try: + parsed.parse(header.encode("ascii")) + except (ValueError, UnicodeEncodeError): + return None + item = parsed.get("requirement") + return str(item.value) if item is not None else None + + +def retry_after(headers: Mapping[str, str]) -> int | None: + """`Retry-After` in seconds, when it is a number.""" + try: + return max(int(headers.get("retry-after", "")), 0) + except ValueError: + return None + + +def pending( + location: str, retry_after: int, requirement: str | None = None +) -> Answer: + """`202`: poll `location` (`#deferred-responses`).""" + return Answer( + 202, + Pending(), + { + "Location": location, + "Retry-After": str(retry_after), + "Cache-Control": "no-store", + "AAuth-Requirement": requirement or APPROVAL_REQUIREMENT, + }, + ) + + +APPROVAL_REQUIREMENT = "requirement=approval" diff --git a/packages/introspection-aauth/introspection_aauth/protocol/server.py b/packages/introspection-aauth/introspection_aauth/protocol/server.py new file mode 100644 index 0000000..b910720 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/protocol/server.py @@ -0,0 +1,117 @@ +"""What a Person Server and an Access Server both do as servers: sign as +themselves, verify the agents and servers that call them, read a resource's +R3 proposal, and answer a request that has to wait.""" + +import logging +import time +import uuid +from collections.abc import Sequence +from typing import NotRequired, Protocol, TypedDict, TypeVar, Unpack + +import httpx2 as httpx +from pydantic import BaseModel, ValidationError + +from introspection_aauth.protocol.claims import ( + ACCESS_DWK, + AGENT_DWK, + PERSON_DWK, + RESOURCE_DWK, + ServerDwk, +) +from introspection_aauth.protocol.discovery import Discovery +from introspection_aauth.protocol.errors import InvalidRequest +from introspection_aauth.protocol.identity import Identity +from introspection_aauth.protocol.keys import Jwks, SigningKey + +__all__ = [ + "ACCESS_DWK", + "AGENT_DWK", + "PERSON_DWK", + "RESOURCE_DWK", + "Clock", + "IdFactory", + "Server", + "ServerOptions", + "now_seconds", + "parse", +] + +logger = logging.getLogger(__name__) + +Model = TypeVar("Model", bound=BaseModel) + + +def now_seconds() -> int: + return int(time.time()) + + +def parse(model: type[Model], body: bytes | None) -> Model: + """A request body, or `400 invalid_request`.""" + try: + return model.model_validate_json(body or b"{}") + except ValidationError as exc: + raise InvalidRequest(exc.errors()[0]["msg"]) from None + + +def new_hex_id() -> str: + return uuid.uuid4().hex + + +class Clock(Protocol): + """Now, in whole seconds since the epoch.""" + + def __call__(self) -> int: ... + + +class IdFactory(Protocol): + """A new id, unique to this server.""" + + def __call__(self) -> str: ... + + +class ServerOptions(TypedDict): + """What every server takes besides its own: its keys' predecessors, where + its protocol routes live, its clock and ids, and how long to wait.""" + + previous_keys: NotRequired[Sequence[SigningKey]] + #: The URL its protocol routes are under, e.g. `{issuer}/aauth`. + endpoint_base: NotRequired[str] + retry_after: NotRequired[int] + clock: NotRequired[Clock] + new_id: NotRequired[IdFactory] + name: NotRequired[str] + #: How it reads metadata and keys; by default, over its `http`. + discovery: NotRequired[Discovery] + + +class Server: + """A server's identity, its HTTP client and discovery, and its options.""" + + def __init__( + self, + issuer: str, + dwk: ServerDwk, + signing_key: SigningKey, + http: httpx.AsyncClient, + **options: Unpack[ServerOptions], + ): + self.identity = Identity( + issuer, dwk, signing_key, options.get("previous_keys", ()) + ) + self.issuer = self.identity.issuer + self.http = http + self.discovery = options.get("discovery") or Discovery(http) + self.endpoint_base = options.get("endpoint_base", self.issuer).rstrip( + "/" + ) + self.retry_after = options.get("retry_after", 5) + self.clock: Clock = options.get("clock", now_seconds) + self.new_id: IdFactory = options.get("new_id", new_hex_id) + self.name = options.get("name") + + def jwks(self) -> Jwks: + return self.identity.jwks() + + def metadata(self) -> BaseModel: + """Its `/.well-known` metadata document.""" + raise NotImplementedError diff --git a/packages/introspection-aauth/introspection_aauth/protocol/urls.py b/packages/introspection-aauth/introspection_aauth/protocol/urls.py new file mode 100644 index 0000000..5bbef85 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/protocol/urls.py @@ -0,0 +1,46 @@ +"""The https URLs AAuth names: server identifiers, endpoints and origins.""" + +from urllib.parse import urlsplit + + +def https_origin(value: str) -> bool: + """A server identifier: `https://host`, with no path, query or fragment.""" + parts = urlsplit(value) + return ( + parts.scheme == "https" + and bool(parts.hostname) + and not parts.path.strip("/") + and not parts.query + and not parts.fragment + ) + + +def https_endpoint(value: str) -> bool: + """An endpoint URL: https, with a host and no query or fragment.""" + parts = urlsplit(value) + return ( + parts.scheme == "https" + and bool(parts.hostname) + and not parts.query + and not parts.fragment + ) + + +def _origin(url: str) -> str | None: + """The https origin `url` is on, or None for any other URL or one + carrying credentials.""" + parts = urlsplit(url) + if ( + parts.scheme != "https" + or not parts.hostname + or parts.username is not None + or parts.password is not None + ): + return None + return f"https://{parts.netloc.lower()}" + + +def same_origin(url: str, origin: str) -> bool: + """Whether `url` is an https URL on `origin`, with no credentials in it.""" + found = _origin(url) + return found is not None and found == _origin(origin) diff --git a/packages/introspection-aauth/introspection_aauth/protocol/verification.py b/packages/introspection-aauth/introspection_aauth/protocol/verification.py new file mode 100644 index 0000000..c87395d --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/protocol/verification.py @@ -0,0 +1,150 @@ +"""Verifying what a server is presented: an agent's or a server's signed +request, and the agent and resource tokens it carries.""" + +from typing import TypeVar + +from introspection_aauth.protocol import httpsig, jwt +from introspection_aauth.protocol.claims import ( + AGENT_DWK, + RESOURCE_DWK, + AgentTokenClaims, + Claims, + ResourceTokenClaims, +) +from introspection_aauth.protocol.discovery import Discovery +from introspection_aauth.protocol.errors import ( + InvalidToken, + Unauthorized, + VerificationError, +) +from introspection_aauth.protocol.expectations import ResourceTokenExpectation +from introspection_aauth.protocol.keys import Jwk +from introspection_aauth.protocol.models import Agent + +ClaimsModel = TypeVar("ClaimsModel", bound=Claims) + + +#: How a `401` names the scheme an agent should sign with. +ACCEPT_JWT_SCHEME = {"Accept-Signature-Scheme": "jwt"} + + +async def verify_resource_token( + token: str, + discovery: Discovery, + resource: str, + now: int, + expectation: ResourceTokenExpectation, +) -> ResourceTokenClaims: + """A resource token, against its resource's keys (`#resource-token`), + and what it must say for this server; `400 invalid_resource_token` + otherwise.""" + try: + rt = await discovery.verified( + resource, + RESOURCE_DWK, + lambda keys: jwt.verify( + token, keys, jwt.RESOURCE_TOKEN, now, ResourceTokenClaims + ), + ) + except VerificationError as exc: + raise InvalidToken( + "resource", f"does not verify: {exc.detail}" + ) from None + wrong = expectation.failures(rt) + if wrong: + raise InvalidToken( + "resource", f"its {', '.join(wrong)} not this request's" + ) + return rt + + +async def verify_agent_token( + token: str, discovery: Discovery, providers: set[str], now: int +) -> AgentTokenClaims: + """An agent token from a trusted agent provider (`#agent-token`).""" + _, unverified = jwt.peek(token) + provider = (unverified.iss or "").rstrip("/") + if provider not in providers: + raise VerificationError( + "invalid_jwt", "its agent provider is not trusted here" + ) + return await discovery.verified( + provider, + AGENT_DWK, + lambda keys: jwt.verify( + token, keys, jwt.AGENT_TOKEN, now, AgentTokenClaims + ), + ) + + +async def verify_agent( + request: httpsig.SignedRequest, + discovery: Discovery, + providers: set[str], + now: int, +) -> Agent: + """An agent's signed request (`sig=jwt`, its agent token), or a `401`. + Any other scheme is refused before anything is read or fetched.""" + try: + scheme = httpsig.signature_scheme( + request.headers.get("signature-key", "") + ) + if scheme != "jwt": + raise VerificationError("unsupported_scheme", "sign with sig=jwt") + verified = await httpsig.verify(request, now) + if verified.scheme != "jwt" or verified.token is None: + raise VerificationError("unsupported_scheme", "sign with sig=jwt") + claims = await verify_agent_token( + verified.token, discovery, providers, now + ) + if claims.cnf.thumbprint != verified.thumbprint: + raise VerificationError( + "invalid_signature", "not signed by the token's key" + ) + except VerificationError as exc: + hint = ACCEPT_JWT_SCHEME if exc.code == "unsupported_scheme" else None + raise Unauthorized.rejected(exc, hint) from None + return Agent( + id=claims.sub, + provider=claims.iss.rstrip("/"), + jkt=verified.thumbprint, + jwk=claims.cnf.jwk, + expires_at=claims.exp, + person_server=claims.ps.rstrip("/") if claims.ps else None, + token=verified.token, + ) + + +async def verify_server( + request: httpsig.SignedRequest, + discovery: Discovery, + servers: set[str], + dwk: str, + now: int, +) -> str: + """A server's signed request (`sig=jwks_uri` under `dwk`), from one of + `servers`; only their keys are ever fetched. Its issuer, or a `401`.""" + named = httpsig.named_server(request.headers.get("signature-key", "")) + if named not in servers: + raise Unauthorized( + "invalid_signature", + "sign as a server this one takes requests from", + ) + + async def resolve(issuer: str, signed_dwk: str, kid: str) -> Jwk: + # Only `dwk` is fetched, and only from an allowed server. + if signed_dwk != dwk or issuer not in servers: + raise VerificationError( + "unsupported_scheme", f"sign with sig=jwks_uri and {dwk}" + ) + return await discovery.key(issuer, signed_dwk, kid) + + try: + verified = await httpsig.verify(request, now, resolve) + except VerificationError as exc: + raise Unauthorized.rejected(exc) from None + if verified.scheme != "jwks_uri" or verified.dwk != dwk: + raise Unauthorized( + "unsupported_scheme", f"sign with sig=jwks_uri and {dwk}" + ) + return str(verified.server) diff --git a/packages/introspection-aauth/introspection_aauth/protocol/wire.py b/packages/introspection-aauth/introspection_aauth/protocol/wire.py new file mode 100644 index 0000000..6c9734e --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/protocol/wire.py @@ -0,0 +1,108 @@ +"""What AAuth's servers send and receive over HTTP: every body is one of +these models, parsed at the boundary and serialised with members that are +None left out.""" + +from typing import Literal + +from pydantic import BaseModel, ConfigDict + +from introspection_aauth.protocol.errors import Problem +from introspection_aauth.protocol.keys import Jwks +from introspection_aauth.protocol.models import Budget + + +class Wire(BaseModel): + """A body on the wire: members that are None are left out.""" + + model_config = ConfigDict(frozen=True, extra="ignore") + + def to_json(self) -> bytes: + return self.model_dump_json(exclude_none=True).encode() + + +class Pending(Wire): + """The body of a `202`: poll its `Location` (`#deferred-responses`).""" + + status: Literal["pending"] = "pending" + + +class Empty(Wire): + """A `200` with nothing more to say.""" + + +class ServerMetadata(Wire): + """Any AAuth server's metadata, as far as finding its keys goes + (`#metadata-documents`).""" + + issuer: str + jwks_uri: str | None = None + jwks: Jwks | None = None + + +class PersonServerMetadata(Wire): + """`/.well-known/aauth-person.json` (`#ps-metadata`).""" + + issuer: str + name: str | None = None + jwks_uri: str + person_token_endpoint: str + auth_token_endpoint: str + mission_endpoint: str + claims_supported: list[str] + + +class AccessServerMetadata(Wire): + """`/.well-known/aauth-access.json` (`#as-metadata`).""" + + issuer: str + name: str | None = None + jwks_uri: str + auth_token_endpoint: str + + +class TokenRequest(Wire): + """A Person Server's request to an Access Server (`#ps-to-as-token-request`), + with the budget it allows (draft-hardt-aauth-budgets).""" + + resource_token: str + agent_token: str + presented_token: str + budget: Budget | None = None + upstream_token: str | None = None + subagent_token: str | None = None + + +class AuthTokenAnswer(Wire): + auth_token: str + expires_in: int + + +class PersonTokenAnswer(Wire): + person_token: str + expires_in: int + + +class R3Display(Wire): + summary: str | None = None + + +class R3Proposal(Wire): + """A resource's R3 proposal, as far as a server shows it to a person.""" + + display: R3Display | None = None + + +__all__ = [ + "AccessServerMetadata", + "AuthTokenAnswer", + "Empty", + "Pending", + "PersonServerMetadata", + "PersonTokenAnswer", + "Problem", + "R3Display", + "R3Proposal", + "ServerMetadata", + "TokenRequest", + "Wire", +] diff --git a/packages/introspection-aauth/introspection_aauth/py.typed b/packages/introspection-aauth/introspection_aauth/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/introspection-aauth/pyproject.toml b/packages/introspection-aauth/pyproject.toml new file mode 100644 index 0000000..f45bc95 --- /dev/null +++ b/packages/introspection-aauth/pyproject.toml @@ -0,0 +1,62 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "introspection-aauth" +version = "0.0.0" +description = "An AAuth Person Server and Access Server (draft-hardt-oauth-aauth-protocol -11): missions, person and auth tokens, budgets, approvals and four-party federation, over a store you provide" +readme = "README.md" +requires-python = ">=3.11" +license = "Apache-2.0" +dependencies = [ + "cryptography>=44.0.0", + "http-sfv>=0.9.9", + "httpx2>=2.12.0", + "opentelemetry-api>=1.39.0", + "pydantic>=2.10.0", +] + +[project.optional-dependencies] +fastapi = ["fastapi>=0.115.0"] +test = [ + # Only to check that what this package signs verifies under the data + # plane's current verifier; the package itself does not use it. + "aauth-signing==0.2.0", + "opentelemetry-sdk>=1.39.0", + "pytest>=9.0.0", + "pytest-asyncio>=1.3.0", + "pytest-cov>=7.1.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["introspection_aauth"] + +[tool.hatch.build.targets.sdist] +include = ["/README.md", "/introspection_aauth"] + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.pytest.ini_options] +asyncio_mode = "auto" + +[tool.coverage.run] +source = ["introspection_aauth"] +branch = true + +[tool.coverage.report] +fail_under = 88 +show_missing = true +precision = 1 + +[tool.deptry] +known_first_party = ["introspection_aauth"] +optional_dependencies_dev_groups = ["test"] + +[dependency-groups] +dev = [ + "deptry>=0.25.1", + "ruff==0.14.14", + "ty>=0.0.7", +] diff --git a/packages/introspection-aauth/tests/__init__.py b/packages/introspection-aauth/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/introspection-aauth/tests/test_boundaries.py b/packages/introspection-aauth/tests/test_boundaries.py new file mode 100644 index 0000000..8226bf1 --- /dev/null +++ b/packages/introspection-aauth/tests/test_boundaries.py @@ -0,0 +1,477 @@ +"""What each server refuses at its edges: a second collect, a proposal off +its resource's origin, a malformed signature, an Access Server that throttles +or answers what is not served, and a Signature-Key naming a host to fetch.""" + +import asyncio +from urllib.parse import urlsplit + +import httpx2 as httpx +import pytest + +from introspection_aauth.access_server import PendingStatus, Verdict +from introspection_aauth.person_server import ( + Approval, + ApprovalStatus, + Outcome, + Step, +) +from introspection_aauth.person_server.models import ( + MissionAction, + MissionProposal, +) +from introspection_aauth.protocol import SigningKey, VerificationError, httpsig +from introspection_aauth.protocol.claims import ( + AGENT_DWK, + AgentTokenClaims, + Confirmation, +) +from introspection_aauth.protocol.jwt import AGENT_TOKEN, encode +from introspection_aauth.protocol.keys import Jwk, verify +from introspection_aauth.protocol.wire import ( + AccessServerMetadata, + TokenRequest, +) + +from .test_federation import auth_token, inline +from .world import AP, AS, PS, RESOURCE, TENANT, World, problem, usd + + +def approval_id(response: httpx.Response) -> str: + return urlsplit(response.headers["location"]).path.rsplit("/", 1)[1] + + +async def test_an_approved_auth_token_is_collected_once(): + world = World() + mission = await world.approved_mission(budget=1_000) + over = await world.ask(mission, 5_000) + await world.ps.decide( + TENANT, approval_id(over), ApprovalStatus.APPROVED, decided_by="dana" + ) + issued = await world.follow(over) + assert issued.status_code == 200, issued.text + again = await world.follow(over) + assert again.status_code == 410 + assert problem(again).error == "invalid_code" + assert await world.spent(mission.s256) == 5_000 + + +async def test_an_approved_auth_token_not_collected_in_time_expires(): + world = World() + world.ps._approval_ttl = 60 + mission = await world.approved_mission(budget=1_000) + over = await world.ask(mission, 5_000) + await world.ps.decide( + TENANT, approval_id(over), ApprovalStatus.APPROVED, decided_by="dana" + ) + world.now += 120 + late = await world.follow(over) + assert late.status_code == 408 + + +async def test_a_mission_approval_polled_after_the_mission_ended_is_refused(): + world = World() + world.ps._mission_ttl = 60 + proposed = await world.call( + "POST", + f"{PS}/aauth/mission", + MissionProposal( + description="Book Sydney", resources=[RESOURCE], login_hint="sam" + ), + ) + await world.ps.decide( + TENANT, + approval_id(proposed), + ApprovalStatus.APPROVED, + decided_by="dana", + budget=usd(1_000), + ) + world.now += 120 + ended = await world.follow(proposed) + assert ended.status_code == 403 + assert problem(ended).error == "mission_terminated" + + +@pytest.mark.parametrize("decision", ["mission", "completion", "denied"]) +async def test_a_pending_url_answers_once_then_is_gone(decision: str): + world = World() + mission = await world.approved_mission(budget=1_000) + if decision == "mission": + proposed = await world.call( + "POST", + f"{PS}/aauth/mission", + MissionProposal( + description="Again", resources=[RESOURCE], login_hint="sam" + ), + ) + else: + proposed = await world.call( + "POST", + f"{PS}/aauth/mission/{mission.s256}", + MissionAction(action="completion", summary="Done"), + ) + await world.ps.decide( + TENANT, + approval_id(proposed), + ApprovalStatus.DENIED + if decision == "denied" + else ApprovalStatus.APPROVED, + decided_by="sam", + budget=usd(1_000) if decision == "mission" else None, + ) + first = await world.follow(proposed) + assert first.status_code == (403 if decision == "denied" else 200) + again = await world.follow(proposed) + assert again.status_code == 410 + assert problem(again).error == "invalid_code" + stored = await world.ps_store.select_approval( + TENANT, approval_id(proposed) + ) + assert stored is not None and stored.collected + assert stored.status is ( + ApprovalStatus.DENIED + if decision == "denied" + else ApprovalStatus.APPROVED + ) + + +async def test_an_access_servers_refusal_is_answered_once(): + world = World(policy=lambda request: Verdict.approval()) + kicked: list[str] = [] + + async def kick(tenant: str, approval: Approval) -> None: + kicked.append(approval.id) + + world.ps._on_federate = kick + mission = await world.approved_mission(budget=100_000) + await world.ask(mission, 10, aud=AS) + due = await world.ps.federation_due(TENANT, kicked[0]) + assert due is not None + client = world.ps.access_server_client() + metadata = await client.discover(due[0].access_server) + assert isinstance(metadata, AccessServerMetadata) + first = await client.request(due[0], due[1], metadata) + [pending] = list(world.access_store.requests.values()) + await world.access.decide(pending.id, False) + assert first.location is not None + denied = await client.poll(first.location, due[0], due[1], metadata) + assert denied.refusal is not None and denied.refusal.status == 403 + gone = await client.poll(first.location, due[0], due[1], metadata) + assert gone.refusal is not None and gone.refusal.status == 410 + [answered] = list(world.access_store.requests.values()) + assert answered.collected and answered.status is PendingStatus.DENIED + + +async def test_spends_against_one_mission_at_once_never_exceed_its_budget(): + world = World() + mission = await world.approved_mission(budget=100_000) + answers = await asyncio.gather( + *(world.ask(mission, 60_000) for _ in range(3)) + ) + assert sorted(a.status_code for a in answers) == [200, 202, 202] + assert await world.spent(mission.s256) == 60_000 + + +async def test_an_agent_token_living_longer_than_an_hour_is_accepted(): + world = World() + world.agent_token = encode( + AgentTokenClaims( + dwk=AGENT_DWK, + iss=AP, + sub="aauth:travel@agents.example", + jti="agent-long", + iat=world.now, + exp=world.now + 12 * 3_600, + cnf=Confirmation(jwk=world.agent_key.public_jwk), + ), + world.ap_key, + AGENT_TOKEN, + ) + assert (await world.approved_mission(budget=1_000)).s256 + + +@pytest.mark.parametrize( + "r3_uri", + [ + "http://booking.example/r3/1", + "https://internal.cluster.local/r3/1", + "https://user:pass@booking.example/r3/1", + "https://10.0.0.5/r3/1", + ], +) +async def test_a_proposal_is_read_only_from_its_resources_https_origin( + r3_uri: str, +): + world = World() + with pytest.raises(ValueError, match="origin"): + await world.ps.identity.read_proposal( + world.http, RESOURCE, r3_uri, "x", world.now + ) + assert world.transport.calls == [] + + +async def test_a_signature_that_is_a_list_is_refused(): + world = World() + url = f"{PS}/aauth/mission" + headers = httpsig.sign( + world.agent_key, + httpsig.jwt_key(world.agent_token), + "GET", + url, + created=world.now, + ) + request = httpsig.SignedRequest( + "GET", url, headers | {"signature": 'sig=("a")'} + ) + with pytest.raises(VerificationError) as refused: + await httpsig.verify(request, world.now) + assert refused.value.code == "invalid_signature" + + +async def test_a_body_signed_without_its_content_type_is_refused(): + world = World() + url = f"{PS}/aauth/mission" + headers = httpsig.sign( + world.agent_key, + httpsig.jwt_key(world.agent_token), + "POST", + url, + body=b"{}", + created=world.now, + ) + params = headers["signature-input"].replace(' "content-type"', "") + request = httpsig.SignedRequest( + "POST", url, headers | {"signature-input": params}, b"{}" + ) + with pytest.raises(VerificationError) as refused: + await httpsig.verify(request, world.now) + assert refused.value.detail == "content-type must be covered" + + +async def test_a_signature_key_naming_a_host_fetches_nothing(): + world = World() + for url, signature_key in ( + ( + f"{PS}/aauth/mission", + 'sig=jwks_uri;id="http://169.254.169.254/latest";dwk="x";kid="k"', + ), + ( + f"{AS}/token", + 'sig=jwks_uri;id="http://169.254.169.254/latest";dwk="aauth-person.json";kid="k"', + ), + ( + f"{AS}/token", + f'sig=jwks_uri;id="{PS}";dwk="aauth-agent.json";kid="k"', + ), + ): + world.transport.calls.clear() + headers = httpsig.sign( + SigningKey.generate(), + signature_key, + "POST", + url, + body=b"{}", + created=world.now, + ) + refused = await world.http.post(url, content=b"{}", headers=headers) + assert refused.status_code == 401 + assert world.transport.calls == [f"POST {url}"], world.transport.calls + + +class Scripted(httpx.AsyncBaseTransport): + """The Access Server, with its first answers to `/token` replaced.""" + + def __init__( + self, app: httpx.AsyncBaseTransport, *answers: httpx.Response + ): + self.app = app + self.answers = list(answers) + + async def handle_async_request( + self, request: httpx.Request + ) -> httpx.Response: + if request.url.path == "/token" and self.answers: + await request.aread() + return self.answers.pop(0) + return await self.app.handle_async_request(request) + + +async def federate(world: World, *answers: httpx.Response) -> httpx.Response: + inline(world) + world.transport.apps[AS] = Scripted(world.transport.apps[AS], *answers) + mission = await world.approved_mission(budget=100_000) + return await world.follow(await world.ask(mission, 60_000, aud=AS)) + + +@pytest.mark.parametrize("status", [429, 503]) +async def test_a_throttled_first_request_is_sent_again(status: int): + world = World() + issued = await federate( + world, httpx.Response(status, headers={"Retry-After": "1"}) + ) + assert issued.status_code == 200, issued.text + assert auth_token(issued).iss == AS + posts = [c for c in world.transport.calls if c == f"POST {AS}/token"] + assert len(posts) == 2 + + +async def test_an_access_server_asking_for_payment_is_refused(): + refused = await federate(World(), httpx.Response(402)) + assert refused.status_code == 403 + assert problem(refused).error == "denied" + + +async def test_a_pending_location_on_another_origin_is_as_unreachable(): + refused = await federate( + World(), + httpx.Response( + 202, headers={"Location": "https://elsewhere.example/p"} + ), + ) + assert refused.status_code == 502 + assert problem(refused).error == "as_unreachable" + + +async def test_an_unknown_requirement_is_relayed_as_approval(): + world = World(policy=lambda request: Verdict.approval()) + kicked: list[str] = [] + + async def kick(tenant: str, approval: Approval) -> None: + kicked.append(tenant) + + world.ps._on_federate = kick + mission = await world.approved_mission(budget=100_000) + asked = await world.ask(mission, 60_000, aud=AS) + approval = urlsplit(asked.headers["location"]).path.rsplit("/", 1)[1] + due = await world.ps.federation_due(TENANT, approval) + assert due is not None + client = world.ps.access_server_client() + metadata = await client.discover(due[0].access_server) + assert isinstance(metadata, AccessServerMetadata) + world.transport.apps[AS] = Scripted( + world.transport.apps[AS], + httpx.Response( + 202, + headers={ + "AAuth-Requirement": "requirement=telepathy", + "Location": "/pending/1", + }, + ), + ) + outcome = await client.request(due[0], due[1], metadata) + assert outcome.step is Step.PENDING + assert outcome.requirement == "requirement=approval" + + +async def test_a_mission_action_is_update_or_completion(): + world = World() + mission = await world.approved_mission(budget=1_000) + refused = await world.call_raw( + "POST", + f"{PS}/aauth/mission/{mission.s256}", + b'{"action":"cancel"}', + ) + assert refused.status_code == 400 + assert problem(refused).error == "invalid_request" + logged = await world.call( + "POST", + f"{PS}/aauth/mission/{mission.s256}", + MissionAction(action="update", description="x"), + ) + assert logged.status_code == 200 + + +async def test_a_token_request_body_is_its_model(): + assert TokenRequest( + resource_token="r", agent_token="a", presented_token="p" + ).to_json() == ( + b'{"resource_token":"r","agent_token":"a","presented_token":"p"}' + ) + + +@pytest.mark.parametrize("tenant", ["org:proj", "a b%c", "a%41"]) +async def test_a_tenant_that_needs_escaping_polls_through_the_router( + tenant: str, +): + world = World() + world.ps._default_tenant = tenant + proposed = await world.call( + "POST", + f"{PS}/aauth/mission", + MissionProposal( + description="Book Sydney", resources=[RESOURCE], login_hint="sam" + ), + ) + assert proposed.status_code == 202, proposed.text + waiting = await world.follow(proposed) + assert waiting.status_code == 202, waiting.text + await world.ps.decide( + tenant, + approval_id(proposed), + ApprovalStatus.APPROVED, + decided_by="dana", + budget=usd(1_000), + ) + approved = await world.follow(proposed) + assert approved.status_code == 200, approved.text + + +@pytest.mark.parametrize( + ("auth_token_endpoint", "jwks_uri"), + [ + ("https://elsewhere.example/token", f"{AS}/jwks.json"), + (f"{AS}/token", "https://elsewhere.example/jwks.json"), + ("https://user@as.booking.example/token", f"{AS}/jwks.json"), + ], +) +async def test_access_server_metadata_keeps_its_endpoints_on_its_origin( + auth_token_endpoint: str, jwks_uri: str +): + world = World() + world.transport.documents[f"{AS}/.well-known/aauth-access.json"] = ( + AccessServerMetadata( + issuer=AS, + jwks_uri=jwks_uri, + auth_token_endpoint=auth_token_endpoint, + ) + ) + found = await world.ps.access_server_client().discover(AS) + assert isinstance(found, Outcome) + assert found.refusal is not None + assert found.refusal.error == "as_unreachable" + + +async def test_an_agent_key_without_alg_takes_its_algorithm_from_its_type(): + world = World() + world.agent_token = encode( + AgentTokenClaims( + dwk=AGENT_DWK, + iss=AP, + sub="aauth:travel@agents.example", + jti="agent-no-alg", + iat=world.now, + exp=world.now + 3_600, + cnf=Confirmation( + jwk=world.agent_key.public_jwk.model_copy( + update={"alg": None, "kid": None, "use": None} + ) + ), + ), + world.ap_key, + AGENT_TOKEN, + ) + assert (await world.approved_mission(budget=1_000)).s256 + + +def test_a_key_type_with_no_supported_algorithm_is_refused(): + key = SigningKey.generate() + for jwk in ( + key.public_jwk.model_copy(update={"alg": None, "crv": "X25519"}), + key.public_jwk.model_copy(update={"alg": "RS256"}), + Jwk(kty="oct"), + ): + assert not verify(jwk, b"data", key.sign(b"data")) + assert verify( + key.public_jwk.model_copy(update={"alg": None}), + b"data", + key.sign(b"data"), + ) diff --git a/packages/introspection-aauth/tests/test_expectations_enforced.py b/packages/introspection-aauth/tests/test_expectations_enforced.py new file mode 100644 index 0000000..41f1beb --- /dev/null +++ b/packages/introspection-aauth/tests/test_expectations_enforced.py @@ -0,0 +1,156 @@ +"""Each server refuses a token that is valid on its own but not the one it +expected: dropping an expectation check fails one of these.""" + +import httpx2 as httpx +import pytest + +from introspection_aauth.person_server.models import MissionAnswer +from introspection_aauth.protocol import SigningKey +from introspection_aauth.protocol.claims import ( + AGENT_DWK, + AgentTokenClaims, + AuthTokenClaims, + Confirmation, + ResourceTokenClaims, +) +from introspection_aauth.protocol.jwt import ( + AGENT_TOKEN, + AUTH_TOKEN, + RESOURCE_TOKEN, + decode, + encode, +) +from introspection_aauth.protocol.wire import AuthTokenAnswer, TokenRequest + +from .test_federation import inline +from .world import AP, AS, T0, World, problem + +OTHER = SigningKey.generate() + + +async def redeem( + world: World, + mission: MissionAnswer, + rt: ResourceTokenClaims, + agent_token: str | None = None, +) -> httpx.Response: + """The Person Server's token request to the Access Server, for `rt`.""" + content = TokenRequest( + resource_token=encode(rt, world.resource_key, RESOURCE_TOKEN), + agent_token=agent_token or world.agent_token, + presented_token=mission.person_tokens[rt.iss], + ).to_json() + url = f"{AS}/token" + headers = world.ps.identity.signed("POST", url, content, world.now) + return await world.http.post(url, content=content, headers=headers) + + +async def redeem_with( + world: World, agent_token: str | None = None, **update: str +) -> httpx.Response: + """`redeem`, for a resource token changed by `update`.""" + mission = await world.approved_mission(budget=100_000) + rt = world.resource_claims(mission, 10, aud=AS).model_copy(update=update) + return await redeem(world, mission, rt, agent_token) + + +async def test_the_access_server_redeems_the_expected_tokens(): + world = World() + answered = await redeem_with(world) + assert answered.status_code == 200, answered.text + + +async def test_the_access_server_refuses_an_agent_token_bound_elsewhere(): + world = World() + answered = await redeem_with(world, agent_jkt=OTHER.kid) + assert answered.status_code == 400 + assert problem(answered).error == "invalid_resource_token" + + +async def test_the_access_server_refuses_an_agent_token_for_another_person_server(): + world = World() + agent_token = encode( + AgentTokenClaims( + iss=AP, + dwk=AGENT_DWK, + sub="aauth:travel@agents.example", + jti="agent-2", + iat=T0, + exp=T0 + 3_600, + cnf=Confirmation(jwk=world.agent_key.public_jwk), + ps="https://other-ps.example", + ), + world.ap_key, + AGENT_TOKEN, + ) + answered = await redeem_with(world, agent_token) + assert answered.status_code == 400 + assert problem(answered).error == "invalid_request" + + +@pytest.mark.parametrize( + "update", + [{"presented_jti": "pt-other"}, {"sub": "person-other"}], +) +async def test_the_access_server_refuses_a_resource_token_not_naming_the_presented_token( + update: dict[str, str], +): + world = World() + answered = await redeem_with(world, **update) + assert answered.status_code == 400 + assert problem(answered).error == "invalid_resource_token" + + +class Tampering(httpx.AsyncBaseTransport): + """The Access Server, re-signing the auth token it issues with `update`.""" + + def __init__( + self, + world: World, + app: httpx.AsyncBaseTransport, + update: dict[str, str | Confirmation], + ): + self.world, self.app, self.update = world, app, update + + async def handle_async_request( + self, request: httpx.Request + ) -> httpx.Response: + response = await self.app.handle_async_request(request) + if request.url.path != "/token" or response.status_code != 200: + return response + await response.aread() + answer = AuthTokenAnswer.model_validate_json(response.content) + claims = AuthTokenClaims.model_validate( + decode(answer.auth_token).claims + ).model_copy(update=self.update) + token = self.world.access.identity.token(claims, AUTH_TOKEN) + return httpx.Response( + 200, + content=answer.model_copy(update={"auth_token": token}).to_json(), + headers={"Content-Type": "application/json"}, + ) + + +@pytest.mark.parametrize( + "update", + [ + {"sub": "person-other"}, + {"aud": "https://elsewhere.example"}, + {"scope": "book cancel"}, + {"ps": "https://other-ps.example"}, + {"cnf": Confirmation(jwk=OTHER.public_jwk)}, + ], +) +async def test_the_person_server_refuses_an_auth_token_it_did_not_ask_for( + update: dict[str, str | Confirmation], +): + world = World() + inline(world) + world.transport.apps[AS] = Tampering( + world, world.transport.apps[AS], update + ) + mission = await world.approved_mission(budget=100_000) + answered = await world.follow(await world.ask(mission, 60_000, aud=AS)) + assert answered.status_code == 502, answered.text + assert problem(answered).error == "as_unreachable" + assert await world.spent(mission.s256) == 0 diff --git a/packages/introspection-aauth/tests/test_fastapi.py b/packages/introspection-aauth/tests/test_fastapi.py new file mode 100644 index 0000000..802559c --- /dev/null +++ b/packages/introspection-aauth/tests/test_fastapi.py @@ -0,0 +1,134 @@ +import pytest +from fastapi import APIRouter, FastAPI +from fastapi.testclient import TestClient + +from introspection_aauth.fastapi import ( + access_server_router, + person_server_router, + person_server_well_known_router, +) +from introspection_aauth.person_server import PersonServer +from introspection_aauth.protocol.json import JsonObject + +from .world import World + + +def paths(app: FastAPI) -> dict[str, dict[str, JsonObject]]: + return app.openapi()["paths"] + + +def test_one_router_serves_the_whole_person_server(): + app = FastAPI() + app.include_router(person_server_router(World().ps)) + served = paths(app) + assert "/.well-known/aauth-person.json" in served + assert "/aauth/token" in served + assert ( + served["/aauth/token"]["post"]["operationId"] + == "aauth_person_server_auth_token" + ) + + +def test_a_host_router_carries_its_tags_and_prefix(): + ps = World().ps + app = FastAPI() + app.include_router( + person_server_router(ps, APIRouter(prefix="/aauth", tags=["AAuth"])) + ) + app.include_router(person_server_well_known_router(ps)) + served = paths(app) + assert served["/aauth/token"]["post"]["tags"] == ["AAuth"] + assert served["/aauth/pending/{pending_path}"]["get"]["tags"] == ["AAuth"] + assert "tags" not in served["/.well-known/aauth-person.json"]["get"] + assert "/aauth/.well-known/aauth-person.json" not in served + + +def test_a_prefix_outside_the_endpoint_base_is_refused(): + with pytest.raises(ValueError, match="endpoint base"): + person_server_router(World().ps, APIRouter(prefix="/elsewhere")) + + +def test_a_router_can_leave_the_access_server_out_of_the_schema(): + app = FastAPI() + app.include_router( + access_server_router( + World().access, APIRouter(include_in_schema=False) + ) + ) + assert paths(app) == {} + + +def test_a_pending_path_is_read_back_by_the_server_that_wrote_it(): + ps = World().ps + assert ps.pending_parts("acme/a-1") == ("acme", "a-1") + assert ps.pending_parts("a%3Ab/a-1") == ("a:b", "a-1") + assert ps.pending_parts("a-1") is None + assert ps.pending_parts("x/y/a-1") is None + + +def test_a_server_that_writes_another_pending_path_reads_it_back(): + class Scoped(PersonServer): + def pending_url(self, tenant: str, approval_id: str) -> str: + org, project = tenant.split(":") + return ( + f"{self.endpoint_base}/pending/{org}/{project}/{approval_id}" + ) + + def pending_parts(self, path: str) -> tuple[str, str] | None: + parts = path.split("/") + return ( + (f"{parts[0]}:{parts[1]}", parts[2]) + if len(parts) == 3 + else None + ) + + world = World() + ps = world.ps + ps.__class__ = Scoped + url = ps.pending_url("org:project", "a-1") + assert url.endswith("/aauth/pending/org/project/a-1") + assert ps.pending_parts(url.split("/pending/", 1)[1]) == ( + "org:project", + "a-1", + ) + + +def test_the_well_known_documents_carry_the_cache_control_asked_for(): + app = FastAPI() + app.include_router( + person_server_well_known_router( + World().ps, cache_control="public, max-age=300" + ) + ) + with TestClient(app) as client: + response = client.get("/.well-known/aauth-person.json") + assert response.status_code == 200 + assert response.headers["cache-control"] == "public, max-age=300" + + +def test_a_resolver_that_finds_no_server_answers_404(): + server: list[PersonServer] = [] + app = FastAPI() + app.include_router( + person_server_router( + lambda request: server[0] if server else None, + APIRouter(prefix="/aauth"), + endpoint_path="/aauth", + ) + ) + app.include_router( + person_server_well_known_router( + lambda request: server[0] if server else None + ) + ) + with TestClient(app) as client: + assert client.get("/.well-known/aauth-person.json").status_code == 404 + assert client.post("/aauth/token", content=b"{}").status_code == 404 + server.append(World().ps) + assert client.get("/.well-known/aauth-person.json").status_code == 200 + assert client.post("/aauth/token", content=b"{}").status_code == 401 + + +def test_a_resolver_needs_the_endpoint_path(): + with pytest.raises(ValueError, match="resolver"): + person_server_router(lambda request: None) diff --git a/packages/introspection-aauth/tests/test_federation.py b/packages/introspection-aauth/tests/test_federation.py new file mode 100644 index 0000000..a84e5ce --- /dev/null +++ b/packages/introspection-aauth/tests/test_federation.py @@ -0,0 +1,193 @@ +"""Four-party (`#access-server-federation`): the Person Server weighs the +call against the mission, then redeems it at the resource's Access Server.""" + +from urllib.parse import urlsplit + +import httpx2 as httpx + +from introspection_aauth.access_server import Verdict +from introspection_aauth.person_server import ( + Approval, + ApprovalStatus, + Step, +) +from introspection_aauth.person_server.models import AuthTokenRequest +from introspection_aauth.protocol.claims import AuthTokenClaims +from introspection_aauth.protocol.keys import Jwks +from introspection_aauth.protocol.wire import ( + AccessServerMetadata, + AuthTokenAnswer, +) + +from .world import ( + AS, + PS, + RESOURCE, + TENANT, + World, + body, + problem, + read, + usd, +) + + +def auth_token(response: httpx.Response) -> AuthTokenClaims: + return read(body(response, AuthTokenAnswer).auth_token, AuthTokenClaims) + + +def inline(world: World) -> list[str]: + """Run each federation to its end before the agent's request is answered.""" + ran: list[str] = [] + + async def run(tenant: str, approval: Approval) -> None: + ran.append(approval.id) + + async def no_wait(_: float) -> None: + return None + + await world.ps.federate(tenant, approval.id, sleep=no_wait) + + world.ps._on_federate = run + return ran + + +async def test_the_access_server_publishes_its_metadata(): + world = World() + metadata = body( + await world.http.get(f"{AS}/.well-known/aauth-access.json"), + AccessServerMetadata, + ) + assert metadata.auth_token_endpoint == f"{AS}/token" + keys = body(await world.http.get(metadata.jwks_uri), Jwks) + assert keys == world.access.jwks() + + +async def test_an_allowed_call_is_issued_by_the_access_server_and_collected_once(): + world = World() + inline(world) + mission = await world.approved_mission(budget=100_000) + asked = await world.ask(mission, 60_000, aud=AS) + issued = await world.follow(asked) + assert issued.status_code == 200, issued.text + claims = auth_token(issued) + assert claims.iss == AS and claims.aud == RESOURCE and claims.ps == PS + assert claims.cnf.jwk == world.agent_key.public_jwk + assert await world.spent(mission.s256) == 60_000 + again = await world.follow(asked) + assert again.status_code == 410 + assert problem(again).error == "invalid_code" + + +async def test_a_refusal_is_relayed_and_nothing_is_spent(): + world = World( + policy=lambda request: Verdict.deny("no Flight Sector account") + ) + inline(world) + mission = await world.approved_mission(budget=100_000) + refused = await world.follow(await world.ask(mission, 60_000, aud=AS)) + assert refused.status_code == 403 + assert problem(refused).error == "denied" + assert "no Flight Sector account" in (problem(refused).detail or "") + assert await world.spent(mission.s256) == 0 + + +async def test_a_smaller_grant_is_all_that_is_spent(): + world = World(policy=lambda request: Verdict.allow(budget=usd(40_000))) + inline(world) + mission = await world.approved_mission(budget=100_000) + issued = await world.follow(await world.ask(mission, 60_000, aud=AS)) + assert issued.status_code == 200, issued.text + assert auth_token(issued).budget == usd(40_000) + assert await world.spent(mission.s256) == 40_000 + + +async def test_the_access_servers_wait_is_journaled_step_by_step(): + """What a durable host does: each step is one call it can record.""" + world = World(policy=lambda request: Verdict.approval()) + kicked: list[str] = [] + + async def kick(tenant: str, approval: Approval) -> None: + kicked.append(approval.id) + + world.ps._on_federate = kick + mission = await world.approved_mission(budget=100_000) + asked = await world.ask(mission, 60_000, aud=AS) + approval_id = kicked[0] + + due = await world.ps.federation_due(TENANT, approval_id) + assert due is not None + fed, jkt = due + client = world.ps.access_server_client() + metadata = await client.discover(fed.access_server) + assert isinstance(metadata, AccessServerMetadata) + outcome = await client.request(fed, jkt, metadata) + assert outcome.step is Step.PENDING + assert await world.ps.record_federation(TENANT, approval_id, outcome) + waiting = await world.follow(asked) + assert waiting.status_code == 202 + assert waiting.headers["aauth-requirement"] == "requirement=approval" + + [pending] = list(world.access_store.requests.values()) + assert pending.request.budget == usd(60_000) + await world.access.decide(pending.id, True, decided_by="flight-sector") + assert outcome.location is not None + outcome = await client.poll(outcome.location, fed, jkt, metadata) + assert outcome.step is Step.ISSUED + assert not await world.ps.record_federation(TENANT, approval_id, outcome) + assert (await world.follow(asked)).status_code == 200 + assert await world.spent(mission.s256) == 60_000 + + +async def test_a_call_over_budget_goes_to_a_person_before_the_access_server(): + world = World() + ran = inline(world) + mission = await world.approved_mission(budget=1_000) + asked = await world.ask(mission, 60_000, aud=AS) + assert ran == [] + assert (await world.follow(asked)).status_code == 202 + approval_id = urlsplit(asked.headers["location"]).path.rsplit("/", 1)[1] + await world.ps.decide( + TENANT, + approval_id, + ApprovalStatus.APPROVED, + decided_by="dana", + ) + assert ran == [approval_id] + issued = await world.follow(asked) + assert issued.status_code == 200, issued.text + assert await world.spent(mission.s256) == 60_000 + + +async def test_sub_agents_are_not_federated(): + world = World() + mission = await world.approved_mission(budget=100_000) + token, person_token = world.resource_token(mission, 10, aud=AS) + refused = await world.call( + "POST", + f"{PS}/aauth/token", + AuthTokenRequest( + resource_token=token, + presented_token=person_token, + subagent_token="x", + ), + ) + assert refused.status_code == 400 + assert problem(refused).error == "invalid_request" + + +async def token_endpoint(world: World) -> str: + metadata = await world.http.get(f"{AS}/.well-known/aauth-access.json") + return body(metadata, AccessServerMetadata).auth_token_endpoint + + +async def test_an_access_server_that_cannot_be_reached_ends_as_unreachable(): + world = World() + inline(world) + mission = await world.approved_mission(budget=100_000) + world.transport.failures[await token_endpoint(world)] = 1 + asked = await world.ask(mission, 60_000, aud=AS) + answered = await world.follow(asked) + assert answered.status_code == 502, answered.text + assert problem(answered).error == "as_unreachable" + assert await world.spent(mission.s256) == 0 diff --git a/packages/introspection-aauth/tests/test_models.py b/packages/introspection-aauth/tests/test_models.py new file mode 100644 index 0000000..4fc0eb2 --- /dev/null +++ b/packages/introspection-aauth/tests/test_models.py @@ -0,0 +1,597 @@ +"""Claims, expectations, wire bodies and discovery: what a token or body must +be on its own, and every check a token that verified fails.""" + +import ast +from pathlib import Path + +import httpx2 as httpx +import pytest +from pydantic import ValidationError + +import introspection_aauth +from introspection_aauth.person_server.models import ( + Approval, + ApprovalKind, + MissionDocument, + MissionGrant, +) +from introspection_aauth.protocol import ( + Discovery, + SigningKey, + VerificationError, + httpsig, + jwks, + jwt, +) +from introspection_aauth.protocol.claims import ( + AGENT_DWK, + PERSON_DWK, + RESOURCE_DWK, + AgentTokenClaims, + AuthGrant, + AuthTokenClaims, + Claims, + Confirmation, + PersonTokenClaims, + ResourceTokenClaims, +) +from introspection_aauth.protocol.discovery import is_identifier +from introspection_aauth.protocol.expectations import ( + AgentExpectation, + DeliveryExpectation, + PresentedExpectation, + ResourceTokenExpectation, +) +from introspection_aauth.protocol.json import JsonObject +from introspection_aauth.protocol.keys import Jwk +from introspection_aauth.protocol.wire import TokenRequest + +from .world import AS, PS, RESOURCE, World, forge, problem, usd + +PACKAGE = Path(introspection_aauth.__file__).parent +TESTS = Path(__file__).parent +AGENT = SigningKey.generate() +OTHER = SigningKey.generate() + + +TYPING = {"typing", "typing_extensions"} +BARE = {"dict", "list", "Dict", "List"} + + +def _annotations(tree: ast.AST) -> list[ast.expr]: + """Every annotation, with string annotations parsed.""" + found: list[ast.expr] = [] + for node in ast.walk(tree): + if isinstance(node, ast.arg) and node.annotation is not None: + found.append(node.annotation) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + if node.returns is not None: + found.append(node.returns) + elif isinstance(node, ast.AnnAssign): + found.append(node.annotation) + parsed: list[ast.expr] = [] + for annotation in found: + parsed.append(annotation) + for n in ast.walk(annotation): + if isinstance(n, ast.Constant) and isinstance(n.value, str): + try: + parsed.append(ast.parse(n.value, mode="eval").body) + except SyntaxError: + pass + return parsed + + +def _untyped(tree: ast.AST) -> list[int]: + """Lines that type something as `Any` or `object`, or as a bare + `dict` or `list`.""" + found: list[int] = [] + typing_names: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + typing_names |= { + a.asname or a.name for a in node.names if a.name in TYPING + } + elif isinstance(node, ast.ImportFrom) and node.module in TYPING: + found += [node.lineno for a in node.names if a.name == "Any"] + elif ( + isinstance(node, ast.Attribute) + and node.attr == "Any" + and isinstance(node.value, ast.Name) + and node.value.id in typing_names + ): + found.append(node.lineno) + elif isinstance(node, ast.Subscript) and any( + isinstance(n, ast.Name) and n.id in {"object", "Any"} + for n in ast.walk(node.slice) + ): + found.append(node.lineno) + for annotation in _annotations(tree): + subscripted = { + id(n.value) + for n in ast.walk(annotation) + if isinstance(n, ast.Subscript) + } + for n in ast.walk(annotation): + if isinstance(n, ast.Name) and ( + n.id in {"object", "Any"} + or (n.id in BARE and id(n) not in subscripted) + ): + found.append(getattr(annotation, "lineno", 0)) + return found + + +def test_no_module_types_anything_as_any_or_object(): + """Bodies and claims are models; `JsonValue` is for arbitrary JSON.""" + found = [ + f"{path.name}:{line}" + for path in [*PACKAGE.rglob("*.py"), *TESTS.rglob("*.py")] + for line in _untyped(ast.parse(path.read_text())) + ] + assert found == [] + + +@pytest.mark.parametrize( + "source", + [ + "import typing as t\nx: t.Any", + "from typing_extensions import Any", + "def f(x: 'object') -> None: ...", + "def f() -> 'Any': ...", + "Alias = dict[str, object]", + "def f(x: dict) -> None: ...", + "def f() -> dict[str, list]: ...", + ], +) +def test_the_guard_finds_each_way_of_writing_an_untyped_type(source: str): + assert _untyped(ast.parse(source)) + + +def resource_claims(**update: str | None) -> ResourceTokenClaims: + claims = ResourceTokenClaims( + dwk=RESOURCE_DWK, + jti="rt-1", + iss=RESOURCE, + aud=AS, + ps=PS, + sub="person-1", + agent_jkt=AGENT.kid, + presented_jti="pt-1", + mission_s256="m-1", + tenant="acme", + account="acct-1", + r3_uri=f"{RESOURCE}/r3/1", + r3_s256="h-1", + scope="book", + budget=usd(60_000), + iat=100, + exp=400, + ) + return claims.model_copy(update=update) + + +def auth_claims(grant: AuthGrant, *, iat: int = 100) -> AuthTokenClaims: + return AuthTokenClaims.issue( + grant, + iss=AS, + dwk="aauth-access.json", + ps=PS, + jwk=AGENT.public_jwk, + jti="at-1", + iat=iat, + exp=iat + 300, + ) + + +def delivery(rt: ResourceTokenClaims) -> DeliveryExpectation: + return DeliveryExpectation( + issuer=AS, + ps=PS, + rt=rt, + agent_jkt=AGENT.kid, + not_after=500, + sent=usd(60_000), + ) + + +def test_a_trailing_slash_on_the_resource_is_the_same_audience(): + rt = resource_claims() + grant = AuthGrant.requested(rt, "person-1") + claims = auth_claims(grant).model_copy(update={"aud": f"{RESOURCE}/"}) + assert delivery(rt).failures(claims) == [] + + +def test_an_auth_token_for_exactly_the_call_passes(): + rt = resource_claims() + assert ( + delivery(rt).failures(auth_claims(AuthGrant.requested(rt, "person-1"))) + == [] + ) + + +@pytest.mark.parametrize( + ("update", "names"), + [ + ({"sub": "person-2"}, ["sub"]), + ({"mission_s256": "m-2"}, ["mission_s256"]), + ({"tenant": "other"}, ["tenant"]), + ({"scope": "book cancel"}, ["scope"]), + ({"aud": "https://elsewhere.example"}, ["aud"]), + ({"budget": usd(70_000)}, ["budget"]), + ({"budget": None}, ["budget"]), + ({"sub": "person-2", "tenant": "other"}, ["sub", "tenant"]), + ({"account": "acct-2"}, ["account"]), + ({"r3_uri": f"{RESOURCE}/r3/2"}, ["r3_uri"]), + ({"r3_s256": "other"}, ["r3_s256"]), + ], +) +def test_each_failed_delivery_check_is_named( + update: dict[str, str | None], names: list[str] +): + rt = resource_claims() + grant = AuthGrant.requested(rt, "person-1").model_copy(update=update) + assert delivery(rt).failures(auth_claims(grant)) == names + + +def test_an_auth_token_from_elsewhere_bound_elsewhere_or_outliving_is_named(): + rt = resource_claims() + grant = AuthGrant.requested(rt, "person-1") + claims = auth_claims(grant, iat=300).model_copy( + update={ + "iss": "https://other.example", + "ps": "https://other-ps.example", + "dwk": "aauth-person.json", + "cnf": Confirmation(jwk=OTHER.public_jwk), + } + ) + assert delivery(rt).failures(claims) == ["dwk", "iss", "cnf", "ps", "exp"] + + +def presented_claims(**update: str) -> PersonTokenClaims: + claims = PersonTokenClaims( + dwk=PERSON_DWK, + iss=PS, + aud=RESOURCE, + sub="person-1", + cnf=Confirmation(jwk=AGENT.public_jwk), + jti="pt-1", + mission_s256="m-1", + tenant="acme", + iat=100, + exp=400, + ) + return claims.model_copy(update=update) + + +@pytest.mark.parametrize( + ("update", "name"), + [ + ({"iss": AS}, "iss"), + ({"aud": "https://elsewhere.example"}, "aud"), + ({"jti": "pt-2"}, "jti"), + ({"sub": "person-2"}, "sub"), + ({"mission_s256": "m-2"}, "mission_s256"), + ({"tenant": "other"}, "tenant"), + ], +) +def test_each_failed_presented_check_is_named( + update: dict[str, str], name: str +): + expectation = PresentedExpectation(ps=PS, rt=resource_claims()) + assert expectation.failures(presented_claims()) == [] + assert expectation.failures(presented_claims(**update)) == [name] + + +def test_a_presented_token_bound_to_another_key_is_named(): + presented = presented_claims().model_copy( + update={"cnf": Confirmation(jwk=OTHER.public_jwk)} + ) + assert PresentedExpectation(ps=PS, rt=resource_claims()).failures( + presented + ) == ["cnf"] + + +@pytest.mark.parametrize( + ("absent", "name"), + [ + ("sub", "sub"), + ("presented_jti", "presented_jti"), + ("agent_jkt", "agent_jkt_present"), + ], +) +def test_a_resource_token_missing_what_a_presented_call_needs_is_named( + absent: str, name: str +): + expectation = ResourceTokenExpectation(ps=PS, requires_presented=True) + assert expectation.failures(resource_claims()) == [] + assert expectation.failures(resource_claims(**{absent: None})) == [name] + + +def test_resource_presented_and_agent_expectations_name_their_failures(): + rt = resource_claims(ps="https://other-ps.example", sub=None) + assert ResourceTokenExpectation( + ps=PS, aud=PS, agent_jkt=OTHER.kid, tenant="x", requires_presented=True + ).failures(rt) == ["ps", "aud", "agent_jkt", "tenant", "sub"] + presented = PersonTokenClaims( + dwk=PERSON_DWK, + iss=PS, + aud=RESOURCE, + sub="person-1", + cnf=Confirmation(jwk=AGENT.public_jwk), + jti="pt-1", + mission_s256="m-1", + tenant="acme", + iat=100, + exp=400, + ) + assert ( + PresentedExpectation(ps=PS, rt=resource_claims()).failures(presented) + == [] + ) + assert PresentedExpectation( + ps=AS, rt=resource_claims(tenant="x") + ).failures(presented) == ["iss", "tenant"] + agent = AgentTokenClaims( + dwk=AGENT_DWK, + jti="agent-x", + iss="https://agents.example", + sub="aauth:a@agents.example", + cnf=Confirmation(jwk=AGENT.public_jwk), + ps="https://other-ps.example", + iat=100, + exp=400, + ) + assert AgentExpectation(agent_jkt=OTHER.kid, ps=PS).failures(agent) == [ + "cnf", + "ps", + ] + + +@pytest.mark.parametrize( + ("payload", "detail"), + [ + ({"iat": 200, "exp": 100}, "exp is not after iat"), + ({"iat": 100, "exp": 100 + 86_401}, "lifetime is over 86400"), + ({"cnf": {"jwk": {"kty": "oct"}}}, "cnf"), + ({"cnf": {}}, "cnf.jwk"), + ({"sub": ""}, "sub"), + ({"dwk": "aauth-person.json"}, "dwk"), + ], +) +def test_malformed_agent_token_claims_do_not_verify( + payload: JsonObject, detail: str +): + key = SigningKey.generate() + claims: JsonObject = { + "iss": "https://agents.example", + "dwk": "aauth-agent.json", + "sub": "aauth:a@agents.example", + "cnf": {"jwk": AGENT.public_jwk.model_dump(exclude_none=True)}, + "jti": "agent-1", + "iat": 100, + "exp": 400, + } | payload + token = forge(claims, key, jwt.AGENT_TOKEN) + with pytest.raises(VerificationError) as refused: + jwt.verify(token, jwks([key]), jwt.AGENT_TOKEN, 150, AgentTokenClaims) + assert refused.value.code == "invalid_jwt" + assert detail in refused.value.detail + + +def test_a_negative_budget_is_refused(): + with pytest.raises(ValidationError): + TokenRequest.model_validate_json( + b'{"resource_token":"r","agent_token":"a","presented_token":"p",' + b'"budget":{"amount":-1,"unit":"USD","decimals":2}}' + ) + + +async def test_a_token_request_without_an_agent_token_is_invalid(): + world = World() + url = f"{AS}/token" + body = b'{"resource_token":"r","presented_token":"p"}' + headers = world.ps.identity.signed("POST", url, body, world.now) + refused = await world.http.post(url, content=body, headers=headers) + assert refused.status_code == 400 + assert problem(refused).error == "invalid_request" + assert refused.headers["content-type"] == "application/problem+json" + + +def test_a_stored_grant_of_any_shape_decodes(): + row = { + "id": "a1", + "kind": ApprovalKind.MISSION, + "agent_id": "aauth:a@agents.example", + "agent_jkt": AGENT.kid, + "agent_jwk": AGENT.public_jwk.model_dump(exclude_none=True), + "agent_expires_at": 1, + "person": "sam", + "description": "Book Sydney", + "expires_at": 1, + "created_at": 0, + } + mission = Approval.model_validate( + row | {"grant": {"resources": [RESOURCE]}} + ) + assert mission.grant == MissionGrant(resources=[RESOURCE]) + assert Approval.model_validate(row | {"grant": {}}).grant == MissionGrant() + assert Approval.model_validate(row).grant == MissionGrant() + auth = Approval.model_validate( + row + | { + "grant": { + "aud": RESOURCE, + "sub": "person-1", + "scope": "book", + "budget": {"amount": 1, "unit": "USD", "decimals": 2}, + "mission_s256": "m-1", + "tenant": "acme", + } + } + ) + assert isinstance(auth.grant, AuthGrant) and auth.grant.budget == usd(1) + + +def test_the_mission_blob_is_raw_utf8_and_names_no_person(): + blob = MissionDocument( + agent="aauth:a@agents.example", + approval="a1", + approved_at="2026-10-21T00:00:00Z", + expires_at="2026-10-22T00:00:00Z", + description="Sam to Zürich, ≤ $2,000", + approved_resources=[RESOURCE], + budget=usd(200_000), + ).to_json() + assert "Zürich, ≤" in blob and "\\u" not in blob + assert '"person"' not in blob and '"approval":"a1"' in blob + + +@pytest.mark.parametrize( + "issuer", + [ + "http://agents.example", + "https://agents.example/", + "https://agents.example:8443", + "https://agents.example/path", + "https://user@agents.example", + "https://agents.example?x=1", + "https://agents.example#f", + "https://Agents.example", + "agents.example", + ], +) +async def test_a_malformed_issuer_is_refused_unfetched(issuer: str): + fetched: list[str] = [] + + def answer(request: httpx.Request) -> httpx.Response: + fetched.append(str(request.url)) + return httpx.Response(404) + + discovery = Discovery( + httpx.AsyncClient(transport=httpx.MockTransport(answer)) + ) + assert not is_identifier(issuer) + with pytest.raises(VerificationError) as refused: + await discovery.metadata(issuer, "aauth-agent.json") + assert refused.value.code == "invalid_issuer" and fetched == [] + + +async def test_an_http_jwks_uri_is_refused_and_a_redirect_not_followed(): + issuer = "https://agents.example" + documents = { + f"{issuer}/.well-known/aauth-agent.json": httpx.Response( + 200, + json={"issuer": issuer, "jwks_uri": "http://agents.example/jwks"}, + ), + f"{issuer}/.well-known/aauth-person.json": httpx.Response( + 302, headers={"Location": f"{issuer}/elsewhere.json"} + ), + f"{issuer}/elsewhere.json": httpx.Response( + 200, json={"issuer": issuer, "jwks_uri": f"{issuer}/jwks"} + ), + } + fetched: list[str] = [] + + def answer(request: httpx.Request) -> httpx.Response: + fetched.append(str(request.url)) + return documents[str(request.url)] + + discovery = Discovery( + httpx.AsyncClient( + transport=httpx.MockTransport(answer), follow_redirects=True + ) + ) + with pytest.raises(VerificationError) as http_keys: + await discovery.metadata(issuer, "aauth-agent.json") + assert http_keys.value.code == "invalid_metadata" + with pytest.raises(VerificationError): + await discovery.metadata(issuer, "aauth-person.json") + assert f"{issuer}/elsewhere.json" not in fetched + + +async def test_an_authorization_header_must_be_signed_when_present(): + url = "https://ps.example/aauth/mission" + token = jwt.encode( + AgentTokenClaims( + dwk=AGENT_DWK, + jti="agent-x", + iss="https://agents.example", + sub="aauth:a@agents.example", + cnf=Confirmation(jwk=AGENT.public_jwk), + iat=1, + exp=200, + ), + AGENT, + jwt.AGENT_TOKEN, + ) + covered = httpsig.sign( + AGENT, + httpsig.jwt_key(token), + "GET", + url, + authorization="AAuth abc", + created=100, + ) + verified = await httpsig.verify( + httpsig.SignedRequest("GET", url, covered), 100 + ) + assert verified.thumbprint == AGENT.kid + uncovered = httpsig.sign( + AGENT, httpsig.jwt_key(token), "GET", url, created=100 + ) + with pytest.raises(VerificationError) as refused: + await httpsig.verify( + httpsig.SignedRequest( + "GET", url, uncovered | {"Authorization": "AAuth abc"} + ), + 100, + ) + assert refused.value.detail == "authorization must be covered" + + +def test_a_jwk_needs_a_key_type(): + with pytest.raises(ValidationError): + Jwk.model_validate({"crv": "Ed25519"}) + + +def lifetime_claims(seconds: int) -> JsonObject: + return { + "iss": "https://ps.example", + "dwk": "aauth-person.json", + "aud": RESOURCE, + "sub": "person-1", + "cnf": {"jwk": AGENT.public_jwk.model_dump(exclude_none=True)}, + "jti": "t-1", + "iat": 100, + "exp": 100 + seconds, + } + + +@pytest.mark.parametrize( + ("model", "dwk", "seconds", "allowed"), + [ + (AgentTokenClaims, "aauth-agent.json", 2 * 3_600, True), + (AgentTokenClaims, "aauth-agent.json", 24 * 3_600, True), + (AgentTokenClaims, "aauth-agent.json", 24 * 3_600 + 1, False), + (PersonTokenClaims, "aauth-person.json", 3_600, True), + (PersonTokenClaims, "aauth-person.json", 3_601, False), + (AuthTokenClaims, "aauth-access.json", 3_601, False), + (ResourceTokenClaims, "aauth-resource.json", 3_600, True), + ], +) +def test_each_token_type_caps_its_own_lifetime( + model: type[Claims], dwk: str, seconds: int, allowed: bool +): + claims = lifetime_claims(seconds) | {"dwk": dwk, "ps": PS} + if allowed: + assert model.model_validate(claims).exp == 100 + seconds + else: + with pytest.raises(ValidationError, match="lifetime is over"): + model.model_validate(claims) + + +@pytest.mark.parametrize("missing", ["dwk", "jti"]) +def test_a_token_without_its_dwk_or_jti_is_malformed(missing: str): + claims = lifetime_claims(60) + del claims[missing] + with pytest.raises(ValidationError): + PersonTokenClaims.model_validate(claims) diff --git a/packages/introspection-aauth/tests/test_person_server.py b/packages/introspection-aauth/tests/test_person_server.py new file mode 100644 index 0000000..6fdfda3 --- /dev/null +++ b/packages/introspection-aauth/tests/test_person_server.py @@ -0,0 +1,339 @@ +"""The Person Server, three-party: missions, person tokens, auth tokens +within and over budget, approvals, completion.""" + +from urllib.parse import urlsplit + +import httpx2 as httpx +import pytest + +from introspection_aauth.person_server import ( + ApprovalStatus, + MemoryStore, + Mission, +) +from introspection_aauth.person_server.models import ( + MissionAction, + MissionAnswer, + MissionProposal, + PersonTokenRequest, +) +from introspection_aauth.protocol import AAuthError, SigningKey +from introspection_aauth.protocol.claims import ( + AGENT_DWK, + AgentTokenClaims, + AuthTokenClaims, + Confirmation, + PersonTokenClaims, +) +from introspection_aauth.protocol.jwt import AGENT_TOKEN, encode +from introspection_aauth.protocol.keys import Jwks +from introspection_aauth.protocol.wire import ( + AuthTokenAnswer, + PersonServerMetadata, + PersonTokenAnswer, +) + +from .world import ( + AP, + PS, + RESOURCE, + T0, + TENANT, + World, + body, + problem, + read, + usd, +) + + +def approval_id(response: httpx.Response) -> str: + return urlsplit(response.headers["location"]).path.rsplit("/", 1)[1] + + +async def test_publishes_its_metadata_and_keys(): + world = World() + metadata = body( + await world.http.get(f"{PS}/.well-known/aauth-person.json"), + PersonServerMetadata, + ) + assert metadata == world.ps.metadata() + assert metadata.auth_token_endpoint == f"{PS}/aauth/token" + keys = body(await world.http.get(metadata.jwks_uri), Jwks) + assert keys == world.ps.jwks() + + +async def test_a_mission_goes_to_a_person_who_sets_its_budget(): + world = World() + proposed = await world.call( + "POST", + f"{PS}/aauth/mission", + MissionProposal( + description="Book Sydney", + resources=[RESOURCE], + login_hint="sam", + ), + ) + assert proposed.status_code == 202 + assert proposed.headers["aauth-requirement"] == "requirement=approval" + waiting = await world.follow(proposed) + assert waiting.status_code == 202 + + mission = await world.approved_mission(budget=100_000) + claims = read(mission.person_tokens[RESOURCE], PersonTokenClaims) + assert claims.aud == RESOURCE and claims.mission_s256 == mission.s256 + assert claims.tenant == TENANT + + +async def test_a_call_within_budget_is_issued_unasked_and_spent(): + world = World() + mission = await world.approved_mission(budget=100_000) + issued = await world.ask(mission, 60_000) + assert issued.status_code == 200, issued.text + claims = read(body(issued, AuthTokenAnswer).auth_token, AuthTokenClaims) + assert claims.iss == PS and claims.aud == RESOURCE + assert claims.budget == usd(60_000) + assert await world.spent(mission.s256) == 60_000 + + +async def test_a_call_over_budget_waits_on_a_person_then_is_issued(): + world = World() + mission = await world.approved_mission(budget=100_000) + await world.ask(mission, 60_000) + over = await world.ask(mission, 60_000) + assert over.status_code == 202 + assert (await world.follow(over)).status_code == 202 + await world.ps.decide( + TENANT, approval_id(over), ApprovalStatus.APPROVED, decided_by="dana" + ) + issued = await world.follow(over) + assert issued.status_code == 200, issued.text + assert await world.spent(mission.s256) == 120_000 + + +async def test_a_declined_call_is_refused(): + world = World() + mission = await world.approved_mission(budget=1_000) + over = await world.ask(mission, 5_000) + await world.ps.decide( + TENANT, approval_id(over), ApprovalStatus.DENIED, decided_by="dana" + ) + refused = await world.follow(over) + assert refused.status_code == 403 + assert problem(refused).error == "denied" + assert await world.spent(mission.s256) == 0 + + +async def test_only_the_missions_person_accepts_its_completion(): + world = World() + mission = await world.approved_mission(budget=1_000) + done = await world.call( + "POST", + f"{PS}/aauth/mission/{mission.s256}", + MissionAction(action="completion", summary="Booked"), + ) + assert done.status_code == 202 + with pytest.raises(AAuthError) as refused: + await world.ps.decide( + TENANT, + approval_id(done), + ApprovalStatus.APPROVED, + decided_by="dana", + ) + assert refused.value.status == 403 + await world.ps.decide( + TENANT, approval_id(done), ApprovalStatus.APPROVED, decided_by="sam" + ) + assert (await world.follow(done)).status_code == 200 + stored = await world.ps_store.select_mission(TENANT, mission.s256) + assert stored is not None and stored.completed_at == world.now + after = await world.ask(mission, 10) + assert after.status_code == 403 + assert problem(after).error == "mission_terminated" + + +async def test_a_mission_logs_updates(): + world = World() + mission = await world.approved_mission(budget=1_000) + logged = await world.call( + "POST", + f"{PS}/aauth/mission/{mission.s256}", + MissionAction(action="update", description="Found two flights"), + ) + assert logged.status_code == 200 + stored = await world.ps_store.select_mission(TENANT, mission.s256) + assert stored is not None and stored.log[0].entry == "Found two flights" + + +async def test_an_unsigned_or_tampered_request_is_refused(): + world = World() + unsigned = await world.http.post(f"{PS}/aauth/mission", json={}) + assert unsigned.status_code == 401 + signed = await world.call("GET", f"{PS}/aauth/pending/{TENANT}/nope") + assert signed.status_code == 404 + + +async def test_an_agent_from_an_untrusted_provider_is_refused(): + world = World() + world.ps._providers = set() + refused = await world.call( + "POST", + f"{PS}/aauth/mission", + MissionProposal(description="x", login_hint="sam"), + ) + assert refused.status_code == 401 + assert problem(refused).error == "invalid_jwt" + + +async def test_a_key_acts_for_one_person(): + world = World() + await world.approved_mission(budget=1_000) + other = await world.call( + "POST", + f"{PS}/aauth/person-token", + PersonTokenRequest(resource=RESOURCE, login_hint="someone-else"), + ) + assert other.status_code == 403 + same = await world.call( + "POST", + f"{PS}/aauth/person-token", + PersonTokenRequest(resource=RESOURCE), + ) + assert same.status_code == 200 + claims = read( + body(same, PersonTokenAnswer).person_token, PersonTokenClaims + ) + assert claims.sub == world.ps.directed_subject("sam", RESOURCE) + + +async def test_identical_missions_for_two_people_in_one_second_stay_apart(): + world = World() + sam = await world.approved_mission(budget=100_000) + # Another key of the same agent, acting for Kim, proposing the same mission + # in the same second: the blob must still name a mission of Kim's own. + world.agent_key = SigningKey.generate() + world.agent_token = encode( + AgentTokenClaims( + dwk=AGENT_DWK, + iss=AP, + sub="aauth:travel@agents.example", + jti="agent-2", + iat=T0, + exp=T0 + 3_600, + cnf=Confirmation(jwk=world.agent_key.public_jwk), + ), + world.ap_key, + AGENT_TOKEN, + ) + proposed = await world.call( + "POST", + f"{PS}/aauth/mission", + MissionProposal( + description="Book Sydney", + resources=[RESOURCE], + login_hint="kim", + ), + ) + await world.ps.decide( + TENANT, + approval_id(proposed), + ApprovalStatus.APPROVED, + decided_by="dana", + budget=usd(100_000), + ) + kim = body(await world.follow(proposed), MissionAnswer) + assert kim.s256 != sam.s256 + sam_sub = read(sam.person_tokens[RESOURCE], PersonTokenClaims).sub + kim_sub = read(kim.person_tokens[RESOURCE], PersonTokenClaims).sub + assert kim_sub != sam_sub + stored = await world.ps_store.select_mission(TENANT, kim.s256) + assert stored is not None and stored.person == "kim" + + +class TakenStore(MemoryStore): + """A store where every mission hash is already taken.""" + + async def insert_mission(self, tenant: str, mission: Mission) -> bool: + return False + + +async def test_a_mission_whose_hash_is_taken_is_refused_not_reused(): + world = World(ps_store=TakenStore()) + proposed = await world.call( + "POST", + f"{PS}/aauth/mission", + MissionProposal( + description="Book Sydney", + resources=[RESOURCE], + login_hint="sam", + ), + ) + with pytest.raises(AAuthError) as refused: + await world.ps.decide( + TENANT, + approval_id(proposed), + ApprovalStatus.APPROVED, + decided_by="dana", + budget=usd(100_000), + ) + assert refused.value.status == 409 + approval = await world.ps_store.select_approval( + TENANT, approval_id(proposed) + ) + assert approval is not None and approval.status is ApprovalStatus.PENDING + + +async def propose(world: World) -> str: + proposed = await world.call( + "POST", + f"{PS}/aauth/mission", + MissionProposal( + description="Book Sydney", + resources=[RESOURCE], + login_hint="sam", + ), + ) + return approval_id(proposed) + + +async def test_a_decision_in_the_callers_unit_of_work_commits_with_it(): + world = World() + pending = await propose(world) + async with world.ps_store.transaction() as tx: + decided = await world.ps.decide( + TENANT, + pending, + ApprovalStatus.APPROVED, + decided_by="dana", + budget=usd(100_000), + store=tx, + ) + assert decided is not None and decided.mission_s256 is not None + await world.ps.decided(TENANT, decided) + assert ( + await world.ps_store.select_mission(TENANT, decided.mission_s256) + is not None + ) + + +async def test_a_decision_in_the_callers_unit_of_work_rolls_back_with_it(): + world = World() + pending = await propose(world) + with pytest.raises(RuntimeError): + async with world.ps_store.transaction() as tx: + decided = await world.ps.decide( + TENANT, + pending, + ApprovalStatus.APPROVED, + decided_by="dana", + budget=usd(100_000), + store=tx, + ) + raise RuntimeError("the host's own write failed") + assert decided is not None and decided.mission_s256 is not None + assert ( + await world.ps_store.select_mission(TENANT, decided.mission_s256) + is None + ) + approval = await world.ps_store.select_approval(TENANT, pending) + assert approval is not None and approval.status is ApprovalStatus.PENDING diff --git a/packages/introspection-aauth/tests/test_protocol.py b/packages/introspection-aauth/tests/test_protocol.py new file mode 100644 index 0000000..cfe3dbb --- /dev/null +++ b/packages/introspection-aauth/tests/test_protocol.py @@ -0,0 +1,317 @@ +"""The shared protocol layer: RFC 9421 against the spec author's library and +the data plane's current verifier, tokens, keys and discovery.""" + +import base64 +import json +import time +from pathlib import Path + +import http_sfv +import httpx2 as httpx +import pytest +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric.utils import ( + decode_dss_signature, +) + +from introspection_aauth.protocol import ( + Discovery, + SigningKey, + VerificationError, + httpsig, + jwks, + jwt, +) +from introspection_aauth.protocol.claims import ( + AGENT_DWK, + AgentTokenClaims, + Claims, + Confirmation, + PersonTokenClaims, +) +from introspection_aauth.protocol.keys import ( + Jwk, + b64url, + b64url_decode, + thumbprint, + verify, +) + +from .world import forge + +VECTORS = json.loads( + (Path(__file__).parent / "vectors" / "hellocoop.json").read_text() +) + + +def vector_key() -> SigningKey: + return SigningKey.from_seed(b64url_decode(VECTORS["seed"])) + + +def test_what_this_package_signs_is_what_hellocoop_verified(): + for case in VECTORS["signed_here_verified_by_hellocoop"]: + signed = httpsig.sign( + vector_key(), + httpsig.jwt_key(VECTORS["jwt"]), + case["method"], + case["url"], + body=case["body"].encode() if case["body"] else None, + content_type=case.get("content_type") or "application/json", + created=VECTORS["created"], + ) + assert signed == case["headers"], case["url"] + + +async def test_what_hellocoop_signed_verifies_here(): + for case in VECTORS["signed_by_hellocoop"]: + request = httpsig.SignedRequest( + case["method"], + case["url"], + case["headers"], + case["body"].encode() if case["body"] else None, + ) + verified = await httpsig.verify(request, VECTORS["created"]) + assert verified.thumbprint == vector_key().kid + assert verified.token == VECTORS["jwt"] + + +def test_what_this_package_signs_verifies_under_the_data_planes_verifier(): + """The data plane verifies with aauth-signing, converting the Signature + header to the base64url that 0.2.0 reads.""" + from aauth_signing import verify_signature + from aauth_signing.tokens.common import verify_agent_claims, verify_jwt + + provider, agent = SigningKey.generate(), SigningKey.generate() + now = int(time.time()) + token = jwt.encode( + AgentTokenClaims( + dwk=AGENT_DWK, + iss="https://agents.example", + sub="aauth:travel@agents.example", + jti="a1", + iat=now, + exp=now + 600, + cnf=Confirmation(jwk=agent.public_jwk), + ), + provider, + jwt.AGENT_TOKEN, + ) + body = b'{"description":"Sydney"}' + url = "https://ps.example/aauth/mission" + headers = httpsig.sign( + agent, httpsig.jwt_key(token), "POST", url, body=body, created=now + ) + keys = jwks([provider]).model_dump(exclude_none=True) + assert ( + verify_agent_claims(token, lambda *_: keys)["sub"] + == "aauth:travel@agents.example" + ) + signatures = http_sfv.Dictionary() + signatures.parse(headers["signature"].encode()) + as_library_reads = ", ".join( + f"{label}=:{b64url(item.value)}:" for label, item in signatures.items() + ) + assert verify_signature( + "POST", + url, + headers, + body, + headers["signature-input"], + as_library_reads, + headers["signature-key"], + jwks_fetcher=lambda *_: keys, + required_components=[ + "@method", + "@authority", + "@path", + "signature-key", + "content-digest", + ], + ) + issued = jwt.encode( + Claims(iss="https://ps.example", jti="c1", iat=now, exp=now + 60), + provider, + jwt.PERSON_TOKEN, + ) + assert verify_jwt(issued, keys)["iss"] == "https://ps.example" + + +async def test_a_tampered_or_stale_request_does_not_verify(): + key = SigningKey.generate() + token = jwt.encode( + AgentTokenClaims( + dwk=AGENT_DWK, + jti="agent-x", + iss="https://agents.example", + sub="aauth:a@agents.example", + cnf=Confirmation(jwk=key.public_jwk), + iat=1, + exp=2, + ), + key, + jwt.AGENT_TOKEN, + ) + headers = httpsig.sign( + key, + httpsig.jwt_key(token), + "POST", + "https://ps.example/x", + body=b"{}", + created=100, + ) + tampered = httpsig.SignedRequest( + "POST", "https://ps.example/x", headers, b'{"a":1}' + ) + with pytest.raises(VerificationError) as digest: + await httpsig.verify(tampered, 100) + assert digest.value.code == "invalid_input" + stale = httpsig.SignedRequest( + "POST", "https://ps.example/x", headers, b"{}" + ) + with pytest.raises(VerificationError) as late: + await httpsig.verify(stale, 1_000) + assert late.value.code == "invalid_signature" + moved = httpsig.SignedRequest( + "POST", "https://other.example/x", headers, b"{}" + ) + with pytest.raises(VerificationError): + await httpsig.verify(moved, 100) + + +async def test_a_server_signature_resolves_its_published_key(): + server = SigningKey.generate() + headers = httpsig.sign( + server, + httpsig.jwks_uri_key( + "https://ps.example", "aauth-person.json", server.kid + ), + "GET", + "https://as.example/pending/1", + created=100, + ) + assert ( + httpsig.named_server(headers["signature-key"]) == "https://ps.example" + ) + + async def resolve(issuer: str, dwk: str, kid: str) -> Jwk: + assert (issuer, dwk, kid) == ( + "https://ps.example", + "aauth-person.json", + server.kid, + ) + return server.public_jwk + + request = httpsig.SignedRequest( + "GET", "https://as.example/pending/1", headers + ) + verified = await httpsig.verify(request, 100, resolve) + assert ( + verified.scheme == "jwks_uri" + and verified.server == "https://ps.example" + ) + + +def test_tokens_verify_only_as_issued(): + key = SigningKey.generate() + token = jwt.encode( + Claims(iss="https://ps.example", jti="c1", iat=100, exp=200), + key, + jwt.AUTH_TOKEN, + ) + assert ( + jwt.verify(token, jwks([key]), jwt.AUTH_TOKEN, 150, Claims).exp == 200 + ) + for typ, now, code in ( + (jwt.PERSON_TOKEN, 150, "invalid_jwt"), + (jwt.AUTH_TOKEN, 250, "expired_jwt"), + (jwt.AUTH_TOKEN, 0, "clock_skew"), + ): + with pytest.raises(VerificationError) as failed: + jwt.verify(token, jwks([key]), typ, now, Claims) + assert failed.value.code == code + with pytest.raises(VerificationError) as other_key: + jwt.verify( + token, jwks([SigningKey.generate()]), jwt.AUTH_TOKEN, 150, Claims + ) + assert other_key.value.code == "unknown_key" + long = forge( + {"iss": "https://ps.example", "jti": "c1", "iat": 100, "exp": 7_300}, + key, + jwt.AUTH_TOKEN, + ) + assert jwt.verify(long, jwks([key]), jwt.AUTH_TOKEN, 150, Claims) + with pytest.raises(VerificationError) as too_long: + jwt.verify(long, jwks([key]), jwt.AUTH_TOKEN, 150, PersonTokenClaims) + assert too_long.value.code == "invalid_jwt" + + +def test_a_previous_key_still_verifies_what_it_signed(): + old, new = SigningKey.generate(), SigningKey.generate() + token = jwt.encode( + Claims(iss="https://ps.example", jti="c1", iat=1, exp=10), + old, + jwt.PERSON_TOKEN, + ) + assert jwt.verify(token, jwks([new, old]), jwt.PERSON_TOKEN, 5, Claims) + + +def test_es256_keys_verify(): + private = ec.generate_private_key(ec.SECP256R1()) + numbers = private.public_key().public_numbers() + jwk = Jwk( + kty="EC", + crv="P-256", + alg="ES256", + x=b64url(numbers.x.to_bytes(32, "big")), + y=b64url(numbers.y.to_bytes(32, "big")), + ) + r, s = decode_dss_signature( + private.sign(b"data", ec.ECDSA(hashes.SHA256())) + ) + signature = r.to_bytes(32, "big") + s.to_bytes(32, "big") + assert verify(jwk, b"data", signature) + assert not verify(jwk, b"other", signature) + assert thumbprint(jwk) == thumbprint( + jwk.model_copy(update={"alg": None, "kid": "x"}) + ) + + +def test_a_key_loads_from_pem(): + key = SigningKey.generate() + pem = key._private.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + assert SigningKey.from_pem(pem).kid == key.kid + assert base64.urlsafe_b64decode(f"{key.public_jwk.x}=") + + +async def test_discovery_caches_keys_and_refetches_a_failing_set_sparingly(): + fetched: list[str] = [] + issuer = "https://agents.example" + + def answer(request: httpx.Request) -> httpx.Response: + fetched.append(str(request.url)) + if request.url.path.endswith("aauth-agent.json"): + return httpx.Response( + 200, json={"issuer": issuer, "jwks_uri": f"{issuer}/jwks"} + ) + return httpx.Response(200, json={"keys": []}) + + clock = [0.0] + discovery = Discovery( + httpx.AsyncClient(transport=httpx.MockTransport(answer)), + clock=lambda: clock[0], + ) + await discovery.jwks(issuer, "aauth-agent.json") + await discovery.jwks(issuer, "aauth-agent.json") + assert len(fetched) == 2 + await discovery.jwks(issuer, "aauth-agent.json", refresh=True) + assert len(fetched) == 2 + clock[0] = 31 + await discovery.jwks(issuer, "aauth-agent.json", refresh=True) + assert len(fetched) == 4 + with pytest.raises(VerificationError): + await discovery.key(issuer, "aauth-agent.json", "missing") diff --git a/packages/introspection-aauth/tests/test_refusals.py b/packages/introspection-aauth/tests/test_refusals.py new file mode 100644 index 0000000..a8e4e45 --- /dev/null +++ b/packages/introspection-aauth/tests/test_refusals.py @@ -0,0 +1,402 @@ +"""Refusals, expiries and summaries, on both servers.""" + +from urllib.parse import urlsplit + +import httpx2 as httpx +import pytest + +from introspection_aauth.access_server import Verdict +from introspection_aauth.person_server import ( + Approval, + ApprovalStatus, + Step, +) +from introspection_aauth.person_server.models import ( + AuthTokenRequest, + MissionProposal, + PersonTokenRequest, +) +from introspection_aauth.protocol import AAuthError, SigningKey, s256 +from introspection_aauth.protocol.httpsig import jwks_uri_key, sign +from introspection_aauth.protocol.jwt import RESOURCE_TOKEN, encode +from introspection_aauth.protocol.wire import ( + AccessServerMetadata, + PersonTokenAnswer, + R3Display, + R3Proposal, + TokenRequest, +) + +from .test_federation import inline +from .world import AS, PS, RESOURCE, TENANT, World, body, problem, usd + + +def approval_id(response: httpx.Response) -> str: + return urlsplit(response.headers["location"]).path.rsplit("/", 1)[1] + + +async def test_an_approval_nobody_answers_expires(): + world = World() + world.ps._approval_ttl = 60 + proposed = await world.call( + "POST", + f"{PS}/aauth/mission", + MissionProposal(description="x", login_hint="sam"), + ) + world.now += 120 + expired = await world.follow(proposed) + assert expired.status_code == 408 + with pytest.raises(AAuthError) as late: + await world.ps.decide( + TENANT, + approval_id(proposed), + ApprovalStatus.APPROVED, + decided_by="dana", + ) + assert late.value.status == 409 + + +async def test_a_decision_is_made_once_and_only_a_mission_takes_a_budget(): + world = World() + mission = await world.approved_mission(budget=1_000) + over = await world.ask(mission, 5_000) + with pytest.raises(AAuthError) as budgeted: + await world.ps.decide( + TENANT, + approval_id(over), + ApprovalStatus.APPROVED, + decided_by="dana", + budget=usd(1), + ) + assert budgeted.value.status == 422 + await world.ps.decide( + TENANT, approval_id(over), ApprovalStatus.DENIED, decided_by="dana" + ) + with pytest.raises(AAuthError) as twice: + await world.ps.decide( + TENANT, + approval_id(over), + ApprovalStatus.APPROVED, + decided_by="dana", + ) + assert twice.value.status == 409 + assert ( + await world.ps.decide( + TENANT, "nope", ApprovalStatus.DENIED, decided_by="d" + ) + is None + ) + + +async def test_a_resource_the_mission_does_not_cover_is_refused_before_its_keys_are_fetched(): + world = World() + mission = await world.approved_mission(budget=1_000) + _, person_token = world.resource_token(mission, 10) + claims = world.resource_claims(mission, 10).model_copy( + update={"iss": "https://elsewhere.example"} + ) + forged = encode(claims, world.resource_key, RESOURCE_TOKEN) + refused = await world.call( + "POST", + f"{PS}/aauth/token", + AuthTokenRequest(resource_token=forged, presented_token=person_token), + ) + assert refused.status_code == 403 + assert not any("elsewhere" in c for c in world.transport.calls) + + +async def test_a_resource_token_not_matching_the_presented_token_is_refused(): + world = World() + mission = await world.approved_mission(budget=1_000) + token, _ = world.resource_token(mission, 10) + other = await world.call( + "POST", + f"{PS}/aauth/person-token", + PersonTokenRequest(resource=RESOURCE), + ) + refused = await world.call( + "POST", + f"{PS}/aauth/token", + AuthTokenRequest( + resource_token=token, + presented_token=body(other, PersonTokenAnswer).person_token, + ), + ) + assert refused.status_code == 400 + assert problem(refused).error == "invalid_resource_token" + + +async def test_an_expired_presented_token_is_never_federated(): + world = World() + world.ps._token_ttl = 60 + mission = await world.approved_mission(budget=1_000) + token, person_token = world.resource_token(mission, 10, aud=AS) + world.now += 120 + refused = await world.call( + "POST", + f"{PS}/aauth/token", + AuthTokenRequest(resource_token=token, presented_token=person_token), + ) + assert problem(refused).error == "expired_presented_token" + + +async def test_an_access_server_that_cannot_be_reached_is_as_unreachable(): + world = World() + inline(world) + del world.transport.apps[AS] + mission = await world.approved_mission(budget=100_000) + refused = await world.follow(await world.ask(mission, 10, aud=AS)) + assert refused.status_code == 502 + assert problem(refused).error == "as_unreachable" + assert await world.spent(mission.s256) == 0 + + +async def test_an_access_server_asking_for_claims_is_refused(): + world = World() + inline(world) + app = world.transport.apps[AS] + + class AsksForClaims(httpx.AsyncBaseTransport): + async def handle_async_request( + self, request: httpx.Request + ) -> httpx.Response: + if request.url.path == "/token": + return httpx.Response( + 202, + headers={ + "AAuth-Requirement": "requirement=claims", + "Location": "/pending/1", + }, + ) + return await app.handle_async_request(request) + + world.transport.apps[AS] = AsksForClaims() + mission = await world.approved_mission(budget=100_000) + refused = await world.follow(await world.ask(mission, 10, aud=AS)) + assert refused.status_code == 403 + + +async def test_a_busy_access_server_is_polled_again_later(): + world = World(policy=lambda request: Verdict.approval()) + kicked: list[str] = [] + + async def kick(tenant: str, approval: Approval) -> None: + kicked.append(approval.id) + + world.ps._on_federate = kick + mission = await world.approved_mission(budget=100_000) + await world.ask(mission, 10, aud=AS) + due = await world.ps.federation_due(TENANT, kicked[0]) + assert due is not None + client = world.ps.access_server_client() + metadata = await client.discover(due[0].access_server) + assert isinstance(metadata, AccessServerMetadata) + first = await client.request(due[0], due[1], metadata) + app = world.transport.apps[AS] + + class Busy(httpx.AsyncBaseTransport): + async def handle_async_request( + self, request: httpx.Request + ) -> httpx.Response: + if request.url.path.startswith("/pending"): + return httpx.Response(429, headers={"Retry-After": "2"}) + return await app.handle_async_request(request) + + world.transport.apps[AS] = Busy() + assert first.location is not None + busy = await client.poll(first.location, due[0], due[1], metadata) + assert busy.step is Step.PENDING and busy.poll_delay() == 7 + + +async def test_the_access_server_refuses_what_it_does_not_decide(): + world = World() + mission = await world.approved_mission(budget=100_000) + token, person_token = world.resource_token(mission, 10, aud=PS) + content = TokenRequest( + resource_token=token, + agent_token=world.agent_token, + presented_token=person_token, + ).to_json() + url = f"{AS}/token" + unsigned = await world.http.post(url, content=content) + assert unsigned.status_code == 401 + stranger = SigningKey.generate() + headers = sign( + stranger, + jwks_uri_key( + "https://stranger.example", "aauth-person.json", stranger.kid + ), + "POST", + url, + body=content, + created=world.now, + ) + refused = await world.http.post(url, content=content, headers=headers) + assert refused.status_code == 401 + ps_signed = world.ps.identity.signed("POST", url, content, world.now) + wrong_audience = await world.http.post( + url, content=content, headers=ps_signed + ) + assert wrong_audience.status_code == 400 + assert problem(wrong_audience).error == "invalid_resource_token" + + +async def test_an_interaction_is_relayed_and_a_decision_settles_once(): + world = World( + policy=lambda request: Verdict.interaction( + "https://as.booking.example/i", "ABCD" + ) + ) + kicked: list[str] = [] + + async def kick(tenant: str, approval: Approval) -> None: + kicked.append(approval.id) + + world.ps._on_federate = kick + mission = await world.approved_mission(budget=100_000) + asked = await world.ask(mission, 10, aud=AS) + due = await world.ps.federation_due(TENANT, kicked[0]) + assert due is not None + client = world.ps.access_server_client() + metadata = await client.discover(due[0].access_server) + assert isinstance(metadata, AccessServerMetadata) + outcome = await client.request(due[0], due[1], metadata) + await world.ps.record_federation(TENANT, kicked[0], outcome) + relayed = await world.follow(asked) + assert "requirement=interaction" in relayed.headers["aauth-requirement"] + [pending] = list(world.access_store.requests.values()) + await world.access.decide(pending.id, False) + with pytest.raises(AAuthError): + await world.access.decide(pending.id, True) + assert outcome.location is not None + denied = await client.poll(outcome.location, due[0], due[1], metadata) + assert denied.step is Step.REFUSED + await world.ps.record_federation(TENANT, kicked[0], denied) + assert (await world.follow(asked)).status_code == 403 + + +async def test_a_policy_may_not_grant_more_than_was_asked(): + world = World(policy=lambda request: Verdict.allow(scope="book cancel")) + inline(world) + mission = await world.approved_mission(budget=100_000) + refused = await world.follow(await world.ask(mission, 10, aud=AS)) + assert refused.status_code == 502 + + +async def test_a_calls_r3_proposal_is_read_into_its_approval(): + world = World() + proposal = R3Proposal( + display=R3Display(summary="Book QF1 for $600") + ).to_json() + r3_uri = f"{RESOURCE}/r3/1" + + world.transport.documents[r3_uri] = proposal + mission = await world.approved_mission(budget=1_000) + _, person_token = world.resource_token(mission, 60_000) + claims = world.resource_claims(mission, 60_000).model_copy( + update={"r3_uri": r3_uri, "r3_s256": s256(proposal)} + ) + with_proposal = encode(claims, world.resource_key, RESOURCE_TOKEN) + over = await world.call( + "POST", + f"{PS}/aauth/token", + AuthTokenRequest( + resource_token=with_proposal, presented_token=person_token + ), + ) + described = await world.ps.describe(TENANT, approval_id(over)) + assert described is not None and described.summary == "Book QF1 for $600" + + +async def over_budget_with_proposal( + world: World, proposal: bytes, r3_s256: str | None = None, aud: str = PS +) -> str: + r3_uri = f"{RESOURCE}/r3/1" + world.transport.documents[r3_uri] = proposal + mission = await world.approved_mission(budget=1_000) + _, person_token = world.resource_token(mission, 60_000, aud) + claims = world.resource_claims(mission, 60_000, aud).model_copy( + update={"r3_uri": r3_uri, "r3_s256": r3_s256 or s256(proposal)} + ) + over = await world.call( + "POST", + f"{PS}/aauth/token", + AuthTokenRequest( + resource_token=encode(claims, world.resource_key, RESOURCE_TOKEN), + presented_token=person_token, + ), + ) + return approval_id(over) + + +SUMMARY = R3Proposal(display=R3Display(summary="Book QF1 for $600")).to_json() + + +async def test_a_summary_written_meanwhile_does_not_make_a_decision_stale(): + world = World() + pending = await over_budget_with_proposal(world, SUMMARY) + read = await world.ps_store.select_approval(TENANT, pending) + assert read is not None + await world.ps.describe(TENANT, pending) + decided = read.model_copy( + update={"status": ApprovalStatus.APPROVED, "version": read.version + 1} + ) + assert await world.ps_store.transition_approval(TENANT, read, decided) + stored = await world.ps_store.select_approval(TENANT, pending) + assert stored is not None and stored.summary == "Book QF1 for $600" + assert stored.status is ApprovalStatus.APPROVED + + +async def test_a_summary_written_meanwhile_does_not_drop_a_federated_token(): + world = World(policy=lambda request: Verdict.approval()) + kicked: list[str] = [] + + async def kick(tenant: str, approval: Approval) -> None: + kicked.append(approval.id) + + world.ps._on_federate = kick + mission = await world.approved_mission(budget=100_000) + r3_uri = f"{RESOURCE}/r3/1" + world.transport.documents[r3_uri] = SUMMARY + _, person_token = world.resource_token(mission, 10, AS) + claims = world.resource_claims(mission, 10, AS).model_copy( + update={"r3_uri": r3_uri, "r3_s256": s256(SUMMARY)} + ) + await world.call( + "POST", + f"{PS}/aauth/token", + AuthTokenRequest( + resource_token=encode(claims, world.resource_key, RESOURCE_TOKEN), + presented_token=person_token, + ), + ) + due = await world.ps.federation_due(TENANT, kicked[0]) + assert due is not None + client = world.ps.access_server_client() + metadata = await client.discover(due[0].access_server) + assert isinstance(metadata, AccessServerMetadata) + first = await client.request(due[0], due[1], metadata) + assert await world.ps.record_federation(TENANT, kicked[0], first) + [pending] = list(world.access_store.requests.values()) + await world.access.decide(pending.id, True) + assert first.location is not None + issued = await client.poll(first.location, due[0], due[1], metadata) + await world.ps.describe(TENANT, kicked[0]) + assert not await world.ps.record_federation(TENANT, kicked[0], issued) + stored = await world.ps_store.select_approval(TENANT, kicked[0]) + assert stored is not None and stored.federation is not None + assert stored.federation.auth_token is not None + assert stored.summary == "Book QF1 for $600" + + +@pytest.mark.parametrize( + ("proposal", "r3_s256"), + [(b"not json", None), (SUMMARY, "the wrong hash")], +) +async def test_an_unreadable_proposal_leaves_the_approval_without_a_summary( + proposal: bytes, r3_s256: str | None +): + world = World() + pending = await over_budget_with_proposal(world, proposal, r3_s256) + described = await world.ps.describe(TENANT, pending) + assert described is not None and described.summary is None diff --git a/packages/introspection-aauth/tests/test_telemetry.py b/packages/introspection-aauth/tests/test_telemetry.py new file mode 100644 index 0000000..35e42d0 --- /dev/null +++ b/packages/introspection-aauth/tests/test_telemetry.py @@ -0,0 +1,87 @@ +"""Federation emits the spans and records the data plane reads, carrying +identifiers and never a token.""" + +import logging +import re + +import pytest +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) + +from .test_federation import inline +from .world import AS, World + +#: The last words of a field the platform's log formatter drops +#: (introspection-cloud docs/design/logging-and-tracing.md). +REDACTED = { + "password", + "passwd", + "pwd", + "secret", + "token", + "api_key", + "key", + "private_key", + "credential", + "credentials", + "auth", + "authorization", + "cookie", + "session", + "email", + "phone", +} +STANDARD = set(logging.LogRecord("", 0, "", 0, "", None, None).__dict__) | { + "message", + "asctime", + "taskName", +} + +EXPORTER = InMemorySpanExporter() +PROVIDER = TracerProvider() +PROVIDER.add_span_processor(SimpleSpanProcessor(EXPORTER)) +trace.set_tracer_provider(PROVIDER) + + +async def test_a_federation_is_traced_and_logged_by_identifier( + caplog: pytest.LogCaptureFixture, +): + EXPORTER.clear() + world = World() + inline(world) + mission = await world.approved_mission(budget=100_000) + with caplog.at_level(logging.INFO, logger="introspection_aauth"): + issued = await world.follow(await world.ask(mission, 60_000, aud=AS)) + assert issued.status_code == 200, issued.text + + names = {span.name for span in EXPORTER.get_finished_spans()} + assert { + "person_server.federate", + "person_server.federate.metadata", + "person_server.federate.request", + "person_server.federate.delivery", + } <= names + + recorded = [ + r + for r in caplog.records + if r.getMessage() == "federation outcome recorded" + ] + assert [r.__dict__["step"] for r in recorded] == ["issued"] + decided = [ + r for r in caplog.records if r.getMessage() == "access request decided" + ] + assert [r.__dict__["decision"] for r in decided] == ["allow"] + + for record in caplog.records: + for key in set(record.__dict__) - STANDARD: + assert re.split(r"[._-]", key)[-1] not in REDACTED, key + + tokens = [world.agent_token, *mission.person_tokens.values()] + for record in caplog.records: + fields = " ".join(str(v) for v in record.__dict__.values()) + assert not any(token in fields for token in tokens) diff --git a/packages/introspection-aauth/tests/vectors/hellocoop.json b/packages/introspection-aauth/tests/vectors/hellocoop.json new file mode 100644 index 0000000..2c7ba7d --- /dev/null +++ b/packages/introspection-aauth/tests/vectors/hellocoop.json @@ -0,0 +1,57 @@ +{ + "about": "RFC 9421 requests under the AAuth profile, for tests/hellocoop.rs. signed_by_hellocoop was signed by the generated_with library; signed_here_verified_by_hellocoop was signed by this crate and verified by that library. Ed25519 is deterministic, so these are exact. Regenerate them when the signer changes on purpose or the library is upgraded.", + "generated_with": "@hellocoop/httpsig 2.6.0", + "seed": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8", + "jwt": "eyJhbGciOiJFZERTQSIsInR5cCI6ImFhLWFnZW50K2p3dCJ9.eyJjbmYiOnsiandrIjp7ImFsZyI6IkVkMjU1MTkiLCJjcnYiOiJFZDI1NTE5Iiwia3R5IjoiT0tQIiwieCI6IkE2RUh2X1BPRUw0ZGNOMFk1MHZBbVdmazFqQ2JwUTFmSGR5R1pCSlZNYmcifX0sImV4cCI6MTc5MTAwMzYwMCwiaXNzIjoiaHR0cHM6Ly9hZ2VudHMuZXhhbXBsZSJ9.sig", + "created": 1791000000, + "signed_by_hellocoop": [ + { + "method": "POST", + "url": "https://ps.example/mission", + "body": "{\"description\":\"Sydney\"}", + "headers": { + "content-digest": "sha-256=:LSMDidPLUvimucguXVhMRnGWr1+PQ/8Oib3VxN63EdY=:", + "content-type": "application/json", + "signature": "sig=:HXndA/ycXeoCY8Lok/N5r9Duzj/jc/4GXPFFNxu8LZt/8hAW9nvjoT1zTyOFXQCBk7ysTr+rwAMnhqcqDnjPAA==:", + "signature-input": "sig=(\"@method\" \"@authority\" \"@path\" \"content-type\" \"signature-key\" \"content-digest\");created=1791000000", + "signature-key": "sig=jwt;jwt=\"eyJhbGciOiJFZERTQSIsInR5cCI6ImFhLWFnZW50K2p3dCJ9.eyJjbmYiOnsiandrIjp7ImFsZyI6IkVkMjU1MTkiLCJjcnYiOiJFZDI1NTE5Iiwia3R5IjoiT0tQIiwieCI6IkE2RUh2X1BPRUw0ZGNOMFk1MHZBbVdmazFqQ2JwUTFmSGR5R1pCSlZNYmcifX0sImV4cCI6MTc5MTAwMzYwMCwiaXNzIjoiaHR0cHM6Ly9hZ2VudHMuZXhhbXBsZSJ9.sig\"" + } + }, + { + "method": "GET", + "url": "https://ps.example/pending/1", + "body": null, + "headers": { + "signature": "sig=:xtnY6M7qO+yJe9fVLWYckLELRv80YGe2McNDazUKr9AI7kvVlKMAHmajj7DErVP0FGahGUNCMd6VQ5QbIerFAQ==:", + "signature-input": "sig=(\"@method\" \"@authority\" \"@path\" \"signature-key\");created=1791000000", + "signature-key": "sig=jwt;jwt=\"eyJhbGciOiJFZERTQSIsInR5cCI6ImFhLWFnZW50K2p3dCJ9.eyJjbmYiOnsiandrIjp7ImFsZyI6IkVkMjU1MTkiLCJjcnYiOiJFZDI1NTE5Iiwia3R5IjoiT0tQIiwieCI6IkE2RUh2X1BPRUw0ZGNOMFk1MHZBbVdmazFqQ2JwUTFmSGR5R1pCSlZNYmcifX0sImV4cCI6MTc5MTAwMzYwMCwiaXNzIjoiaHR0cHM6Ly9hZ2VudHMuZXhhbXBsZSJ9.sig\"" + } + } + ], + "signed_here_verified_by_hellocoop": [ + { + "body": "{\"description\":\"Sydney\"}", + "content_type": "application/json", + "headers": { + "content-digest": "sha-256=:LSMDidPLUvimucguXVhMRnGWr1+PQ/8Oib3VxN63EdY=:", + "content-type": "application/json", + "signature": "sig=:XqqztJ20Ltswj94hgFtOwAIR3fCKXVfbQ3uFKRiRGkOhcBpiTCHko7S5XlyVikCcpwSpryf21lJHXOGoRxmdAA==:", + "signature-input": "sig=(\"@method\" \"@authority\" \"@path\" \"signature-key\" \"content-digest\" \"content-type\");created=1791000000", + "signature-key": "sig=jwt;jwt=\"eyJhbGciOiJFZERTQSIsInR5cCI6ImFhLWFnZW50K2p3dCJ9.eyJjbmYiOnsiandrIjp7ImFsZyI6IkVkMjU1MTkiLCJjcnYiOiJFZDI1NTE5Iiwia3R5IjoiT0tQIiwieCI6IkE2RUh2X1BPRUw0ZGNOMFk1MHZBbVdmazFqQ2JwUTFmSGR5R1pCSlZNYmcifX0sImV4cCI6MTc5MTAwMzYwMCwiaXNzIjoiaHR0cHM6Ly9hZ2VudHMuZXhhbXBsZSJ9.sig\"" + }, + "method": "POST", + "url": "https://ps.example/mission" + }, + { + "body": null, + "content_type": null, + "headers": { + "signature": "sig=:xtnY6M7qO+yJe9fVLWYckLELRv80YGe2McNDazUKr9AI7kvVlKMAHmajj7DErVP0FGahGUNCMd6VQ5QbIerFAQ==:", + "signature-input": "sig=(\"@method\" \"@authority\" \"@path\" \"signature-key\");created=1791000000", + "signature-key": "sig=jwt;jwt=\"eyJhbGciOiJFZERTQSIsInR5cCI6ImFhLWFnZW50K2p3dCJ9.eyJjbmYiOnsiandrIjp7ImFsZyI6IkVkMjU1MTkiLCJjcnYiOiJFZDI1NTE5Iiwia3R5IjoiT0tQIiwieCI6IkE2RUh2X1BPRUw0ZGNOMFk1MHZBbVdmazFqQ2JwUTFmSGR5R1pCSlZNYmcifX0sImV4cCI6MTc5MTAwMzYwMCwiaXNzIjoiaHR0cHM6Ly9hZ2VudHMuZXhhbXBsZSJ9.sig\"" + }, + "method": "GET", + "url": "https://ps.example/pending/1" + } + ] +} diff --git a/packages/introspection-aauth/tests/world.py b/packages/introspection-aauth/tests/world.py new file mode 100644 index 0000000..21fded4 --- /dev/null +++ b/packages/introspection-aauth/tests/world.py @@ -0,0 +1,300 @@ +"""Every party of AAuth -11, in process: an agent provider, a resource, a +Person Server and an Access Server behind their FastAPI routers, and an agent. +One in-process transport routes each origin to its party.""" + +import json +from typing import TypeVar +from urllib.parse import urlsplit + +import httpx2 as httpx +from fastapi import FastAPI +from pydantic import BaseModel + +from introspection_aauth.access_server import AccessServer, Policy, Verdict +from introspection_aauth.access_server import MemoryStore as AccessStore +from introspection_aauth.fastapi import ( + access_server_router, + person_server_router, +) +from introspection_aauth.person_server import ( + ApprovalStatus, + MemoryStore, + PersonServer, +) +from introspection_aauth.person_server.models import ( + AuthTokenRequest, + MissionAnswer, + MissionProposal, +) +from introspection_aauth.protocol import Budget, SigningKey, jwks +from introspection_aauth.protocol.claims import ( + AGENT_DWK, + RESOURCE_DWK, + AgentTokenClaims, + Claims, + Confirmation, + PersonTokenClaims, + ResourceTokenClaims, +) +from introspection_aauth.protocol.errors import Problem +from introspection_aauth.protocol.httpsig import jwt_key, sign +from introspection_aauth.protocol.json import JsonObject +from introspection_aauth.protocol.jwt import ( + AGENT_TOKEN, + RESOURCE_TOKEN, + Header, + decode, + encode, +) +from introspection_aauth.protocol.keys import b64url +from introspection_aauth.protocol.wire import ServerMetadata + +AP = "https://agents.example" +RESOURCE = "https://booking.example" +PS = "https://ps.example" +AS = "https://as.booking.example" +TENANT = "acme" +T0 = 1_791_000_000 +ClaimsModel = TypeVar("ClaimsModel", bound=Claims) + + +def usd(amount: int) -> Budget: + return Budget(amount=amount, unit="USD", decimals=2) + + +def read(token: str, model: type[ClaimsModel]) -> ClaimsModel: + """A token's claims, unverified, as `model`.""" + return model.model_validate(decode(token).claims) + + +Body = TypeVar("Body", bound=BaseModel) + + +def body(response: httpx.Response, model: type[Body]) -> Body: + """A response's body, as `model`.""" + return model.model_validate_json(response.content) + + +def problem(response: httpx.Response) -> Problem: + return body(response, Problem) + + +def forge(claims: JsonObject, key: SigningKey, typ: str) -> str: + """A token over a raw payload no claims model would build, for tests of + malformed tokens.""" + header = Header(alg="Ed25519", typ=typ, kid=key.kid) + signing_input = ".".join( + ( + b64url(header.model_dump_json(exclude_none=True).encode()), + b64url(json.dumps(claims).encode()), + ) + ) + return f"{signing_input}.{b64url(key.sign(signing_input.encode()))}" + + +class InProcessTransport(httpx.AsyncBaseTransport): + """Routes a request by its origin: to an app, or to a fixed document.""" + + def __init__(self) -> None: + self.apps: dict[str, httpx.AsyncBaseTransport] = {} + self.documents: dict[str, BaseModel | bytes] = {} + self.calls: list[str] = [] + #: How many more times a request to each URL fails to connect. + self.failures: dict[str, int] = {} + + async def handle_async_request( + self, request: httpx.Request + ) -> httpx.Response: + url = str(request.url) + self.calls.append(f"{request.method} {url}") + if self.failures.get(url, 0) > 0: + self.failures[url] -= 1 + raise httpx.ConnectError("connection refused", request=request) + if url in self.documents: + document = self.documents[url] + if isinstance(document, bytes): + return httpx.Response(200, content=document) + return httpx.Response( + 200, + content=document.model_dump_json(exclude_none=True), + headers={"Content-Type": "application/json"}, + ) + origin = f"{request.url.scheme}://{request.url.host}" + if origin in self.apps: + return await self.apps[origin].handle_async_request(request) + return httpx.Response(404, json={"error": "not_found"}) + + +class World: + def __init__( + self, policy: Policy | None = None, ps_store: MemoryStore | None = None + ) -> None: + self.now = T0 + self.transport = InProcessTransport() + self.http = httpx.AsyncClient(transport=self.transport) + self.ap_key, self.resource_key, self.agent_key = ( + SigningKey.generate() for _ in range(3) + ) + self.transport.documents |= { + f"{AP}/.well-known/aauth-agent.json": ServerMetadata( + issuer=AP, jwks_uri=f"{AP}/jwks.json" + ), + f"{AP}/jwks.json": jwks([self.ap_key]), + f"{RESOURCE}/.well-known/aauth-resource.json": ServerMetadata( + issuer=RESOURCE, jwks_uri=f"{RESOURCE}/jwks.json" + ), + f"{RESOURCE}/jwks.json": jwks([self.resource_key]), + } + self.ps_store = ps_store or MemoryStore() + self.ps = PersonServer( + PS, + SigningKey.generate(), + self.ps_store, + subject_secret=b"test-subject-secret", + agent_providers=[AP], + http=self.http, + endpoint_base=f"{PS}/aauth", + default_tenant=TENANT, + clock=lambda: self.now, + ) + self.policy: Policy = policy or (lambda request: Verdict.allow()) + self.access_store = AccessStore() + self.access = AccessServer( + AS, + SigningKey.generate(), + lambda request: self.policy(request), + resources=[RESOURCE], + person_servers=[PS], + agent_providers=[AP], + http=self.http, + store=self.access_store, + clock=lambda: self.now, + ) + for origin, router in ( + (PS, person_server_router(self.ps)), + (AS, access_server_router(self.access)), + ): + app = FastAPI() + app.include_router(router) + self.transport.apps[origin] = httpx.ASGITransport(app=app) + self.agent_token = encode( + AgentTokenClaims( + iss=AP, + dwk=AGENT_DWK, + sub="aauth:travel@agents.example", + jti="agent-1", + iat=T0, + exp=T0 + 3_600, + cnf=Confirmation(jwk=self.agent_key.public_jwk), + ), + self.ap_key, + AGENT_TOKEN, + ) + + async def call( + self, + method: str, + url: str, + body: BaseModel | None = None, + ) -> httpx.Response: + """A request the agent signs (`sig=jwt`, its agent token).""" + content = ( + body.model_dump_json(exclude_none=True).encode() + if body is not None + else None + ) + return await self.call_raw(method, url, content) + + async def call_raw( + self, method: str, url: str, content: bytes | None + ) -> httpx.Response: + """A request the agent signs, with a body no request model would build.""" + headers = sign( + self.agent_key, + jwt_key(self.agent_token), + method, + url, + body=content, + created=self.now, + ) + return await self.http.request( + method, url, content=content, headers=headers + ) + + async def follow(self, response: httpx.Response) -> httpx.Response: + assert response.status_code == 202, response.text + return await self.call("GET", response.headers["location"]) + + async def approved_mission(self, budget: int) -> MissionAnswer: + proposed = await self.call( + "POST", + f"{PS}/aauth/mission", + MissionProposal( + description="Book Sydney", + resources=[RESOURCE], + login_hint="sam", + ), + ) + approval_id = urlsplit(proposed.headers["location"]).path.rsplit( + "/", 1 + )[1] + await self.ps.decide( + TENANT, + approval_id, + ApprovalStatus.APPROVED, + decided_by="dana", + budget=usd(budget), + ) + done = await self.follow(proposed) + assert done.status_code == 200, done.text + return MissionAnswer.model_validate_json(done.content) + + def resource_claims( + self, mission: MissionAnswer, amount: int | None, aud: str = PS + ) -> ResourceTokenClaims: + """A resource token's claims for a call costing `amount`.""" + presented = read(mission.person_tokens[RESOURCE], PersonTokenClaims) + return ResourceTokenClaims( + iss=RESOURCE, + dwk=RESOURCE_DWK, + jti=f"rt-{self.now}-{amount}-{aud}", + aud=aud, + ps=PS, + agent_jkt=self.agent_key.kid, + presented_jti=presented.jti, + sub=presented.sub, + mission_s256=mission.s256, + tenant=TENANT, + scope="book", + iat=self.now, + exp=self.now + 300, + budget=usd(amount) if amount is not None else None, + ) + + def resource_token( + self, mission: MissionAnswer, amount: int | None, aud: str = PS + ) -> tuple[str, str]: + """A resource token for a call costing `amount`, and the person token it answers.""" + token = encode( + self.resource_claims(mission, amount, aud), + self.resource_key, + RESOURCE_TOKEN, + ) + return token, mission.person_tokens[RESOURCE] + + async def ask( + self, mission: MissionAnswer, amount: int | None, aud: str = PS + ) -> httpx.Response: + token, person_token = self.resource_token(mission, amount, aud) + return await self.call( + "POST", + f"{PS}/aauth/token", + AuthTokenRequest( + resource_token=token, presented_token=person_token + ), + ) + + async def spent(self, s256: str) -> int: + mission = await self.ps_store.select_mission(TENANT, s256) + assert mission is not None + return mission.spent diff --git a/packages/introspection-aauth/uv.lock b/packages/introspection-aauth/uv.lock new file mode 100644 index 0000000..1ae66ff --- /dev/null +++ b/packages/introspection-aauth/uv.lock @@ -0,0 +1,988 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' or sys_platform != 'emscripten'", +] + +[[package]] +name = "aauth-signing" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "http-sfv" }, + { name = "pyjwt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/72/8600e0ed64179e8dcaa84e7e49cd59a2b94947a818f211e6f4054f0c6684/aauth_signing-0.2.0.tar.gz", hash = "sha256:2f2532bd15df0c8206911e678b0add7f5d3148a1d05459c206506bf08f549392", size = 20056, upload-time = "2026-10-06T19:30:21.053Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/6a/9077b9469ba84552e274ed2365b9d78c58df446528c0bc8affe9a2cb1722/aauth_signing-0.2.0-py3-none-any.whl", hash = "sha256:ac646f959fb8e58fc902db9e789954c8d958d61c5e3afd41a4c26ccd488ccaee", size = 24368, upload-time = "2026-10-06T19:30:20.153Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.16.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/55/d1eaf3e73781174340a00dc1ba2aee8a65f82fadb18e2797b192b6b3925b/coverage-7.16.2.tar.gz", hash = "sha256:ca64d9f1f384f151b9511bec01126072acd2f313439f8ed015a22d8790aab6fa", size = 971999, upload-time = "2026-09-27T12:29:01.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/fa/ce3baf63d85b730398d92a7162f486f3a5e4e2cc3382a02488b3943725ba/coverage-7.16.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:732d950e51f3ba4fb6209c73250f3e8924fefca42953ee04a9e65d8c02414d7d", size = 223797, upload-time = "2026-09-27T12:25:54.756Z" }, + { url = "https://files.pythonhosted.org/packages/7a/57/9ba29c2aac7f756d479f03d45762120060f0f988788001001bf36e0e6fca/coverage-7.16.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5dca0bb66b4c3d624ba047887bf70270030c150692d543cb501293dc38a9f4b5", size = 224303, upload-time = "2026-09-27T12:25:56.214Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/0d6d60906dca7d28cc1e3fce12a9861801c4fbb6cbf220ad78cd059c9467/coverage-7.16.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:af2a2a8c7c74de0559e0c368d94c8def9e16c58faaee33a0bf081057c4227e3b", size = 254715, upload-time = "2026-09-27T12:25:57.755Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b8/9198b865679379fb165c689c64f6e11105ef380f6bd1c7673e83f73d9f5c/coverage-7.16.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db5f8394e17f877a625b257f2ba0ce8e728a499c2c1579ad66220272cd3df510", size = 256627, upload-time = "2026-09-27T12:25:59.131Z" }, + { url = "https://files.pythonhosted.org/packages/98/79/9521462cb6072fe394701bc8974b74afd576c9c9355156c7844e1a86a42b/coverage-7.16.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3146d2317c75f70df2509066d979dadd941f7021cdf9b5db4bcd8568258e25", size = 258724, upload-time = "2026-09-27T12:26:00.691Z" }, + { url = "https://files.pythonhosted.org/packages/a6/76/8d7d5d633db9fe0f3182fedc731bf09f9bcf2366055735152504ad614677/coverage-7.16.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e1d0ced76318bab499693ff25f64faa343415187cb2e4d7befdfdd391a1cf6a", size = 260692, upload-time = "2026-09-27T12:26:02.083Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a7/76cb09c89ba46d74d37428bf93251fc14fb0bbe9e05cc2a5ef61773d318a/coverage-7.16.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:af98ad5ed9d6daaca956201e00bb429a7eb2b080426686f70a20353e0f9839f5", size = 254800, upload-time = "2026-09-27T12:26:03.369Z" }, + { url = "https://files.pythonhosted.org/packages/72/b6/2351c1979aaeb5b4a8091a75b90ca997ad60de36e181ddba267cf61dac97/coverage-7.16.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d56e4d21c56d2046447733f8b118409597db48c01efe898ee9ac24e858ec2d6", size = 256423, upload-time = "2026-09-27T12:26:04.751Z" }, + { url = "https://files.pythonhosted.org/packages/0f/f4/ad9a4f8b5cb2d494fa9452b546fe742ed2f9d3847cc14c05e36279a3e649/coverage-7.16.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1d5d0e3b660506fb84f995814e3118a21efdc0c8eb80127da1be627d90093c17", size = 254487, upload-time = "2026-09-27T12:26:06.082Z" }, + { url = "https://files.pythonhosted.org/packages/6c/1f/a520470472f3e8b01169bf42162b1470c9ba992230432f62ca36269bf3a0/coverage-7.16.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:17228fbca0f22976f797be94e975dcd237799c657d49551c7de1e0654d1202e9", size = 258259, upload-time = "2026-09-27T12:26:07.513Z" }, + { url = "https://files.pythonhosted.org/packages/09/d2/ff26d5938274745855fa61cfcba0245c88ccc10d98d2cbd96064f16cd5a7/coverage-7.16.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:bc0b0ac781d489304b741269857f1f8338b7a26b1b89c06c0344658001ec0035", size = 254216, upload-time = "2026-09-27T12:26:08.982Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1d/5d832d3b06785d9f53267e4f2724a9f60c312eee6ebed9063a461d0d3b45/coverage-7.16.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bf1bd822ec4e387ed245bed0d71151582cf7be9e5309bc4145eefe36083d5878", size = 255125, upload-time = "2026-09-27T12:26:10.35Z" }, + { url = "https://files.pythonhosted.org/packages/55/4d/1d33edbc2fcf7d99e384e393e712aa5a2ebbbd8409825357815982207976/coverage-7.16.2-cp311-cp311-win32.whl", hash = "sha256:7ed238d227e23cc300c3d464babdaf9f6ddc740aa1b15a77ae96136e6a7c4516", size = 225898, upload-time = "2026-09-27T12:26:11.7Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7c/676df4882118756c4f8f560c954eddb93e166d84dda8c5f0b6a829689bde/coverage-7.16.2-cp311-cp311-win_amd64.whl", hash = "sha256:a90700f743e29aa3d75a6ff5f01953176a889c00e526194bc4d281731b88d99d", size = 226369, upload-time = "2026-09-27T12:26:13.375Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0e/a457f4a461b3c5610d845137fdd45fa465e011a64c25af440518ab1f4e41/coverage-7.16.2-cp311-cp311-win_arm64.whl", hash = "sha256:a336eec40e3520d369b8a6cdabb4f596e69a8b42927ca074aa1452fed943238a", size = 226344, upload-time = "2026-09-27T12:26:15.127Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/f8296c63c5d542f3d21aed685e56b7031a419037d155bb3382fc0940d249/coverage-7.16.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:218d742afca2b5ad5ca759e93eddedfbcc6eadf8322f080dcefc40b7bd4e2d48", size = 223969, upload-time = "2026-09-27T12:26:16.753Z" }, + { url = "https://files.pythonhosted.org/packages/90/23/6f3dcb1423a0d43216e402ea1746e4a7c7c44f38896b97dd573790f56a40/coverage-7.16.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9a638be322a8d76a41cdb17781c7f82aaee6a66493d8ffb7e2c09ee22423d99", size = 224328, upload-time = "2026-09-27T12:26:18.15Z" }, + { url = "https://files.pythonhosted.org/packages/ac/7d/8f3b6dc920e3fc6732f7678785a2091db439f186afbec30dbf2214d9b1f7/coverage-7.16.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:724bd0f1e81856b35e59fc98cf7b4e544a3cb662e4e0864dca73d4326ee9d808", size = 255832, upload-time = "2026-09-27T12:26:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/d1/36/6c45f15be4eca4ac1062c6a55a323286494c99726a7e58951fe85967ac08/coverage-7.16.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5375ebd99038021b35e99dc88255022912c06565d316212f4a576e4b08d30f5d", size = 258571, upload-time = "2026-09-27T12:26:21.199Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/b54cbeba3ad89082c2e441278681859e538322cc34b84b2af7ebff00080f/coverage-7.16.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a076277ca9f5750cc230f0f578ebd2620cec60255b25707361699fef6fb465c", size = 259680, upload-time = "2026-09-27T12:26:22.822Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a2/0dc65ec3d61930e1e4c2e371763b15eb4290896eb343a12d5d3091308116/coverage-7.16.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58d4a54c6ea672afef66d49be922a2c69826c5ae1a42a9cd94f0c9c2bacdf800", size = 261945, upload-time = "2026-09-27T12:26:24.336Z" }, + { url = "https://files.pythonhosted.org/packages/d6/93/5fad7a61f2c14e08e98946fc31c1c7ffc1195061bf3fdc351db3be77a863/coverage-7.16.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcbcfcc059117284c603ff8cb61a65872512882f84a8cf0339241f7f7c2f148", size = 256191, upload-time = "2026-09-27T12:26:25.89Z" }, + { url = "https://files.pythonhosted.org/packages/2d/47/74e5de9227b939ece9f64e729645ddc4296bea10dbfa98721c1333c8be2e/coverage-7.16.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:afdf43b72ef3876c1fe66423b91466e37877c9e81e8cec70542b7e8525b9d1b7", size = 257610, upload-time = "2026-09-27T12:26:27.35Z" }, + { url = "https://files.pythonhosted.org/packages/13/fe/2cf28d40b43645d1b72388fe3ee7f7c747533a6a9557bb8c24a7ae74fe1a/coverage-7.16.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9acc7f7ec4a1b5f89bd929fde5b8a714f6fafdc6cc18725413d510aa082b47ad", size = 255757, upload-time = "2026-09-27T12:26:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/d7/3d/7c149fd99fc8bbc39c80db5e688d1d39fd040be2ecb78b8335a51a55b9c0/coverage-7.16.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:80d3f7b48d43ee8fc5e8707a8adb43d743a5a1a85256c25a24f9d6d0e2238fa6", size = 259822, upload-time = "2026-09-27T12:26:30.515Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/b283fce09d5995e227bd8e513358dd7471bedc0f78abc85a925ebdb0a2f6/coverage-7.16.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:126d1af8804d7224421fe991ff65d3ce649081560df7a98b1a5ffff07f9923bd", size = 255325, upload-time = "2026-09-27T12:26:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/bf/91/f3325edf0c4223fb1fe1532b8dbef2a1d2f729459a9a7d1a44d073bae534/coverage-7.16.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c19cd6d025c1673f22afcd22c7df8a662d779e05d8e3fa6820c22afb895b0206", size = 257191, upload-time = "2026-09-27T12:26:33.525Z" }, + { url = "https://files.pythonhosted.org/packages/c4/89/21eb5e83ecf2eed523c4eb3d65ae513cd082c8fd1b6deb34c4cb6c332f97/coverage-7.16.2-cp312-cp312-win32.whl", hash = "sha256:152877cdc8a07264882cfcd503ba56a3ef6cba56a70e8c70f6eb8ffd7384789a", size = 226034, upload-time = "2026-09-27T12:26:35.021Z" }, + { url = "https://files.pythonhosted.org/packages/db/de/e3ad6d864c0833624b4f1f9b53f9e58e116c945e5e965c3f1e172c5e84cd/coverage-7.16.2-cp312-cp312-win_amd64.whl", hash = "sha256:e6c52d3307824ff93b39efd99e4185d557db40bd841452abfb32e5d9151ca162", size = 226569, upload-time = "2026-09-27T12:26:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c1/bccc58ebe5489cc70628f635c1932fd371f5d7da850dbcf960f95f4c4afc/coverage-7.16.2-cp312-cp312-win_arm64.whl", hash = "sha256:a678c0b6b22086ec2427359d22e37445d4a792f5fdbbc744112c7dade65cad02", size = 226372, upload-time = "2026-09-27T12:26:38.406Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f6/8eb4f220ef24f84fb27d852d4f9bf83e0c73ec1a4a08dd9a87e3f4529739/coverage-7.16.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1a37c6e478cf687e1aa30a593d19c92c02fad9d122b51ab73f51b8dc7a0c0fc9", size = 223993, upload-time = "2026-09-27T12:26:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/40/23/d4bbaf0c154e0b0c2b5264890dbf6ef098dcb50ec8f2469be9490d191660/coverage-7.16.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0993d0e90858c03943d3cb152e068a20dd4707924deec84dd2230261baae3b1b", size = 224368, upload-time = "2026-09-27T12:26:41.762Z" }, + { url = "https://files.pythonhosted.org/packages/7f/48/fc1e88fd571ec5cb38150b7f89f7696ca1bdf9920e01432febb69774cc85/coverage-7.16.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bb2fc905bbf4e6b7f40806ea79e31515abf6349594cdf0adf27c4215f0463204", size = 255358, upload-time = "2026-09-27T12:26:43.442Z" }, + { url = "https://files.pythonhosted.org/packages/1d/56/6785397d07c29c8e70fbb9a07e97d062b43c21ffc5f12385917847f09f63/coverage-7.16.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4358b9c8c0125b460407f3017c6cce8156e904b32772c5630d27112f52bdbfe5", size = 257955, upload-time = "2026-09-27T12:26:45.725Z" }, + { url = "https://files.pythonhosted.org/packages/27/3b/c8cdd07721e5f99abd81cea970d971997f99bf158c0b85f51bd284179c8b/coverage-7.16.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f15254427c9b33eedac4f198eaf9e356eb4f6214551afb43da6194a2c088ad7", size = 259183, upload-time = "2026-09-27T12:26:47.208Z" }, + { url = "https://files.pythonhosted.org/packages/9b/11/606b192fe43d32574ec6238549d48de588fdcc18485682a5ec0a8ac357f2/coverage-7.16.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9a75a4704ff640e46170042eec1f984385a121227c505d5a16ad8e495f452541", size = 261325, upload-time = "2026-09-27T12:26:49.084Z" }, + { url = "https://files.pythonhosted.org/packages/67/90/eea481f8b0305ceeb33f081a5f47e298391dbd1b589de0c4b3b3aa50d3f2/coverage-7.16.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:14253fc7bb15749b849795a06f5d3b6d8bc3fb8a4b5ddc341faf7a89dce205fc", size = 255531, upload-time = "2026-09-27T12:26:50.509Z" }, + { url = "https://files.pythonhosted.org/packages/6b/be/dedbf9aea1457b120c27ac10b8fc2a357f37fa2b54c3e7286d42980a0a2a/coverage-7.16.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:921415102a90637fcc2e3f169f61dad7699ecf690e8639fc21b813acbedc0967", size = 257329, upload-time = "2026-09-27T12:26:52.005Z" }, + { url = "https://files.pythonhosted.org/packages/fa/cb/b25c19d5bb2bd0f2e4e27fe8e2ffcae80c7a91ae181c0dc749ed60e9b1a4/coverage-7.16.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cce2bc991293f15cc4084ca116827b5900c5f34e1a54dfe83f10ab5c43162eb7", size = 255294, upload-time = "2026-09-27T12:26:53.634Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a2/892c5c5f4ad44b7b2ca009aee705191f3f268f15052244f2f9e3539b2e35/coverage-7.16.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1fa594c887365b69745f25a416806e61085dd07b94c9eae68a6e20730629b23", size = 259444, upload-time = "2026-09-27T12:26:55.243Z" }, + { url = "https://files.pythonhosted.org/packages/ed/99/a562537deba0a3e370182ae71c149be796c39d8087365f17a09188f27145/coverage-7.16.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:11e597173af1dc33d5f8a7332ada544199269a223af1ee1770ddd5e245ad0fe8", size = 255111, upload-time = "2026-09-27T12:26:56.851Z" }, + { url = "https://files.pythonhosted.org/packages/2d/20/854ec68641a9b3362ff068a32dfa41637299761617ef253791dbade6fc76/coverage-7.16.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e7f99698ba3a7d13988bdd984b7ebf13af4dbe2166dc8502eef90d77603b0a4", size = 256882, upload-time = "2026-09-27T12:26:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/db/0d/748e4518b0ac0f9ff2687c248a6e5f8c0737306e709372632a2556f84443/coverage-7.16.2-cp313-cp313-win32.whl", hash = "sha256:f80bd9f9633eafc73d0a913ba2645c96ba58bba1befc30590f7c0fbfde59d865", size = 226045, upload-time = "2026-09-27T12:26:59.983Z" }, + { url = "https://files.pythonhosted.org/packages/31/fa/6e46edba66a183fe4d99d4bb52c173287e9b8dddabe0888d24cb8210e580/coverage-7.16.2-cp313-cp313-win_amd64.whl", hash = "sha256:8be099e979fc42559328a21828281b4578304191ae46ed4e80a407048a82eee6", size = 226581, upload-time = "2026-09-27T12:27:01.494Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d9/9ef6845367600b336ff75d000444a0d32497d6972c833141bd39356abf68/coverage-7.16.2-cp313-cp313-win_arm64.whl", hash = "sha256:28ff850182a67d117990fa2ce5ea1032836d8c9630dae867e8bdd3bff4533b79", size = 226409, upload-time = "2026-09-27T12:27:03.116Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/577fc0803dab4155dcf808faffbdd7b159256781c0874a8586e17b81b149/coverage-7.16.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4ee546b9e4872ffa194bf07ac87bfa1202ebb824d0795dc1ef22f175545ca90a", size = 224138, upload-time = "2026-09-27T12:27:05.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/9e/e3785ba3ecba2bd11efc74bfe2801ca4b78c4480b15a375648d809a59da3/coverage-7.16.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a2fac6895eb299a2e52d7bbb8fb3903502b9da8d3f5309ceb16ec40c646b58ee", size = 224445, upload-time = "2026-09-27T12:27:06.805Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d0/963ff22d3fd27117da3b8cc442f5bdc91196f783321e1a8ff0ec43476772/coverage-7.16.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:57ff3783f99d75a1e81dd56a9737eb5665e6736a5d93258ba596b6dcad8fd05b", size = 256112, upload-time = "2026-09-27T12:27:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d4/a306940c81c6ae759e82fff27d20b7fdc6896e422b821f51313cce212b6c/coverage-7.16.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f37886699cb9abd29958247d718628d5bc6f39e623dff66a09e546c42a7e03", size = 258727, upload-time = "2026-09-27T12:27:09.927Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a3/d3d99d93b02517087aa05bc0cf2d04d372956b849e5443e059079901429b/coverage-7.16.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fd7a86fdda7cb6d616d178654bd0ad6bc0f3f33c2e478aa598500a1a9e34eda", size = 259909, upload-time = "2026-09-27T12:27:11.55Z" }, + { url = "https://files.pythonhosted.org/packages/08/44/39dd599181726758dd185ae4dc0c0ab3aeabf7ca70e68e145060feeaaa16/coverage-7.16.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ac0f3b379c94acc2f7dce5f5f0b24d44fa1cc6a509717ef83dfee07450c2117c", size = 262481, upload-time = "2026-09-27T12:27:13.17Z" }, + { url = "https://files.pythonhosted.org/packages/99/e8/91ee43f6ded411460c359d7e1aebde4d6fd8f00a2e5394182d9d212eb23c/coverage-7.16.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d0732c83746bc24123c581a85d9dd96b70ddb538c9076020aa1a041790361e9", size = 256012, upload-time = "2026-09-27T12:27:14.91Z" }, + { url = "https://files.pythonhosted.org/packages/11/8c/e9499ddc33197bd7eabcb1118ca81756fc874457b324e2b479a4804b2ad2/coverage-7.16.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b451c68218c150f616bc9649783ec8de76a59792c759b43aa0c9c0466a465e4", size = 257898, upload-time = "2026-09-27T12:27:16.588Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6e/c081cb5991a0afba99f9c4ad6c74a5fce9513a38ddc64e3e6680c6fed9af/coverage-7.16.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a56ac4fa5a75c7e182e8f62600cfb4aff43c5ed7356a034f3557659c3bec1d90", size = 255938, upload-time = "2026-09-27T12:27:18.19Z" }, + { url = "https://files.pythonhosted.org/packages/b2/42/1c3d819e8f9b6eb01c2fe90874d67a8882adb9507e0bbb09361ed131ea89/coverage-7.16.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4cc4f73aa3fabc36e32046d6cd2971405948d8a903636508a3d3b2f9128b3a95", size = 260368, upload-time = "2026-09-27T12:27:19.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/4f/d70eac07901fd587b6ab05e659b52afe13959992aa5113bf6cce059cc572/coverage-7.16.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:723dcdab91357159b722935b500ee8abc0a66c8c432e1e9fabf4cc7598952de8", size = 255620, upload-time = "2026-09-27T12:27:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/6d87af88317d3d9a9b18a9ca1bc1673eb516917f296e579d0d4a55cb3490/coverage-7.16.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5397e21a90dde0e9c6896b77ded8f0be26b66f8b22b33aed41f6043ed95d55e6", size = 257521, upload-time = "2026-09-27T12:27:23.358Z" }, + { url = "https://files.pythonhosted.org/packages/79/bb/90c2641170d2fa1a6757b3f8450ba2740197317b0ddd749e9604b914e886/coverage-7.16.2-cp314-cp314-win32.whl", hash = "sha256:848893e1d361448c113dc2f0913503522a6f7be231d0e38333d2a22d9698a011", size = 226248, upload-time = "2026-09-27T12:27:25.153Z" }, + { url = "https://files.pythonhosted.org/packages/30/08/d8d0478bb02c8eb0ae20a496fc80c40fcf4d3450bd184300d682ba2d28a6/coverage-7.16.2-cp314-cp314-win_amd64.whl", hash = "sha256:5a27b731c171e43dc8b5f32b76a5051dde2ec9b9366c87028f08a7088ebc2c7b", size = 226732, upload-time = "2026-09-27T12:27:26.907Z" }, + { url = "https://files.pythonhosted.org/packages/32/3f/0001da22155b0a8ce063ec0f7e64ecbe17b373f306e7a74435f6d6accb72/coverage-7.16.2-cp314-cp314-win_arm64.whl", hash = "sha256:1c569a9fd25505f1cd6bea90588818f90373ce90e2632e2cacf19ddbd6e14fdb", size = 226645, upload-time = "2026-09-27T12:27:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/d7/85/6d8813aff9b8b8586691a9d33c43c5604f7227622574da7cdc3d91a86861/coverage-7.16.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d93db87adb6b1c1b408dce4763314b55d76a9f589e96783a84ac9e7689e48bdf", size = 224871, upload-time = "2026-09-27T12:27:30.32Z" }, + { url = "https://files.pythonhosted.org/packages/5c/70/444f3a4981ac2cda40fdcf4cc9b56a4e1a33c222abeb33e51ed3e3eb2a6b/coverage-7.16.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aa62c85046473959c13ba9edca9dc90a77d5c1095b1ba313556314d77fe5b036", size = 225123, upload-time = "2026-09-27T12:27:32.33Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c1/980681cd7b33eb66ac835044116ef0a92e11fcc7bdd866cc89d10b1130b9/coverage-7.16.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:db76506aa5416081f3e8974ae0f7965c58ada0bb0ef7339ac86099588dbb20d3", size = 265049, upload-time = "2026-09-27T12:27:34.085Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e3/87679875c33bb2191f0f05544a1cc9adcc940fe0c35443a10f2df753dde5/coverage-7.16.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a0f2285329dac10ab08f79cb11f5692c497018e6c7c511f95e6fd63a70b8f831", size = 267717, upload-time = "2026-09-27T12:27:36.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/64/5d372776d6eb523d4e93bafba2253f96984e3b18261c4cc56a50863c6d0d/coverage-7.16.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:382d3346d56b0eec1b793d53a4c88799c8053f516aa3a8d7c44315696954bacf", size = 270047, upload-time = "2026-09-27T12:27:37.96Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/44082ff0cbf9f97d0043f57970a71204097ec7ba606361a9fd2065393669/coverage-7.16.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:648352b94507179d82637292e7ae8802508d95f78e2f00a705a50b6c48011681", size = 271360, upload-time = "2026-09-27T12:27:39.766Z" }, + { url = "https://files.pythonhosted.org/packages/b8/17/9a215efe25b5e0ecc87c89dbe525c4a87d14d87c8c0c7316ef140a5f6f3e/coverage-7.16.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb2bde05838fffae1a1bf75e5d411a6cac3e4e9bb97e6640fed8cd47888b33f0", size = 264736, upload-time = "2026-09-27T12:27:42.072Z" }, + { url = "https://files.pythonhosted.org/packages/a2/da/7f0a31af8e448107d4d32844bd684757f51ea907bc0c68c8fd537b2123ff/coverage-7.16.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6a75180829efb8ae62b4aded25be6ddca1c888d138d2d82e21d93bfbd88f41cb", size = 267600, upload-time = "2026-09-27T12:27:43.85Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/3bfecbd3366b775bacdcb3330394d356cf384b5d8f5b2146ac4b14b252b5/coverage-7.16.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:99704f73721e23859112072d522076e11c31744fc96b5652e5dd2018aa4359f7", size = 264767, upload-time = "2026-09-27T12:27:45.768Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3f/5d62163732d87e4a0c4710a0eab30f0fd6a2d480112abe2029f014fe8c9d/coverage-7.16.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:29309ccc86b7f33df7db12813c299f215bbbc470ed6292d0bedd63ffae1ebf64", size = 269026, upload-time = "2026-09-27T12:27:47.787Z" }, + { url = "https://files.pythonhosted.org/packages/49/4d/8e4579f225426535085a9be371cc75e3b026d058d679b80affbdfb4c3ef0/coverage-7.16.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:30c1b65d529e46569899fadca59e4a87c1faf2886923f1307ba61e654d4f3c20", size = 264147, upload-time = "2026-09-27T12:27:49.681Z" }, + { url = "https://files.pythonhosted.org/packages/d1/36/ef1f77e2c3f7bb03c2b13b9a2006f88700fdd75535ef158d70049f425c1c/coverage-7.16.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcf4bc2aab4e16b1c4c0c2005918f23a7dd5d7821ddae82caed9e3342dc2fcce", size = 266376, upload-time = "2026-09-27T12:27:51.551Z" }, + { url = "https://files.pythonhosted.org/packages/be/79/0cb2bf4428830dec971c718c2c841a039c084415c99e67281f5a72841aab/coverage-7.16.2-cp314-cp314t-win32.whl", hash = "sha256:a9cd3de0a5bfe7b0e21ee10e1a14e3d61bf52efc88217ab1d95d6ace6970bd46", size = 226585, upload-time = "2026-09-27T12:27:53.945Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f9/da17121c16667fd84998e972200ae226a41540f6ea4795776c6d99e8976f/coverage-7.16.2-cp314-cp314t-win_amd64.whl", hash = "sha256:611a44e5229a59d7483ce830160e1a0e85f700562c7a5651c7c63fb8f4eb528c", size = 227378, upload-time = "2026-09-27T12:27:55.778Z" }, + { url = "https://files.pythonhosted.org/packages/74/89/01179c62d1b7e6e33bd5001566b02d7f778cf33d3ec1e81e94ca170c517f/coverage-7.16.2-cp314-cp314t-win_arm64.whl", hash = "sha256:22957cef43ce038641de78ba995de7568d2d6a37c6ddbf7fa0fd7d1ae2344d91", size = 227064, upload-time = "2026-09-27T12:27:57.496Z" }, + { url = "https://files.pythonhosted.org/packages/4c/57/52935003c3f627ba6e5203d7179aad32448c10899663a30336aba8e81a2c/coverage-7.16.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:414c26dfdb96aac2d570a54e03008f001e32eb2d413705365503648c6bd361d8", size = 224140, upload-time = "2026-09-27T12:27:59.343Z" }, + { url = "https://files.pythonhosted.org/packages/31/38/df472520f3e626524d7e2fc9d6da0afe7895a2f1489d36b48af8ca40bb41/coverage-7.16.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:00d3eb96e9988c45f50cccd1f1496571ac5c1f91386ac02c4d55516eeda19a24", size = 224449, upload-time = "2026-09-27T12:28:01.299Z" }, + { url = "https://files.pythonhosted.org/packages/0c/aa/3be084d5b82e63ccdad4ed751e4acbae294673573e30481d29f8b7402eec/coverage-7.16.2-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4dbbd1155ca46e6e0b6b89d204428c56ef6a459af21333f365d135a2820e5a09", size = 256060, upload-time = "2026-09-27T12:28:03.185Z" }, + { url = "https://files.pythonhosted.org/packages/de/29/48fca82a7ebf7ff7b2e35019cc9537e7f65e4d2aa1215cc5a8792c989251/coverage-7.16.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8fc15cc8d0d06e873c00ef18e1372d605f9aaf3de27d8c24e50782e75bc8b843", size = 259106, upload-time = "2026-09-27T12:28:05.15Z" }, + { url = "https://files.pythonhosted.org/packages/06/3d/b2d5986f2dd53fe201aa1be2e4ab204fa1aed5101e67c0dbbb419b850aee/coverage-7.16.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c6afdd69218202bc1758c9a14b86b8cf1084f37ed2ca143e567a103772b16d1", size = 260674, upload-time = "2026-09-27T12:28:06.868Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7e/b50160be3506ead12e6480d14279af7f0f17627694300a2d1fd2c42d2ff5/coverage-7.16.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aba5c63b7afdc749cc9eae943d5b868cba2b261a176378fa1c5a30bc8bc89982", size = 263139, upload-time = "2026-09-27T12:28:08.771Z" }, + { url = "https://files.pythonhosted.org/packages/14/5e/7c805ac9a32606de1399bd7e9bd375aa2f973dc61b12680d9e6403c2e891/coverage-7.16.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9174f0af24e5eff248b9dbfe76ec5275a3d19d37edbc2810543f12cf97347a34", size = 256591, upload-time = "2026-09-27T12:28:10.842Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9e/76f1ed129a2daf658a3ea17122824cf2e3b91fea0460d8d3664fc5a61018/coverage-7.16.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:80e9fdb4c3d926b6ba721d4bf7435bdb869c3527ae7803290361d0ab73db13b6", size = 258708, upload-time = "2026-09-27T12:28:12.962Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b7/8d62e75f48b527619239a65294f842d4b7fd02a0839d43ae1de80184e2df/coverage-7.16.2-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:7b3bce4a0d05401d70b7d0d5ca783e686bc9d30e81dbd7d980d532609bf809e4", size = 256562, upload-time = "2026-09-27T12:28:14.934Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8d/0a15f95c3afb78e947c52644786ba4bc9de259905687dd720d5e6fae2e76/coverage-7.16.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:44f21e407b278efdfc1ee5e481e00518bd1d500310a30a5fbf2bcbedfef4aaf0", size = 261077, upload-time = "2026-09-27T12:28:17.215Z" }, + { url = "https://files.pythonhosted.org/packages/25/00/88389987305a47d732866c07c8a500000ab574df9505e3114ac69c8d027f/coverage-7.16.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:59c3926585e1cd1f2190f4b2ac9014de1bbeaf0d5d0587b0dc6b0aa90d17896a", size = 256034, upload-time = "2026-09-27T12:28:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/92/02/34d079d4952ad461bde037d353f9a6e037a7edc45fe0f9ee8781ff73f028/coverage-7.16.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:066429634299e14dd2d511e1e85f8f9cecc500781f6b41907c0dd6f1baea7e63", size = 258034, upload-time = "2026-09-27T12:28:21.242Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d8/3e59a62879285b464ec1b10fd824fbc1af9ce66e842cd39974f80a0becc4/coverage-7.16.2-cp315-cp315-win32.whl", hash = "sha256:893ea9cf86cb8d2546812ac93d973aaf2ee1fb45110a873b014214fd23e3725e", size = 226252, upload-time = "2026-09-27T12:28:23.102Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e1/128026e1b2836e9ad6b219207ba9edf1c5e0088a7869e23088aee7fbbe7a/coverage-7.16.2-cp315-cp315-win_amd64.whl", hash = "sha256:01c6908bc613b420c26c818fe948e1b97dfd041a53c98b01c63bd8321f5c9aae", size = 226728, upload-time = "2026-09-27T12:28:25.21Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f4/c9fa8e7cf525ca7748ac52b0ee89331d13fe09808e45c679830708782e90/coverage-7.16.2-cp315-cp315-win_arm64.whl", hash = "sha256:967d72c835d7a8cf0af99ec813a2d06e3db6df706402f1fe85b31b437645f495", size = 226648, upload-time = "2026-09-27T12:28:27.136Z" }, + { url = "https://files.pythonhosted.org/packages/a2/13/e96b045447a856666f36f9c653e2a80bdaa732aaaf72412b19aa2c26a473/coverage-7.16.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:98d9c97f51b334b0adce7b964442a9af33c1a00c6ac856984cc5dc8d18f81c75", size = 224858, upload-time = "2026-09-27T12:28:29.169Z" }, + { url = "https://files.pythonhosted.org/packages/23/90/087f6ad1bd3df059632ca3407a4e6552ed1053ee35354de0a771acf35423/coverage-7.16.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:3e861f1071dcc2fec1e88bef0920f6b1eaa66a143555b4f8ab79ba2b0f30ef55", size = 225139, upload-time = "2026-09-27T12:28:31.131Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8e/285dcef0184358044e7cbcd810a1bdc9566bc620f54702d605477155df4a/coverage-7.16.2-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb9d92ecfe2d5b494367c67f7446f8b75b68d8d0c8cf3bc3e6997478be25d9e2", size = 265090, upload-time = "2026-09-27T12:28:33.04Z" }, + { url = "https://files.pythonhosted.org/packages/06/b2/cc83f3a6e5789a4e89059c69555bc641c2efcde568405a1c06fc702951ab/coverage-7.16.2-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb57acff4a74246ae513c142d4b36e18c389c3aed8661914a53f7cd0071031b2", size = 268254, upload-time = "2026-09-27T12:28:35.135Z" }, + { url = "https://files.pythonhosted.org/packages/ac/41/f548c19530f5d66ac6e3c92bbcbc49da7261de3a458b9f3e54a3efb1a0b2/coverage-7.16.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:444889f7f66b74e4455c0a97e0e166dd41177f1dca8c0239a47cff25e05ba7e1", size = 270695, upload-time = "2026-09-27T12:28:36.959Z" }, + { url = "https://files.pythonhosted.org/packages/94/61/4dc27cf82ef96434d2874110ad0cc10ea4621025705dc5049862bd3bd181/coverage-7.16.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a740ea6f083c6db7b926534d159508f80ba275ab35e722522de0d18d0f56e55f", size = 271853, upload-time = "2026-09-27T12:28:38.821Z" }, + { url = "https://files.pythonhosted.org/packages/38/29/bf8072b1b8bd5f2de8b21460a404460b1a2b97e80a9464c78ec0271f6199/coverage-7.16.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e209591f7c41ae4a9171335cf6156afda0b21de73b02f73f5aa95b2d5fbb08d", size = 265644, upload-time = "2026-09-27T12:28:40.815Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2f/0aecb8721be5cdeb8afd9d6d9f6b463f074e4d8d37f00f4c42442522709f/coverage-7.16.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:396bb16e04ce04efbb3df91456ae4e3da918e69ecdf67fb711b0a0fdf35ccce0", size = 268530, upload-time = "2026-09-27T12:28:42.725Z" }, + { url = "https://files.pythonhosted.org/packages/ab/0b/92b4b7628268ee711249958e68fc0328779bd3d9a7ab4715379465aedb84/coverage-7.16.2-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9cdf19874e0d247f32f03609200370343c3c7aa260b191d8c2bb251d36198283", size = 265154, upload-time = "2026-09-27T12:28:44.684Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/41c95c1ab29b3dcd357cd1227181d1c98185632aca41ce670ce671b23a43/coverage-7.16.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:fd3d72233eb8b48acc94fa57d44e2d32ce8e7abed02882ccb6d855ccc4ed33ec", size = 269799, upload-time = "2026-09-27T12:28:46.672Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ebeb259aa5362b033a137b86d7274ff4b109d59be8cc9913889b783bf75a/coverage-7.16.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:bb4ffe96aa663cee727659db5a2afeb38c95f8677b747d447b90d6d4874ea2c5", size = 265196, upload-time = "2026-09-27T12:28:48.996Z" }, + { url = "https://files.pythonhosted.org/packages/b2/18/8437620f90d023680a072eee02f968055f3658bbfb7d386d0ea34cfb7f30/coverage-7.16.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dba2edfb054f6d4a08df9d1637c39a5aa3865bca6617c13c86be21e45658a59c", size = 267266, upload-time = "2026-09-27T12:28:51.361Z" }, + { url = "https://files.pythonhosted.org/packages/28/6c/f08e8ee4293e6434035424180bef4d45e028e8ecc006c61bf9453e74405e/coverage-7.16.2-cp315-cp315t-win32.whl", hash = "sha256:251aed777c47c77aba047096d4542889db089227655711dfc2b9c54ef0e15e35", size = 226583, upload-time = "2026-09-27T12:28:53.33Z" }, + { url = "https://files.pythonhosted.org/packages/f7/fd/3f939c2847f4a72c20cff8b1ac33da78ea91a2d38d9b43336e60db719103/coverage-7.16.2-cp315-cp315t-win_amd64.whl", hash = "sha256:2aca0bdfa9e91621d5b09d815357bf63def4fc0e9cb66da67bf2cf93f3b1a6f5", size = 227374, upload-time = "2026-09-27T12:28:55.158Z" }, + { url = "https://files.pythonhosted.org/packages/5a/35/b98cdc354c952402132e675a87f2cc3227fb68f959c84aaa491fbe15933d/coverage-7.16.2-cp315-cp315t-win_arm64.whl", hash = "sha256:b88841e654f09732804809e435b3e005a929ffd9998b872b7b213957b8759cb8", size = 227064, upload-time = "2026-09-27T12:28:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0c/7a64e1ac90541a8edf50daef0914848011fb057a5bf55284a4811e21939a/coverage-7.16.2-py3-none-any.whl", hash = "sha256:11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f", size = 215754, upload-time = "2026-09-27T12:28:59.075Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "50.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" }, + { url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" }, + { url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" }, + { url = "https://files.pythonhosted.org/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" }, + { url = "https://files.pythonhosted.org/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" }, + { url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" }, + { url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" }, + { url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" }, + { url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" }, + { url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" }, + { url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" }, + { url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" }, + { url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" }, + { url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" }, + { url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, + { url = "https://files.pythonhosted.org/packages/1d/7a/f08d34ce09d60f89ebd391e2ebc6ba2b995e6dd7552f41820f8085f94e53/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:92e665960f25fcdc73725b9cec7a3824f279ba97a98653afe9ffac2e43668f67", size = 4716035, upload-time = "2026-09-30T15:29:48.681Z" }, + { url = "https://files.pythonhosted.org/packages/45/67/e18fb65592451a2acb76e9f2fbe14e0f47a8318b4c5430f1633851d03daa/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:eef4c2f3423810b3070ab391f85436d2f8bbfcb286ac15cbc73190b3563b1f1a", size = 4726917, upload-time = "2026-09-30T15:29:50.608Z" }, + { url = "https://files.pythonhosted.org/packages/83/28/38fdce17e60f6b825e69fc3b7f75e70a6612759980704697e1de4cbfaf6e/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7c6d0330c472d96f6a6afe24d80dfdf15176c33096f0a4397ae4c60f3dd3be48", size = 4715341, upload-time = "2026-09-30T15:29:52.522Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b1/d9121a717e0f893c64bd6ca7702614778d7df2a5c309128a002421788516/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:1ba34f04897fcdaa73f74145c25f3ec146fbd56593853e88adc2e811303c5f42", size = 4726322, upload-time = "2026-09-30T15:29:54.263Z" }, + { url = "https://files.pythonhosted.org/packages/36/8b/e6d153808bf353e152abd2fd4d8f09670d956ac78379ac46e60d7efbf04c/cryptography-50.0.2-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:3dc4fd8058cea1644971207d530e1a03a184a805ffc8ebdddf0599d78a331b81", size = 3873097, upload-time = "2026-09-30T15:29:56.097Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1d/1271f287ff7170ddafc2aad36260c4eec20ccd2fea70f38455e9d56d427b/cryptography-50.0.2-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:7b75de3c8b3be1cdb1052747c929440c3eea46c1bc2cb8a6e3a48388e9b7b452", size = 3805376, upload-time = "2026-09-30T15:29:58.729Z" }, +] + +[[package]] +name = "deptry" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "packaging" }, + { name = "requirements-parser" }, + { name = "tomli", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b8/b2/50ccc99362ae7757342978b7ecb3b98e47fade721fd617d74db1948ec3a1/deptry-0.25.1.tar.gz", hash = "sha256:45c8cd982c85cd4faae573ddff6920de7eec735336db6973f26a765ae7950f7d", size = 509748, upload-time = "2026-03-18T23:22:18.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/1d/b538dc635e873b25360d761cfe1fa0ccd7d6c69b698047e552f33401e60d/deptry-0.25.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:a4dd1148db24a1ddacfa8b840836c6019c2f864fcb7579dd089fd217606338c8", size = 1850319, upload-time = "2026-03-18T23:22:15.65Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a9/511477a8f0ae4f6021d68a80bdca77e7ffb0722008dc24ee5d9ef49f5c88/deptry-0.25.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c67c666d916ef12013c0772e40d78be0f21577a495d8d99ec5fcb18c332d393d", size = 1759259, upload-time = "2026-03-18T23:22:30.853Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4b/c9f0bdda410912a6df79a789cb118fa29acae02a397794ead3c84adcda5c/deptry-0.25.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58d39279828dbf4efc1abb40bf50a71b21499c36759bed5a8d8a3c0e3149b091", size = 1872012, upload-time = "2026-03-18T23:22:19.145Z" }, + { url = "https://files.pythonhosted.org/packages/72/9c/6f6f9125bac74b5d5d2af89536cbdb3fa159b6466aa097b74e7e85e8e030/deptry-0.25.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14bfcc28b4326ed8c6abb30691b19077d4ef8613cfba6c37ef5b1f471775bf6f", size = 1926575, upload-time = "2026-03-18T23:22:11.269Z" }, + { url = "https://files.pythonhosted.org/packages/52/48/2a5e705a7f898295966ade67bd1223e2af96da433e25b39f6b9483ba2c7b/deptry-0.25.1-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:555f5f9a487899ec9bf301eecba1745e14d212c4b354f4d3a5fd691e907366d3", size = 2050816, upload-time = "2026-03-18T23:22:27.439Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c6/50f189a894e1f3bf21266299112c8a06cb731838976e1b9a9cadd0b4a86e/deptry-0.25.1-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:18d21b3545ab2bfec53f3f45c6f5f201d55f713323327f8d12674505469ae6b7", size = 2145416, upload-time = "2026-03-18T23:22:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/7a/6a/3f82f7a06217778282bc4456af1b4ffb3bc4b2c8e7891d00e8323f9ad0b8/deptry-0.25.1-cp310-abi3-win_amd64.whl", hash = "sha256:b59a560cb7dffb21832a98bb80d33d614cfb5630ea36ce21833eabf4eae3df99", size = 1718489, upload-time = "2026-03-18T23:22:28.589Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7f/cd6b3ac8cf95f2f1c5c7a74ff6452e9098af89a9b56607381f677880641e/deptry-0.25.1-cp310-abi3-win_arm64.whl", hash = "sha256:6efffd8116fb9d2c45a251382ce4ce1c38dbb17179f581ec9231ed5390f7fc12", size = 1647020, upload-time = "2026-03-18T23:22:23.311Z" }, + { url = "https://files.pythonhosted.org/packages/46/e7/b554568a84197c0a4177b51c9880b55e9861de08d9acfd914a08148a1faa/deptry-0.25.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:30d64d4df1c08bc69de56cb0b4ec1f4cd9fa2e42582347d5b1eb25fd0e401745", size = 1846779, upload-time = "2026-03-18T23:22:21.887Z" }, + { url = "https://files.pythonhosted.org/packages/d9/63/38cf5ab4b81fcb1c58909ab0fe1ccc62b36f61c5f7d213a7d0474f620925/deptry-0.25.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:87bcd90f99a98bb059c7580bc315c3f87d97fe2db725530030bc974176834735", size = 1758420, upload-time = "2026-03-18T23:22:14.315Z" }, + { url = "https://files.pythonhosted.org/packages/a8/fb/234c333d5dfcc810bb3ca5b3b420355bdd759901c75e41f0441a9871a1cd/deptry-0.25.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80f31eb5c520651b102568dd91f738222b250a3e44c9e95d4941322109b8d40a", size = 1870345, upload-time = "2026-03-18T23:22:29.734Z" }, + { url = "https://files.pythonhosted.org/packages/59/62/cb63e5210d1ba36cf68cdc0e4fdea73e48f80ac3b7680228816f39ff696a/deptry-0.25.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df88952a2bab7517ef23cb304b979199b28449e5d9db2e9ba9bc27a286ac852b", size = 1922759, upload-time = "2026-03-18T23:22:17.121Z" }, + { url = "https://files.pythonhosted.org/packages/a3/8c/e079c44ed98464930e83ca54ea5d40fec522d234e8428e06a1be7f6c7a9a/deptry-0.25.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e6f7b8fa72932e51e86799b10dcd29381b2132dc799c790dca3b28ab08dffb28", size = 2049576, upload-time = "2026-03-18T23:22:12.797Z" }, + { url = "https://files.pythonhosted.org/packages/4d/f2/6a89ad9e5e8e9d37def57a28020d6d7fbcf900b2e5f4dfbbace349cdca91/deptry-0.25.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e3fa3321078e11cd1ac3f10ce3ff0547731c53f9253b87c757a8749c76fe8fa9", size = 2144676, upload-time = "2026-03-18T23:22:26.319Z" }, + { url = "https://files.pythonhosted.org/packages/04/9a/b3358690a1a47381d995c3d3587798ab2cd086baf4b839e35183599aa2e1/deptry-0.25.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:03c032c32492fde434736954fbcaff09c02bf207b0f793b77e9040300e34b344", size = 1715518, upload-time = "2026-03-18T23:22:20.486Z" }, +] + +[[package]] +name = "fastapi" +version = "0.158.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/51/d8ed723a728ba8d61d0984a8cf18d546ac1d7256b41100b4f1e8cc51d725/fastapi-0.158.2.tar.gz", hash = "sha256:b06b177c35b7739c3d9ca246ebc366026a830b6de81f7eac4645813fcf5a0894", size = 513741, upload-time = "2026-10-21T19:20:53.276Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/67/1e5ec1d8bd2c497dffbec7e2ef49abe19f24414e471a349e48fba710cea8/fastapi-0.158.2-py3-none-any.whl", hash = "sha256:d65d72a4a93858a6ff703360823bd0bf75e00773de32dcda166fd8131436e2c0", size = 149396, upload-time = "2026-10-21T19:20:51.769Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "http-sfv" +version = "0.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/ae/7684925d335eb4182d95b2d179c81fc05dbc89dc93e42b239c13f800525c/http_sfv-0.9.9.tar.gz", hash = "sha256:e132dc9bef990832bc01824f5fa9d4efc7d0f4271e4b227db35c8ef38540c739", size = 15973, upload-time = "2024-01-25T05:16:26.969Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/f7/f5adcd777e7e641c7824f4e45875a14cc7e0c6cd30b0f9db0c3698fd8e16/http_sfv-0.9.9-py3-none-any.whl", hash = "sha256:5feed51c90e9a1dc797701662d044d936923cf0027255c75452b8240e33d6c82", size = 16846, upload-time = "2024-01-25T05:16:24.072Z" }, +] + +[[package]] +name = "httpcore2" +version = "2.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/61/afa9d6b7452f9eca73f73d45324eb0bb52f098fd5e6049bf6b8263be9b4e/httpcore2-2.16.0.tar.gz", hash = "sha256:c72cd0413563b17e1cd8354677dff887466f8ec2551679759e93126534428643", size = 69886, upload-time = "2026-10-21T07:09:45.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/c2/ce417c397afa5181f0e3c6ff01a4d01639414e1adef08730d97571297151/httpcore2-2.16.0-py3-none-any.whl", hash = "sha256:5c4358319f2602d984ad93182c9fee5160f22e9da7babdda870a5e7c4e2dc34f", size = 85070, upload-time = "2026-10-21T07:09:42.802Z" }, +] + +[[package]] +name = "httpx2" +version = "2.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/73/5ca2cedf5e4e50423d1c258877865a6b62793633b561c58499f8aeb33b7e/httpx2-2.16.0.tar.gz", hash = "sha256:0d54c98128e8f2ff7fbbde0fca8c359e5f032edeaaaac443cdb7f31f685bcd7f", size = 103116, upload-time = "2026-10-21T07:09:46.348Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/3d/2e4bc4fd1ef9770871669adb348d612a6e4786b303a23f9935b81c48dd60/httpx2-2.16.0-py3-none-any.whl", hash = "sha256:fd2c5d7909e8d86fe92d53020c310ca0e4e431b838c207c5754b9bbf0308d49a", size = 97880, upload-time = "2026-10-21T07:09:44.01Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "introspection-aauth" +version = "0.0.0" +source = { editable = "." } +dependencies = [ + { name = "cryptography" }, + { name = "http-sfv" }, + { name = "httpx2" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, +] + +[package.optional-dependencies] +fastapi = [ + { name = "fastapi" }, +] +test = [ + { name = "aauth-signing" }, + { name = "opentelemetry-sdk" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, +] + +[package.dev-dependencies] +dev = [ + { name = "deptry" }, + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [ + { name = "aauth-signing", marker = "extra == 'test'", specifier = "==0.2.0" }, + { name = "cryptography", specifier = ">=44.0.0" }, + { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.115.0" }, + { name = "http-sfv", specifier = ">=0.9.9" }, + { name = "httpx2", specifier = ">=2.12.0" }, + { name = "opentelemetry-api", specifier = ">=1.39.0" }, + { name = "opentelemetry-sdk", marker = "extra == 'test'", specifier = ">=1.39.0" }, + { name = "pydantic", specifier = ">=2.10.0" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=9.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=1.3.0" }, + { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=7.1.0" }, +] +provides-extras = ["fastapi", "test"] + +[package.metadata.requires-dev] +dev = [ + { name = "deptry", specifier = ">=0.25.1" }, + { name = "ruff", specifier = "==0.14.14" }, + { name = "ty", specifier = ">=0.0.7" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/79/7392e21a1c8f0c61d90b223e31c7e48cb9d452e91a6b820ad24cca5f23c4/opentelemetry_sdk-1.45.1.tar.gz", hash = "sha256:63d24a6ca645019a631e6a51999c73e93adcac1196ca640b8ae78a7cc4762bf3", size = 218324, upload-time = "2026-10-06T17:33:13.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/3c/87c42b4bd6dd297536f04cd9383d212ac557ecd49f2cbdcd46da1c9ef5c8/opentelemetry_sdk-1.45.1-py3-none-any.whl", hash = "sha256:c604c11dc429810812348989115fa44bd558772a3d7442afc43d024f2c250ca4", size = 140063, upload-time = "2026-10-06T17:32:55.04Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/e4/dbbfb2a010c4db2224a5114638acede6fe563d33cc20fb1752cebcbe6298/opentelemetry_semantic_conventions-0.66b1.tar.gz", hash = "sha256:497ca63bf383723411e8eaf60c8779e9877633c936bb641080adab59d0eb6ec8", size = 150250, upload-time = "2026-10-06T17:33:14.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/14/67f8aa798857f8cf686f515bf93d9bb877ce952ddc8efae0fa25b45ce0d6/opentelemetry_semantic_conventions-0.66b1-py3-none-any.whl", hash = "sha256:d4cddeb4315490b35213f55e2bdc9ac54bb1e4d318927475bed62b35545e581b", size = 206279, upload-time = "2026-10-06T17:32:56.103Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, +] + +[[package]] +name = "pycparser" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/a8/c5fdbeee588bb8ada9458774f43adf1bdd30bd59157055142183e769a024/pycparser-3.11.tar.gz", hash = "sha256:d875f09c3507d00e1aba0eecc6dcadc1352f30fff09dc6bff2f1c2935e97c2bc", size = 113796, upload-time = "2026-10-09T12:56:59.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl", hash = "sha256:51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80", size = 51178, upload-time = "2026-10-09T12:56:58.131Z" }, +] + +[[package]] +name = "pydantic" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26", size = 900362, upload-time = "2026-10-11T18:37:55.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454", size = 488106, upload-time = "2026-10-11T18:37:53.437Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.50.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09", size = 513515, upload-time = "2026-10-11T18:35:44.82Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/1b/ca81811da3d2aea724fb3c4ba6b83f50ab8f210576306ccd601973142d35/pydantic_core-2.50.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c531166c42ea7bdfecc8c50049581f05dd1993b09cc7c52bb36a14e96deaec7d", size = 2152145, upload-time = "2026-10-11T18:31:58.046Z" }, + { url = "https://files.pythonhosted.org/packages/59/20/938ef7e0541af0a314e2f9d63848dbd1656ba2ebfe5ca0469f7ebee00d5e/pydantic_core-2.50.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6d0c2183008c188e19f4906d426b293bdc4f67ab17df8e180fe16cda208fa71", size = 2045761, upload-time = "2026-10-11T18:31:59.35Z" }, + { url = "https://files.pythonhosted.org/packages/2f/4b/456497fe2affa18f49b226e89bde31e8a204751ee343dd9beaac5438123e/pydantic_core-2.50.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94be440c03fede26969a5ce75468e0e6a9927a1b46d9b679ee8adc1b057b0350", size = 2049557, upload-time = "2026-10-11T18:32:01.121Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/cd5dfe13928f55e236dff3a1bee06721d318ca6c955d9ca993b0a5d09f39/pydantic_core-2.50.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:36c426eac0af8d1529ff8467e612b933346caec1fdc0d774f78f67a1a11e16c1", size = 2188153, upload-time = "2026-10-11T18:32:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8d/d0b25bc484f8d9136ea76ece69e0b2d24ab370cc2e7bbd1b79e61723b763/pydantic_core-2.50.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:028e2f212273d4a39b1ec1e0de8166b1165a65fc0f1111452a9d94fc7c625c63", size = 2388308, upload-time = "2026-10-11T18:32:03.767Z" }, + { url = "https://files.pythonhosted.org/packages/c5/64/6513cac7c9f78492abafa01d57a69de1d246bf3085fd7b0330bf14b025b3/pydantic_core-2.50.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6f0cc1bb9900dc558960894adeb30b0c083366fc1d69b856209fb2ca5c36fe5", size = 2431187, upload-time = "2026-10-11T18:32:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/ed/60/7f2ae89c37e69a3dc0da9ff57b0388c285542c688d05f36767770429de85/pydantic_core-2.50.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8812592c85d0edf423f10eadcef42716d71e8219085ad9e85b775057b7306133", size = 2157623, upload-time = "2026-10-11T18:32:06.616Z" }, + { url = "https://files.pythonhosted.org/packages/87/02/e3a71373b85a7973099b9a229a4302c7792262717e0b4997e41c01f7dd16/pydantic_core-2.50.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:bbce99252ba3167b2b6277f1829d5bf4b43b754524bddf7f944707c3db7d2253", size = 2254539, upload-time = "2026-10-11T18:32:07.968Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d5/5b620aaf40dc631d911565b64d49d3fa8422027c046a7a96dc4e4921ae98/pydantic_core-2.50.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:476f6ed8e43cd1e0b460920e23571700872b284e77331cb30c4faf459cf48a4b", size = 2267344, upload-time = "2026-10-11T18:32:09.399Z" }, + { url = "https://files.pythonhosted.org/packages/e0/48/f46baf4580d8a56ca4782aa65842e16d3cf39736b29fcce8e9c0521b3422/pydantic_core-2.50.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:2cbd1b75b09e976ed0d6b6ca297675632ca35df86130088457cdc60ef36970ae", size = 2300877, upload-time = "2026-10-11T18:32:10.903Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9d/e7d28505e6092c1fe075838a4f793ccbe6dd676e44ab16175815ee06cfe0/pydantic_core-2.50.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:5958c72adb417c39b12ac87525ac60b0d73315fcdc59e21f44ee4a5e2512c9ef", size = 2464270, upload-time = "2026-10-11T18:32:12.329Z" }, + { url = "https://files.pythonhosted.org/packages/22/b1/3bf63b82efd93ebc9968f9c12fc46037a28bcb452c88e4688f33e5ff7dca/pydantic_core-2.50.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d8f9e8a6c4ab04b78d61f78627370d834eb004b2869dcb28cfffa647b4ea1980", size = 2478653, upload-time = "2026-10-11T18:32:13.735Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/26d1e4b9c2ba6a92b7dc75bbcb99ef44456b7e3af1bced868403667ea7d3/pydantic_core-2.50.1-cp311-cp311-win32.whl", hash = "sha256:4be846f55c9477f5f3ddde8f2ce941137e16862a56d018ed885d422bb6ae02f2", size = 2120246, upload-time = "2026-10-11T18:32:15.591Z" }, + { url = "https://files.pythonhosted.org/packages/24/65/3e875e7991eaecd4d70686293ff0d8a99aa8f93dc295e6a21c3d702912fb/pydantic_core-2.50.1-cp311-cp311-win_amd64.whl", hash = "sha256:0048b6dddc8ef4b64fccaad878bd143b0c3882ea9936279dc11d613f6b7dd1bc", size = 2176332, upload-time = "2026-10-11T18:32:17.153Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5c/58dbd17c61f209d91c70f549eb7a3bbec7023e89ea12dead091462d6d658/pydantic_core-2.50.1-cp311-cp311-win_arm64.whl", hash = "sha256:6a733778df2f7087ec1100ed0b41533e4f3001976e99570fa34f57c66e7f8e3e", size = 2182471, upload-time = "2026-10-11T18:32:18.49Z" }, + { url = "https://files.pythonhosted.org/packages/ee/94/101b49a6c63f99586755690696eae14532772587d9e39e93f7b8d28a49da/pydantic_core-2.50.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:704075d10b74f2f3c6e15407c696d88701df35fc8953f434a431add0d0074db0", size = 2222428, upload-time = "2026-10-11T18:32:20.228Z" }, + { url = "https://files.pythonhosted.org/packages/db/a3/f8b1b39349c33037c73d7a1b794c9c29d8f07d5bd2b74399c1375a529cc6/pydantic_core-2.50.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e8e1d6ce820aa23317e8209a86bd65a540973c12dc7552b48a4f6c8e9926815e", size = 2025779, upload-time = "2026-10-11T18:32:21.723Z" }, + { url = "https://files.pythonhosted.org/packages/d2/2d/862a7d115305cc621358e51ba13f296e0ffdf4fbd984be9f63bc7862730b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c18db21573bd2c6489f9a544b7499f0df2853958c568e5e783536ee1f690af41", size = 2048429, upload-time = "2026-10-11T18:32:23.3Z" }, + { url = "https://files.pythonhosted.org/packages/c0/7d/fed95d18bf2b5abe2d5ab2bbec4184bfc22b6805994a33ccdae8edffd25a/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb57f304525a5e3c13333b772bf9a473f36326e9c821b2e8e1b2fd36f80ae2c3", size = 2173274, upload-time = "2026-10-11T18:32:24.697Z" }, + { url = "https://files.pythonhosted.org/packages/93/1e/c6f22ceac65dc3d291ff79bea0eb74cbd1de8b128f01fee48af03bcea1e1/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a27c09d86600f1bf2fe3f37e1ae697faf3143931c09322cd799da94deee923b5", size = 2360403, upload-time = "2026-10-11T18:32:26.281Z" }, + { url = "https://files.pythonhosted.org/packages/2c/97/190b5b6bfe4c72f265feb657913eb30749193927a5ad7b83cfa7866a25a2/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46b3301d3b5c886f77de7546e47274a5842c622ea2020b8c6524c6b66913b4a6", size = 2439799, upload-time = "2026-10-11T18:32:27.844Z" }, + { url = "https://files.pythonhosted.org/packages/90/24/48cd98388e4ac2c08d0800af36d15413db15e20387f1038123a2c86cb2f6/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ba4e9d8210d941c200431a56b2c0400b131865947903937ed3ec5404307d2e", size = 2160157, upload-time = "2026-10-11T18:32:29.553Z" }, + { url = "https://files.pythonhosted.org/packages/00/86/d909133d0b9979ed14c20f110f21124a0e346caf071bd66ac5af3009062b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e5faeaee74a57d32b3ab3aebad2e348f06d3ba946fc5d28c1728455f00a3d13a", size = 2229523, upload-time = "2026-10-11T18:32:31.027Z" }, + { url = "https://files.pythonhosted.org/packages/90/31/4b43f08131b2098e73a852fd932063385f3103b33839fd9d1abdb89e673d/pydantic_core-2.50.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3cda0e538208e5d722bbf3698b24f19c0a7d05bc8d5f8a7f9b121ea7fa243d9", size = 2242700, upload-time = "2026-10-11T18:32:32.622Z" }, + { url = "https://files.pythonhosted.org/packages/88/77/de39658569dc7a4c293d110a89629f61145e7538f00d1e4280c28debefd3/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:57f51b31ff826e2859120cf4737c5a758a48d96f3e97da40ccee1796d58078ff", size = 2278452, upload-time = "2026-10-11T18:32:34.165Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/1eeb9e4e791e81210ef9096f58080b32ba15b5b13c108d50aaa3f8ce9f73/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8daa7ee75245d43ad7d747e5c9ecc1b1d06552f72b14887e9276f787d57375f4", size = 2450098, upload-time = "2026-10-11T18:32:35.547Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/b2accfdff17e5a88d6358536702146de00f9339adaa4d5bbe1ee93cb4832/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:acbf31f37c53a5ac0c34706c80b4f5107ba20b05fdd3816124bf236ef0c57dd2", size = 2476839, upload-time = "2026-10-11T18:32:37.119Z" }, + { url = "https://files.pythonhosted.org/packages/3b/7e/c8a83eb5d2cd42c2e13507150fb777b1edea5a018adc9cbf2858a1d08285/pydantic_core-2.50.1-cp312-cp312-win32.whl", hash = "sha256:45b11cac094aa25725581d9304eee93c9028516b9ea80dd9e175e13a5a2c840e", size = 2101425, upload-time = "2026-10-11T18:32:38.593Z" }, + { url = "https://files.pythonhosted.org/packages/c5/17/8bbd530b8659e9d963f5b16f6cb8e159f4cd174b4d6304e48969ab12dba8/pydantic_core-2.50.1-cp312-cp312-win_amd64.whl", hash = "sha256:132529c83901437ff642f585216831bf5fd7a91df66829907e155192ead62498", size = 2172522, upload-time = "2026-10-11T18:32:40.112Z" }, + { url = "https://files.pythonhosted.org/packages/af/9f/a79d690b127e4e3cff143d031538ab9aa715f6b2fbeecbd07cd6f2bf9fb7/pydantic_core-2.50.1-cp312-cp312-win_arm64.whl", hash = "sha256:4e834f6a8e4ff772dcc34f58ef5504147a3ea5b0f4eeb13b0f8eb2ca75ac57f1", size = 2161366, upload-time = "2026-10-11T18:32:41.606Z" }, + { url = "https://files.pythonhosted.org/packages/ae/57/0e237d7091d2cd44a35d243b7344227f90440166860469cd036afc23a04d/pydantic_core-2.50.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d5e062c01286d861fd6a1c4ff6e063547b3e713067f2df033c0ff97ac2ca006b", size = 2222774, upload-time = "2026-10-11T18:32:43.103Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b9/c720e56858d4e1539503297ed37063e0c08e0f3541c41b777f6a800f75fa/pydantic_core-2.50.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c003c3b7f49debb893d2d85ae099ac5959c9839e2f330fadb1fcdf7a6594482", size = 2024738, upload-time = "2026-10-11T18:32:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b8/fbfc25875219cc060e613170ff10e850c6d8924beb4910da57c2ee3ba1d2/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:409e0ea40ec30d9158f33574fd758e689f6045a0f2596701828c27816ca9687d", size = 2047049, upload-time = "2026-10-11T18:32:46.164Z" }, + { url = "https://files.pythonhosted.org/packages/d7/43/34210124d504c553688f2f04b48500b131237528ac545b445e0d6d30e0d5/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:131059670f1d2444269b8585cb888963994871932447c08b39ac6a51fcfef658", size = 2173415, upload-time = "2026-10-11T18:32:47.722Z" }, + { url = "https://files.pythonhosted.org/packages/65/cf/6e178e8fdc11da5965bef980983bf46326a42f436871dd51ba0a57f39df1/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dbcbee53bf17196a7f745aa9bf5a9603953a1e365b1f020be3207c676a3e7c4", size = 2361719, upload-time = "2026-10-11T18:32:49.217Z" }, + { url = "https://files.pythonhosted.org/packages/11/14/bd5169d356aa91bf777e28ba0b281c192d286d03c2812c9c9db023a2f00e/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:325c23f3e35cfbf0fe3486fa5f7260d1e45885173002d30a28ca019994124255", size = 2439803, upload-time = "2026-10-11T18:32:51.025Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/d79d000e5203ebef39af839f2ce77a777fcad6e08ad26af9a2fcd114ffc8/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17e722e156d0444ecaefbe640bdb60928752bf2013e2b7a11cdb099aaae19bec", size = 2158402, upload-time = "2026-10-11T18:32:52.714Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/4902cb5fd599422c130bd3124ab31ee8b771199bd56662702eed07d3fec7/pydantic_core-2.50.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:aa8224f10880d9bf1b5993988ba153d42a8b4f3f4f511f93b1f09c93ff613c72", size = 2229755, upload-time = "2026-10-11T18:32:54.126Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f5/c1481f8669f6060d89110c9b1374173fc8767ca276b84f4870bd8acd5e3f/pydantic_core-2.50.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:41bc8237121bd8dc8d888dfd6279fc166ffc88c1f1bf3a8bf00869680533ca4c", size = 2242303, upload-time = "2026-10-11T18:32:55.641Z" }, + { url = "https://files.pythonhosted.org/packages/04/f9/77fc3c7653ba9b6e42049e17e96b25388187d4a7c274ab1cb07f58ac8419/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:45c6266d071c241f2a168d45bf8c54344f0effce35e7e6b73afdec11f3687568", size = 2278345, upload-time = "2026-10-11T18:32:57.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/9b/0579c5d12e7f2b16b27e6782427987341fcce07b0725e02ddb0b74add0c4/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:1deeacb112d14d3f4fcb16b165f7dbaf76c70ba6e82f37ba042bdab51970a0b8", size = 2449947, upload-time = "2026-10-11T18:32:58.896Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ac/1b677db91eba54cc5922f4de6edc46ba45c2bd712dfdc0130382d158e214/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c96fd793b73d1b92e65570132505498fe7b21eaef73cdf74e67e5dfba7ac9e4", size = 2476889, upload-time = "2026-10-11T18:33:00.665Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2a/3a9f6624ee3ea9ccba5249dde11418bb4c35780a7a92608f8768bd3fea39/pydantic_core-2.50.1-cp313-cp313-win32.whl", hash = "sha256:06ead20d39ffd6f2f6f2a8f8a6de67ff8bb1b4f14a8a30e058502514ee2ac685", size = 2101118, upload-time = "2026-10-11T18:33:02.329Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1f/323f78ddd9d9938aac420c1abb4e8ba799fc8bdab0acc67c0593b837c979/pydantic_core-2.50.1-cp313-cp313-win_amd64.whl", hash = "sha256:7816e98acc08119dc0f340ab167048ecc54126316330c1f0caf7c6756c88e28f", size = 2170840, upload-time = "2026-10-11T18:33:03.919Z" }, + { url = "https://files.pythonhosted.org/packages/cb/09/8497c52a739ae425c3ac2f7f56414cbc711c67d374346174f40fe2062644/pydantic_core-2.50.1-cp313-cp313-win_arm64.whl", hash = "sha256:c17799a62c142d61b8a3c51752a7cbc87fe2ad4ccfab10e628a77b405075c662", size = 2161633, upload-time = "2026-10-11T18:33:05.518Z" }, + { url = "https://files.pythonhosted.org/packages/49/33/28b96e81677153715e3eafb9f26663a80841e859fde282a380359b0d3fa1/pydantic_core-2.50.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1cf41f1ae3fa155cf167a72689ad044bcc1e3c97e064123677149bdfb5dafc4a", size = 2224428, upload-time = "2026-10-11T18:33:07.153Z" }, + { url = "https://files.pythonhosted.org/packages/94/40/15c06410c9b7b8da5805d27b64e09bd3f900e986728106db2689dbc51513/pydantic_core-2.50.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4df197990c15b5a37c5a277d131d9f2c67de6133f2e5dafd80d9bba4b99f46f9", size = 2023740, upload-time = "2026-10-11T18:33:08.763Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4e/5eb629f6efc2a27e421d789dd4bfacbb5f6d09c80c28b13d1e87d73163d5/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0036473f5583e6a60e50b8b21651511564277a3f05cc5dab8cf579f552cd5f6c", size = 2049435, upload-time = "2026-10-11T18:33:10.366Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8e/f195aebec49ad12318876ac2368c197a7939f5d52f8e156d2238ef8a4588/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:992c3514ec891fa7858099183e4d64e6bd5a5d4ff452fae29df22faa77a006bb", size = 2172975, upload-time = "2026-10-11T18:33:12.368Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f5/7ad9fb83010cd5ea0948409db105676ed779c4e709e2d3d89b9fe2558794/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:739dc730e6be3bd5ec2f4ab5cfc7eb047cc45fc1497b3bafec74ff2ed07df597", size = 2364197, upload-time = "2026-10-11T18:33:14.244Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/bf0aab3e78301d202b82a0322ec968cd703b11fc0623fde9a28708936f62/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32fad3a91e51b6d2039c572db04a5a873260b399f6bd62c3552671fa7a4a2899", size = 2444259, upload-time = "2026-10-11T18:33:15.79Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/38f6d6564fae57d9b12e5676dfa947e0e7d5c46dbeaa7eb3352261d6d299/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42b54c2c90ad348b5e3a85e03e715d572c1fde357ef104cdfe3b03b697a404ea", size = 2159857, upload-time = "2026-10-11T18:33:17.51Z" }, + { url = "https://files.pythonhosted.org/packages/65/a0/fb0a3ca10f139dcf765b2d312d10cf0f65c57c59993229d00ad12b14ceff/pydantic_core-2.50.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:2df1ff41884de2bc4b307bafd7c40a691094fad2ff8e767e5b45a319257bcf4e", size = 2233954, upload-time = "2026-10-11T18:33:19.591Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6a/e63842252702aa4ec6e6b7178ca85e541a77459076592c23ebe2d9840b33/pydantic_core-2.50.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe90228920fd8ff2be62622b6bb8a2b11acd65046d50c6b130614b5879605a20", size = 2243876, upload-time = "2026-10-11T18:33:21.393Z" }, + { url = "https://files.pythonhosted.org/packages/39/25/5991cf8318b37e0dfab47b87541619a1df8501a793cba0d978846cba37a7/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:844b869f118e22a41a091bdcedda8a71bc1b0f62c38d1a0c3211cece47e1d8fc", size = 2277908, upload-time = "2026-10-11T18:33:23.324Z" }, + { url = "https://files.pythonhosted.org/packages/a7/3e/3ee8baaa6cc25a6961c69168cf9ff0f002d56f4e0d541ad6724c18fb61f3/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:2eb75304506894a281d346220a4f7481a1b8729577c5ed2a05395991966a8396", size = 2449285, upload-time = "2026-10-11T18:33:24.942Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/acbec6deac13fe697a80c275a9b6661db62c4d323bac8a47347b1f39c7cd/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6b20a4bffabdad0db2927ac034ae3b8a681b1f7a0182f3e60b479ad2fde21ebb", size = 2479013, upload-time = "2026-10-11T18:33:26.925Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/21a3f237b264389f6219d053ee78fc1b5c2fd402c1b1cb2b6cb8b85f9834/pydantic_core-2.50.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:99ba9bc2b8062ea0c326a990f7f00e6530c23579de66dd246e72c4cafef950a5", size = 1484400, upload-time = "2026-10-11T18:33:28.693Z" }, + { url = "https://files.pythonhosted.org/packages/09/ce/077a6d262d12ef09108377ac0717f029f420d773ace63cafd6143af75568/pydantic_core-2.50.1-cp314-cp314-win32.whl", hash = "sha256:cf356f70551d40374eaffb1aa63f1eb6d2006681cbd7a9faea173ce0f4dd7cd2", size = 2103222, upload-time = "2026-10-11T18:33:30.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/4c/350a2415209c43d670eb71d3c040f332c04a30583d39e311b05c7ac15762/pydantic_core-2.50.1-cp314-cp314-win_amd64.whl", hash = "sha256:d32f3acc081cc3923386d88f422cde8892335e95f034e0104bb4cf9310d9915f", size = 2171586, upload-time = "2026-10-11T18:33:32.139Z" }, + { url = "https://files.pythonhosted.org/packages/bf/92/9bea6ca96580a0902fed366f064f0829e404f41889b34543279a1162b888/pydantic_core-2.50.1-cp314-cp314-win_arm64.whl", hash = "sha256:bed5163e03b98bc1fa2eb05d74c63d9c5c95d8ed6254985481640fbf5e237dea", size = 2155773, upload-time = "2026-10-11T18:33:33.896Z" }, + { url = "https://files.pythonhosted.org/packages/86/8c/f121f073cf32bdd5cba7e6230b1ce0ac845b0cf43e44dd597d95af272db2/pydantic_core-2.50.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9572c1369e9c9da2d64a7b7992c786d90ff295abc93964cfe3125e4290768070", size = 2234017, upload-time = "2026-10-11T18:33:35.588Z" }, + { url = "https://files.pythonhosted.org/packages/a4/69/2bb2bcd6146cffe98d760a46c42ae71efd5151d9b2f9c9bf6619a3b32083/pydantic_core-2.50.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2005207aafe1231315718bf6ed5d064a7300fb4772754af35ee72fc68159492e", size = 2001016, upload-time = "2026-10-11T18:33:37.57Z" }, + { url = "https://files.pythonhosted.org/packages/20/b3/fbf854c7d07ec114260c26e9e2071a4381740f9ae09641dbfcbdf2a18c45/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f6047f62a6c5ae08d0a6afb035667aa2d97c3d20d69762e034c5ea144d92a5", size = 2023015, upload-time = "2026-10-11T18:33:39.433Z" }, + { url = "https://files.pythonhosted.org/packages/65/20/6de55b2f92cdb614b745c6e9ced639fc4fd7e1e77604825a88bdece6fcbd/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ef800dd7d85bcdadf4c3076e4c94e43939493558a3b69a1ea830c706d4617bb", size = 2192543, upload-time = "2026-10-11T18:33:41.381Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d9/19e91c94bd5c405945ce2f15526808aea37e162c253160de8ed7bf70b406/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0135bcdcaa0f23573f286e4cb5e0fd2962700964ed13df085b85f2b97aeab9e", size = 2388704, upload-time = "2026-10-11T18:33:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/b6/bc/2e24c8415eae1a25ee5a5484946a917123bad2e9d01689a759236928175a/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b3a6f334c6a2345ca15318ff894502a90012536404b37c844a976c76c846e0b", size = 2464118, upload-time = "2026-10-11T18:33:44.856Z" }, + { url = "https://files.pythonhosted.org/packages/bd/1f/945b8053cb061c64e102bcaf7bfb9ed740c0bd4349f9bb978a7d4ddff4ab/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06e01fbbfdb9be777b316a71b6c49efaf4a08b615d0a98d678cda3023f79d019", size = 2150142, upload-time = "2026-10-11T18:33:46.661Z" }, + { url = "https://files.pythonhosted.org/packages/2b/78/96a3e50bf0d64aaae781107eb9335b7529d05a85793ed6e7241c4cd1d931/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a29a061fec0b4e2d714f277e70a3a18125ecff803f2fea6eade2f2e53711d112", size = 2245577, upload-time = "2026-10-11T18:33:48.574Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5d/a6038a0322232758a6ebfa709f4ca14a60bf5b93940cd0b345056a557da4/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f5187624823423e1d1b82b1072ac41dc837389e18d3d0572cc19bbee46cd550a", size = 2263728, upload-time = "2026-10-11T18:33:50.527Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4b/76ded3333a457a9344c4f2d63f2d82d0101654b05178fa4ddfe7d3627674/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3e46a9eb0a0901dd6275e6b06ac3a464885ef350ec4121fe486869de8053e4bb", size = 2293630, upload-time = "2026-10-11T18:33:52.362Z" }, + { url = "https://files.pythonhosted.org/packages/a9/00/9eca378335c9c1b72bc779bf6e4c2a82ce784f14ec48a0e87102c9870e05/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:756d669f04e62ec4148ecfe22be6a4484d9b1181a6ef32e205ebfd200540858b", size = 2468048, upload-time = "2026-10-11T18:33:54.118Z" }, + { url = "https://files.pythonhosted.org/packages/35/ca/e3832e9cf93651251de43c8c5c029ed680a3c1a0a1b17ee26c81bed08cbd/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:c516cc5367ca3448995d42cb994bf3f4c9002d2a7c22eac9622551269ad1b807", size = 2512159, upload-time = "2026-10-11T18:33:55.99Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f2/773469b5a10a39116a2cb17edaf6d722f017dd8da07161b371465311c542/pydantic_core-2.50.1-cp314-cp314t-win32.whl", hash = "sha256:9d1bed94af6a63835461f3cf7502058eb166c58c4778e11d0f433cfb1bd69e19", size = 2098406, upload-time = "2026-10-11T18:33:58.043Z" }, + { url = "https://files.pythonhosted.org/packages/ee/42/0bb74f8f25204b259b11ab7c12dc7f180893b6bd706118b385147fb6efd5/pydantic_core-2.50.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c8dce1f1e0e5358b682a6ad3fa5e31b31d4560997b8e61417e9217c8d60f8a0c", size = 2148481, upload-time = "2026-10-11T18:33:59.913Z" }, + { url = "https://files.pythonhosted.org/packages/47/0d/d801646c9679a4e630e15cf521d4108b93854b42a6e6e02391bd4c6b1095/pydantic_core-2.50.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ceff0acc940be2715bd6ad17b24c0e5304abf44f6efd0f81ee8499e640f9dc86", size = 2174972, upload-time = "2026-10-11T18:34:01.875Z" }, + { url = "https://files.pythonhosted.org/packages/b2/84/23984b763d8862a02a13d27a44b6e8169428fd85ecfed88f54c29108604d/pydantic_core-2.50.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:8a6791afa2245e6c6b180122d105941644f5bd410bb18623b408808cc41a3102", size = 2224599, upload-time = "2026-10-11T18:34:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/4d/90/a63cf8586abc1d1a3f6d9b18f0224789ebf003851f092ebda3c7c863fd32/pydantic_core-2.50.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:84f34323a61a365b4e9295de6028474754829aaddd59c7bf1a040e7487ef8f3c", size = 2024191, upload-time = "2026-10-11T18:34:05.901Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6a/f34bff9808ffb4907fbf5f5040457d253cacf2da7fce9efbc99ca1b1a44d/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23edad659e8dbd8ca7e4e877fe6c81573abbdf215bd25a68b53e1272f58b80c7", size = 2048252, upload-time = "2026-10-11T18:34:07.757Z" }, + { url = "https://files.pythonhosted.org/packages/b2/54/13f419bf1eb59852003818e25935aaf175687d978f4c4bca70e08fe40a3b/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a5fce22f1e87d181e924e12da7d81cfe031fb3881a5ddf26ad28f141756ca43", size = 2173376, upload-time = "2026-10-11T18:34:09.592Z" }, + { url = "https://files.pythonhosted.org/packages/6d/6c/b5a34d24cd0c81669d8f8339d74e6815abcf2f8fb48ab4b49b84c09be1d5/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c73622ef819328873b53109ee4f77ceb598bffedd02daf916102be3228866b78", size = 2364559, upload-time = "2026-10-11T18:34:11.534Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8d/d64d6216a8df365082665927ff923f183056f9049fee08e9777c9ac05296/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce8c25ca38cc0e3d7753ba180808de2c0c8cb24eae0df64491e40921454e9831", size = 2444697, upload-time = "2026-10-11T18:34:13.535Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0d/1b1149f60a00ea21ba5f70e28acbd40feb4598af414f80f53c921fac07c9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7689580e72a642ab5ec64d5f55b2e33636fa43b4ebe63c0c2c965ef307c7d1aa", size = 2159961, upload-time = "2026-10-11T18:34:15.56Z" }, + { url = "https://files.pythonhosted.org/packages/1e/35/f236549299dcc78e71e945495d7ec67e78d20844d0999c60601685003031/pydantic_core-2.50.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:d5c0e32fdbce7f1e8ef4d11f655694bf5f4175c757a9f1dc2be09b8864e5bcf5", size = 2234581, upload-time = "2026-10-11T18:34:17.502Z" }, + { url = "https://files.pythonhosted.org/packages/6a/85/26901a490522b7f75ef9bb9a7afb73e5bb550f0e1cb5b99a0069183e2eb9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40f523349960fa30f3ea51404308ff50f9997a90df639590f47a057c1f32b415", size = 2244090, upload-time = "2026-10-11T18:34:19.402Z" }, + { url = "https://files.pythonhosted.org/packages/ca/2c/481bcfc70ceeb77a778ca6e5b705fe592cb64735c108157f81b7dd9c280e/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:d4193206b6587047437f6f11d7e776df23e1c1e23af2a54d9347275614791e10", size = 2277948, upload-time = "2026-10-11T18:34:21.317Z" }, + { url = "https://files.pythonhosted.org/packages/24/eb/f1e09333faa7ba447cde967310f758430cc7c5e987bdab5228816c70030d/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:84bc765b282a9d5b7fe0348b8648904f25a6a04b2139da52b1dd30c8ac3a2c8f", size = 2449688, upload-time = "2026-10-11T18:34:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b3/036bde636db8f76d92996e81aefc75678ab5cec4a07eea1ad0c72a893fc3/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:ed1e728b39a383c81035b2459cfcb35d99dfb01f7d6ebe3a913bc1cc5b81e459", size = 2479286, upload-time = "2026-10-11T18:34:25.214Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/07f018294ee18d144afeb6df1a47d9e92960f014be45c5ed13519fa5af95/pydantic_core-2.50.1-cp315-cp315-win32.whl", hash = "sha256:bc94f474417604bd383d2cd445d071b07dd55fedceed3ce33407bf1fcc107290", size = 2103683, upload-time = "2026-10-11T18:34:27.42Z" }, + { url = "https://files.pythonhosted.org/packages/5f/98/f9bd7e1f9b6709f155acb9ef826d9c3884fe925f811fd8e55b9b52280bad/pydantic_core-2.50.1-cp315-cp315-win_amd64.whl", hash = "sha256:983a662de2571cb2502fc8ff47b6770b03d025d2eb314c92f77b3f07c74720ed", size = 2171681, upload-time = "2026-10-11T18:34:29.508Z" }, + { url = "https://files.pythonhosted.org/packages/10/87/4bb3e1e7f385c076ab5af4d6dd0571b22042cd8207eb810d5b9fef15ae31/pydantic_core-2.50.1-cp315-cp315-win_arm64.whl", hash = "sha256:94845ff54dc5193f228cab81b2662a04bfbb892e95bdc15edf7399000ce57d54", size = 2156251, upload-time = "2026-10-11T18:34:31.455Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/83643225b08f2aef6c8cc4bbe6e3f79c5e139c4364a6e450d0f399d87774/pydantic_core-2.50.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:4a53d13cdfbedbfa87f08b83c1a0a5efcc767d785a4b41934fa9cb672670493a", size = 2234363, upload-time = "2026-10-11T18:34:33.65Z" }, + { url = "https://files.pythonhosted.org/packages/5e/66/127ca649ba2f2462039dc1e694c6c01e00a3689f023a617364d3507e6d97/pydantic_core-2.50.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:efbecf43d321f7b9281441f1f213f7c21c66988b0e06c2730ba13ed47a46bb08", size = 2000972, upload-time = "2026-10-11T18:34:36.037Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4f/e421e0a5d653b1203b090b2e48745976988d7338a647940704b5b9c2b399/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc1f08f68dac9f9e83845a8039880aba2ab553eb9b2259c3243a313182c253fe", size = 2022925, upload-time = "2026-10-11T18:34:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/ea5568e2491e1a71100f15ae8c2d01ef52db42a184a2d4e716bc79e5eb8f/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5dfe41f232befddb9c4377f6cfc702b51595e2d78ed082672adf8758d2c4619f", size = 2192867, upload-time = "2026-10-11T18:34:39.921Z" }, + { url = "https://files.pythonhosted.org/packages/ae/5e/3b8c3a35acbe219909ada5defad5d7d9fed845fb2b37bd5ec518f453c119/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adc06d218a1cadfd2ec4628424d7d79ce4eba69c2965e7e7b55106f0da5208c8", size = 2388687, upload-time = "2026-10-11T18:34:42.184Z" }, + { url = "https://files.pythonhosted.org/packages/b6/aa/7889b4e515f91a2e8c0ae6b5081fec0feb30c4398d1434e14793c60f173a/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cf91809d0721ab81592ba67bea7694821679c10b1a2e3c3460082b286c1918a", size = 2464415, upload-time = "2026-10-11T18:34:44.384Z" }, + { url = "https://files.pythonhosted.org/packages/08/78/93449e628eb8a6fdcce3eff9043081179d1bc7ce6f1bff32dc5006f41e00/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23923ab9292c40da026330b1ecf4dc2618c8e86e0422e5d1fbf50d94d64ca4f8", size = 2150823, upload-time = "2026-10-11T18:34:46.392Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f1/72c5bc129fceb0d00f05dc1e67f518c1728de1928c55f81fc13d7690de39/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:f3377c8c2b3ce898423c5e5dd94c7982e30aa7717a7e6ab2470b9de364963709", size = 2245806, upload-time = "2026-10-11T18:34:48.805Z" }, + { url = "https://files.pythonhosted.org/packages/28/2a/922a0e78f3aa6ab837b59f88190233fb8dad546c17995fde9bb3ed3b9b49/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:455a773617b5913bf5c20d0692e5787b119e52c4d40ea644ca31f5758fd31be2", size = 2264160, upload-time = "2026-10-11T18:34:50.876Z" }, + { url = "https://files.pythonhosted.org/packages/52/a8/0f1449e3e1b20941c9372faa18e7b6a092e30cdfa841902473f7989532ac/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:1a9006395dece0e32e704c315eff8a00bede494f6108546cfc5539c89fef4f9a", size = 2294031, upload-time = "2026-10-11T18:34:52.876Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d0/1031f492857de70355fb16524bbb03efce5fd34c93ed4a1ec60be07e0d4b/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d2d82aa62521c55ddfb000ae70f88cdd8de974078f6024e821dfe5addd0c818f", size = 2468423, upload-time = "2026-10-11T18:34:54.995Z" }, + { url = "https://files.pythonhosted.org/packages/46/52/269ffffa645b8e47906395a39cf9db1151ae9fa4bcd7f47b960bc31baf37/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:009634b83993777ddcd69cad0ffcace43dabde692109528e35f0fde91e386a8b", size = 2512598, upload-time = "2026-10-11T18:34:57.149Z" }, + { url = "https://files.pythonhosted.org/packages/31/5c/e47e28281f20326ff6f3c31d626f0a83e615d2d94ba41bb0ad6ec237184a/pydantic_core-2.50.1-cp315-cp315t-win32.whl", hash = "sha256:3fde4fdc6487a58d944ca87cf5adc95d5f266e872c19599f5f4c0a8a1b1f9f9f", size = 2098535, upload-time = "2026-10-11T18:34:59.313Z" }, + { url = "https://files.pythonhosted.org/packages/03/ad/759e181e69c1b472c60b2049e5f61d5da1deefdd5ce1df4bb2ebcf771f25/pydantic_core-2.50.1-cp315-cp315t-win_amd64.whl", hash = "sha256:1c8632d4ac04e6f91128fca584b3a8a507d81604c24eeaaad00d4be42765c32b", size = 2148645, upload-time = "2026-10-11T18:35:01.571Z" }, + { url = "https://files.pythonhosted.org/packages/6d/56/8a702c27e5be9f47e5f19d8669227424290e4c024e7c370279cbaf244b4e/pydantic_core-2.50.1-cp315-cp315t-win_arm64.whl", hash = "sha256:c3ede305158e75510be50869b319550ab072008c13d64d4ab1e094fb286b6f44", size = 2175297, upload-time = "2026-10-11T18:35:04.079Z" }, + { url = "https://files.pythonhosted.org/packages/e7/73/a23a327237d9bb985298ce47702e196f995ce850224c12c70886bd66bb5c/pydantic_core-2.50.1-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:062e891facce5ca296a1c37098e5e466780457f86413894b399f0cf22934f769", size = 2162560, upload-time = "2026-10-11T18:35:06.274Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/33b37b5e82408f402a5e82649a43e7c95cd5dab7beaeef2c35cedbfd18f2/pydantic_core-2.50.1-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:49c2cbb2397fe4d0987e84606e691af6cb87bc0ee1bd3e7b737f7e10b4c142f9", size = 2075250, upload-time = "2026-10-11T18:35:08.455Z" }, + { url = "https://files.pythonhosted.org/packages/04/62/c7d5b9249466ccab90f3ce4790c2399dbfb8172ae9e74a7753dd295fe65a/pydantic_core-2.50.1-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e4472072de0137ee0d8e72d6620e85939c271d2f90f6bbb4b15c24638b79f92", size = 2107891, upload-time = "2026-10-11T18:35:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7e/28d29bc1657195933147f772dee0558d9e0f045bbc11db7f992f40c37bb5/pydantic_core-2.50.1-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ed4f3cef55164b026fefb41341b7754cc6b624c75dfe7142d2ecceb5ad21c87", size = 2268026, upload-time = "2026-10-11T18:35:13.336Z" }, + { url = "https://files.pythonhosted.org/packages/a5/09/1bcf160f3cd6333e2a76982fae5963e5032de0bcaaba38464223dc266bfe/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:76e2e83fa6ec8cdc972d438dafc2522b3a47bee4ec0ae668b29cfb1977ab5242", size = 2217070, upload-time = "2026-10-11T18:35:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/3d/81/f02de95eec7794dfc7a5bc69a834f0e834b31b3009014ce6de903855f775/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:a51eee75939cf811ac09b278745a6cee7dc873ccfbc8b9af3cc88fe4b7ce25b5", size = 2039027, upload-time = "2026-10-11T18:35:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/4d/16/227746ed3771d9bf2304568b55134849ece34e13b38b1d76de763015c77f/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae28183297fb0d2b8dc46a1f01d51f5e45825fc5afe76a835a6cb7fb34821295", size = 2109009, upload-time = "2026-10-11T18:35:20.218Z" }, + { url = "https://files.pythonhosted.org/packages/e5/b9/7664d1592a0e5a74903f357cd5b28ceb7336f3473256c5a4e8c8432f1881/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88e492e8b9d0312e7dc13667c30222abf284dc3b79b5302b3607b41a5784ce61", size = 2269883, upload-time = "2026-10-11T18:35:22.48Z" }, + { url = "https://files.pythonhosted.org/packages/bd/35/002a378302ff91d2a7f149bc8d22363faf581ba40ac39d0123444315a32d/pydantic_core-2.50.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7456d699b13954e9c0164dcb267250a10ae0dfb03e6e26d6796ab0d46e189c84", size = 2154076, upload-time = "2026-10-11T18:35:24.743Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5c/7a034330082c00f3c9795052bd9b161aaaf6e1951e897ac00a094823ef7e/pydantic_core-2.50.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0d955195bbbe489ad343fcc956eacea9357b79cb22192c66cacdefcbc14b32f", size = 2085850, upload-time = "2026-10-11T18:35:27.009Z" }, + { url = "https://files.pythonhosted.org/packages/57/fa/d5b44a7d3d4eceed6ef8713a0003af56fefde6ec75915513bcdd5d730cd9/pydantic_core-2.50.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2c634642694e6a0dad2ab1d375589fa671fd442edd5caf7d9737b8f6ca22906", size = 2259754, upload-time = "2026-10-11T18:35:29.763Z" }, + { url = "https://files.pythonhosted.org/packages/b6/88/e01dd37301710bd8b3b2daf34ec9e2a6e83a333130831c0d468b0292a48c/pydantic_core-2.50.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6db2fbed51a7991302e8fac498cd67e336246026d0dfa84cf5166ce1412760", size = 2268712, upload-time = "2026-10-11T18:35:32.654Z" }, + { url = "https://files.pythonhosted.org/packages/3e/43/5bb80d4a4d2b2d84612a30205662cc521a20d209c94abc079ea48b4e0df2/pydantic_core-2.50.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:79490e33c4c0fcb933bbbcfc3a62184d8803b99f535863dfbb925e1bcb6945ad", size = 2305049, upload-time = "2026-10-11T18:35:35.392Z" }, + { url = "https://files.pythonhosted.org/packages/e8/7b/e3ef76b269cfe4a5bd3bdcf5d7124cf05e5a8909d443b91639307116bdae/pydantic_core-2.50.1-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:48569b0ade9edfbe065cad1d700175546592aebbb42f02adcebcc26e75b896fe", size = 2466001, upload-time = "2026-10-11T18:35:37.791Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7a/ea0947aadc1d9f72d4be00d2e19939611ec17363bcb5e6eb9ec7d8221c34/pydantic_core-2.50.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:5f3cae32fc46121f787cb2486de9cf95a8bf72aec5cc78f64c606fa1735a6ef5", size = 2481777, upload-time = "2026-10-11T18:35:40.287Z" }, + { url = "https://files.pythonhosted.org/packages/24/f3/d15bc0b1fb0c4f1e07b3326ffb7489f7ae70da2b56a0884d22641b0eb47c/pydantic_core-2.50.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:7f476456ac2bb0d937f75191494a09c83a30765fea4f70f3b404942fe25f6cdf", size = 2336787, upload-time = "2026-10-11T18:35:42.558Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +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.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "requirements-parser" +version = "0.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/1a/5f3c22d38bf1d87d1f4a961489d9eba35c4370a21395562d94410cdd0e73/requirements_parser-0.13.1.tar.gz", hash = "sha256:78811383b2089b6c5197a1431bc2c12ff950245edca39a23eea3460782038dd3", size = 22783, upload-time = "2026-06-18T07:52:25.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/f9/15b44d5e4401b0013bbcefe3c09d7bfddcce28cc3d41b1d3077bcedf5b1f/requirements_parser-0.13.1-py3-none-any.whl", hash = "sha256:6e385663eb32589d16e5b22bb6e5251a57908e73803ffff438b53cd6ea2056e0", size = 14926, upload-time = "2026-06-18T07:52:24.171Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245, upload-time = "2026-01-22T22:30:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273, upload-time = "2026-01-22T22:30:34.642Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753, upload-time = "2026-01-22T22:30:31.781Z" }, + { url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052, upload-time = "2026-01-22T22:30:24.827Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637, upload-time = "2026-01-22T22:30:13.239Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761, upload-time = "2026-01-22T22:30:22.542Z" }, + { url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701, upload-time = "2026-01-22T22:30:20.499Z" }, + { url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455, upload-time = "2026-01-22T22:29:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882, upload-time = "2026-01-22T22:29:57.032Z" }, + { url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549, upload-time = "2026-01-22T22:30:27.175Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416, upload-time = "2026-01-22T22:30:01.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491, upload-time = "2026-01-22T22:30:29.51Z" }, + { url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525, upload-time = "2026-01-22T22:30:06.499Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626, upload-time = "2026-01-22T22:30:36.848Z" }, + { url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" }, +] + +[[package]] +name = "starlette" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/eb/4e197bf26b023abafc5e9043ec8710d7de0fcb032d0969045236d72aa0b1/starlette-1.9.0.tar.gz", hash = "sha256:3d931de246ed870e35ce605974440816ef895e592f255d1396cfdf191d4c4318", size = 2732466, upload-time = "2026-10-18T15:50:33.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/93/181df62699d9b5aca1d87e5cfda131dac87e3cdaebf668662f7206ca3470/starlette-1.9.0-py3-none-any.whl", hash = "sha256:fd3f159936e882d06c1108f84688f7cda2cf85fc6a592a50edbd175e882c2ddc", size = 80335, upload-time = "2026-10-18T15:50:31.526Z" }, +] + +[[package]] +name = "tomli" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/78/9ad63712633ed3ab5cc1a648d863d7e7da371e9425e209555a0fe711b695/tomli-2.5.0.tar.gz", hash = "sha256:264507556cd8b8c8e7c6ee037cdf443a463f03f4c958e57195e3d369711b8ff6", size = 17662, upload-time = "2026-10-07T12:23:37.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/ab99b60ee52acd949684febabc3005d0045d0f66bebd9cdebd67372d26dd/tomli-2.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c4dc1c1781f2f716de763d1e9a7b34c6a894e167e291c7c5d16c72f7a9538545", size = 163901, upload-time = "2026-10-07T12:22:15.601Z" }, + { url = "https://files.pythonhosted.org/packages/bc/00/ee01b7ed4579180fff07142d290257f25ba786f23f3ec6005f620933c2f5/tomli-2.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:eff8babca5a7999bc137acbc7482a8b7e17ffca5075ab41f5d770ab408c7bfef", size = 163756, upload-time = "2026-10-07T12:22:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/72/c2/4efebf65372f6583185f79799312109dddb61102d47e5c33dcfd1a297aca/tomli-2.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86665cee9c4835b7a7f1e8ec2c719b5258d4dc782887aded5a8ae7352a96843b", size = 268038, upload-time = "2026-10-07T12:22:18.135Z" }, + { url = "https://files.pythonhosted.org/packages/53/07/5850468e925d898abb36038666f9c333a94d2a223e802a8ba5b6d319d23f/tomli-2.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e369fd63331746182360977b1892bfc215476a30d61612d732425311639f56", size = 276422, upload-time = "2026-10-07T12:22:19.567Z" }, + { url = "https://files.pythonhosted.org/packages/b4/87/f293984cdcf83c054196d4fd3dad44fc68ae55b4b8c44bc76cef360c3150/tomli-2.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ad1ea345759240d6463efa0ed1c704402752e49aa21476620738d74d72d8aa1", size = 272616, upload-time = "2026-10-07T12:22:20.794Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ce/db582886b3c1219d3fec93ebd669332482e5aee7a91e0f7838d84f2d1759/tomli-2.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:96243987194634bd411066ce40c952e108f86af04db533ecd8ac3ff2a85b1885", size = 276593, upload-time = "2026-10-07T12:22:22.12Z" }, + { url = "https://files.pythonhosted.org/packages/bf/72/7619b87dea4261fc27dd7b54c4461c129c1f7d9bb7ba3aec89c797a431b8/tomli-2.5.0-cp311-cp311-win32.whl", hash = "sha256:610b27d99f28ec5f191c7064a48f3ddb179a1fe6ca73d571483ae859f57b605e", size = 101830, upload-time = "2026-10-07T12:22:23.651Z" }, + { url = "https://files.pythonhosted.org/packages/1e/74/220106da34502304b6751a2a9b8a9fbca6c3fd47e737a2e2e3da7c61c9db/tomli-2.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:c804ae44fe7b4bab5da295e4f980a1ff04670bca9d23fe0a4e887e08ebd741a8", size = 112742, upload-time = "2026-10-07T12:22:24.972Z" }, + { url = "https://files.pythonhosted.org/packages/27/99/7d9c8b41837a7773613e169504147375c157a290167aa59ad74a085f521f/tomli-2.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:cfac177ebd6236003846ea339981f71457cb6eb748f23381eb257e45092e3980", size = 109332, upload-time = "2026-10-07T12:22:26.117Z" }, + { url = "https://files.pythonhosted.org/packages/52/ed/7baa86f87493646a594de388c7c1c40a39dd0461f7e9c0359cbeefc91fe8/tomli-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f4a40d03fb9f63424f0979855bdeaf44dd7696b8d59501822c10ed30ba532df", size = 164854, upload-time = "2026-10-07T12:22:27.444Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b1/44c0341f2224397855723c7a8a39f718ea6fcbcc3dacc66e5aeca0f334e3/tomli-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9ebf8d19b17bd0daeb7b7dec81a946a439b753942fd0210d6e96c532249eea6b", size = 164074, upload-time = "2026-10-07T12:22:28.679Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e2d5b7d3fba47adedb23de616c16d428ea076c79a3d8e1d95d649ffe197e/tomli-2.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf0b5e8e0f68ebb494356e577c06c139161efd8d3b9050f93b39b7c26cc54ff0", size = 274274, upload-time = "2026-10-07T12:22:29.804Z" }, + { url = "https://files.pythonhosted.org/packages/43/90/6090e706ff27a6f89f4a40578e3324b95c3cd8c4150868aabf33a8f414c3/tomli-2.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cf74416bdc94ae458b14e37286c1073081850ac8459a00d0c5efef5d44294c6", size = 286435, upload-time = "2026-10-07T12:22:31.297Z" }, + { url = "https://files.pythonhosted.org/packages/0a/9e/a2c40768df16c408f22430afb0a73e9d7e5f79c950884954649d1146b74d/tomli-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:61ea1ebe1e55a34ea8199cc8dbff398d35027b82271c8ac4802fd3a1fd5b1bcc", size = 278119, upload-time = "2026-10-07T12:22:32.601Z" }, + { url = "https://files.pythonhosted.org/packages/12/25/3c0cb485b98e9cfac495629b1c93c87ccf0b72fbe9d2689fd8fe62c6d5a3/tomli-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed53f7e89bb04f6d9e8e7799112360b0c4d5cbff067de0814c98c37c39b920f7", size = 286177, upload-time = "2026-10-07T12:22:33.745Z" }, + { url = "https://files.pythonhosted.org/packages/77/8b/0144c65f0e37e51c18d04ae15c21b19431c165002d0131fe9aa8b0b8b1e8/tomli-2.5.0-cp312-cp312-win32.whl", hash = "sha256:e7ad033e27a516a233bea839cdb77b80146facb3b4f40bf02cd0cac165cdd5c2", size = 102760, upload-time = "2026-10-07T12:22:34.887Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/5d6d8f42fc9a05fce69354e00ff256484192f5f2fc9a2165718fa0de61ec/tomli-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:bd05de8c1698f8413dd7d869492693a0bf2211543b787ac78cd5e7536af1a6d7", size = 112722, upload-time = "2026-10-07T12:22:36.162Z" }, + { url = "https://files.pythonhosted.org/packages/30/65/df18032218db0fb9b769fb23c8039a051f15c811993995ea04c350273a32/tomli-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:069435bd5480429b98c5e5afb02ab21c219b6f0064680671c6dc0d46817346ea", size = 109534, upload-time = "2026-10-07T12:22:37.296Z" }, + { url = "https://files.pythonhosted.org/packages/42/e5/51736d70da209350969e15aca5c5ab6e2ce1ea87a0a892a6c13aec172a86/tomli-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:943276cf269e0071948d9ff697159c1735e623c1151d88abb09b74659ef0cbea", size = 163328, upload-time = "2026-10-07T12:22:38.373Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/086f80dab4ab497602644274e6dea7ec5dd0b4e262e443a8ad3bb7edee2d/tomli-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:463b16086865b97facd8d0b3fb4cb7c544e3f58d2a69dc3113d6db9653fdb043", size = 162246, upload-time = "2026-10-07T12:22:39.673Z" }, + { url = "https://files.pythonhosted.org/packages/aa/eb/3ecc94459f3635c92321f4e7bde571323fdb2267c50e19e3188a281eae3b/tomli-2.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1245a6638fc4bb0a60af38a7d45413db34a13842027c77597c712c998c62fdf0", size = 272655, upload-time = "2026-10-07T12:22:41.08Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d7/494fd1f0c37a621f1ad9975c2efadb523e8101f144ed6edb2e7fe64738f2/tomli-2.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d8bac3d603c97e6854424e5b2b5b741bdbde387e09f162fb0446812b4a8362b", size = 283595, upload-time = "2026-10-07T12:22:42.222Z" }, + { url = "https://files.pythonhosted.org/packages/70/51/bb8d62b1317e6640866f6949b2d5855e5300f2c99d46de1cd245570bba65/tomli-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:21e4cae4114aba25aa0d4f85cdf486d290fb35c0954d7bba536248da64d43066", size = 276253, upload-time = "2026-10-07T12:22:43.625Z" }, + { url = "https://files.pythonhosted.org/packages/66/f4/f46bd7f0763cd47de2db697dca9257c6a4adfd1a93b018cc75c8190ed5a8/tomli-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bbaefc84548d754be821bba7c4141c4787dda182f9e77f2f87b71213529efa7b", size = 283582, upload-time = "2026-10-07T12:22:44.983Z" }, + { url = "https://files.pythonhosted.org/packages/ac/03/70f2bcb2923a6db37818d917e124270a7f4cfd38ea576f5aa753a91c0ef5/tomli-2.5.0-cp313-cp313-win32.whl", hash = "sha256:abdbf6313b8d9efe157edeb7ab6eae4de064b1300ad31abf73755154b30abe68", size = 102628, upload-time = "2026-10-07T12:22:46.508Z" }, + { url = "https://files.pythonhosted.org/packages/dc/98/d52024bb5b0ff68b4f0d276d867f634c84a67319a7e9f6b7708a37742333/tomli-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd4dc129784e0c5335bd4e61dfcc4487499a013419e655cf2da1d091b7e0efdc", size = 113301, upload-time = "2026-10-07T12:22:47.647Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f2/540db3a70572a8c23a28aba3e9c358ce0ffffbafc990905c1343aa265b31/tomli-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:69491c143d2fe063046e0301e62a810bed338fa4d1ce0fd870c27dc1e09b0d84", size = 109744, upload-time = "2026-10-07T12:22:48.925Z" }, + { url = "https://files.pythonhosted.org/packages/e4/49/caf6b307766eb9567664a8707e9d6be5fcc0e8903f18781c6677a60d80c7/tomli-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d3182ee2d887e507bd67319a0a61105d1dd33facc111329559a233b772c1a105", size = 162899, upload-time = "2026-10-07T12:22:50.088Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c8/68cfce773a2733a49c74f99d627fb461bd990756860099eac25617889585/tomli-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:521345fd1f19d45b8df87657aaa38b6f2ca3800059fadf428e7ebf479a383646", size = 162080, upload-time = "2026-10-07T12:22:51.558Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b2/e5bb8651fdad593f670501a7d718b1a7f73f064d44dea15e04c04dfef45d/tomli-2.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95c7614e705bfe2b04b27aa124adec59752d15813df37e2156747cab3a006b", size = 273380, upload-time = "2026-10-07T12:22:52.918Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d2/9e2d7f8b1dfe0e2b34c245986ebd55c4c553ea4ce6c47c443b332673253f/tomli-2.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ac2027d37c3afbdf4bdd377f2676f6f1d2122a5be1f1137b49dced590b37e75", size = 283228, upload-time = "2026-10-07T12:22:54.173Z" }, + { url = "https://files.pythonhosted.org/packages/ba/df/ec7b876b7b1a2718bd74a3743c076fff565b04029ba33e8f61fac262739f/tomli-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c414be4ed9d3cac80c42e348fa5a956117d1a48227f48026e31f59cb4a7671eb", size = 277189, upload-time = "2026-10-07T12:22:55.342Z" }, + { url = "https://files.pythonhosted.org/packages/7d/7b/e192d9eed0b9cb80da799f4d77052297fb9a2c3cc9b19f571f56ea88add6/tomli-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9b03d7dc168353b4132965bde20feceabaa470e570c6f59660dfae59b1f9eeb3", size = 283632, upload-time = "2026-10-07T12:22:56.735Z" }, + { url = "https://files.pythonhosted.org/packages/84/50/ff94454e75461d75623e47401ed323d65c10aab8fe9033242c20cd2fdf32/tomli-2.5.0-cp314-cp314-win32.whl", hash = "sha256:6f041843c4d3a37245c0c056fd955b186bf8b1fb85690cbe40b81230891dc34b", size = 103535, upload-time = "2026-10-07T12:22:58.084Z" }, + { url = "https://files.pythonhosted.org/packages/54/0b/bdacf05f963bd6026ebf6eeb0beda847d1d60e03e440725c64a4e08a0afd/tomli-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:f4b653094e18f9031102d3a1da5c729c8f222d85225b18037dac621695e46e1a", size = 114621, upload-time = "2026-10-07T12:22:59.2Z" }, + { url = "https://files.pythonhosted.org/packages/61/99/53f438fa6ae4f9d4ed0ddde3e7242b3bdc34b48c8f9948b72b9e9b127676/tomli-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3f89d10c1ff6a38d992c27fc8a4816af71a909e08a40ec66934240b1e74347c3", size = 111572, upload-time = "2026-10-07T12:23:00.479Z" }, + { url = "https://files.pythonhosted.org/packages/b9/20/1f88f19427d380a40e90a770e087489eaafe4aeee070ae88ed2bbec00acd/tomli-2.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:e9e15b4a6c7dd6b85b5fbab29488a73f1f70de516942308daa266bf0e0aeb0d4", size = 171814, upload-time = "2026-10-07T12:23:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/d0/56/cbe5079c9f9a54b9b3e27fc82f08f3cb36edee75561679f53d2380c801d6/tomli-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e12bbcd32897272fb05929110362ae9ff4c1b9bb26bd9e971e71dcd3275b4c3d", size = 171324, upload-time = "2026-10-07T12:23:03.18Z" }, + { url = "https://files.pythonhosted.org/packages/2b/30/1d53fd3b0f1cb3ba542e345ec32c26aefdddc4e829e4f3429af8a4f27782/tomli-2.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20aa36de8f2cf87237143bc1fa1aae8d6612c09118f4da21c6a684db5dd1f6f9", size = 297441, upload-time = "2026-10-07T12:23:04.345Z" }, + { url = "https://files.pythonhosted.org/packages/66/d9/0800acb6a111686f764c1b91ef15cc42a20a66a46013bb42220f1d2c61c1/tomli-2.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22185fad8a1e622f064e78008018a0dd3323550dcb479cb7a1d296888d74024f", size = 307476, upload-time = "2026-10-07T12:23:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/e8/63/30a8f3cd51b5bec37f04744bad0b0dc6160df84aad4f27b0e9283d66f221/tomli-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:984012f71908165449a951de2050d52f276bfe3aa5d5f570f63ddad814370374", size = 296113, upload-time = "2026-10-07T12:23:07.202Z" }, + { url = "https://files.pythonhosted.org/packages/ab/18/0b9ffc597e69c5a1e20a7823cb60d54b39a9f54e91edcb8574f022186758/tomli-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f79203b3965b4000e91808aaa7c040206093f2b8bf86f455982f2274c9ccf442", size = 307725, upload-time = "2026-10-07T12:23:08.508Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c7/18f8baae0b5607a60e8e19b4a7fedee43a8ff6458e3896dcbbadeeac9c22/tomli-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:91294a9fb94a75542f6e46e4a2ae709bd8d9b51134098cae5cf3bea5478b6d03", size = 108546, upload-time = "2026-10-07T12:23:09.956Z" }, + { url = "https://files.pythonhosted.org/packages/72/34/4cca9739254130627bde87500b3f2b512154fe2f278efa7e2a5e10ad4bcb/tomli-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f15e3e0b835a6d68b10c86bf80a3149780498d6911c93c3ffd1861d19f9200f1", size = 117814, upload-time = "2026-10-07T12:23:11.486Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fb/afa530d47dd80a78fce43beac6bc6e00f84558eafcffbc6f37b21e80d056/tomli-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6664b7ae7af7294256c53960a6103077f4914cec8ff98479c352f622c6f6b2f0", size = 115188, upload-time = "2026-10-07T12:23:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/66/98/316fdc00f8c0939e6fe50461dd343c162d3ad51d1286eb25b7db54361d50/tomli-2.5.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a525685c2f97da40762b8695eb7aa0af4c8344ca1905c73e4e29cb04d34607dc", size = 162775, upload-time = "2026-10-07T12:23:13.941Z" }, + { url = "https://files.pythonhosted.org/packages/c5/22/7b10fa5bb01c9539f53f69b619361b19350acc73657772ea7ac70ba309a8/tomli-2.5.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:9dbb18c1cfb2f6517942fc9314437f66aa06d94436ffb1f06102ef3572f35276", size = 161406, upload-time = "2026-10-07T12:23:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e7/1a069d86dfd20f1f84f71c63faed9f83c1d890bc06c27d82dc7d888fb573/tomli-2.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:752e8b1aa6a4367ef8bf6a1a1e005540f7ed055ba36d7193796812ca5404eb52", size = 273855, upload-time = "2026-10-07T12:23:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/ae/83/d1ef43d1687d092ab9c235455c76e6e709483b346b056f086095c7c263a5/tomli-2.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47300f9bf791808f77d82747691c4bb09cb14bdf3060cca99b42cdc4361d5a7", size = 284910, upload-time = "2026-10-07T12:23:18.166Z" }, + { url = "https://files.pythonhosted.org/packages/cc/05/f4d9cf7de61822ece0c3873f30d291e324911c71a378b8bfe5ced13fd9f5/tomli-2.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:19b0dd8749f4ea2f112c5fcfb3c5248390c899d7e2e173f1d91abee1fa0ff391", size = 277723, upload-time = "2026-10-07T12:23:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/42/28/78262493141fa543151cf005760c3cb01d09fc28a11f993c05109902cb8c/tomli-2.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:57b1c3b01fab802e2899bc3d168dca320e14165e2fd9fd584760fb4ca5826859", size = 285115, upload-time = "2026-10-07T12:23:20.698Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b9/e1dab9a30bcb677b5cc5cee810609cfd64f24306a3055767dd3fda00b1e0/tomli-2.5.0-cp315-cp315-win32.whl", hash = "sha256:667e521b37a6c5ccaa044202c235b530f90177ffe2cd4a64ecc213c7dd535feb", size = 103475, upload-time = "2026-10-07T12:23:21.941Z" }, + { url = "https://files.pythonhosted.org/packages/4c/bd/31a3790c11d6ea95fcf5e6022ac0f8d0543c9b61120b730fc481bd43d3b4/tomli-2.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:d747252933c8a65ef6bd8da0fbb7ce28a90eb6119d8cd00772cd528aa07b68d5", size = 114589, upload-time = "2026-10-07T12:23:23.098Z" }, + { url = "https://files.pythonhosted.org/packages/47/a2/4f6310fa699364f0e3af7ee3af88dddd9af066d33e716a0265bbe2b3ea84/tomli-2.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:75dbcde8751b0a960aa3de173aa5e894d590755c6d7758b7e774c06f1dc3cbdd", size = 111493, upload-time = "2026-10-07T12:23:24.233Z" }, + { url = "https://files.pythonhosted.org/packages/68/14/00853f0b396d8971107ae1921bb5b322fdee1650d2f16bf06c20adb532e5/tomli-2.5.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2419c2a189551987b59d80e63ec355671283336f41c6b9b89462df679c7d0c57", size = 171380, upload-time = "2026-10-07T12:23:25.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/ad/fa6949321dadee46b27363974fb197b94c911c3b0f7a5fd26d7dc18fc2a0/tomli-2.5.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0dc598040da8d42cf20f0be588ed7004f46db12a0ac6c32e03a59dccedaaadcd", size = 170553, upload-time = "2026-10-07T12:23:26.855Z" }, + { url = "https://files.pythonhosted.org/packages/53/aa/3056c919eb3e084df3752b2cf5f865dcc04af0b27dba2f66d7b28af4633a/tomli-2.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49096930c8d886c9bbdab62d2d0d17ce823ddeea522309a190b36245d5b49e01", size = 294428, upload-time = "2026-10-07T12:23:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/96/b2/faeeb5d8769ea3832021d73e892c8391eae7b4b4f8b55a789127bd8b18a9/tomli-2.5.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8ade5023067f99fe72b88accd30d0ea05a158e9e32a11f124e731ea9695313f", size = 304909, upload-time = "2026-10-07T12:23:29.381Z" }, + { url = "https://files.pythonhosted.org/packages/f6/52/f094c09e73fb654b621716d019acb5d29bdfd1be01df80c281d552bda48d/tomli-2.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b69564772b5c8f22ea5f498dff08cfa825045b4d4c4400529000bdf818aa3b2a", size = 293220, upload-time = "2026-10-07T12:23:30.608Z" }, + { url = "https://files.pythonhosted.org/packages/86/f5/0c30541078ca4b505ce3bd76ed931facbfec524dd018535d691d1af0a6d2/tomli-2.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8ff3a2ca028c7eee0c777f9a092038d0a594a9fa04e215f929a22c329e2cb142", size = 305705, upload-time = "2026-10-07T12:23:32.181Z" }, + { url = "https://files.pythonhosted.org/packages/05/74/590e7d19d6a118fc5cc5704ff358e21d95b8573f6b9443b1519f29ca8825/tomli-2.5.0-cp315-cp315t-win32.whl", hash = "sha256:62fc1bc8eb03e3a9cadfca713d65614ed8e09d974a283295ffe3a831976b4dc5", size = 108432, upload-time = "2026-10-07T12:23:33.496Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b8/63a75cfb27a17c38550e44025d3a6e7be64516fd8608a3b75703bf37d81b/tomli-2.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f3fcbc57b1791fa6cbe5d8434179d51de12be1a4811469529f47f6e7487a2571", size = 117281, upload-time = "2026-10-07T12:23:34.648Z" }, + { url = "https://files.pythonhosted.org/packages/72/01/e8c1debb2173973372934c68fc8e46170ab60ef23ed4592dff4dec6e8993/tomli-2.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d2ba24db8a9376921b5e87b4762b9adb0f3f1deaea68f2b8b0bb2c11efb9c3e7", size = 115069, upload-time = "2026-10-07T12:23:35.77Z" }, + { url = "https://files.pythonhosted.org/packages/60/3f/3e3f8fd0919249b0200c80fbc4f9a1e70be19f9883da71dfb7f8b9ab8aca/tomli-2.5.0-py3-none-any.whl", hash = "sha256:32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b", size = 14765, upload-time = "2026-10-07T12:23:36.875Z" }, +] + +[[package]] +name = "truststore" +version = "0.10.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/9f/c5201d42a484c061e528825fc8e2d565f5abd50a4ced6fb7d29c4ec99b2b/truststore-0.10.5.tar.gz", hash = "sha256:30d36967ccaded5cbb38d602c433f53600036c79d502f4533a49b60a03bbefcd", size = 28091, upload-time = "2026-10-12T22:27:31.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/e9/3a7820be2bb0fe53b6bc9c3be26d3d1158004e4c3ab953aa6840b955b1e9/truststore-0.10.5-py3-none-any.whl", hash = "sha256:9aaaedaefaf06d8b206278cf8b5012bc897f485a874503501e12d776df78951c", size = 19017, upload-time = "2026-10-12T22:27:30.377Z" }, +] + +[[package]] +name = "ty" +version = "0.0.90" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/b1/613b5534624442a9118828d7a9e54d50b16f0f5c8b67c80eb6623f33b921/ty-0.0.90.tar.gz", hash = "sha256:25cef63ec07a0254c2f5d885a63b001d0bfa437b20caa0a408f4f52e66b133e7", size = 7996206, upload-time = "2026-10-20T14:25:19.477Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/33/0e3395afa2c31c0e8e91547629858d2a5de213c69af237e6e50f37545565/ty-0.0.90-py3-none-linux_armv6l.whl", hash = "sha256:0dedb54977b40bc86638fec2c90eefd593f3a6683003a86b7e6cb67c17bcc476", size = 14281945, upload-time = "2026-10-20T14:24:21.018Z" }, + { url = "https://files.pythonhosted.org/packages/d7/fd/5d632b2f88d5c18991a4936da8643c7f204cfbc4980f8c7cd9080af988ad/ty-0.0.90-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3d7c30ceaac76dfbfcf36b72d36272d21c3898e98fc4de296aa9cbae956dec9d", size = 13786355, upload-time = "2026-10-20T14:24:24.392Z" }, + { url = "https://files.pythonhosted.org/packages/79/08/0d2f37bb4f766ab9630f01b07f4d11f1f7a6a6188f920d5ca9fa8a4676b1/ty-0.0.90-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c0b7e4d9ae0939294f0a18652de9ad7d81da2d7f567cf93f1edda1263008c124", size = 12856535, upload-time = "2026-10-20T14:24:27.386Z" }, + { url = "https://files.pythonhosted.org/packages/e4/77/70658f6b80c345ae0c12f2192a5cc89b8ca13eccfe06616b0fac3806c261/ty-0.0.90-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76c4a095a86a132df374e652894fe08392aeb1601d6347e1eb4b58808094a302", size = 13696342, upload-time = "2026-10-20T14:24:30.413Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3e/693049f93653d2f6412bb0c2c0af1d01675e31643af29bef7177d3ff7e79/ty-0.0.90-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4e24a8aa07c05d9d14648289e79a88b7c7c2c522c58b53bb5f466a6ba4704e22", size = 14014180, upload-time = "2026-10-20T14:24:33.783Z" }, + { url = "https://files.pythonhosted.org/packages/27/08/19e373f97501eaadc8fc42e8c07cd54a64e375fbcf1470797be13590d5ba/ty-0.0.90-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40c3ea8fc1dfcf7502f71b93b12e80da1f7a8a5fd7e55ec9feabcf4ac39f72ea", size = 14935189, upload-time = "2026-10-20T14:24:37.459Z" }, + { url = "https://files.pythonhosted.org/packages/33/54/3331984f2c896a7383b6b18c86e968a63dd046baf503119a8e8e7682c752/ty-0.0.90-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1af75532bbe3e305089c30806fa5e804f8b01de231c1fa07918f7f3b335626da", size = 15311482, upload-time = "2026-10-20T14:24:40.87Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f3/179acf8d730c2c864767ffcc9081f5ea6fdb1b34ef711f1bd226200e9cff/ty-0.0.90-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f09449b34d837a044dab043aeb2b4b6ff2d2cef5eaf8116c7c19d34cc2a16a21", size = 15278844, upload-time = "2026-10-20T14:24:44.539Z" }, + { url = "https://files.pythonhosted.org/packages/71/7e/661bc2fa20e0ac84c4d5f92e4c098ec667a0e920851a6d10c10a0251f0dc/ty-0.0.90-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc868ffa7dfcb3169eea3ba7d634e5a3283c7133fb932c6e47a2e16620d26ca5", size = 14558774, upload-time = "2026-10-20T14:24:47.814Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2e/3f65f61f4545f75255373be7646a606e004bb4cdc851e236bf17ca94bdc2/ty-0.0.90-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e2405206533dc896a2b711e781f2955d5883d85536f4df0ac262fca6517103c2", size = 14788158, upload-time = "2026-10-20T14:24:51.223Z" }, + { url = "https://files.pythonhosted.org/packages/1e/10/3c91a529591667b89d63c86806bfe2ac56d6989c7e0a09ee0964b7512deb/ty-0.0.90-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0dca4a29e8778285d2fc204af19254571486106fd14aed60769bfd272c795f6f", size = 13687177, upload-time = "2026-10-20T14:24:54.611Z" }, + { url = "https://files.pythonhosted.org/packages/21/b2/1b6d48606c67614bfb0c8e61a5d67a053ca1e3c5956ddb584f8569b8dc3d/ty-0.0.90-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d004b8e4e898fead929eceea669b73936444671748e0dd44e624cc109346c0dd", size = 14027780, upload-time = "2026-10-20T14:24:59.045Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c4/a0771debf725312524ca1434f8a146d37e573d45257ca4bebb9cc8e35bbc/ty-0.0.90-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a6be182da4281b4581c4a1b27c6a0caebeed7212e4bdc98c6b8fe0d9bc36dea0", size = 14355890, upload-time = "2026-10-20T14:25:03.034Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4c/036724b6936ffc38e28ff5e20ab7972d609c65a3dd9645f3d10f57cfd16f/ty-0.0.90-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0ba6fe0a24ff4cbc5034f3aa1c88b3ad1397315dd3369357e694fd157a11864e", size = 14692306, upload-time = "2026-10-20T14:25:07.461Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2a/c74fd5fc3fb5b046fe06a9067cabf5417b73e28249dc9af90ef72700f8fd/ty-0.0.90-py3-none-win32.whl", hash = "sha256:4c46a79caf84c4da0f7293e16bd769bd467c7cbc38015cac7608ab4e5ec8ba9c", size = 13235434, upload-time = "2026-10-20T14:25:10.545Z" }, + { url = "https://files.pythonhosted.org/packages/d2/54/be5cd6b17d8087a5e4c26892311c07e39d42b66c925b0f06cfc51a9ce83b/ty-0.0.90-py3-none-win_amd64.whl", hash = "sha256:8d77c16d20201b7a07ccaafb610eaf60488783dbba9865c4f9182e584f1426f8", size = 13855668, upload-time = "2026-10-20T14:25:13.648Z" }, + { url = "https://files.pythonhosted.org/packages/33/68/6dea089a897e8b314504eecc2633de3c8fe14f70e71a35606d14a2ba169e/ty-0.0.90-py3-none-win_arm64.whl", hash = "sha256:bfb63e74eeedb0a29d49cf58d611c540bfdfc0975dfce26efc6575bcf439208d", size = 13330853, upload-time = "2026-10-20T14:25:16.642Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] diff --git a/pyproject.toml b/pyproject.toml index fb23f81..a9efd56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,6 +82,7 @@ select = [ "B", # flake8-bugbear "C4", # flake8-comprehensions "UP", # pyupgrade + "TID252", # relative imports # Complexity ratchet: see "Complexity ratchet" in AGENTS.md. "C901", # mccabe complexity "PLR0911", # too many return statements @@ -96,6 +97,9 @@ ignore = [ # Ratchet: each threshold is today's worst value. Lower it as offenders # are refactored, down to the target in AGENTS.md. Never raise it. +[tool.ruff.lint.flake8-tidy-imports] +ban-relative-imports = "all" + [tool.ruff.lint.mccabe] max-complexity = 20 # target 10 @@ -109,6 +113,8 @@ max-positional-args = 5 # target 5 (PLR0917, preview) # Generated by scripts/gen_sync.py: the `for x in it: yield x` and # `[x for x in it]` an `async for` leaves behind stay as written. "introspection_sdk/_sync/**" = ["UP028", "C416"] +# A test suite imports its own helpers relatively. +"**/tests/**" = ["TID252"] [tool.ruff.format] quote-style = "double" @@ -169,7 +175,8 @@ dev = [ # examples/ is its own uv project with its own pyproject.toml and lock. # tests/ stays excluded (deptry's default): it imports the dev/test tools, # which would read as dev dependencies leaking into shipped code (DEP004). -extend_exclude = ["examples"] +# packages/ holds its own distributions, each with its own [tool.deptry]. +extend_exclude = ["examples", "packages"] known_first_party = ["introspection_sdk"] # The `dev` and `test` extras are tooling, not runtime dependencies. optional_dependencies_dev_groups = ["dev", "test"] diff --git a/release-please-config.json b/release-please-config.json index 83a4388..66f243b 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -14,6 +14,21 @@ "jsonpath": "$.project.version" } ] + }, + "packages/introspection-aauth": { + "release-type": "simple", + "component": "introspection-aauth", + "include-component-in-tag": true, + "changelog-path": "CHANGELOG.md", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": false, + "extra-files": [ + { + "type": "toml", + "path": "pyproject.toml", + "jsonpath": "$.project.version" + } + ] } } } diff --git a/uv.lock b/uv.lock index ed575fb..03fb3f2 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 5 +revision = 3 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'win32'", @@ -13,16 +13,6 @@ resolution-markers = [ "(python_full_version < '3.12' and sys_platform == 'emscripten') or (python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32')", ] -[options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. -exclude-newer-span = "P3D" - -[options.exclude-newer-package] -introspection-harbor = false -introspection-recipe-check = false -introspection-remote-proto = false -introspection-sdk = false - [[package]] name = "ag-ui-protocol" version = "0.1.19" @@ -826,7 +816,7 @@ black = [ [[package]] name = "introspection-sdk" -version = "0.29.0" +version = "0.29.1" source = { editable = "." } dependencies = [ { name = "ag-ui-protocol" },