From ee5306a7e9f336779d3e565e3aafb1f2fa78aff4 Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 12:37:50 -0700 Subject: [PATCH 01/34] feat(aauth): introspection-aauth, a Person Server and Access Server library A new distribution, packages/introspection-aauth: an AAuth -11 Person Server and Access Server over a store the host provides, in three layers so the two servers share code without importing each other: - protocol: errors, Ed25519 keys (current and previous), RFC 9421 signing and verification with Signature-Key, AAuth tokens with an agent-token lifetime cap, discovery with a bounded cache, 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); - person_server: missions, person and auth tokens, budgets, approvals, completion, and federation with a resource's Access Server, as steps a host can journal or a default in-process runner; a SQL-free Store and Directory; - access_server: a policy over Person Servers' token requests, pending requests and decisions; - fastapi (extra): routers for either. Signatures and tokens are written on cryptography and http-sfv; the hellocoop vectors pass both ways, and what this package signs verifies under the data plane's current aauth-signing verifier. An example runs the four-party flow in one process. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 47 + .github/workflows/examples.yml | 6 + .release-please-manifest.json | 3 +- README.md | 8 + .../introspection_examples/aauth/__init__.py | 1 + .../aauth/four_party.py | 250 +++++ examples/pyproject.toml | 2 + examples/uv.lock | 277 ++++- packages/introspection-aauth/.jscpd.json | 14 + packages/introspection-aauth/.python-version | 1 + packages/introspection-aauth/LICENSE | 191 ++++ packages/introspection-aauth/README.md | 119 +++ .../introspection_aauth/__init__.py | 7 + .../access_server/__init__.py | 17 + .../access_server/models.py | 105 ++ .../access_server/server.py | 426 ++++++++ .../access_server/store.py | 37 + .../introspection_aauth/fastapi.py | 106 ++ .../person_server/__init__.py | 29 + .../person_server/federation.py | 506 +++++++++ .../person_server/models.py | 147 +++ .../person_server/server.py | 855 ++++++++++++++++ .../person_server/store.py | 191 ++++ .../introspection_aauth/protocol/__init__.py | 28 + .../introspection_aauth/protocol/discovery.py | 90 ++ .../introspection_aauth/protocol/errors.py | 54 + .../introspection_aauth/protocol/httpsig.py | 302 ++++++ .../introspection_aauth/protocol/jwt.py | 99 ++ .../introspection_aauth/protocol/keys.py | 133 +++ .../introspection_aauth/protocol/memory.py | 29 + .../introspection_aauth/protocol/models.py | 40 + .../introspection_aauth/protocol/server.py | 258 +++++ packages/introspection-aauth/pyproject.toml | 60 ++ .../introspection-aauth/tests/__init__.py | 0 .../tests/test_federation.py | 156 +++ .../tests/test_person_server.py | 166 +++ .../tests/test_protocol.py | 284 ++++++ .../tests/test_refusals.py | 289 ++++++ .../tests/vectors/hellocoop.json | 57 ++ packages/introspection-aauth/tests/world.py | 224 ++++ packages/introspection-aauth/uv.lock | 957 ++++++++++++++++++ pyproject.toml | 3 +- release-please-config.json | 15 + 43 files changed, 6585 insertions(+), 4 deletions(-) create mode 100644 examples/introspection_examples/aauth/__init__.py create mode 100644 examples/introspection_examples/aauth/four_party.py create mode 100644 packages/introspection-aauth/.jscpd.json create mode 100644 packages/introspection-aauth/.python-version create mode 100644 packages/introspection-aauth/LICENSE create mode 100644 packages/introspection-aauth/README.md create mode 100644 packages/introspection-aauth/introspection_aauth/__init__.py create mode 100644 packages/introspection-aauth/introspection_aauth/access_server/__init__.py create mode 100644 packages/introspection-aauth/introspection_aauth/access_server/models.py create mode 100644 packages/introspection-aauth/introspection_aauth/access_server/server.py create mode 100644 packages/introspection-aauth/introspection_aauth/access_server/store.py create mode 100644 packages/introspection-aauth/introspection_aauth/fastapi.py create mode 100644 packages/introspection-aauth/introspection_aauth/person_server/__init__.py create mode 100644 packages/introspection-aauth/introspection_aauth/person_server/federation.py create mode 100644 packages/introspection-aauth/introspection_aauth/person_server/models.py create mode 100644 packages/introspection-aauth/introspection_aauth/person_server/server.py create mode 100644 packages/introspection-aauth/introspection_aauth/person_server/store.py create mode 100644 packages/introspection-aauth/introspection_aauth/protocol/__init__.py create mode 100644 packages/introspection-aauth/introspection_aauth/protocol/discovery.py create mode 100644 packages/introspection-aauth/introspection_aauth/protocol/errors.py create mode 100644 packages/introspection-aauth/introspection_aauth/protocol/httpsig.py create mode 100644 packages/introspection-aauth/introspection_aauth/protocol/jwt.py create mode 100644 packages/introspection-aauth/introspection_aauth/protocol/keys.py create mode 100644 packages/introspection-aauth/introspection_aauth/protocol/memory.py create mode 100644 packages/introspection-aauth/introspection_aauth/protocol/models.py create mode 100644 packages/introspection-aauth/introspection_aauth/protocol/server.py create mode 100644 packages/introspection-aauth/pyproject.toml create mode 100644 packages/introspection-aauth/tests/__init__.py create mode 100644 packages/introspection-aauth/tests/test_federation.py create mode 100644 packages/introspection-aauth/tests/test_person_server.py create mode 100644 packages/introspection-aauth/tests/test_protocol.py create mode 100644 packages/introspection-aauth/tests/test_refusals.py create mode 100644 packages/introspection-aauth/tests/vectors/hellocoop.json create mode 100644 packages/introspection-aauth/tests/world.py create mode 100644 packages/introspection-aauth/uv.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17dd873..09638a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -205,3 +205,50 @@ 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 + + - name: Run tests with coverage + run: uv run pytest tests/ -v --cov=introspection_aauth --cov-report=term-missing + + - 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/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/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..0cce39c --- /dev/null +++ b/examples/introspection_examples/aauth/four_party.py @@ -0,0 +1,250 @@ +"""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 introspection_examples.aauth.four_party:person_server_app --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 introspection_examples.aauth.four_party:access_server_app --port 8002 +""" + +from __future__ import annotations + +import asyncio +import json +import os +from typing import Any + +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.protocol import Budget, SigningKey, jwks +from introspection_aauth.protocol.httpsig import jwt_key, sign +from introspection_aauth.protocol.jwt import ( + AGENT_TOKEN, + RESOURCE_TOKEN, + decode, + encode, +) + +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" +USD = {"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 servers(http: httpx.AsyncClient) -> tuple[PersonServer, AccessServer]: + person_server = PersonServer( + PS, + SigningKey.generate(), + MemoryStore(), + subject_secret=os.urandom(32), + agent_providers=[AP], + http=http, + endpoint_base=f"{PS}/aauth", + default_tenant=TENANT, + ) + access_server = AccessServer( + AS, + SigningKey.generate(), + flight_sector, + resources=[RESOURCE], + person_servers=[PS], + agent_providers=[AP], + http=http, + ) + return person_server, access_server + + +def app(router: Any) -> FastAPI: + served = FastAPI() + served.include_router(router) + return served + + +# #endregion docs:aauth-servers + +_http = httpx.AsyncClient() +_person_server, _access_server = servers(_http) +person_server_app = app(person_server_router(_person_server)) +access_server_app = app(access_server_router(_access_server)) + + +class Internet(httpx.AsyncBaseTransport): + """Routes each origin to its party, in this process.""" + + def __init__(self) -> None: + self.parties: dict[str, httpx.AsyncBaseTransport] = {} + self.documents: dict[str, Any] = {} + + async def handle_async_request( + self, request: httpx.Request + ) -> httpx.Response: + if str(request.url) in self.documents: + return httpx.Response(200, json=self.documents[str(request.url)]) + 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: + internet = Internet() + http = httpx.AsyncClient(transport=internet) + person_server, access_server = servers(http) + internet.parties[PS] = httpx.ASGITransport( + app=app(person_server_router(person_server)) + ) + internet.parties[AS] = httpx.ASGITransport( + app=app(access_server_router(access_server)) + ) + + # The agent provider and the airline's booking API, as fixed documents. + provider, airline, agent = (SigningKey.generate() for _ in range(3)) + internet.documents |= { + f"{AP}/.well-known/aauth-agent.json": { + "issuer": AP, + "jwks_uri": f"{AP}/jwks.json", + }, + f"{AP}/jwks.json": jwks([provider]), + f"{RESOURCE}/.well-known/aauth-resource.json": { + "issuer": RESOURCE, + "jwks_uri": f"{RESOURCE}/jwks.json", + }, + f"{RESOURCE}/jwks.json": jwks([airline]), + } + now = person_server.clock() + agent_token = encode( + { + "iss": AP, + "dwk": "aauth-agent.json", + "sub": "aauth:travel@agents.example", + "jti": "agent-1", + "iat": now, + "exp": now + 3_600, + "cnf": {"jwk": agent.public_jwk}, + }, + provider, + AGENT_TOKEN, + ) + + async def call( + method: str, url: str, body: dict[str, Any] | None = None + ) -> httpx.Response: + content = json.dumps(body).encode() if body is not None else None + headers = sign( + agent, + jwt_key(agent_token), + method, + url, + body=content, + created=person_server.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", + { + "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 person_server.decide( + TENANT, + approval_id, + ApprovalStatus.APPROVED, + decided_by="dana@acme.example", + budget=Budget(amount=200_000, **USD), + ) + mission = (await call("GET", proposed.headers["location"])).json() + print("2. Dana approves it with a $2,000 budget; the agent collects it") + + person_token = mission["person_tokens"][RESOURCE] + presented = decode(person_token).claims + resource_token = encode( + { + "iss": RESOURCE, + "dwk": "aauth-resource.json", + "aud": AS, + "ps": PS, + "agent_jkt": agent.kid, + "presented_jti": presented["jti"], + "sub": presented["sub"], + "mission_s256": mission["s256"], + "tenant": TENANT, + "scope": "book", + "budget": {"amount": 145_000, **USD}, + "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", + {"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) + claims = decode(answered.json()["auth_token"]).claims + print( + f"4. {answered.status_code}: an auth token from {claims['iss']}" + f" for {claims['aud']}, ${claims['budget']['amount'] / 100:,.2f}" + ) + stored = await person_server.store.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..f7d6a86 100644 --- a/examples/uv.lock +++ b/examples/uv.lock @@ -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,63 @@ 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 = "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 = "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 +548,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 +565,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 +673,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 +818,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..f5e0edb --- /dev/null +++ b/packages/introspection-aauth/.jscpd.json @@ -0,0 +1,14 @@ +{ + "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"] +} 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..286bbac --- /dev/null +++ b/packages/introspection-aauth/README.md @@ -0,0 +1,119 @@ +# 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` and its 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 `federation`: the client for a resource's Access Server and the loop around it. | +| `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` (the `fastapi` extra). | + +Neither server imports the other, and `protocol` imports neither. + +## 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)) + +# 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: +bind an agent key to a person, read and insert missions, spend from a +mission only if it fits (atomically), log and end missions, read and save +approvals, and replace 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. Whether a store encrypts what it keeps is its own +choice. `access_server.Store` is three methods over pending requests. + +## Durable federation + +`PersonServer(on_federate=...)` is told of each approved request a +resource's Access Server must decide. Unset, `federation.run` drives it in +this process. A host with a durable executor journals the steps itself: +`federation.due`, `AccessServerClient.discover`, `.request`, +`federation.record`, wait `federation.poll_delay`, `.poll`, `record`, and so +on until `record` returns False. + +## 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..de4b834 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/access_server/__init__.py @@ -0,0 +1,17 @@ +"""An AAuth Access Server: a resource's own policy over the auth tokens a +Person Server redeems for it.""" + +from .models import AccessRequest, PendingAccess, PendingStatus, Verdict +from .server import AccessServer, Policy +from .store import MemoryStore, Store + +__all__ = [ + "AccessRequest", + "AccessServer", + "MemoryStore", + "PendingAccess", + "PendingStatus", + "Policy", + "Store", + "Verdict", +] 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..dc00d99 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/access_server/models.py @@ -0,0 +1,105 @@ +"""What an Access Server decides on, and what it keeps while it waits.""" + +from enum import StrEnum +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + +from ..protocol.models import Budget + + +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: dict[str, dict[str, Any]] = Field(default_factory=dict) + + +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 + ) -> "Verdict": + return cls(decision="allow", scope=scope, budget=budget) + + @classmethod + def deny(cls, detail: str | None = None) -> "Verdict": + return cls(decision="deny", detail=detail) + + @classmethod + def approval(cls) -> "Verdict": + return cls(decision="approval") + + @classmethod + def interaction(cls, url: str, code: str) -> "Verdict": + return cls(decision="interaction", url=url, code=code) + + +class PendingStatus(StrEnum): + PENDING = "pending" + APPROVED = "approved" + DENIED = "denied" + #: Approved, and its auth token handed out: an auth token is collected once. + COLLECTED = "collected" + + +class PendingAccess(BaseModel): + """A request the policy is waiting on.""" + + id: str + status: PendingStatus = PendingStatus.PENDING + version: int = 0 + verdict: Verdict + request: AccessRequest + #: The claims the auth token is issued with, once approved. + grant: dict[str, Any] + #: The agent's key the auth token binds. + jwk: dict[str, Any] + #: 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 + created_at: int + expires_at: int + + +class TokenRequest(BaseModel): + model_config = ConfigDict(extra="ignore") + + resource_token: str + agent_token: str + presented_token: str + budget: Budget | None = None + upstream_token: str | None = None + subagent_token: str | None = None 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..9d830b2 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/access_server/server.py @@ -0,0 +1,426 @@ +"""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.""" + +from collections.abc import Awaitable, Callable, Sequence +from typing import Any, Unpack + +import httpx2 as httpx + +from ..protocol import jwt +from ..protocol.errors import AAuthError, Answer, VerificationError +from ..protocol.httpsig import SignedRequest +from ..protocol.keys import SigningKey, thumbprint +from ..protocol.models import Budget +from ..protocol.server import ( + ACCESS_DWK, + PERSON_DWK, + RESOURCE_DWK, + Server, + ServerOptions, + parse, + pending, + verify_agent_token, + verify_server, +) +from .models import ( + AccessRequest, + PendingAccess, + PendingStatus, + TokenRequest, + Verdict, +) +from .store import MemoryStore, Store + +Policy = Callable[[AccessRequest], Verdict | Awaitable[Verdict]] +#: Resource token claims an auth token carries over as they are. +CARRIED = ("account", "mission_s256", "tenant", "r3_uri", "r3_s256") + + +def _bad(token: str, detail: str) -> AAuthError: + return AAuthError(400, f"invalid_{token}_token", detail) + + +def _narrowed(asked: str | None, scope: str | None) -> str | None: + """`scope`, which may be no broader than what was asked.""" + if scope is None: + return asked + if not set(scope.split()) <= set((asked or "").split()): + raise AAuthError( + 500, + "server_error", + "the policy granted a scope that was not asked for", + ) + return scope + + +def _lowered(asked: Budget | None, granted: Budget | None) -> Budget | None: + """`granted`, which may be no more than what was asked.""" + if granted is None: + return asked + if ( + asked is None + or not asked.same_scale(granted) + or granted.amount > asked.amount + ): + raise AAuthError( + 500, "server_error", "the policy granted more than was asked" + ) + return granted + + +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: Callable[[PendingAccess], Awaitable[None]] | 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) -> dict[str, Any]: + return { + "issuer": self.issuer, + **({"name": self.name} if self.name else {}), + "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}" + + # region verification + async def _resource_token(self, token: str, ps: str) -> dict[str, Any]: + try: + resource = str(jwt.decode(token).claims.get("iss", "")).rstrip("/") + except VerificationError: + raise _bad("resource", "not a JWT") from None + # Checked before verifying, since verifying fetches its keys. + if resource not in self._resources: + raise _bad( + "resource", + f"this Access Server does not decide for {resource}", + ) + try: + rt = await self.discovery.verified( + resource, + RESOURCE_DWK, + lambda keys: jwt.verify( + token, keys, jwt.RESOURCE_TOKEN, self.clock() + ), + ) + except VerificationError as exc: + raise _bad("resource", exc.detail) from None + if rt.get("dwk") != RESOURCE_DWK or rt.get("aud") != self.issuer: + raise _bad( + "resource", "not a resource token for this Access Server" + ) + if rt.get("ps") != ps: + raise _bad( + "resource", "its ps is not the Person Server that sent it" + ) + if any( + not isinstance(rt.get(c), str) or not rt[c] + for c in ("sub", "presented_jti", "agent_jkt") + ): + raise _bad( + "resource", "sub, presented_jti and agent_jkt are required" + ) + return rt + + async def _agent_token( + self, token: str, rt: dict[str, Any], ps: str + ) -> dict[str, Any]: + try: + claims = await verify_agent_token( + token, self.discovery, self._providers, self.clock() + ) + except VerificationError as exc: + raise AAuthError( + 400, "invalid_request", f"agent_token: {exc.detail}" + ) from None + if thumbprint(claims["cnf"]["jwk"]) != rt["agent_jkt"]: + raise _bad("resource", "agent_jkt is not the agent token's key") + if "ps" in claims and str(claims["ps"]).rstrip("/") != ps: + raise AAuthError( + 400, + "invalid_request", + "agent_token names another Person Server", + ) + return claims + + async def _presented_token( + self, token: str, rt: dict[str, Any], ps: str + ) -> dict[str, Any]: + """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() + ), + ) + except VerificationError as exc: + code = ( + "expired_presented_token" + if exc.code == "expired_jwt" + else "invalid_presented_token" + ) + raise AAuthError(400, code, exc.detail) from None + cnf = presented.get("cnf") + jwk = cnf.get("jwk") if isinstance(cnf, dict) else None + if ( + presented.get("iss") != ps + or presented.get("aud") != rt.get("iss") + or not isinstance(jwk, dict) + or thumbprint(jwk) != rt["agent_jkt"] + or any( + presented.get(c) != rt.get(r) + for c, r in ( + ("jti", "presented_jti"), + ("sub", "sub"), + ("mission_s256", "mission_s256"), + ("tenant", "tenant"), + ) + ) + ): + raise _bad("resource", "it does not match the presented token") + return presented + + async def _summary(self, rt: dict[str, Any]) -> str | None: + if not isinstance(rt.get("r3_uri"), str) or not isinstance( + rt.get("r3_s256"), str + ): + return None + try: + proposal = await self.identity.read_proposal( + self.http, rt["r3_uri"], rt["r3_s256"], self.clock() + ) + except (httpx.HTTPError, ValueError): + return None + display = proposal.get("display") + summary = display.get("summary") if isinstance(display, dict) else None + return str(summary) if summary else None + + # endregion + + 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 AAuthError( + 400, + "invalid_request", + "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) + rt_budget = ( + Budget.model_validate(rt["budget"]) + if rt.get("budget") is not None + else None + ) + sent = body.budget + if ( + sent is not None + and rt_budget is not None + and ( + not sent.same_scale(rt_budget) + or sent.amount > rt_budget.amount + ) + ): + raise AAuthError( + 400, "invalid_request", "budget is above the resource token's" + ) + asked = AccessRequest( + resource=str(rt["iss"]), + ps=ps, + sub=str(rt["sub"]), + tenant=rt.get("tenant"), + scope=rt.get("scope"), + mission_s256=rt.get("mission_s256"), + budget=sent or rt_budget, + summary=await self._summary(rt), + agent_id=str(agent["sub"]), + agent_provider=str(agent["iss"]), + agent_jkt=str(rt["agent_jkt"]), + claims={ + "resource_token": rt, + "agent_token": agent, + "presented_token": presented, + }, + ) + verdict = self.policy(asked) + verdict = verdict if isinstance(verdict, Verdict) else await verdict + grant = { + "aud": rt["iss"], + "ps": ps, + "sub": rt["sub"], + **{c: rt[c] for c in CARRIED if c in rt}, + } + record = PendingAccess( + id=self.new_id(), + verdict=verdict, + request=asked, + grant=grant, + jwk=agent["cnf"]["jwk"], + not_after=min(int(agent["exp"]), int(presented["exp"])), + created_at=self.clock(), + expires_at=min( + self.clock() + self._pending_ttl, + int(agent["exp"]), + int(presented["exp"]), + ), + ) + if verdict.decision == "allow": + return self._issue( + record, + _narrowed(asked.scope, verdict.scope), + _lowered(asked.budget, verdict.budget), + ) + if verdict.decision == "deny": + raise AAuthError( + 403, + "denied", + verdict.detail or "this resource's policy refuses it", + ) + await self.store.put_pending(record) + if self._on_pending is not None: + await self._on_pending(record) + return self._waiting(record) + + def _waiting(self, record: PendingAccess) -> Answer: + verdict = record.verdict + requirement = ( + f'requirement=interaction;url="{verdict.url}";code="{verdict.code}"' + if verdict.decision == "interaction" + else "requirement=approval" + ) + return pending( + self.pending_url(record.id), self.retry_after, requirement + ) + + 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) + token = self.identity.token( + { + "iss": self.issuer, + "dwk": ACCESS_DWK, + "jti": self.new_id(), + "iat": iat, + "exp": exp, + **record.grant, + **({"scope": scope} if scope else {}), + **({"budget": budget.model_dump()} if budget else {}), + "cnf": {"jwk": record.jwk}, + }, + jwt.AUTH_TOKEN, + ) + return Answer(200, {"auth_token": token, "expires_in": exp - iat}) + + 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.pending(pending_id) + if record is None or record.request.ps != ps: + raise AAuthError(404, "not_found", "no such pending request") + if record.status is PendingStatus.DENIED: + raise AAuthError( + 403, "denied", "this resource's policy refuses it" + ) + if record.status is PendingStatus.COLLECTED: + raise AAuthError( + 410, "invalid_code", "its auth token was issued already" + ) + if self.clock() >= record.expires_at: + raise AAuthError(408, "expired", "nobody decided in time") + if record.status is PendingStatus.PENDING: + return self._waiting(record) + collected = record.model_copy( + update={ + "status": PendingStatus.COLLECTED, + "version": record.version + 1, + } + ) + if not await self.store.transition_pending(record, collected): + raise AAuthError( + 410, "invalid_code", "its auth token was issued already" + ) + 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.pending(pending_id) + if record is None: + raise AAuthError(404, "not_found", "no such request") + if record.status is not PendingStatus.PENDING: + raise AAuthError(409, "invalid_request", "already decided") + if self.clock() >= record.expires_at: + raise AAuthError(410, "expired", "this request has expired") + decided = record.model_copy( + update={ + "status": PendingStatus.APPROVED + if approved + else PendingStatus.DENIED, + "version": record.version + 1, + "scope": _narrowed(record.request.scope, scope), + "budget": _lowered(record.request.budget, budget), + "decided_by": decided_by, + } + ) + if not await self.store.transition_pending(record, decided): + raise AAuthError(409, "invalid_request", "already decided") + return decided 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..a1f76cd --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/access_server/store.py @@ -0,0 +1,37 @@ +"""What an Access Server keeps: the requests its policy is waiting on.""" + +from typing import Protocol + +from ..protocol.memory import compare_and_set +from .models import PendingAccess + + +class Store(Protocol): + async def pending(self, pending_id: str) -> PendingAccess | None: ... + + async def put_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 pending(self, pending_id: str) -> PendingAccess | None: + return self.requests.get(pending_id) + + async def put_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.py b/packages/introspection-aauth/introspection_aauth/fastapi.py new file mode 100644 index 0000000..bcecfd3 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/fastapi.py @@ -0,0 +1,106 @@ +"""FastAPI routers for either server (`pip install introspection-aauth[fastapi]`). + +Each router serves its server's well-known documents at the root and its +protocol routes under the path of its `endpoint_base`. A request is verified +as its signer addressed it, at `endpoint_base`, whatever proxy delivered it. +""" + +from collections.abc import Awaitable, Callable +from urllib.parse import urlsplit + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse + +from .access_server.server import AccessServer +from .person_server.server import PersonServer +from .protocol.errors import AAuthError, Answer +from .protocol.httpsig import SignedRequest + +Handler = Callable[[SignedRequest], Awaitable[Answer]] + + +def _respond(answer: Answer) -> JSONResponse: + return JSONResponse(answer.body, answer.status, answer.headers) + + +async def _serve(request: Request, base: str, handle: Handler) -> JSONResponse: + path = request.url.path.removeprefix(urlsplit(base).path) + query = f"?{request.url.query}" if request.url.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 person_server_router(ps: PersonServer) -> APIRouter: + router = APIRouter() + base = ps.endpoint_base + prefix = urlsplit(base).path + + @router.get("/.well-known/aauth-person.json") + async def metadata() -> JSONResponse: + return JSONResponse(ps.metadata()) + + @router.get("/.well-known/aauth-person-jwks.json") + async def jwks() -> JSONResponse: + return JSONResponse(ps.jwks()) + + @router.post(f"{prefix}/mission") + async def mission(request: Request) -> JSONResponse: + return await _serve(request, base, ps.propose_mission) + + @router.post(prefix + "/mission/{s256}") + async def mission_action(request: Request, s256: str) -> JSONResponse: + return await _serve( + request, base, lambda r: ps.mission_action(r, s256) + ) + + @router.post(f"{prefix}/person-token") + async def person_token(request: Request) -> JSONResponse: + return await _serve(request, base, ps.person_token) + + @router.post(f"{prefix}/token") + async def auth_token(request: Request) -> JSONResponse: + return await _serve(request, base, ps.auth_token) + + @router.get(prefix + "/pending/{tenant}/{approval_id}") + async def poll( + request: Request, tenant: str, approval_id: str + ) -> JSONResponse: + return await _serve( + request, base, lambda r: ps.poll(r, tenant, approval_id) + ) + + return router + + +def access_server_router(server: AccessServer) -> APIRouter: + router = APIRouter() + base = server.endpoint_base + prefix = urlsplit(base).path + + @router.get("/.well-known/aauth-access.json") + async def metadata() -> JSONResponse: + return JSONResponse(server.metadata()) + + @router.get("/.well-known/aauth-access-jwks.json") + async def jwks() -> JSONResponse: + return JSONResponse(server.jwks()) + + @router.post(f"{prefix}/token") + async def token(request: Request) -> JSONResponse: + return await _serve(request, base, server.token) + + @router.get(prefix + "/pending/{pending_id}") + async def poll(request: Request, pending_id: str) -> JSONResponse: + return await _serve( + request, base, lambda r: server.poll(r, pending_id) + ) + + return router 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..f3069d3 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/person_server/__init__.py @@ -0,0 +1,29 @@ +"""An AAuth Person Server: missions, person and auth tokens, budgets, +approvals, and federation with a resource's own Access Server.""" + +from . import federation +from .models import ( + Approval, + ApprovalKind, + ApprovalStatus, + Federation, + LogEntry, + Mission, +) +from .server import PersonServer +from .store import AnyoneDirectory, Directory, MemoryStore, Store + +__all__ = [ + "AnyoneDirectory", + "Approval", + "ApprovalKind", + "ApprovalStatus", + "Directory", + "Federation", + "LogEntry", + "MemoryStore", + "Mission", + "PersonServer", + "Store", + "federation", +] diff --git a/packages/introspection-aauth/introspection_aauth/person_server/federation.py b/packages/introspection-aauth/introspection_aauth/person_server/federation.py new file mode 100644 index 0000000..1271887 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/person_server/federation.py @@ -0,0 +1,506 @@ +"""The Person Server's side of four-party access (`#access-server-federation`). + +A resource token whose `aud` is the resource's own Access Server is redeemed +there: the Person Server signs as itself (`#ps-to-as-token-request`), follows +the deferred loop (`#deferred-responses`), and checks the auth token before +the agent gets it (`#auth-token-delivery`). + +The loop is steps a host can journal: `discover`, `request`, `record`, wait +`poll_delay`, `poll`, `record`, and so on; `run` is that loop in-process. +""" + +import asyncio +import json +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from enum import StrEnum +from typing import TYPE_CHECKING, Any +from urllib.parse import urljoin, urlsplit + +import http_sfv +import httpx2 as httpx +from pydantic import BaseModel, ValidationError + +from ..protocol import jwt +from ..protocol.errors import VerificationError +from ..protocol.keys import thumbprint +from ..protocol.models import Budget +from ..protocol.server import ACCESS_DWK +from .models import Approval, ApprovalStatus, Federation, Refusal + +if TYPE_CHECKING: + from .server import PersonServer + +SERVED = frozenset({"approval", "interaction"}) +#: Requirements draft -11 defines that this Person Server does not serve. +NOT_SERVED = frozenset({"claims", "clarification"}) +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 + location: str | None = None + requirement: str | None = None + retry_after: int | None = None + refusal: Refusal | None = None + + +@dataclass(frozen=True) +class Metadata: + issuer: str + jwks_uri: str + auth_token_endpoint: str + + +def refused(status: int, error: str, detail: str) -> Outcome: + return Outcome( + step=Step.REFUSED, + refusal=Refusal(status=status, error=error, detail=detail), + ) + + +def unreachable(detail: str) -> Outcome: + """`as_unreachable`: no verifiable answer from the Access Server.""" + return refused(502, "as_unreachable", detail) + + +def poll_delay(outcome: Outcome) -> int: + """`Retry-After`, 5 seconds when absent; never a hot loop, never a stall.""" + if outcome.retry_after is None: + return DEFAULT_DELAY + return min(max(outcome.retry_after, MIN_DELAY), MAX_DELAY) + + +def _https(value: Any, endpoint: bool) -> bool: + if not isinstance(value, str): + return False + parts = urlsplit(value) + return ( + parts.scheme == "https" + and bool(parts.hostname) + and not (endpoint and (parts.query or parts.fragment)) + ) + + +def _origin(url: str) -> tuple[str, str | None, int | None]: + parts = urlsplit(url) + return parts.scheme, parts.hostname, parts.port + + +def _retry_after(response: httpx.Response) -> int | None: + try: + return max(int(response.headers.get("retry-after", "")), 0) + except ValueError: + return None + + +def _requirement(header: str) -> str | None: + 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 _scopes(value: Any) -> set[str]: + return set(value.split()) if isinstance(value, str) else set() + + +class AccessServerClient: + """Talks to the Access Server a verified resource token names, signed as the Person Server.""" + + def __init__(self, ps: "PersonServer"): + self._ps = ps + + async def discover(self, access_server: str) -> Metadata | Outcome: + """`{aud}/.well-known/aauth-access.json`, whose `issuer` is the `aud`.""" + try: + document = await self._ps.discovery.get_json( + f"{access_server}/.well-known/{ACCESS_DWK}" + ) + except VerificationError as exc: + return unreachable(f"its metadata could not be read: {exc.detail}") + if document.get("issuer") != access_server: + return unreachable("its metadata names another issuer") + if not _https(document.get("auth_token_endpoint"), True) or not _https( + document.get("jwks_uri"), False + ): + return unreachable( + "its metadata lacks an https auth_token_endpoint and jwks_uri" + ) + return Metadata( + access_server, + str(document["jwks_uri"]), + str(document["auth_token_endpoint"]), + ) + + async def request( + self, federation: Federation, agent_jkt: str, metadata: Metadata + ) -> Outcome: + """The token request, with the budget the Person Server allows.""" + body = json.dumps( + { + "resource_token": federation.resource_token, + "agent_token": federation.agent_token, + "presented_token": federation.presented_token, + **( + {"budget": federation.budget.model_dump()} + if federation.budget + else {} + ), + }, + separators=(",", ":"), + ).encode() + url = metadata.auth_token_endpoint + headers = self._ps.identity.signed("POST", url, body, self._ps.clock()) + response = await self._ps.http.post(url, content=body, headers=headers) + return await self._outcome( + response, url, federation, agent_jkt, metadata + ) + + async def poll( + self, + location: str, + federation: Federation, + agent_jkt: str, + metadata: Metadata, + ) -> Outcome: + headers = self._ps.identity.signed( + "GET", location, None, self._ps.clock() + ) + response = await self._ps.http.get(location, headers=headers) + 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: Metadata, + ) -> Outcome: + status = response.status_code + if status == 200: + try: + auth_token = response.json().get("auth_token") + except (ValueError, AttributeError): + auth_token = None + if not isinstance(auth_token, str): + return unreachable("it answered 200 without an auth_token") + return await self._delivered( + auth_token, federation, agent_jkt, metadata + ) + if status == 202: + return self._waiting(response, requested) + if status == 402: + return refused( + 403, + "denied", + "the Access Server asks for payment, which this Person Server does not make", + ) + if status in (429, 503) and requested != metadata.auth_token_endpoint: + retry = _retry_after(response) or 0 + return Outcome( + step=Step.PENDING, + location=requested, + requirement=federation.requirement or "requirement=approval", + retry_after=retry + 5 if status == 429 else retry, + ) + try: + problem = response.json() + except ValueError: + problem = None + error = problem.get("error") if isinstance(problem, dict) else None + if 400 <= status < 500 and isinstance(error, str) and error: + detail = ( + problem.get("detail") if isinstance(problem, dict) else None + ) + return refused( + status, + error, + f"the Access Server refused: {detail if isinstance(detail, str) else error}", + ) + return unreachable(f"it answered {status}") + + @staticmethod + def _waiting(response: httpx.Response, requested: str) -> Outcome: + header = response.headers.get("aauth-requirement") + requirement = _requirement(header) if header else "approval" + if requirement is None: + return unreachable("its AAuth-Requirement is malformed") + if requirement in NOT_SERVED: + return 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 _origin(polled) != _origin(requested): + return 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), + ) + + async def _delivered( + self, + auth_token: str, + federation: Federation, + agent_jkt: str, + metadata: Metadata, + ) -> Outcome: + """`#auth-token-delivery`, and the budget granted no more than was sent.""" + rt = jwt.decode(federation.resource_token).claims + presented = jwt.decode(federation.presented_token).claims + try: + keys = await self._ps.discovery.get_json(metadata.jwks_uri) + claims = jwt.verify( + auth_token, keys, jwt.AUTH_TOKEN, self._ps.clock() + ) + granted = ( + Budget.model_validate(claims["budget"]) + if "budget" in claims + else None + ) + except (VerificationError, ValidationError) as exc: + return unreachable(f"its auth token does not verify: {exc}") + cnf = claims.get("cnf") + jwk = cnf.get("jwk") if isinstance(cnf, dict) else None + sent = federation.budget + checks = { + "dwk": claims.get("dwk") != ACCESS_DWK, + "iss": claims.get("iss") != metadata.issuer, + "aud": claims.get("aud") != rt.get("iss"), + "cnf": not isinstance(jwk, dict) or _thumbprint(jwk) != agent_jkt, + "sub": claims.get("sub") != rt.get("sub"), + "ps": claims.get("ps") != self._ps.issuer, + "mission_s256": claims.get("mission_s256") + != rt.get("mission_s256"), + "scope": not _scopes(claims.get("scope")) + <= _scopes(rt.get("scope")), + "exp": claims["exp"] > presented.get("exp", 0), + "lifetime": claims["exp"] - claims["iat"] + > jwt.MAX_TOKEN_LIFETIME_SECONDS, + "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 + ) + ), + } + wrong = [name for name, failed in checks.items() if failed] + if wrong: + return unreachable( + f"its auth token has the wrong {', '.join(wrong)}" + ) + return Outcome( + step=Step.ISSUED, + auth_token=auth_token, + auth_token_expires_at=int(claims["exp"]), + granted=granted, + ) + + +def _thumbprint(jwk: dict[str, Any]) -> str | None: + try: + return thumbprint(jwk) + except VerificationError: + return None + + +async def due( + ps: "PersonServer", 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 ps.store.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( + ps: "PersonServer", 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: + return await _record(ps, tenant, approval_id, outcome) + except _Stale: + return False + + +async def _record( + ps: "PersonServer", tenant: str, approval_id: str, outcome: Outcome +) -> bool: + async with ps.store.transaction(): + approval = await ps.store.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 = ps.clock() + status = 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 _spend(ps, 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 ps.store.transition_approval( + tenant, approval, changed + ) + ): + raise _Stale + return outcome.step is Step.PENDING + + +class _Stale(Exception): + """Rolls a `record` back: the approval changed or lapsed meanwhile.""" + + +async def _spend( + ps: "PersonServer", + 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 ps.store.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 ps.store.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", + ) + + +async def run( + ps: "PersonServer", + tenant: str, + approval_id: str, + sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, +) -> Step | None: + """Redeem an approved request at its Access Server, polling while it decides.""" + found = await due(ps, tenant, approval_id) + if found is None: + return None + federation, agent_jkt = found + client = AccessServerClient(ps) + metadata = await client.discover(federation.access_server) + outcome = ( + metadata + if isinstance(metadata, Outcome) + else await client.request(federation, agent_jkt, metadata) + ) + while True: + waiting = await record(ps, tenant, approval_id, outcome) + if ( + not waiting + or outcome.location is None + or isinstance(metadata, Outcome) + ): + return outcome.step + await sleep(poll_delay(outcome)) + federation = federation.model_copy( + update={"requirement": outcome.requirement} + ) + outcome = await client.poll( + outcome.location, federation, agent_jkt, metadata + ) 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..49baeca --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/person_server/models.py @@ -0,0 +1,147 @@ +"""What a Person Server keeps, and the requests it takes.""" + +from enum import StrEnum +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from ..protocol.models import Budget + + +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 + + +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 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: dict[str, Any] + 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 + #: The claims an approved auth token is issued with. + grant: dict[str, Any] = Field(default_factory=dict) + federation: Federation | None = None + 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: str + 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 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..2d25a16 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/person_server/server.py @@ -0,0 +1,855 @@ +"""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 json +import logging +from collections.abc import Awaitable, Callable, Sequence +from datetime import UTC, datetime +from typing import Any, Unpack +from urllib.parse import quote + +import httpx2 as httpx + +from ..protocol import jwt +from ..protocol.errors import AAuthError, Answer, VerificationError +from ..protocol.httpsig import SignedRequest +from ..protocol.keys import SigningKey, b64url, s256 +from ..protocol.models import Agent, Budget +from ..protocol.server import ( + PERSON_DWK, + RESOURCE_DWK, + Server, + ServerOptions, + https_origin, + parse, + pending, + verify_agent, +) +from .federation import run +from .models import ( + Approval, + ApprovalKind, + ApprovalStatus, + AuthTokenRequest, + Federation, + LogEntry, + Mission, + MissionAction, + MissionProposal, + PersonTokenRequest, +) +from .store import AnyoneDirectory, Directory, Store + +logger = logging.getLogger(__name__) + +Hook = Callable[[str, Approval], Awaitable[None]] + + +def _budget(claims: dict[str, Any]) -> Budget | None: + try: + return ( + Budget.model_validate(claims["budget"]) + if claims.get("budget") is not None + else None + ) + except ValueError: + raise AAuthError( + 400, "invalid_resource_token", "budget is not a budget object" + ) from None + + +def _fits(mission: Mission, budget: Budget | None) -> bool: + """Whether a request is within what is left of its mission's budget.""" + return budget is None or ( + mission.budget is not None + and mission.budget.same_scale(budget) + and mission.budget.amount - mission.spent >= budget.amount + ) + + +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 `federation.run` 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, self._mission_ttl = token_ttl, mission_ttl + self._approval_ttl = approval_ttl + self._on_approval, self._on_federate = on_approval, on_federate + self._running: set[asyncio.Task[Any]] = set() + + # region documents + def metadata(self) -> dict[str, Any]: + return { + "issuer": self.issuer, + **({"name": self.name} if self.name else {}), + "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 pending_url(self, tenant: str, approval_id: str) -> str: + return f"{self.endpoint_base}/pending/{quote(tenant, safe='')}/{approval_id}" + + # endregion + + # region people and tokens + def _tenant(self, named: str | None) -> str: + tenant = named or self._default_tenant + if not tenant: + raise AAuthError(400, "invalid_request", "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 AAuthError( + 403, "invalid_request", "this agent's Person Server is another" + ) + 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.binding(tenant, agent.jkt) + if bound is None: + raise AAuthError( + 400, "invalid_request", "name the person: login_hint" + ) + return bound + person = self.directory.person(login_hint, tenant) + if person is None: + raise AAuthError( + 403, + "access_denied", + "this Person Server does not speak for them", + ) + if await self.store.bind(tenant, agent.jkt, person) != person: + raise AAuthError( + 403, "invalid_request", "this agent acts for another person" + ) + return person + + 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() + ) + + def _issue( + self, typ: str, claims: dict[str, Any], expires_at: int, tenant: str + ) -> tuple[str, int]: + now = self.clock() + token = self.identity.token( + { + "iss": self.issuer, + "dwk": PERSON_DWK, + "tenant": tenant, + "jti": self.new_id(), + "iat": now, + "exp": expires_at, + **claims, + }, + typ, + ) + return token, expires_at - now + + 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, + ) -> dict[str, Any]: + token, expires_in = self._issue( + jwt.PERSON_TOKEN, + { + "aud": resource, + "sub": self.directed_subject(person, resource), + "cnf": {"jwk": agent.jwk}, + **({"mission_s256": mission.s256} if mission else {}), + }, + self._expiry(agent, mission.expires_at if mission else None), + tenant, + ) + return {"person_token": token, "expires_in": expires_in} + + def _auth_token( + self, + tenant: str, + agent: Agent, + grant: dict[str, Any], + mission: Mission, + ) -> dict[str, Any]: + token, expires_in = self._issue( + jwt.AUTH_TOKEN, + { + "ps": self.issuer, + "cnf": {"jwk": agent.jwk}, + "mission_s256": mission.s256, + **grant, + }, + self._expiry(agent, mission.expires_at), + tenant, + ) + return {"auth_token": token, "expires_in": expires_in} + + # endregion + + # region missions + 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 AAuthError(404, "mission_not_found", "no such mission") + if mission.completed_at is not None: + raise AAuthError(403, "mission_terminated", "mission completed") + if mission.expires_at <= self.clock(): + raise AAuthError(403, "mission_terminated", "mission expired") + return mission + + 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={"resources": resources}, + ) + await self.store.put_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.mission(tenant, mission_s256), agent.id + ) + if action.action == "update": + entry = action.description or "" + if not await self.store.log_mission( + tenant, + mission.s256, + LogEntry(at=self.clock(), entry=entry), + self.clock(), + ): + raise AAuthError( + 403, "mission_terminated", "the mission has ended" + ) + return Answer(200, {"s256": s256(entry.encode())}) + if action.action != "completion": + raise AAuthError( + 400, "invalid_request", "action is update or completion" + ) + 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.put_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 AAuthError( + 400, "invalid_request", "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.mission(tenant, body.mission_s256), agent.id + ) + if mission.person != person: + raise AAuthError(404, "mission_not_found", "no such mission") + return Answer( + 200, self._person_token(tenant, agent, person, resource, mission) + ) + + # endregion + + # region auth tokens + def _presented(self, token: str) -> dict[str, Any]: + """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() + ) + except VerificationError as exc: + code = ( + "expired_presented_token" + if exc.code == "expired_jwt" + else "invalid_presented_token" + ) + raise AAuthError( + 400, code, "not a live token this Person Server issued" + ) from None + + async def _resource_token( + self, token: str, tenant: str, agent: Agent + ) -> tuple[dict[str, Any], 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: + decoded = jwt.decode(token) + except VerificationError: + raise AAuthError( + 400, "invalid_resource_token", "not a JWT" + ) from None + unverified = decoded.claims + if ( + decoded.header.get("typ") != jwt.RESOURCE_TOKEN + or unverified.get("dwk") != RESOURCE_DWK + ): + raise AAuthError( + 400, "invalid_resource_token", "not a resource token" + ) + if unverified.get("tenant") != tenant: + raise AAuthError( + 400, "invalid_resource_token", "tenant does not match" + ) + if not isinstance(unverified.get("mission_s256"), str): + raise AAuthError( + 403, + "access_denied", + "an auth token is issued only within a mission", + ) + mission = self._live( + await self.store.mission(tenant, unverified["mission_s256"]), + agent.id, + ) + resource = str(unverified.get("iss", "")).rstrip("/") + if resource not in mission.resources: + raise AAuthError( + 403, + "access_denied", + "this mission does not cover that resource", + ) + try: + rt = await self.discovery.verified( + resource, + RESOURCE_DWK, + lambda keys: jwt.verify( + token, keys, jwt.RESOURCE_TOKEN, self.clock() + ), + ) + except VerificationError as exc: + raise AAuthError( + 400, "invalid_resource_token", f"does not verify: {exc.detail}" + ) from None + if str(rt.get("ps", "")).rstrip("/") != self.issuer: + raise AAuthError( + 400, "invalid_resource_token", "not for this Person Server" + ) + if rt.get("agent_jkt") != agent.jkt: + raise AAuthError( + 400, "invalid_resource_token", "agent_jkt is not the signer" + ) + return rt, mission + + def _access_server(self, rt: dict[str, Any]) -> str | None: + """The resource's own Access Server, when `aud` is not this Person Server.""" + audience = rt.get("aud") + if audience == self.issuer: + return None + if not https_origin(audience): + raise AAuthError( + 400, + "invalid_resource_token", + "aud is neither this Person Server nor an Access Server", + ) + return str(audience).rstrip("/") + + 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.get("tenant") != tenant: + raise AAuthError( + 400, "invalid_resource_token", "tenant does not match" + ) + rt, mission = await self._resource_token( + body.resource_token, tenant, agent + ) + resource = str(rt["iss"]).rstrip("/") + if ( + presented.get("jti") != rt.get("presented_jti") + or presented.get("sub") != rt.get("sub") + or presented.get("mission_s256") != rt.get("mission_s256") + or presented.get("aud") != resource + ): + raise AAuthError( + 400, + "invalid_resource_token", + "does not match the presented token", + ) + budget = _budget(rt) + grant = { + "aud": resource, + "sub": rt["sub"], + **{k: rt[k] for k in ("scope", "r3_uri", "r3_s256") if k in rt}, + **({"budget": budget.model_dump()} if budget else {}), + } + 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 AAuthError( + 400, + "invalid_request", + "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=_fits(mission, 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.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, + str(rt.get("scope") or "a request"), + mission_s256=mission.s256, + resource=resource, + budget=budget, + grant=grant, + ) + await self.store.put_approval(tenant, approval) + return await self._ask(tenant, approval) + + async def _federated( + self, + tenant: str, + agent: Agent, + mission: Mission, + grant: dict[str, Any], + 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, + str(grant.get("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.put_approval(tenant, approval) + return await self._ask(tenant, approval) + approval = approval.model_copy( + update={ + "status": ApprovalStatus.APPROVED, + "decided_at": self.clock(), + } + ) + await self.store.put_approval(tenant, approval) + await self.decided(tenant, approval) + return pending(self.pending_url(tenant, approval.id), self.retry_after) + + # endregion + + # region approvals + def _new_approval( + self, + kind: ApprovalKind, + agent: Agent, + person: str, + description: str, + **fields: Any, + ) -> Approval: + now = self.clock() + return Approval( + id=self.new_id(), + kind=kind, + agent_id=agent.id, + agent_jkt=agent.jkt, + agent_jwk=agent.jwk, + person=person, + description=description, + expires_at=now + self._approval_ttl, + created_at=now, + **fields, + ) + + 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 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.approval(tenant, approval_id) + if approval is None or approval.agent_jkt != agent.jkt: + raise AAuthError(404, "not_found", "no such pending request") + mission = ( + await self.store.mission(tenant, approval.mission_s256) + if approval.mission_s256 + else None + ) + federation = approval.federation + if approval.status is ApprovalStatus.DENIED: + refusal = federation.refusal if federation else None + if refusal is not None: + raise AAuthError(refusal.status, refusal.error, refusal.detail) + raise AAuthError(403, "denied", "the approver declined") + if approval.status is ApprovalStatus.PENDING: + if approval.expires_at <= self.clock(): + raise AAuthError(408, "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.kind is ApprovalKind.COMPLETION: + return Answer(200, {}) + if mission is None: + raise AAuthError(404, "mission_not_found", "no such mission") + if approval.kind is ApprovalKind.MISSION: + person_tokens = { + r: self._person_token( + tenant, agent, mission.person, r, mission + )["person_token"] + for r in mission.resources + } + return Answer( + 200, + { + "s256": mission.s256, + "mission": b64url(mission.mission.encode()), + "person_tokens": person_tokens, + }, + ) + self._live(mission, agent.id) + return Answer( + 200, self._auth_token(tenant, agent, approval.grant, mission) + ) + + 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 AAuthError( + 410, + "invalid_code", + "this request's auth token was already collected", + ) + if federation.auth_token is None: + if approval.expires_at <= now: + raise AAuthError( + 408, "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: + raise AAuthError( + 408, + "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 AAuthError( + 410, + "invalid_code", + "this request's auth token was already collected", + ) + return Answer( + 200, + {"auth_token": federation.auth_token, "expires_in": expires_in}, + ) + + async def decide( + self, + tenant: str, + approval_id: str, + status: ApprovalStatus, + *, + decided_by: str, + budget: Budget | 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.""" + if status is ApprovalStatus.PENDING: + raise AAuthError( + 422, "invalid_request", "status is approved or denied" + ) + async with self.store.transaction(): + approval = await self.store.approval(tenant, approval_id) + if approval is None: + return None + if ( + approval.kind is not ApprovalKind.MISSION + and budget is not None + ): + raise AAuthError( + 422, "invalid_request", "only a mission takes a budget" + ) + if ( + approval.kind is ApprovalKind.COMPLETION + and decided_by != approval.person + ): + raise AAuthError( + 403, + "access_denied", + "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 AAuthError( + 409, + "invalid_request", + "this approval is already decided or has expired", + ) + mission_s256 = approval.mission_s256 + if status is ApprovalStatus.APPROVED: + mission_s256 = await self._carry_out( + 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 self.store.transition_approval( + tenant, approval, decided + ): + raise AAuthError( + 409, + "invalid_request", + "this approval is already decided or has expired", + ) + await self.decided(tenant, decided) + return decided + + async def _carry_out( + self, 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.get("resources", [])) + blob = json.dumps( + { + "agent": approval.agent_id, + "approved_at": datetime.fromtimestamp( + now, UTC + ).isoformat(), + "expires_at": datetime.fromtimestamp( + expires_at, UTC + ).isoformat(), + "description": approval.description, + "approved_resources": resources, + **({"budget": budget.model_dump()} if budget else {}), + }, + separators=(",", ":"), + ) + mission = Mission( + s256=s256(blob.encode()), + mission=blob, + agent_id=approval.agent_id, + person=approval.person, + resources=resources, + budget=budget, + expires_at=expires_at, + ) + await self.store.insert_mission(tenant, mission) + return mission.s256 + if approval.mission_s256 is None: + return None + current = await self.store.mission(tenant, approval.mission_s256) + if approval.kind is ApprovalKind.COMPLETION: + ended = await self.store.end_mission( + 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 self.store.spend( + tenant, approval.mission_s256, spend, False, now + ) + if ended is None: + raise AAuthError( + 409, "mission_terminated", "the mission has ended" + ) + return approval.mission_s256 + + 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(run(self, 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.approval(tenant, approval_id) + if approval is None or not isinstance( + approval.grant.get("r3_uri"), str + ): + return approval + proposal = await self.identity.read_proposal( + self.http, + approval.grant["r3_uri"], + str(approval.grant.get("r3_s256")), + self.clock(), + ) + display = proposal.get("display") + summary = display.get("summary") if isinstance(display, dict) else None + if not summary: + return approval + changed = approval.model_copy( + update={ + "summary": str(summary)[:2_000], + "version": approval.version + 1, + } + ) + return ( + changed + if await self.store.transition_approval(tenant, approval, changed) + else approval + ) + + # 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..8803b45 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/person_server/store.py @@ -0,0 +1,191 @@ +"""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.""" + +import asyncio +import copy +from collections.abc import AsyncGenerator +from contextlib import AbstractAsyncContextManager, asynccontextmanager +from typing import Protocol + +from ..protocol.memory import compare_and_set +from .models import Approval, LogEntry, Mission + + +class Store(Protocol): + """A Person Server's state, in logical operations, per tenant. + + `transaction()` groups writes that commit together: a decision that + creates or spends a mission with the approval it decides, and a federated + auth token with the spend it pays for. Anything raised inside it rolls + every write in it back. + """ + + async def binding(self, tenant: str, jkt: str) -> str | None: + """The person an agent key is bound to.""" + ... + + async def bind(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 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 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 log_mission( + self, tenant: str, s256: str, entry: LogEntry, now: int + ) -> bool: + """Append to a live mission's log; whether it was live.""" + ... + + async def end_mission( + 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 approval( + self, tenant: str, approval_id: str + ) -> Approval | None: ... + + async def put_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.""" + ... + + def transaction(self) -> AbstractAsyncContextManager[None]: ... + + +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 + + +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 binding(self, tenant: str, jkt: str) -> str | None: + return self.bindings.get((tenant, jkt)) + + async def bind(self, tenant: str, jkt: str, person: str) -> str: + return self.bindings.setdefault((tenant, jkt), person) + + async def 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 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 log_mission( + 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 end_mission( + 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 approval(self, tenant: str, approval_id: str) -> Approval | None: + return self.approvals.get((tenant, approval_id)) + + async def put_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: + return compare_and_set( + self.approvals, (tenant, current.id), current, changed + ) + + def transaction(self) -> AbstractAsyncContextManager[None]: + return self._transaction() + + @asynccontextmanager + async def _transaction(self) -> AsyncGenerator[None, None]: + async with self._lock: + saved = copy.deepcopy( + (self.bindings, self.missions, self.approvals) + ) + try: + yield + except BaseException: + self.bindings, self.missions, self.approvals = saved + raise 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..b95ea43 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/protocol/__init__.py @@ -0,0 +1,28 @@ +"""What a Person Server and an Access Server share: errors, keys, RFC 9421 +signatures, tokens, discovery, budgets, and the plumbing of a server.""" + +from .discovery import Discovery +from .errors import AAuthError, Answer, VerificationError +from .httpsig import SignedRequest, Verified +from .keys import SigningKey, jwks, s256, thumbprint +from .models import Agent, Budget +from .server import Identity, pending, verify_agent, verify_server + +__all__ = [ + "AAuthError", + "Agent", + "Answer", + "Budget", + "Discovery", + "Identity", + "SignedRequest", + "SigningKey", + "VerificationError", + "Verified", + "jwks", + "pending", + "s256", + "thumbprint", + "verify_agent", + "verify_server", +] 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..72908a8 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/protocol/discovery.py @@ -0,0 +1,90 @@ +"""Metadata and keys: `{issuer}/.well-known/{dwk}` → `jwks_uri` → JWKS +(`#metadata-documents`), cached for a bounded time.""" + +import time +from collections.abc import Callable +from typing import Any, TypeVar + +import httpx2 as httpx + +from .errors import VerificationError + +T = TypeVar("T") +#: 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 + + +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: Callable[[], float] = 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, dict[str, Any]]] = {} + + async def metadata(self, issuer: str, dwk: str) -> dict[str, Any]: + """The issuer's metadata document, which must name it as `issuer`.""" + document = await self.get_json(f"{issuer}/.well-known/{dwk}") + if str(document.get("issuer", "")).rstrip("/") != issuer: + raise VerificationError( + "issuer_mismatch", "metadata names another issuer" + ) + return document + + async def jwks( + self, issuer: str, dwk: str, *, refresh: bool = False + ) -> dict[str, Any]: + """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) + keys = await self.get_json(str(document.get("jwks_uri", ""))) + self._cache[(issuer, dwk)] = (self._clock() + self._ttl, keys) + return keys + + async def verified( + self, issuer: str, dwk: str, check: Callable[[dict[str, Any]], 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) -> dict[str, Any]: + """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.get("keys", []): + if isinstance(key, dict) and key.get("kid") == kid: + return key + raise VerificationError("unknown_key", f"no key {kid} for {issuer}") + + async def get_json(self, url: str) -> dict[str, Any]: + try: + response = await self.http.get(url) + response.raise_for_status() + document = response.json() + except (httpx.HTTPError, ValueError) as exc: + raise VerificationError("unknown_key", f"{url}: {exc}") from None + if not isinstance(document, dict): + raise VerificationError( + "unknown_key", f"{url} is not a JSON object" + ) + return document 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..35b4630 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/protocol/errors.py @@ -0,0 +1,54 @@ +"""AAuth errors, answered as `application/problem+json` (`#error-responses`).""" + +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True) +class Answer: + """What a protocol request is answered with, for any web framework.""" + + status: int + body: dict[str, Any] + headers: dict[str, str] = field(default_factory=dict) + + +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: + body: dict[str, Any] = {"error": self.error} + if self.detail: + body["detail"] = self.detail + return Answer( + self.status, + body, + {"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 diff --git a/packages/introspection-aauth/introspection_aauth/protocol/httpsig.py b/packages/introspection-aauth/introspection_aauth/protocol/httpsig.py new file mode 100644 index 0000000..2156f87 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/protocol/httpsig.py @@ -0,0 +1,302 @@ +"""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. +""" + +import base64 +import hashlib +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass +from typing import Any +from urllib.parse import urlsplit + +import http_sfv + +from . import jwt +from .errors import VerificationError +from .keys import SigningKey, thumbprint +from .keys import verify as verify_with + +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", + } +) + +#: Finds a published key: `(id, dwk, kid)` to its JWK. +KeyResolver = Callable[[str, str, str], Awaitable[dict[str, Any]]] + + +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 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.""" + 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())) + if not isinstance(item, http_sfv.Item) 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", + created: int, +) -> dict[str, str]: + """The headers that sign a request to `url` with `key`.""" + 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} + 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}:", + } + + +@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: dict[str, Any] + 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: dict[str, Any], resolve: KeyResolver | None +): + 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") + jwk = cnf.get("jwk") if isinstance(cnf, dict) else None + if not isinstance(jwk, dict): + raise VerificationError("invalid_jwt", "the token has no cnf.jwk") + return jwk, {"token": token} + if scheme == "jwks_uri": + server, dwk, kid = (params.get(n) for n in ("id", "dwk", "kid")) + if not all(isinstance(v, str) for v in (server, dwk, kid)): + 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(str(server), str(dwk), str(kid)), { + "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, + *, + require_content_digest: bool = True, +) -> 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), dict(item.params), resolve) + if body and require_content_digest and "content-digest" not in names: + raise VerificationError( + "invalid_input", "content-digest 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 signature is None 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, **named) 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..7613851 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/protocol/jwt.py @@ -0,0 +1,99 @@ +"""Compact JWS for AAuth's tokens, signed and verified under fully-specified +algorithms (`#token-format`).""" + +import json +from dataclasses import dataclass +from typing import Any + +from .errors import VerificationError +from .keys import SigningKey, b64url, b64url_decode +from .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 +#: The longest an agent or auth token may claim to live, so a forged one +#: cannot outlive the revocation of the key that signed it. +MAX_TOKEN_LIFETIME_SECONDS = 3_600 + + +def encode(claims: dict[str, Any], key: SigningKey, typ: str) -> str: + header = {"alg": "Ed25519", "typ": typ, "kid": key.kid} + signing_input = ".".join( + b64url(json.dumps(part, separators=(",", ":")).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: dict[str, Any] + claims: dict[str, Any] + 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, claims = (json.loads(b64url_decode(p)) for p in parts[:2]) + signature = b64url_decode(parts[2]) + except ValueError: + raise VerificationError("invalid_jwt", "not a JWT") from None + if not isinstance(header, dict) or not isinstance(claims, dict): + raise VerificationError("invalid_jwt", "not a JWT") + return Decoded( + header, claims, f"{parts[0]}.{parts[1]}".encode(), signature + ) + + +def verify( + token: str, + keys: dict[str, Any], + typ: str, + now: int, + *, + max_lifetime: int | None = None, +) -> dict[str, Any]: + """The claims of `token`, signed by one of `keys` (a JWKS), of type `typ`, + unexpired at `now`; `max_lifetime` caps `exp - iat`.""" + decoded = decode(token) + if decoded.header.get("typ") != typ: + raise VerificationError("invalid_jwt", f"not an {typ}") + kid = decoded.header.get("kid") + candidates = [ + k + for k in keys.get("keys", []) + if isinstance(k, dict) and (kid is None or k.get("kid") == kid) + ] + if not candidates: + raise VerificationError("unknown_key", "signed by a key not published") + if not any( + k.get("alg") == decoded.header.get("alg") + and verify_signature(k, decoded.signing_input, decoded.signature) + for k in candidates + ): + raise VerificationError("invalid_jwt", "the signature does not verify") + claims = decoded.claims + exp, iat = claims.get("exp"), claims.get("iat") + if not isinstance(exp, int) or not isinstance(iat, int): + raise VerificationError("invalid_jwt", "exp and iat are required") + if exp <= now: + raise VerificationError("expired_jwt", "the token has expired") + if iat > now + CLOCK_SKEW_SECONDS: + raise VerificationError( + "clock_skew", "the token's iat is in the future" + ) + if max_lifetime is not None and exp - iat > max_lifetime: + raise VerificationError( + "invalid_jwt", "the token claims too long a life" + ) + 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..904a9db --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/protocol/keys.py @@ -0,0 +1,133 @@ +"""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 Any + +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 .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)) + + +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: dict[str, Any]) -> str: + """RFC 7638 thumbprint of an OKP or EC public key.""" + members = {"OKP": ("crv", "kty", "x"), "EC": ("crv", "kty", "x", "y")} + names = members.get(jwk.get("kty", "")) + if names is None or any(not isinstance(jwk.get(n), str) for n in names): + raise VerificationError("invalid_key", "not an OKP or EC public key") + canonical = json.dumps( + {n: jwk[n] for n in names}, separators=(",", ":"), sort_keys=True + ) + return s256(canonical.encode()) + + +def algorithm(jwk: dict[str, Any]) -> str: + """The key's algorithm, from its `alg`, which must match its type.""" + alg = jwk.get("alg") + if alg not in ALGORITHMS: + raise VerificationError( + "unsupported_algorithm", "the key's alg is not Ed25519 or ES256" + ) + if (jwk.get("kty"), jwk.get("crv")) != ALGORITHMS[alg]: + raise VerificationError( + "invalid_key", "the key's type is not its alg's" + ) + return alg + + +def verify(jwk: dict[str, Any], data: bytes, signature: bytes) -> bool: + """Whether `jwk` made `signature` over `data`.""" + try: + if algorithm(jwk) == "Ed25519": + Ed25519PublicKey.from_public_bytes(b64url_decode(jwk["x"])).verify( + signature, data + ) + return True + if len(signature) != 64: + 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): + 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 + ) + public = {"kty": "OKP", "crv": "Ed25519", "x": b64url(raw)} + self.kid = thumbprint(public) + self.public_jwk = { + **public, + "alg": "Ed25519", + "kid": self.kid, + "use": "sig", + } + + @classmethod + def generate(cls) -> "SigningKey": + return cls(Ed25519PrivateKey.generate()) + + @classmethod + def from_pem(cls, pem: str | bytes) -> "SigningKey": + 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) -> "SigningKey": + return cls(Ed25519PrivateKey.from_private_bytes(seed)) + + def sign(self, data: bytes) -> bytes: + return self._private.sign(data) + + +def jwks(keys: Sequence[SigningKey]) -> dict[str, Any]: + """A JWKS publishing `keys`: the current key and any previous ones.""" + return {"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..29363e8 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/protocol/memory.py @@ -0,0 +1,29 @@ +"""What both servers' in-memory stores share.""" + +from typing import Any, Protocol, TypeVar + + +class Versioned(Protocol): + @property + def status(self) -> Any: ... + + @property + def version(self) -> int: ... + + +Record = TypeVar("Record", bound=Versioned) + + +def compare_and_set( + records: dict[Any, Record], key: Any, 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..c969e1f --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/protocol/models.py @@ -0,0 +1,40 @@ +"""Values both servers exchange.""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +#: 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: "Budget") -> bool: + return self.unit == other.unit and self.decimals == other.decimals + + +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: dict[str, Any] + 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/server.py b/packages/introspection-aauth/introspection_aauth/protocol/server.py new file mode 100644 index 0000000..7c6f38a --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/protocol/server.py @@ -0,0 +1,258 @@ +"""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 time +import uuid +from collections.abc import Callable, Sequence +from typing import Any, NotRequired, TypedDict, TypeVar, Unpack +from urllib.parse import urlsplit + +import httpx2 as httpx +from pydantic import BaseModel, ValidationError + +from . import httpsig, jwt +from .discovery import Discovery +from .errors import AAuthError, Answer, VerificationError +from .keys import SigningKey, jwks, s256, thumbprint +from .models import Agent + +AGENT_DWK = "aauth-agent.json" +PERSON_DWK = "aauth-person.json" +ACCESS_DWK = "aauth-access.json" +RESOURCE_DWK = "aauth-resource.json" +Model = TypeVar("Model", bound=BaseModel) + + +def now_seconds() -> int: + return int(time.time()) + + +def https_origin(value: object) -> bool: + """A server identifier: `https://host`, with no path, query or fragment.""" + if not isinstance(value, str): + return False + 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 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 AAuthError( + 400, "invalid_request", exc.errors()[0]["msg"] + ) from None + + +def pending( + location: str, retry_after: int, requirement: str | None = None +) -> Answer: + """`202`: poll `location` (`#deferred-responses`).""" + return Answer( + 202, + {"status": "pending"}, + { + "Location": location, + "Retry-After": str(retry_after), + "Cache-Control": "no-store", + "AAuth-Requirement": requirement or "requirement=approval", + }, + ) + + +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: str, + 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) -> dict[str, Any]: + return jwks(self.keys) + + def token(self, claims: dict[str, Any], typ: str) -> str: + return jwt.encode(claims, self.key, typ) + + def verify_own(self, token: str, typ: str, now: int) -> dict[str, Any]: + claims = jwt.verify(token, self.jwks(), typ, now) + if claims.get("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, r3_uri: str, r3_s256: str, now: int + ) -> dict[str, Any]: + """A resource's R3 proposal, fetched signed and checked against its hash.""" + response = await http.get( + r3_uri, headers=self.signed("GET", r3_uri, None, now) + ) + response.raise_for_status() + if s256(response.content) != r3_s256: + raise ValueError("the proposal does not match its hash") + document = response.json() + return document if isinstance(document, dict) else {} + + +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[Callable[[], int]] + new_id: NotRequired[Callable[[], str]] + name: NotRequired[str] + + +class Server: + """A server's identity, its HTTP client and discovery, and its options.""" + + def __init__( + self, + issuer: str, + dwk: str, + 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 = Discovery(http) + self.endpoint_base = options.get("endpoint_base", self.issuer).rstrip( + "/" + ) + self.retry_after = options.get("retry_after", 5) + self.clock = options.get("clock", now_seconds) + self.new_id = options.get("new_id", lambda: uuid.uuid4().hex) + self.name = options.get("name") + + def jwks(self) -> dict[str, Any]: + return self.identity.jwks() + + +async def verify_agent_token( + token: str, discovery: Discovery, providers: set[str], now: int +) -> dict[str, Any]: + """An agent token from a trusted agent provider (`#agent-token`).""" + provider = str(jwt.decode(token).claims.get("iss", "")).rstrip("/") + if provider not in providers: + raise VerificationError( + "invalid_jwt", "its agent provider is not trusted here" + ) + claims = await discovery.verified( + provider, + AGENT_DWK, + lambda keys: jwt.verify( + token, + keys, + jwt.AGENT_TOKEN, + now, + max_lifetime=jwt.MAX_TOKEN_LIFETIME_SECONDS, + ), + ) + cnf = claims.get("cnf") + if claims.get("dwk") != AGENT_DWK or not isinstance( + claims.get("sub"), str + ): + raise VerificationError("invalid_jwt", "not an agent token") + if not isinstance(cnf, dict) or not isinstance(cnf.get("jwk"), dict): + raise VerificationError( + "invalid_jwt", "the agent token has no cnf.jwk" + ) + return claims + + +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`.""" + try: + 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 thumbprint(claims["cnf"]["jwk"]) != verified.thumbprint: + raise VerificationError( + "invalid_signature", "not signed by the token's key" + ) + except VerificationError as exc: + headers = ( + {"Accept-Signature-Scheme": "jwt"} + if exc.code == "unsupported_scheme" + else {} + ) + raise AAuthError(401, exc.code, exc.detail, headers) from None + return Agent( + id=claims["sub"], + provider=str(claims["iss"]).rstrip("/"), + jkt=verified.thumbprint, + jwk=claims["cnf"]["jwk"], + expires_at=int(claims["exp"]), + person_server=str(claims["ps"]).rstrip("/") + if "ps" in claims + 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 AAuthError( + 401, + "invalid_signature", + "sign as a server this one takes requests from", + ) + try: + verified = await httpsig.verify(request, now, discovery.key) + except VerificationError as exc: + raise AAuthError(401, exc.code, exc.detail) from None + if verified.scheme != "jwks_uri" or verified.dwk != dwk: + raise AAuthError( + 401, "unsupported_scheme", f"sign with sig=jwks_uri and {dwk}" + ) + return str(verified.server) diff --git a/packages/introspection-aauth/pyproject.toml b/packages/introspection-aauth/pyproject.toml new file mode 100644 index 0000000..5c24773 --- /dev/null +++ b/packages/introspection-aauth/pyproject.toml @@ -0,0 +1,60 @@ +[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", + "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", + "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 = 84 +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_federation.py b/packages/introspection-aauth/tests/test_federation.py new file mode 100644 index 0000000..1826753 --- /dev/null +++ b/packages/introspection-aauth/tests/test_federation.py @@ -0,0 +1,156 @@ +"""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 + +from introspection_aauth.access_server import Verdict +from introspection_aauth.person_server import ApprovalStatus, federation +from introspection_aauth.protocol.jwt import decode + +from .world import AS, PS, RESOURCE, TENANT, World, usd + + +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) -> None: + ran.append(approval.id) + + async def no_wait(_: float) -> None: + return None + + await federation.run(world.ps, 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 = ( + await world.http.get(f"{AS}/.well-known/aauth-access.json") + ).json() + assert metadata["auth_token_endpoint"] == f"{AS}/token" + keys = (await world.http.get(metadata["jwks_uri"])).json() + 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 = decode(issued.json()["auth_token"]).claims + assert ( + claims["iss"] == AS + and claims["aud"] == RESOURCE + and claims["ps"] == PS + ) + assert claims["cnf"]["jwk"]["x"] == world.agent_key.public_jwk["x"] + assert await world.spent(mission["s256"]) == 60_000 + again = await world.follow(asked) + assert again.status_code == 410 + assert again.json()["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 refused.json()["error"] == "denied" + assert "no Flight Sector account" in refused.json()["detail"] + 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 ( + decode(issued.json()["auth_token"]).claims["budget"]["amount"] + == 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) -> 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 federation.due(world.ps, TENANT, approval_id) + assert due is not None + fed, jkt = due + client = federation.AccessServerClient(world.ps) + metadata = await client.discover(fed.access_server) + assert isinstance(metadata, federation.Metadata) + outcome = await client.request(fed, jkt, metadata) + assert outcome.step is federation.Step.PENDING + assert await federation.record(world.ps, 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 federation.Step.ISSUED + assert not await federation.record(world.ps, 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", + { + "resource_token": token, + "presented_token": person_token, + "subagent_token": "x", + }, + ) + assert refused.status_code == 400 + assert refused.json()["error"] == "invalid_request" 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..c3a6ef6 --- /dev/null +++ b/packages/introspection-aauth/tests/test_person_server.py @@ -0,0 +1,166 @@ +"""The Person Server, three-party: missions, person tokens, auth tokens +within and over budget, approvals, completion.""" + +from urllib.parse import urlsplit + +import pytest + +from introspection_aauth.person_server import ApprovalStatus +from introspection_aauth.protocol import AAuthError +from introspection_aauth.protocol.jwt import decode + +from .world import PS, RESOURCE, TENANT, World + + +def approval_id(response) -> str: + return urlsplit(response.headers["location"]).path.rsplit("/", 1)[1] + + +async def test_publishes_its_metadata_and_keys(): + world = World() + metadata = ( + await world.http.get(f"{PS}/.well-known/aauth-person.json") + ).json() + assert metadata["issuer"] == PS + assert metadata["auth_token_endpoint"] == f"{PS}/aauth/token" + keys = (await world.http.get(metadata["jwks_uri"])).json() + 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", + { + "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) + blob = decode(mission["person_tokens"][RESOURCE]).claims + assert blob["aud"] == RESOURCE and blob["mission_s256"] == mission["s256"] + assert blob["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 = decode(issued.json()["auth_token"]).claims + assert claims["iss"] == PS and claims["aud"] == RESOURCE + assert claims["budget"]["amount"] == 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 refused.json()["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']}", + {"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.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 after.json()["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']}", + {"action": "update", "description": "Found two flights"}, + ) + assert logged.status_code == 200 + stored = await world.ps_store.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", + {"description": "x", "login_hint": "sam"}, + ) + assert refused.status_code == 401 + assert refused.json()["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", + {"resource": RESOURCE, "login_hint": "someone-else"}, + ) + assert other.status_code == 403 + same = await world.call( + "POST", f"{PS}/aauth/person-token", {"resource": RESOURCE} + ) + assert same.status_code == 200 + claims = decode(same.json()["person_token"]).claims + assert claims["sub"] == world.ps.directed_subject("sam", RESOURCE) diff --git a/packages/introspection-aauth/tests/test_protocol.py b/packages/introspection-aauth/tests/test_protocol.py new file mode 100644 index 0000000..2bbf03b --- /dev/null +++ b/packages/introspection-aauth/tests/test_protocol.py @@ -0,0 +1,284 @@ +"""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.keys import ( + b64url, + b64url_decode, + thumbprint, + verify, +) + +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( + { + "iss": "https://agents.example", + "dwk": "aauth-agent.json", + "sub": "aauth:travel@agents.example", + "jti": "a1", + "iat": now, + "exp": now + 600, + "cnf": {"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]) + 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( + {"iss": "https://ps.example", "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( + {"cnf": {"jwk": key.public_jwk}, "exp": 2, "iat": 1}, + 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) -> dict: + 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({"iat": 100, "exp": 200}, key, jwt.AUTH_TOKEN) + assert jwt.verify(token, jwks([key]), jwt.AUTH_TOKEN, 150)["exp"] == 200 + for typ, now, code in ( + (jwt.PERSON_TOKEN, 150, "invalid_jwt"), + (jwt.AUTH_TOKEN, 250, "expired_jwt"), + ): + with pytest.raises(VerificationError) as failed: + jwt.verify(token, jwks([key]), typ, now) + assert failed.value.code == code + with pytest.raises(VerificationError) as other_key: + jwt.verify(token, jwks([SigningKey.generate()]), jwt.AUTH_TOKEN, 150) + assert other_key.value.code == "unknown_key" + long = jwt.encode({"iat": 100, "exp": 100 + 7_200}, key, jwt.AGENT_TOKEN) + with pytest.raises(VerificationError): + jwt.verify( + long, + jwks([key]), + jwt.AGENT_TOKEN, + 150, + max_lifetime=jwt.MAX_TOKEN_LIFETIME_SECONDS, + ) + + +def test_a_previous_key_still_verifies_what_it_signed(): + old, new = SigningKey.generate(), SigningKey.generate() + token = jwt.encode({"iat": 1, "exp": 10}, old, jwt.PERSON_TOKEN) + assert jwt.verify(token, jwks([new, old]), jwt.PERSON_TOKEN, 5) + + +def test_es256_keys_verify(): + private = ec.generate_private_key(ec.SECP256R1()) + numbers = private.public_key().public_numbers() + 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, "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(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..081ec92 --- /dev/null +++ b/packages/introspection-aauth/tests/test_refusals.py @@ -0,0 +1,289 @@ +"""Refusals, expiries and summaries, on both servers.""" + +import json +from urllib.parse import urlsplit + +import httpx2 as httpx +import pytest + +from introspection_aauth.access_server import Verdict +from introspection_aauth.person_server import ApprovalStatus, federation +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, decode, encode + +from .test_federation import inline +from .world import AS, PS, RESOURCE, TENANT, World, usd + + +def approval_id(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", + {"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) + token, person_token = world.resource_token(mission, 10) + claims = decode(token).claims | {"iss": "https://elsewhere.example"} + forged = encode(claims, world.resource_key, RESOURCE_TOKEN) + refused = await world.call( + "POST", + f"{PS}/aauth/token", + {"resource_token": forged, "presented_token": person_token}, + ) + assert refused.status_code == 403 + assert not any("elsewhere" in c for c in world.net.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", {"resource": RESOURCE} + ) + refused = await world.call( + "POST", + f"{PS}/aauth/token", + { + "resource_token": token, + "presented_token": other.json()["person_token"], + }, + ) + assert refused.status_code == 400 + assert refused.json()["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", + {"resource_token": token, "presented_token": person_token}, + ) + assert refused.json()["error"] == "expired_presented_token" + + +async def test_an_access_server_that_cannot_be_reached_is_as_unreachable(): + world = World() + inline(world) + del world.net.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 refused.json()["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.net.apps[AS] + + class AsksForClaims(httpx.AsyncBaseTransport): + async def handle_async_request(self, request): + 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.net.apps[AS] = AsksForClaims() # type: ignore[assignment] + 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, 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 federation.due(world.ps, TENANT, kicked[0]) + assert due is not None + client = federation.AccessServerClient(world.ps) + metadata = await client.discover(due[0].access_server) + assert isinstance(metadata, federation.Metadata) + first = await client.request(due[0], due[1], metadata) + app = world.net.apps[AS] + + class Busy(httpx.AsyncBaseTransport): + async def handle_async_request(self, request): + if request.url.path.startswith("/pending"): + return httpx.Response(429, headers={"Retry-After": "2"}) + return await app.handle_async_request(request) + + world.net.apps[AS] = Busy() # type: ignore[assignment] + assert first.location is not None + busy = await client.poll(first.location, due[0], due[1], metadata) + assert ( + busy.step is federation.Step.PENDING + and federation.poll_delay(busy) == 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) + body = json.dumps( + { + "resource_token": token, + "agent_token": world.agent_token, + "presented_token": person_token, + } + ).encode() + url = f"{AS}/token" + unsigned = await world.http.post(url, content=body) + 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=body, + created=world.now, + ) + refused = await world.http.post(url, content=body, headers=headers) + assert refused.status_code == 401 + ps_signed = world.ps.identity.signed("POST", url, body, world.now) + wrong_audience = await world.http.post( + url, content=body, headers=ps_signed + ) + assert wrong_audience.status_code == 400 + assert wrong_audience.json()["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, 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 federation.due(world.ps, TENANT, kicked[0]) + assert due is not None + client = federation.AccessServerClient(world.ps) + metadata = await client.discover(due[0].access_server) + assert isinstance(metadata, federation.Metadata) + outcome = await client.request(due[0], due[1], metadata) + await federation.record(world.ps, 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 federation.Step.REFUSED + await federation.record(world.ps, 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 = json.dumps( + {"display": {"summary": "Book QF1 for $600"}} + ).encode() + r3_uri = f"{RESOURCE}/r3/1" + + world.net.documents[r3_uri] = proposal + mission = await world.approved_mission(budget=1_000) + token, person_token = world.resource_token(mission, 60_000) + claims = decode(token).claims | { + "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", + {"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" 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..203de79 --- /dev/null +++ b/packages/introspection-aauth/tests/world.py @@ -0,0 +1,224 @@ +"""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 transport stands in for the internet, routing each origin to its party.""" + +import json +from typing import Any +from urllib.parse import urlsplit + +import httpx2 as httpx +from fastapi import FastAPI + +from introspection_aauth.access_server import AccessServer, 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.protocol import Budget, SigningKey, jwks +from introspection_aauth.protocol.httpsig import jwt_key, sign +from introspection_aauth.protocol.jwt import ( + AGENT_TOKEN, + RESOURCE_TOKEN, + decode, + encode, +) + +AP = "https://agents.example" +RESOURCE = "https://booking.example" +PS = "https://ps.example" +AS = "https://as.booking.example" +TENANT = "acme" +T0 = 1_791_000_000 +USD = {"unit": "USD", "decimals": 2} + + +def usd(amount: int) -> Budget: + return Budget(amount=amount, unit="USD", decimals=2) + + +class Net(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.ASGITransport] = {} + self.documents: dict[str, Any] = {} + self.calls: list[str] = [] + + async def handle_async_request( + self, request: httpx.Request + ) -> httpx.Response: + url = str(request.url) + self.calls.append(f"{request.method} {url}") + if url in self.documents: + document = self.documents[url] + if isinstance(document, bytes): + return httpx.Response(200, content=document) + return httpx.Response(200, json=document) + 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: Any = None) -> None: + self.now = T0 + self.net = Net() + self.http = httpx.AsyncClient(transport=self.net) + self.ap_key, self.resource_key, self.agent_key = ( + SigningKey.generate() for _ in range(3) + ) + self.net.documents |= { + f"{AP}/.well-known/aauth-agent.json": { + "issuer": AP, + "jwks_uri": f"{AP}/jwks.json", + }, + f"{AP}/jwks.json": jwks([self.ap_key]), + f"{RESOURCE}/.well-known/aauth-resource.json": { + "issuer": RESOURCE, + "jwks_uri": f"{RESOURCE}/jwks.json", + }, + f"{RESOURCE}/jwks.json": jwks([self.resource_key]), + } + self.ps_store = 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 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.net.apps[origin] = httpx.ASGITransport(app=app) + self.agent_token = encode( + { + "iss": AP, + "dwk": "aauth-agent.json", + "sub": "aauth:travel@agents.example", + "jti": "agent-1", + "iat": T0, + "exp": T0 + 3_600, + "cnf": {"jwk": self.agent_key.public_jwk}, + }, + self.ap_key, + AGENT_TOKEN, + ) + + async def call( + self, method: str, url: str, body: dict[str, Any] | None = None + ) -> httpx.Response: + """A request the agent signs (`sig=jwt`, its agent token).""" + content = json.dumps(body).encode() if body is not None else None + 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) -> dict[str, Any]: + proposed = await self.call( + "POST", + f"{PS}/aauth/mission", + { + "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 done.json() + + def resource_token( + self, mission: dict[str, Any], amount: int | None, aud: str = PS + ) -> tuple[str, str]: + """A resource token for a call costing `amount`, and the person token it answers.""" + person_token = mission["person_tokens"][RESOURCE] + presented = decode(person_token).claims + token = encode( + { + "iss": RESOURCE, + "dwk": "aauth-resource.json", + "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": {"amount": amount, **USD}} + if amount is not None + else {} + ), + }, + self.resource_key, + RESOURCE_TOKEN, + ) + return token, person_token + + async def ask( + self, mission: dict[str, Any], 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", + {"resource_token": token, "presented_token": person_token}, + ) + + async def spent(self, s256: str) -> int: + mission = await self.ps_store.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..36a2b5e --- /dev/null +++ b/packages/introspection-aauth/uv.lock @@ -0,0 +1,957 @@ +version = 1 +revision = 5 +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 = "pydantic" }, +] + +[package.optional-dependencies] +fastapi = [ + { name = "fastapi" }, +] +test = [ + { name = "aauth-signing" }, + { 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 = "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 = "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..50ea1b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -169,7 +169,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" + } + ] } } } From f67d1b3b16e2884e01da3511928f201d5be63b7e Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 13:25:16 -0700 Subject: [PATCH 02/34] fix(aauth): a mission names its approval and person, and federation retries then ends as_unreachable A mission blob now carries the approval's id and the person, so two alike approvals decided in the same second hash to two missions: before, the second got the first's s256 and acted under the first person's subject. A taken hash is refused (409) instead of reused. The default federation runner retries a request or poll that cannot connect, then records as_unreachable, so the pending URL answers instead of waiting for ever. Co-Authored-By: Claude Opus 5.5 --- .../person_server/federation.py | 26 +++++- .../person_server/server.py | 11 ++- .../tests/test_federation.py | 28 +++++++ .../tests/test_person_server.py | 82 ++++++++++++++++++- packages/introspection-aauth/tests/world.py | 5 ++ uv.lock | 14 +--- 6 files changed, 147 insertions(+), 19 deletions(-) diff --git a/packages/introspection-aauth/introspection_aauth/person_server/federation.py b/packages/introspection-aauth/introspection_aauth/person_server/federation.py index 1271887..79bce68 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/federation.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/federation.py @@ -14,6 +14,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from enum import StrEnum +from functools import partial from typing import TYPE_CHECKING, Any from urllib.parse import urljoin, urlsplit @@ -34,6 +35,8 @@ SERVED = frozenset({"approval", "interaction"}) #: Requirements draft -11 defines that this Person Server does not serve. NOT_SERVED = frozenset({"claims", "clarification"}) +#: Seconds to wait before each retry of a request that could not connect. +TRANSPORT_RETRY_DELAYS = (1.0, 2.0, 4.0) DEFAULT_DELAY, MIN_DELAY, MAX_DELAY = 5, 1, 300 @@ -484,10 +487,25 @@ async def run( federation, agent_jkt = found client = AccessServerClient(ps) metadata = await client.discover(federation.access_server) + + async def attempt(call: Callable[[], Awaitable[Outcome]]) -> Outcome: + """`call`, retried while the Access Server cannot be reached, then `as_unreachable`.""" + for delay in TRANSPORT_RETRY_DELAYS: + try: + return await call() + except httpx.TransportError: + await sleep(delay) + try: + return await call() + except httpx.TransportError as exc: + return unreachable(f"it could not be reached: {exc}") + outcome = ( metadata if isinstance(metadata, Outcome) - else await client.request(federation, agent_jkt, metadata) + else await attempt( + partial(client.request, federation, agent_jkt, metadata) + ) ) while True: waiting = await record(ps, tenant, approval_id, outcome) @@ -501,6 +519,8 @@ async def run( federation = federation.model_copy( update={"requirement": outcome.requirement} ) - outcome = await client.poll( - outcome.location, federation, agent_jkt, metadata + outcome = await attempt( + partial( + client.poll, outcome.location, federation, agent_jkt, metadata + ) ) diff --git a/packages/introspection-aauth/introspection_aauth/person_server/server.py b/packages/introspection-aauth/introspection_aauth/person_server/server.py index 2d25a16..837148a 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/server.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/server.py @@ -754,9 +754,13 @@ async def _carry_out( if approval.kind is ApprovalKind.MISSION: expires_at = now + self._mission_ttl resources = list(approval.grant.get("resources", [])) + # The approval and the person make the blob, and so its `s256`, the + # mission's own: two alike approvals in one second must not share it. blob = json.dumps( { "agent": approval.agent_id, + "approval": approval.id, + "person": approval.person, "approved_at": datetime.fromtimestamp( now, UTC ).isoformat(), @@ -778,7 +782,12 @@ async def _carry_out( budget=budget, expires_at=expires_at, ) - await self.store.insert_mission(tenant, mission) + if not await self.store.insert_mission(tenant, mission): + raise AAuthError( + 409, + "invalid_request", + "a mission with this hash exists already", + ) return mission.s256 if approval.mission_s256 is None: return None diff --git a/packages/introspection-aauth/tests/test_federation.py b/packages/introspection-aauth/tests/test_federation.py index 1826753..b103c65 100644 --- a/packages/introspection-aauth/tests/test_federation.py +++ b/packages/introspection-aauth/tests/test_federation.py @@ -154,3 +154,31 @@ async def test_sub_agents_are_not_federated(): ) assert refused.status_code == 400 assert refused.json()["error"] == "invalid_request" + + +async def token_endpoint(world: World) -> str: + metadata = await world.http.get(f"{AS}/.well-known/aauth-access.json") + return metadata.json()["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.net.failures[await token_endpoint(world)] = 100 + asked = await world.ask(mission, 60_000, aud=AS) + answered = await world.follow(asked) + assert answered.status_code == 502, answered.text + assert answered.json()["error"] == "as_unreachable" + assert await world.spent(mission["s256"]) == 0 + + +async def test_a_dropped_connection_is_retried(): + world = World() + inline(world) + mission = await world.approved_mission(budget=100_000) + world.net.failures[await token_endpoint(world)] = 1 + asked = await world.ask(mission, 60_000, aud=AS) + issued = await world.follow(asked) + assert issued.status_code == 200, issued.text + assert await world.spent(mission["s256"]) == 60_000 diff --git a/packages/introspection-aauth/tests/test_person_server.py b/packages/introspection-aauth/tests/test_person_server.py index c3a6ef6..8e51b65 100644 --- a/packages/introspection-aauth/tests/test_person_server.py +++ b/packages/introspection-aauth/tests/test_person_server.py @@ -6,10 +6,10 @@ import pytest from introspection_aauth.person_server import ApprovalStatus -from introspection_aauth.protocol import AAuthError -from introspection_aauth.protocol.jwt import decode +from introspection_aauth.protocol import AAuthError, SigningKey +from introspection_aauth.protocol.jwt import AGENT_TOKEN, decode, encode -from .world import PS, RESOURCE, TENANT, World +from .world import AP, PS, RESOURCE, T0, TENANT, World, usd def approval_id(response) -> str: @@ -164,3 +164,79 @@ async def test_a_key_acts_for_one_person(): assert same.status_code == 200 claims = decode(same.json()["person_token"]).claims 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( + { + "iss": AP, + "dwk": "aauth-agent.json", + "sub": "aauth:travel@agents.example", + "jti": "agent-2", + "iat": T0, + "exp": T0 + 3_600, + "cnf": {"jwk": world.agent_key.public_jwk}, + }, + world.ap_key, + AGENT_TOKEN, + ) + proposed = await world.call( + "POST", + f"{PS}/aauth/mission", + { + "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 = (await world.follow(proposed)).json() + assert kim["s256"] != sam["s256"] + sam_sub = decode(sam["person_tokens"][RESOURCE]).claims["sub"] + kim_sub = decode(kim["person_tokens"][RESOURCE]).claims["sub"] + assert kim_sub != sam_sub + stored = await world.ps_store.mission(TENANT, kim["s256"]) + assert stored is not None and stored.person == "kim" + + +async def test_a_mission_whose_hash_is_taken_is_refused_not_reused(): + world = World() + await world.approved_mission(budget=100_000) + proposed = await world.call( + "POST", + f"{PS}/aauth/mission", + { + "description": "Book Sydney", + "resources": [RESOURCE], + "login_hint": "sam", + }, + ) + taken = world.ps_store.insert_mission + + async def refuse(tenant, mission): + return False + + world.ps_store.insert_mission = refuse + with pytest.raises(AAuthError) as refused: + await world.ps.decide( + TENANT, + approval_id(proposed), + ApprovalStatus.APPROVED, + decided_by="dana", + budget=usd(100_000), + ) + world.ps_store.insert_mission = taken + assert refused.value.status == 409 + approval = await world.ps_store.approval(TENANT, approval_id(proposed)) + assert approval is not None and approval.status is ApprovalStatus.PENDING diff --git a/packages/introspection-aauth/tests/world.py b/packages/introspection-aauth/tests/world.py index 203de79..6db67ba 100644 --- a/packages/introspection-aauth/tests/world.py +++ b/packages/introspection-aauth/tests/world.py @@ -49,12 +49,17 @@ def __init__(self) -> None: self.apps: dict[str, httpx.ASGITransport] = {} self.documents: dict[str, Any] = {} 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): 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" }, From 96651ea94ab9892a7dd4cf8c12429de69e766660 Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 13:30:56 -0700 Subject: [PATCH 03/34] test(aauth): refuse a taken mission hash with a store, not a swapped method Co-Authored-By: Claude Opus 5.5 --- .../tests/test_person_server.py | 23 +++++++++++-------- packages/introspection-aauth/tests/world.py | 6 +++-- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/packages/introspection-aauth/tests/test_person_server.py b/packages/introspection-aauth/tests/test_person_server.py index 8e51b65..fb7057a 100644 --- a/packages/introspection-aauth/tests/test_person_server.py +++ b/packages/introspection-aauth/tests/test_person_server.py @@ -5,7 +5,11 @@ import pytest -from introspection_aauth.person_server import ApprovalStatus +from introspection_aauth.person_server import ( + ApprovalStatus, + MemoryStore, + Mission, +) from introspection_aauth.protocol import AAuthError, SigningKey from introspection_aauth.protocol.jwt import AGENT_TOKEN, decode, encode @@ -210,9 +214,15 @@ async def test_identical_missions_for_two_people_in_one_second_stay_apart(): 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() - await world.approved_mission(budget=100_000) + world = World(ps_store=TakenStore()) proposed = await world.call( "POST", f"{PS}/aauth/mission", @@ -222,12 +232,6 @@ async def test_a_mission_whose_hash_is_taken_is_refused_not_reused(): "login_hint": "sam", }, ) - taken = world.ps_store.insert_mission - - async def refuse(tenant, mission): - return False - - world.ps_store.insert_mission = refuse with pytest.raises(AAuthError) as refused: await world.ps.decide( TENANT, @@ -236,7 +240,6 @@ async def refuse(tenant, mission): decided_by="dana", budget=usd(100_000), ) - world.ps_store.insert_mission = taken assert refused.value.status == 409 approval = await world.ps_store.approval(TENANT, approval_id(proposed)) assert approval is not None and approval.status is ApprovalStatus.PENDING diff --git a/packages/introspection-aauth/tests/world.py b/packages/introspection-aauth/tests/world.py index 6db67ba..60bf7e9 100644 --- a/packages/introspection-aauth/tests/world.py +++ b/packages/introspection-aauth/tests/world.py @@ -72,7 +72,9 @@ async def handle_async_request( class World: - def __init__(self, policy: Any = None) -> None: + def __init__( + self, policy: Any = None, ps_store: MemoryStore | None = None + ) -> None: self.now = T0 self.net = Net() self.http = httpx.AsyncClient(transport=self.net) @@ -91,7 +93,7 @@ def __init__(self, policy: Any = None) -> None: }, f"{RESOURCE}/jwks.json": jwks([self.resource_key]), } - self.ps_store = MemoryStore() + self.ps_store = ps_store or MemoryStore() self.ps = PersonServer( PS, SigningKey.generate(), From a8b0c0bce68e29339986db0b6f8919756855413e Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 13:38:49 -0700 Subject: [PATCH 04/34] feat(aauth): the FastAPI routers take a router of the host's, for its tags, dependencies and schema Co-Authored-By: Claude Opus 5.5 --- packages/introspection-aauth/README.md | 6 +- .../introspection_aauth/fastapi.py | 134 +++++++++++++++--- .../introspection-aauth/tests/test_fastapi.py | 57 ++++++++ 3 files changed, 174 insertions(+), 23 deletions(-) create mode 100644 packages/introspection-aauth/tests/test_fastapi.py diff --git a/packages/introspection-aauth/README.md b/packages/introspection-aauth/README.md index 286bbac..ccfcdb6 100644 --- a/packages/introspection-aauth/README.md +++ b/packages/introspection-aauth/README.md @@ -18,7 +18,7 @@ pip install 'introspection-aauth[fastapi]' # and FastAPI routers for them | `introspection_aauth.protocol` | What both servers share, written once: `AAuthError` and its 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 `federation`: the client for a resource's Access Server and the loop around it. | | `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` (the `fastapi` extra). | +| `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. @@ -56,6 +56,10 @@ person_server = PersonServer( 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, diff --git a/packages/introspection-aauth/introspection_aauth/fastapi.py b/packages/introspection-aauth/introspection_aauth/fastapi.py index bcecfd3..0093f9e 100644 --- a/packages/introspection-aauth/introspection_aauth/fastapi.py +++ b/packages/introspection-aauth/introspection_aauth/fastapi.py @@ -1,8 +1,13 @@ """FastAPI routers for either server (`pip install introspection-aauth[fastapi]`). -Each router serves its server's well-known documents at the root and its -protocol routes under the path of its `endpoint_base`. A request is verified -as its signer addressed it, at `endpoint_base`, whatever proxy delivered it. +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 request is verified as its signer addressed it, at +`endpoint_base`, whatever proxy delivered it. """ from collections.abc import Awaitable, Callable @@ -38,38 +43,92 @@ async def _serve(request: Request, base: str, handle: Handler) -> JSONResponse: return _respond(exc.answer()) -def person_server_router(ps: PersonServer) -> APIRouter: - router = APIRouter() - base = ps.endpoint_base - prefix = urlsplit(base).path +def _under(router: APIRouter, base: str) -> str: + """The endpoint base's path, less the router's prefix.""" + path = urlsplit(base).path + 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) + - @router.get("/.well-known/aauth-person.json") +def person_server_well_known_router( + ps: PersonServer, router: APIRouter | None = None +) -> APIRouter: + """The Person Server's metadata and keys, to mount at the root.""" + router = APIRouter() if router is None else router + + @router.get( + "/.well-known/aauth-person.json", + summary="Person Server metadata", + operation_id="aauth_person_server_metadata", + ) async def metadata() -> JSONResponse: return JSONResponse(ps.metadata()) - @router.get("/.well-known/aauth-person-jwks.json") + @router.get( + "/.well-known/aauth-person-jwks.json", + summary="Person Server signing keys", + operation_id="aauth_person_server_jwks", + ) async def jwks() -> JSONResponse: return JSONResponse(ps.jwks()) - @router.post(f"{prefix}/mission") + return router + + +def person_server_router( + ps: PersonServer, router: APIRouter | None = None +) -> APIRouter: + """The Person Server's protocol routes, and its well-known documents + when `router` has no prefix.""" + router = APIRouter() if router is None else router + base = ps.endpoint_base + prefix = _under(router, base) + if not router.prefix: + person_server_well_known_router(ps, router) + + @router.post( + f"{prefix}/mission", + summary="Propose a mission", + operation_id="aauth_person_server_propose_mission", + ) async def mission(request: Request) -> JSONResponse: return await _serve(request, base, ps.propose_mission) - @router.post(prefix + "/mission/{s256}") + @router.post( + prefix + "/mission/{s256}", + summary="Act on a mission", + operation_id="aauth_person_server_mission_action", + ) async def mission_action(request: Request, s256: str) -> JSONResponse: return await _serve( request, base, lambda r: ps.mission_action(r, s256) ) - @router.post(f"{prefix}/person-token") + @router.post( + f"{prefix}/person-token", + summary="Request a person token", + operation_id="aauth_person_server_person_token", + ) async def person_token(request: Request) -> JSONResponse: return await _serve(request, base, ps.person_token) - @router.post(f"{prefix}/token") + @router.post( + f"{prefix}/token", + summary="Request an auth token", + operation_id="aauth_person_server_auth_token", + ) async def auth_token(request: Request) -> JSONResponse: return await _serve(request, base, ps.auth_token) - @router.get(prefix + "/pending/{tenant}/{approval_id}") + @router.get( + prefix + "/pending/{tenant}/{approval_id}", + summary="Poll a pending request", + operation_id="aauth_person_server_poll", + ) async def poll( request: Request, tenant: str, approval_id: str ) -> JSONResponse: @@ -80,24 +139,55 @@ async def poll( return router -def access_server_router(server: AccessServer) -> APIRouter: - router = APIRouter() - base = server.endpoint_base - prefix = urlsplit(base).path +def access_server_well_known_router( + server: AccessServer, router: APIRouter | None = None +) -> APIRouter: + """The Access Server's metadata and keys, to mount at the root.""" + router = APIRouter() if router is None else router - @router.get("/.well-known/aauth-access.json") + @router.get( + "/.well-known/aauth-access.json", + summary="Access Server metadata", + operation_id="aauth_access_server_metadata", + ) async def metadata() -> JSONResponse: return JSONResponse(server.metadata()) - @router.get("/.well-known/aauth-access-jwks.json") + @router.get( + "/.well-known/aauth-access-jwks.json", + summary="Access Server signing keys", + operation_id="aauth_access_server_jwks", + ) async def jwks() -> JSONResponse: return JSONResponse(server.jwks()) - @router.post(f"{prefix}/token") + return router + + +def access_server_router( + server: AccessServer, router: APIRouter | None = None +) -> APIRouter: + """The Access Server's protocol routes, and its well-known documents + when `router` has no prefix.""" + router = APIRouter() if router is None else router + base = server.endpoint_base + prefix = _under(router, base) + if not router.prefix: + access_server_well_known_router(server, router) + + @router.post( + f"{prefix}/token", + summary="Request an auth token, as a Person Server", + operation_id="aauth_access_server_token", + ) async def token(request: Request) -> JSONResponse: return await _serve(request, base, server.token) - @router.get(prefix + "/pending/{pending_id}") + @router.get( + prefix + "/pending/{pending_id}", + summary="Poll a pending request, as the Person Server that asked", + operation_id="aauth_access_server_poll", + ) async def poll(request: Request, pending_id: str) -> JSONResponse: return await _serve( request, base, lambda r: server.poll(r, pending_id) diff --git a/packages/introspection-aauth/tests/test_fastapi.py b/packages/introspection-aauth/tests/test_fastapi.py new file mode 100644 index 0000000..aa16404 --- /dev/null +++ b/packages/introspection-aauth/tests/test_fastapi.py @@ -0,0 +1,57 @@ +import pytest +from fastapi import APIRouter, FastAPI + +from introspection_aauth.fastapi import ( + access_server_router, + person_server_router, + person_server_well_known_router, +) + +from .world import World + + +def paths(app: FastAPI) -> dict[str, dict]: + 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/{tenant}/{approval_id}"]["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) == {} From 20e52888d7341212a41e97076972a21ae5a9ebf2 Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 13:43:08 -0700 Subject: [PATCH 05/34] feat(aauth): routers resolve their server per request, read pending paths back, document themselves, and cache well-known documents; Clock and IdFactory protocols Co-Authored-By: Claude Opus 5.5 --- .../introspection_aauth/fastapi.py | 253 ++++++++++++++---- .../person_server/server.py | 11 +- .../introspection_aauth/protocol/__init__.py | 11 +- .../introspection_aauth/protocol/server.py | 28 +- .../introspection-aauth/tests/test_fastapi.py | 82 +++++- 5 files changed, 329 insertions(+), 56 deletions(-) diff --git a/packages/introspection-aauth/introspection_aauth/fastapi.py b/packages/introspection-aauth/introspection_aauth/fastapi.py index 0093f9e..7d9c44e 100644 --- a/packages/introspection-aauth/introspection_aauth/fastapi.py +++ b/packages/introspection-aauth/introspection_aauth/fastapi.py @@ -6,22 +6,78 @@ `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 request is verified as its signer addressed it, at -`endpoint_base`, whatever proxy delivered it. +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 collections.abc import Awaitable, Callable +from collections.abc import Awaitable +from http import HTTPStatus +from typing import Protocol from urllib.parse import urlsplit -from fastapi import APIRouter, Request +from fastapi import APIRouter, HTTPException, Request from fastapi.responses import JSONResponse +from pydantic import BaseModel +from .access_server.models import TokenRequest from .access_server.server import AccessServer +from .person_server.models import ( + AuthTokenRequest, + MissionAction, + MissionProposal, + PersonTokenRequest, +) from .person_server.server import PersonServer from .protocol.errors import AAuthError, Answer from .protocol.httpsig import SignedRequest -Handler = Callable[[SignedRequest], Awaitable[Answer]] + +class PersonServerResolver(Protocol): + """The Person Server that serves `request`, or None when there is none.""" + + def __call__(self, request: Request) -> PersonServer | None: ... + + +class AccessServerResolver(Protocol): + """The Access Server that serves `request`, or None when there is none.""" + + def __call__(self, request: Request) -> AccessServer | None: ... + + +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." +) +PENDING_RESPONSE: dict[int | str, dict[str, object]] = { + HTTPStatus.ACCEPTED.value: { + "description": "Put to whoever decides; poll the `Location` it names" + } +} + + +def _body(model: type[BaseModel]) -> dict[str, object]: + """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 _respond(answer: Answer) -> JSONResponse: @@ -43,9 +99,8 @@ async def _serve(request: Request, base: str, handle: Handler) -> JSONResponse: return _respond(exc.answer()) -def _under(router: APIRouter, base: str) -> str: +def _under(router: APIRouter, path: str) -> str: """The endpoint base's path, less the router's prefix.""" - path = urlsplit(base).path if not path.startswith(router.prefix): raise ValueError( f"the router's prefix {router.prefix!r} is not the start of " @@ -54,143 +109,251 @@ def _under(router: APIRouter, base: str) -> str: return path.removeprefix(router.prefix) +def _cached(response: JSONResponse, cache_control: str | None) -> JSONResponse: + if cache_control: + response.headers["Cache-Control"] = cache_control + return response + + +def _person(source: "PersonServer | PersonServerResolver"): + def find(request: Request) -> PersonServer: + server = ( + source if isinstance(source, PersonServer) else source(request) + ) + if server is None: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND) + return server + + return find + + +def _access(source: "AccessServer | AccessServerResolver"): + def find(request: Request) -> AccessServer: + server = ( + source if isinstance(source, AccessServer) else source(request) + ) + if server is None: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND) + return server + + return find + + +def _endpoint_path( + source: PersonServer | AccessServer | object, endpoint_path: str | None +) -> str: + if isinstance(source, (PersonServer, AccessServer)): + 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("/") + + def person_server_well_known_router( - ps: PersonServer, router: APIRouter | None = None + 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.""" router = APIRouter() if router is None else router + find = _person(ps) @router.get( "/.well-known/aauth-person.json", summary="Person Server metadata", + description="Its AAuth discovery document: issuer, keys, endpoints.", operation_id="aauth_person_server_metadata", ) - async def metadata() -> JSONResponse: - return JSONResponse(ps.metadata()) + async def metadata(request: Request) -> JSONResponse: + return _cached(JSONResponse(find(request).metadata()), cache_control) @router.get( "/.well-known/aauth-person-jwks.json", summary="Person Server signing keys", operation_id="aauth_person_server_jwks", ) - async def jwks() -> JSONResponse: - return JSONResponse(ps.jwks()) + async def jwks(request: Request) -> JSONResponse: + return _cached(JSONResponse(find(request).jwks()), cache_control) return router def person_server_router( - ps: PersonServer, router: APIRouter | None = None + 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.""" + 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 - base = ps.endpoint_base - prefix = _under(router, base) + prefix = _under(router, _endpoint_path(ps, endpoint_path)) + find = _person(ps) if not router.prefix: person_server_well_known_router(ps, router) @router.post( f"{prefix}/mission", summary="Propose a mission", + description=f"Always put to an approver, who may set its budget. {SIGNED}", operation_id="aauth_person_server_propose_mission", + openapi_extra=_body(MissionProposal), + responses=PENDING_RESPONSE, ) async def mission(request: Request) -> JSONResponse: - return await _serve(request, base, ps.propose_mission) + server = find(request) + return await _serve( + request, server.endpoint_base, server.propose_mission + ) @router.post( prefix + "/mission/{s256}", - summary="Act on a mission", + 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", + openapi_extra=_body(MissionAction), + responses=PENDING_RESPONSE, ) async def mission_action(request: Request, s256: str) -> JSONResponse: - return await _serve( - request, base, lambda r: ps.mission_action(r, s256) - ) + server = find(request) + + async def act(signed: SignedRequest) -> Answer: + return await server.mission_action(signed, s256) + + return await _serve(request, server.endpoint_base, act) @router.post( f"{prefix}/person-token", - summary="Request a person token", + 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", + openapi_extra=_body(PersonTokenRequest), ) async def person_token(request: Request) -> JSONResponse: - return await _serve(request, base, ps.person_token) + server = find(request) + return await _serve(request, server.endpoint_base, server.person_token) @router.post( f"{prefix}/token", - summary="Request an auth token", + 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", + openapi_extra=_body(AuthTokenRequest), + responses=PENDING_RESPONSE, ) async def auth_token(request: Request) -> JSONResponse: - return await _serve(request, base, ps.auth_token) + server = find(request) + return await _serve(request, server.endpoint_base, server.auth_token) @router.get( - prefix + "/pending/{tenant}/{approval_id}", + prefix + "/pending/{pending_path:path}", 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", + responses=PENDING_RESPONSE, ) - async def poll( - request: Request, tenant: str, approval_id: str - ) -> JSONResponse: - return await _serve( - request, base, lambda r: ps.poll(r, tenant, approval_id) - ) + async def poll(request: Request, pending_path: str) -> JSONResponse: + server = find(request) + + async def read(signed: SignedRequest) -> Answer: + parts = server.pending_parts(pending_path) + if parts is None: + raise AAuthError(404, "not_found", "no such pending request") + return await server.poll(signed, *parts) + + return await _serve(request, server.endpoint_base, read) return router def access_server_well_known_router( - server: AccessServer, router: APIRouter | None = None + 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.""" router = APIRouter() if router is None else router + find = _access(server) @router.get( "/.well-known/aauth-access.json", summary="Access Server metadata", + description="Its AAuth discovery document: issuer, keys, token endpoint.", operation_id="aauth_access_server_metadata", ) - async def metadata() -> JSONResponse: - return JSONResponse(server.metadata()) + async def metadata(request: Request) -> JSONResponse: + return _cached(JSONResponse(find(request).metadata()), cache_control) @router.get( "/.well-known/aauth-access-jwks.json", summary="Access Server signing keys", operation_id="aauth_access_server_jwks", ) - async def jwks() -> JSONResponse: - return JSONResponse(server.jwks()) + async def jwks(request: Request) -> JSONResponse: + return _cached(JSONResponse(find(request).jwks()), cache_control) return router def access_server_router( - server: AccessServer, router: APIRouter | None = None + server: AccessServer | AccessServerResolver, + router: APIRouter | None = None, + *, + endpoint_path: str | None = None, ) -> APIRouter: """The Access Server's protocol routes, and its well-known documents - when `router` has no prefix.""" + 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 - base = server.endpoint_base - prefix = _under(router, base) + prefix = _under(router, _endpoint_path(server, endpoint_path)) + find = _access(server) if not router.prefix: access_server_well_known_router(server, router) @router.post( f"{prefix}/token", 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", + openapi_extra=_body(TokenRequest), + responses=PENDING_RESPONSE, ) async def token(request: Request) -> JSONResponse: - return await _serve(request, base, server.token) + found = find(request) + return await _serve(request, found.endpoint_base, found.token) @router.get( prefix + "/pending/{pending_id}", summary="Poll a pending request, as the Person Server that asked", operation_id="aauth_access_server_poll", + responses=PENDING_RESPONSE, ) async def poll(request: Request, pending_id: str) -> JSONResponse: - return await _serve( - request, base, lambda r: server.poll(r, pending_id) - ) + found = find(request) + + async def read(signed: SignedRequest) -> Answer: + return await found.poll(signed, pending_id) + + return await _serve(request, found.endpoint_base, read) return router diff --git a/packages/introspection-aauth/introspection_aauth/person_server/server.py b/packages/introspection-aauth/introspection_aauth/person_server/server.py index 837148a..5cb19b7 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/server.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/server.py @@ -11,7 +11,7 @@ from collections.abc import Awaitable, Callable, Sequence from datetime import UTC, datetime from typing import Any, Unpack -from urllib.parse import quote +from urllib.parse import quote, unquote import httpx2 as httpx @@ -127,6 +127,15 @@ def metadata(self) -> dict[str, Any]: 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 diff --git a/packages/introspection-aauth/introspection_aauth/protocol/__init__.py b/packages/introspection-aauth/introspection_aauth/protocol/__init__.py index b95ea43..d8f618d 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/__init__.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/__init__.py @@ -6,14 +6,23 @@ from .httpsig import SignedRequest, Verified from .keys import SigningKey, jwks, s256, thumbprint from .models import Agent, Budget -from .server import Identity, pending, verify_agent, verify_server +from .server import ( + Clock, + Identity, + IdFactory, + pending, + verify_agent, + verify_server, +) __all__ = [ "AAuthError", "Agent", "Answer", "Budget", + "Clock", "Discovery", + "IdFactory", "Identity", "SignedRequest", "SigningKey", diff --git a/packages/introspection-aauth/introspection_aauth/protocol/server.py b/packages/introspection-aauth/introspection_aauth/protocol/server.py index 7c6f38a..8ea4425 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/server.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/server.py @@ -4,8 +4,8 @@ import time import uuid -from collections.abc import Callable, Sequence -from typing import Any, NotRequired, TypedDict, TypeVar, Unpack +from collections.abc import Sequence +from typing import Any, NotRequired, Protocol, TypedDict, TypeVar, Unpack from urllib.parse import urlsplit import httpx2 as httpx @@ -119,6 +119,22 @@ async def read_proposal( return document if isinstance(document, dict) else {} +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.""" @@ -127,8 +143,8 @@ class ServerOptions(TypedDict): #: The URL its protocol routes are under, e.g. `{issuer}/aauth`. endpoint_base: NotRequired[str] retry_after: NotRequired[int] - clock: NotRequired[Callable[[], int]] - new_id: NotRequired[Callable[[], str]] + clock: NotRequired[Clock] + new_id: NotRequired[IdFactory] name: NotRequired[str] @@ -153,8 +169,8 @@ def __init__( "/" ) self.retry_after = options.get("retry_after", 5) - self.clock = options.get("clock", now_seconds) - self.new_id = options.get("new_id", lambda: uuid.uuid4().hex) + 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) -> dict[str, Any]: diff --git a/packages/introspection-aauth/tests/test_fastapi.py b/packages/introspection-aauth/tests/test_fastapi.py index aa16404..07d07e2 100644 --- a/packages/introspection-aauth/tests/test_fastapi.py +++ b/packages/introspection-aauth/tests/test_fastapi.py @@ -1,11 +1,13 @@ 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 .world import World @@ -35,9 +37,7 @@ def test_a_host_router_carries_its_tags_and_prefix(): app.include_router(person_server_well_known_router(ps)) served = paths(app) assert served["/aauth/token"]["post"]["tags"] == ["AAuth"] - assert served["/aauth/pending/{tenant}/{approval_id}"]["get"]["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 @@ -55,3 +55,79 @@ def test_a_router_can_leave_the_access_server_out_of_the_schema(): ) ) 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) From 936a82b324421bd726ded8a0805f166b752f2aff Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 13:44:54 -0700 Subject: [PATCH 06/34] feat(aauth): a store transaction yields the unit of work, and decide takes the host's Co-Authored-By: Claude Opus 5.5 --- .../person_server/federation.py | 20 +-- .../person_server/server.py | 157 +++++++++++------- .../person_server/store.py | 17 +- .../tests/test_person_server.py | 52 ++++++ 4 files changed, 165 insertions(+), 81 deletions(-) diff --git a/packages/introspection-aauth/introspection_aauth/person_server/federation.py b/packages/introspection-aauth/introspection_aauth/person_server/federation.py index 79bce68..5734676 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/federation.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/federation.py @@ -28,6 +28,7 @@ from ..protocol.models import Budget from ..protocol.server import ACCESS_DWK from .models import Approval, ApprovalStatus, Federation, Refusal +from .store import Store if TYPE_CHECKING: from .server import PersonServer @@ -365,8 +366,8 @@ async def record( async def _record( ps: "PersonServer", tenant: str, approval_id: str, outcome: Outcome ) -> bool: - async with ps.store.transaction(): - approval = await ps.store.approval(tenant, approval_id) + async with ps.store.transaction() as tx: + approval = await tx.approval(tenant, approval_id) federation = approval.federation if approval else None if ( approval is None @@ -384,7 +385,7 @@ async def _record( update={"requirement": outcome.requirement} ) elif outcome.step is Step.ISSUED: - refusal = await _spend(ps, tenant, approval, outcome, now) + refusal = await _spend(tx, tenant, approval, outcome, now) if refusal is None: federation = federation.model_copy( update={ @@ -409,11 +410,8 @@ async def _record( "version": approval.version + 1, } ) - if ( - approval.expires_at <= now - or not await ps.store.transition_approval( - tenant, approval, changed - ) + if approval.expires_at <= now or not await tx.transition_approval( + tenant, approval, changed ): raise _Stale return outcome.step is Step.PENDING @@ -424,7 +422,7 @@ class _Stale(Exception): async def _spend( - ps: "PersonServer", + tx: Store, tenant: str, approval: Approval, outcome: Outcome, @@ -439,7 +437,7 @@ async def _spend( return Refusal( status=404, error="mission_not_found", detail="no such mission" ) - mission = await ps.store.mission(tenant, mission_s256) + mission = await tx.mission(tenant, mission_s256) granted = outcome.granted spend = ( granted.amount @@ -449,7 +447,7 @@ async def _spend( and mission.budget.same_scale(granted) else None ) - if await ps.store.spend( + if await tx.spend( tenant, mission_s256, spend, diff --git a/packages/introspection-aauth/introspection_aauth/person_server/server.py b/packages/introspection-aauth/introspection_aauth/person_server/server.py index 5cb19b7..15d1356 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/server.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/server.py @@ -694,71 +694,106 @@ async def decide( *, 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.""" + 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 AAuthError( 422, "invalid_request", "status is approved or denied" ) - async with self.store.transaction(): - approval = await self.store.approval(tenant, approval_id) - if approval is None: - return None - if ( - approval.kind is not ApprovalKind.MISSION - and budget is not None - ): - raise AAuthError( - 422, "invalid_request", "only a mission takes a budget" - ) - if ( - approval.kind is ApprovalKind.COMPLETION - and decided_by != approval.person - ): - raise AAuthError( - 403, - "access_denied", - "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 AAuthError( - 409, - "invalid_request", - "this approval is already decided or has expired", - ) - mission_s256 = approval.mission_s256 - if status is ApprovalStatus.APPROVED: - mission_s256 = await self._carry_out( - 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 self.store.transition_approval( - tenant, approval, decided - ): - raise AAuthError( - 409, - "invalid_request", - "this approval is already decided or has expired", - ) - await self.decided(tenant, decided) + 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 _decide( + self, + tx: Store, + tenant: str, + approval_id: str, + status: ApprovalStatus, + *, + decided_by: str, + budget: Budget | None, + ) -> Approval | None: + approval = await tx.approval(tenant, approval_id) + if approval is None: + return None + if approval.kind is not ApprovalKind.MISSION and budget is not None: + raise AAuthError( + 422, "invalid_request", "only a mission takes a budget" + ) + if ( + approval.kind is ApprovalKind.COMPLETION + and decided_by != approval.person + ): + raise AAuthError( + 403, + "access_denied", + "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 AAuthError( + 409, + "invalid_request", + "this approval is already decided or has expired", + ) + 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 AAuthError( + 409, + "invalid_request", + "this approval is already decided or has expired", + ) return decided async def _carry_out( - self, tenant: str, approval: Approval, budget: Budget | None, now: int + 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 @@ -791,7 +826,7 @@ async def _carry_out( budget=budget, expires_at=expires_at, ) - if not await self.store.insert_mission(tenant, mission): + if not await tx.insert_mission(tenant, mission): raise AAuthError( 409, "invalid_request", @@ -800,11 +835,9 @@ async def _carry_out( return mission.s256 if approval.mission_s256 is None: return None - current = await self.store.mission(tenant, approval.mission_s256) + current = await tx.mission(tenant, approval.mission_s256) if approval.kind is ApprovalKind.COMPLETION: - ended = await self.store.end_mission( - tenant, approval.mission_s256, now - ) + ended = await tx.end_mission(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. @@ -817,7 +850,7 @@ async def _carry_out( and current.budget.same_scale(approval.budget) else None ) - ended = await self.store.spend( + ended = await tx.spend( tenant, approval.mission_s256, spend, False, now ) if ended is None: diff --git a/packages/introspection-aauth/introspection_aauth/person_server/store.py b/packages/introspection-aauth/introspection_aauth/person_server/store.py index 8803b45..414524d 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/store.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/store.py @@ -14,10 +14,11 @@ class Store(Protocol): """A Person Server's state, in logical operations, per tenant. - `transaction()` groups writes that commit together: a decision that - creates or spends a mission with the approval it decides, and a federated - auth token with the spend it pays for. Anything raised inside it rolls - every write in it back. + `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 binding(self, tenant: str, jkt: str) -> str | None: @@ -74,7 +75,7 @@ async def transition_approval( `current`'s status and version; whether it did.""" ... - def transaction(self) -> AbstractAsyncContextManager[None]: ... + def transaction(self) -> AbstractAsyncContextManager["Store"]: ... class Directory(Protocol): @@ -175,17 +176,17 @@ async def transition_approval( self.approvals, (tenant, current.id), current, changed ) - def transaction(self) -> AbstractAsyncContextManager[None]: + def transaction(self) -> AbstractAsyncContextManager[Store]: return self._transaction() @asynccontextmanager - async def _transaction(self) -> AsyncGenerator[None, None]: + async def _transaction(self) -> AsyncGenerator[Store, None]: async with self._lock: saved = copy.deepcopy( (self.bindings, self.missions, self.approvals) ) try: - yield + yield self except BaseException: self.bindings, self.missions, self.approvals = saved raise diff --git a/packages/introspection-aauth/tests/test_person_server.py b/packages/introspection-aauth/tests/test_person_server.py index fb7057a..f94a193 100644 --- a/packages/introspection-aauth/tests/test_person_server.py +++ b/packages/introspection-aauth/tests/test_person_server.py @@ -243,3 +243,55 @@ async def test_a_mission_whose_hash_is_taken_is_refused_not_reused(): assert refused.value.status == 409 approval = await world.ps_store.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", + { + "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.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.mission(TENANT, decided.mission_s256) is None + approval = await world.ps_store.approval(TENANT, pending) + assert approval is not None and approval.status is ApprovalStatus.PENDING From b0feada749ad75c67d9361fe712ce47d391580a5 Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 14:02:38 -0700 Subject: [PATCH 07/34] refactor(aauth): every wire body and token is a pydantic model, with typed claims and named expectation failures Bodies the library sends or parses are models in protocol.wire, serialised with members that are None left out. Each token type's claims are a model in protocol.claims that checks what the token must be on its own (exp after iat, lifetime capped, cnf a valid public key); an expectation names every check a verified token fails. Grants are AuthGrant models, built explicitly into auth token claims. The FastAPI routers declare their response models and problem responses, share one well-known and one signed-route helper, and no module uses typing.Any. Co-Authored-By: Claude Opus 5.5 --- .../access_server/models.py | 40 +- .../access_server/server.py | 223 +++++------ .../introspection_aauth/fastapi.py | 366 +++++++++++------- .../person_server/federation.py | 164 +++----- .../person_server/models.py | 55 ++- .../person_server/server.py | 326 ++++++++-------- .../introspection_aauth/protocol/__init__.py | 37 +- .../introspection_aauth/protocol/claims.py | 299 ++++++++++++++ .../introspection_aauth/protocol/discovery.py | 55 ++- .../introspection_aauth/protocol/errors.py | 26 +- .../introspection_aauth/protocol/httpsig.py | 67 +++- .../introspection_aauth/protocol/json.py | 8 + .../introspection_aauth/protocol/jwt.py | 123 ++++-- .../introspection_aauth/protocol/keys.py | 82 ++-- .../introspection_aauth/protocol/memory.py | 9 +- .../introspection_aauth/protocol/models.py | 6 +- .../introspection_aauth/protocol/server.py | 113 ++++-- .../introspection_aauth/protocol/wire.py | 108 ++++++ .../tests/test_federation.py | 77 ++-- .../tests/test_person_server.py | 109 ++++-- .../tests/test_protocol.py | 97 +++-- .../tests/test_refusals.py | 90 +++-- packages/introspection-aauth/tests/world.py | 160 +++++--- 23 files changed, 1737 insertions(+), 903 deletions(-) create mode 100644 packages/introspection-aauth/introspection_aauth/protocol/claims.py create mode 100644 packages/introspection-aauth/introspection_aauth/protocol/json.py create mode 100644 packages/introspection-aauth/introspection_aauth/protocol/wire.py diff --git a/packages/introspection-aauth/introspection_aauth/access_server/models.py b/packages/introspection-aauth/introspection_aauth/access_server/models.py index dc00d99..3332287 100644 --- a/packages/introspection-aauth/introspection_aauth/access_server/models.py +++ b/packages/introspection-aauth/introspection_aauth/access_server/models.py @@ -1,13 +1,30 @@ """What an Access Server decides on, and what it keeps while it waits.""" from enum import StrEnum -from typing import Any, Literal +from typing import Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict +from ..protocol.claims import ( + AgentTokenClaims, + AuthGrant, + PersonTokenClaims, + ResourceTokenClaims, +) +from ..protocol.keys import Jwk from ..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.""" @@ -30,7 +47,7 @@ class AccessRequest(BaseModel): agent_provider: str agent_jkt: str #: The verified claims of each token, for anything else a policy reads. - claims: dict[str, dict[str, Any]] = Field(default_factory=dict) + claims: VerifiedTokens | None = None class Verdict(BaseModel): @@ -81,10 +98,10 @@ class PendingAccess(BaseModel): version: int = 0 verdict: Verdict request: AccessRequest - #: The claims the auth token is issued with, once approved. - grant: dict[str, Any] + #: What the auth token grants, once approved, before the policy narrows it. + grant: AuthGrant #: The agent's key the auth token binds. - jwk: dict[str, Any] + jwk: Jwk #: The latest the auth token may expire: its agent and presented tokens' `exp`. not_after: int scope: str | None = None @@ -92,14 +109,3 @@ class PendingAccess(BaseModel): decided_by: str | None = None created_at: int expires_at: int - - -class TokenRequest(BaseModel): - model_config = ConfigDict(extra="ignore") - - resource_token: str - agent_token: str - presented_token: str - budget: Budget | None = None - upstream_token: str | None = None - subagent_token: str | None = None diff --git a/packages/introspection-aauth/introspection_aauth/access_server/server.py b/packages/introspection-aauth/introspection_aauth/access_server/server.py index 9d830b2..fd42df9 100644 --- a/packages/introspection-aauth/introspection_aauth/access_server/server.py +++ b/packages/introspection-aauth/introspection_aauth/access_server/server.py @@ -5,38 +5,53 @@ token here, and this server issues it or makes the Person Server wait.""" from collections.abc import Awaitable, Callable, Sequence -from typing import Any, Unpack +from typing import Protocol, Unpack import httpx2 as httpx from ..protocol import jwt +from ..protocol.claims import ( + ACCESS_DWK, + PERSON_DWK, + AgentExpectation, + AgentTokenClaims, + AuthGrant, + AuthTokenClaims, + PersonTokenClaims, + PresentedExpectation, + ResourceTokenClaims, + ResourceTokenExpectation, +) from ..protocol.errors import AAuthError, Answer, VerificationError from ..protocol.httpsig import SignedRequest -from ..protocol.keys import SigningKey, thumbprint +from ..protocol.keys import SigningKey from ..protocol.models import Budget from ..protocol.server import ( - ACCESS_DWK, - PERSON_DWK, - RESOURCE_DWK, Server, ServerOptions, parse, pending, verify_agent_token, + verify_resource_token, verify_server, ) +from ..protocol.wire import AccessServerMetadata, AuthTokenAnswer, TokenRequest from .models import ( AccessRequest, PendingAccess, PendingStatus, - TokenRequest, Verdict, + VerifiedTokens, ) from .store import MemoryStore, Store Policy = Callable[[AccessRequest], Verdict | Awaitable[Verdict]] -#: Resource token claims an auth token carries over as they are. -CARRIED = ("account", "mission_s256", "tenant", "r3_uri", "r3_s256") + + +class PendingHook(Protocol): + """Told of each request the policy waits on.""" + + def __call__(self, pending: PendingAccess, /) -> Awaitable[None]: ... def _bad(token: str, detail: str) -> AAuthError: @@ -88,7 +103,7 @@ def __init__( store: Store | None = None, token_ttl: int = jwt.MAX_TOKEN_LIFETIME_SECONDS, pending_ttl: int = 86_400, - on_pending: Callable[[PendingAccess], Awaitable[None]] | None = None, + on_pending: PendingHook | None = None, **options: Unpack[ServerOptions], ): super().__init__(issuer, ACCESS_DWK, signing_key, http, **options) @@ -101,23 +116,26 @@ def __init__( self._pending_ttl = pending_ttl self._on_pending = on_pending - def metadata(self) -> dict[str, Any]: - return { - "issuer": self.issuer, - **({"name": self.name} if self.name else {}), - "jwks_uri": f"{self.issuer}/.well-known/aauth-access-jwks.json", - "auth_token_endpoint": f"{self.endpoint_base}/token", - } + 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}" # region verification - async def _resource_token(self, token: str, ps: str) -> dict[str, Any]: + async def _resource_token( + self, token: str, ps: str + ) -> ResourceTokenClaims: try: - resource = str(jwt.decode(token).claims.get("iss", "")).rstrip("/") + _, unverified = jwt.peek(token) except VerificationError: raise _bad("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 _bad( @@ -125,35 +143,23 @@ async def _resource_token(self, token: str, ps: str) -> dict[str, Any]: f"this Access Server does not decide for {resource}", ) try: - rt = await self.discovery.verified( - resource, - RESOURCE_DWK, - lambda keys: jwt.verify( - token, keys, jwt.RESOURCE_TOKEN, self.clock() - ), + rt = await verify_resource_token( + token, self.discovery, resource, self.clock() ) except VerificationError as exc: raise _bad("resource", exc.detail) from None - if rt.get("dwk") != RESOURCE_DWK or rt.get("aud") != self.issuer: - raise _bad( - "resource", "not a resource token for this Access Server" - ) - if rt.get("ps") != ps: + wrong = ResourceTokenExpectation( + ps=ps, aud=self.issuer, requires_presented=True + ).failures(rt) + if wrong: raise _bad( - "resource", "its ps is not the Person Server that sent it" - ) - if any( - not isinstance(rt.get(c), str) or not rt[c] - for c in ("sub", "presented_jti", "agent_jkt") - ): - raise _bad( - "resource", "sub, presented_jti and agent_jkt are required" + "resource", f"its {', '.join(wrong)} not for this request" ) return rt async def _agent_token( - self, token: str, rt: dict[str, Any], ps: str - ) -> dict[str, Any]: + self, token: str, rt: ResourceTokenClaims, ps: str + ) -> AgentTokenClaims: try: claims = await verify_agent_token( token, self.discovery, self._providers, self.clock() @@ -162,9 +168,12 @@ async def _agent_token( raise AAuthError( 400, "invalid_request", f"agent_token: {exc.detail}" ) from None - if thumbprint(claims["cnf"]["jwk"]) != rt["agent_jkt"]: + wrong = AgentExpectation(agent_jkt=rt.agent_jkt or "", ps=ps).failures( + claims + ) + if "cnf" in wrong: raise _bad("resource", "agent_jkt is not the agent token's key") - if "ps" in claims and str(claims["ps"]).rstrip("/") != ps: + if wrong: raise AAuthError( 400, "invalid_request", @@ -173,8 +182,8 @@ async def _agent_token( return claims async def _presented_token( - self, token: str, rt: dict[str, Any], ps: str - ) -> dict[str, Any]: + 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.""" @@ -183,7 +192,11 @@ async def _presented_token( ps, PERSON_DWK, lambda keys: jwt.verify( - token, keys, jwt.PERSON_TOKEN, self.clock() + token, + keys, + jwt.PERSON_TOKEN, + self.clock(), + PersonTokenClaims, ), ) except VerificationError as exc: @@ -193,40 +206,24 @@ async def _presented_token( else "invalid_presented_token" ) raise AAuthError(400, code, exc.detail) from None - cnf = presented.get("cnf") - jwk = cnf.get("jwk") if isinstance(cnf, dict) else None - if ( - presented.get("iss") != ps - or presented.get("aud") != rt.get("iss") - or not isinstance(jwk, dict) - or thumbprint(jwk) != rt["agent_jkt"] - or any( - presented.get(c) != rt.get(r) - for c, r in ( - ("jti", "presented_jti"), - ("sub", "sub"), - ("mission_s256", "mission_s256"), - ("tenant", "tenant"), - ) + wrong = PresentedExpectation(ps=ps, rt=rt).failures(presented) + if wrong: + raise _bad( + "resource", + f"it does not match the presented token: {', '.join(wrong)}", ) - ): - raise _bad("resource", "it does not match the presented token") return presented - async def _summary(self, rt: dict[str, Any]) -> str | None: - if not isinstance(rt.get("r3_uri"), str) or not isinstance( - rt.get("r3_s256"), str - ): + async def _summary(self, rt: ResourceTokenClaims) -> str | None: + if rt.r3_uri is None or rt.r3_s256 is None: return None try: proposal = await self.identity.read_proposal( - self.http, rt["r3_uri"], rt["r3_s256"], self.clock() + self.http, rt.r3_uri, rt.r3_s256, self.clock() ) except (httpx.HTTPError, ValueError): return None - display = proposal.get("display") - summary = display.get("summary") if isinstance(display, dict) else None - return str(summary) if summary else None + return proposal.display.summary if proposal.display else None # endregion @@ -249,61 +246,50 @@ async def token(self, request: SignedRequest) -> Answer: 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) - rt_budget = ( - Budget.model_validate(rt["budget"]) - if rt.get("budget") is not None - else None - ) - sent = body.budget + sent, asked_budget = body.budget, rt.budget if ( sent is not None - and rt_budget is not None + and asked_budget is not None and ( - not sent.same_scale(rt_budget) - or sent.amount > rt_budget.amount + not sent.same_scale(asked_budget) + or sent.amount > asked_budget.amount ) ): raise AAuthError( 400, "invalid_request", "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=str(rt["iss"]), + resource=rt.iss, ps=ps, - sub=str(rt["sub"]), - tenant=rt.get("tenant"), - scope=rt.get("scope"), - mission_s256=rt.get("mission_s256"), - budget=sent or rt_budget, + 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=str(agent["sub"]), - agent_provider=str(agent["iss"]), - agent_jkt=str(rt["agent_jkt"]), - claims={ - "resource_token": rt, - "agent_token": agent, - "presented_token": presented, - }, + 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 - grant = { - "aud": rt["iss"], - "ps": ps, - "sub": rt["sub"], - **{c: rt[c] for c in CARRIED if c in rt}, - } record = PendingAccess( id=self.new_id(), verdict=verdict, request=asked, - grant=grant, - jwk=agent["cnf"]["jwk"], - not_after=min(int(agent["exp"]), int(presented["exp"])), + 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, - int(agent["exp"]), - int(presented["exp"]), + self.clock() + self._pending_ttl, agent.exp, presented.exp ), ) if verdict.decision == "allow": @@ -340,21 +326,22 @@ def _issue( """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) - token = self.identity.token( - { - "iss": self.issuer, - "dwk": ACCESS_DWK, - "jti": self.new_id(), - "iat": iat, - "exp": exp, - **record.grant, - **({"scope": scope} if scope else {}), - **({"budget": budget.model_dump()} if budget else {}), - "cnf": {"jwk": record.jwk}, - }, - jwt.AUTH_TOKEN, + 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) ) - return Answer(200, {"auth_token": token, "expires_in": exp - iat}) async def poll(self, request: SignedRequest, pending_id: str) -> Answer: """`GET /pending/{id}`: only the Person Server that asked may poll.""" diff --git a/packages/introspection-aauth/introspection_aauth/fastapi.py b/packages/introspection-aauth/introspection_aauth/fastapi.py index 7d9c44e..54b2192 100644 --- a/packages/introspection-aauth/introspection_aauth/fastapi.py +++ b/packages/introspection-aauth/introspection_aauth/fastapi.py @@ -19,24 +19,48 @@ from collections.abc import Awaitable from http import HTTPStatus -from typing import Protocol +from typing import Protocol, TypedDict, TypeVar, Unpack from urllib.parse import urlsplit -from fastapi import APIRouter, HTTPException, Request -from fastapi.responses import JSONResponse +from fastapi import APIRouter, HTTPException, Request, Response from pydantic import BaseModel -from .access_server.models import TokenRequest from .access_server.server import AccessServer from .person_server.models import ( AuthTokenRequest, MissionAction, + MissionAnswer, + MissionLogged, MissionProposal, PersonTokenRequest, ) from .person_server.server import PersonServer -from .protocol.errors import AAuthError, Answer +from .protocol.errors import AAuthError, Answer, Problem from .protocol.httpsig import SignedRequest +from .protocol.keys import Jwks +from .protocol.server import Server +from .protocol.wire import ( + AccessServerMetadata, + AuthTokenAnswer, + Empty, + Pending, + PersonServerMetadata, + PersonTokenAnswer, + TokenRequest, +) + +_Found = TypeVar("_Found", bound=Server, covariant=True) +_Server = TypeVar("_Server", bound=Server) +_Acting = TypeVar("_Acting", bound=Server, contravariant=True) + + +class _Docs(TypedDict, total=False): + summary: str + description: str + operation_id: str + status_code: int + response_model: object + responses: dict[int | str, dict[str, object]] class PersonServerResolver(Protocol): @@ -61,10 +85,19 @@ def __call__(self, request: SignedRequest, /) -> Awaitable[Answer]: ... "Signed: the request carries an RFC 9421 signature with `Signature-Key`, " "and errors are AAuth problem+json." ) +PROBLEM_RESPONSES: dict[int | str, dict[str, object]] = { + "4XX": { + "model": Problem, + "description": "Refused", + "content": {"application/problem+json": {}}, + }, +} PENDING_RESPONSE: dict[int | str, dict[str, object]] = { HTTPStatus.ACCEPTED.value: { - "description": "Put to whoever decides; poll the `Location` it names" - } + "model": Pending, + "description": "Put to whoever decides; poll the `Location` it names", + }, + **PROBLEM_RESPONSES, } @@ -80,11 +113,23 @@ def _body(model: type[BaseModel]) -> dict[str, object]: } -def _respond(answer: Answer) -> JSONResponse: - return JSONResponse(answer.body, answer.status, answer.headers) +def _json(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) -async def _serve(request: Request, base: str, handle: Handler) -> JSONResponse: +async def _serve(request: Request, base: str, handle: Handler) -> Response: path = request.url.path.removeprefix(urlsplit(base).path) query = f"?{request.url.query}" if request.url.query else "" signed = SignedRequest( @@ -109,29 +154,21 @@ def _under(router: APIRouter, path: str) -> str: return path.removeprefix(router.prefix) -def _cached(response: JSONResponse, cache_control: str | None) -> JSONResponse: - if cache_control: - response.headers["Cache-Control"] = cache_control - return response +class _Resolver(Protocol[_Found]): + def __call__(self, request: Request) -> _Found | None: ... -def _person(source: "PersonServer | PersonServerResolver"): - def find(request: Request) -> PersonServer: - server = ( - source if isinstance(source, PersonServer) else source(request) - ) - if server is None: - raise HTTPException(status_code=HTTPStatus.NOT_FOUND) - return server +class _Find(Protocol[_Found]): + def __call__(self, request: Request) -> _Found: ... - return find +def _fixed(server: _Server) -> _Resolver[_Server]: + return lambda request: server -def _access(source: "AccessServer | AccessServerResolver"): - def find(request: Request) -> AccessServer: - server = ( - source if isinstance(source, AccessServer) else source(request) - ) + +def _finder(resolve: _Resolver[_Server]) -> _Find[_Server]: + def find(request: Request) -> _Server: + server = resolve(request) if server is None: raise HTTPException(status_code=HTTPStatus.NOT_FOUND) return server @@ -139,6 +176,39 @@ def find(request: Request) -> AccessServer: return find +def _well_known( + router: APIRouter, + find: _Find[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(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(find(request).jwks(), cache_control) + + return router + + def _endpoint_path( source: PersonServer | AccessServer | object, endpoint_path: str | None ) -> str: @@ -156,27 +226,80 @@ def person_server_well_known_router( cache_control: str | None = None, ) -> APIRouter: """The Person Server's metadata and keys, to mount at the root.""" - router = APIRouter() if router is None else router - find = _person(ps) - - @router.get( - "/.well-known/aauth-person.json", - summary="Person Server metadata", - description="Its AAuth discovery document: issuer, keys, endpoints.", - operation_id="aauth_person_server_metadata", + return _well_known( + 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 metadata(request: Request) -> JSONResponse: - return _cached(JSONResponse(find(request).metadata()), cache_control) - @router.get( - "/.well-known/aauth-person-jwks.json", - summary="Person Server signing keys", - operation_id="aauth_person_server_jwks", + +class _Act(Protocol[_Acting]): + """A server's answer to one signed request, given its path parameters.""" + + def __call__( + self, server: _Acting, signed: SignedRequest, path: dict[str, str], / + ) -> Awaitable[Answer]: ... + + +def _parameters(path: str) -> list[dict[str, object]]: + 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: _Find[_Server], + act: _Act[_Server], + *, + body: type[BaseModel] | None = None, + **docs: Unpack[_Docs], +) -> None: + """A signed protocol route: the server `find` resolves answers it by `act`.""" + extra: dict[str, object] = _body(body) if body is not None else {} + if parameters := _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, + **docs, ) - async def jwks(request: Request) -> JSONResponse: - return _cached(JSONResponse(find(request).jwks()), cache_control) - return router + +async def _poll_person_server( + server: PersonServer, signed: SignedRequest, path: dict[str, str] +) -> Answer: + parts = server.pending_parts(path["pending_path"]) + if parts is None: + raise AAuthError(404, "not_found", "no such pending request") + return await server.poll(signed, *parts) def person_server_router( @@ -190,74 +313,79 @@ def person_server_router( path of its servers' endpoint base.""" router = APIRouter() if router is None else router prefix = _under(router, _endpoint_path(ps, endpoint_path)) - find = _person(ps) + find = _finder(_fixed(ps) if isinstance(ps, PersonServer) else ps) if not router.prefix: person_server_well_known_router(ps, router) - - @router.post( + _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", - openapi_extra=_body(MissionProposal), - responses=PENDING_RESPONSE, + status_code=HTTPStatus.ACCEPTED, + response_model=Pending, + responses=PROBLEM_RESPONSES, ) - async def mission(request: Request) -> JSONResponse: - server = find(request) - return await _serve( - request, server.endpoint_base, server.propose_mission - ) - - @router.post( + _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", - openapi_extra=_body(MissionAction), + response_model=MissionLogged, responses=PENDING_RESPONSE, ) - async def mission_action(request: Request, s256: str) -> JSONResponse: - server = find(request) - - async def act(signed: SignedRequest) -> Answer: - return await server.mission_action(signed, s256) - - return await _serve(request, server.endpoint_base, act) - - @router.post( + _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", - openapi_extra=_body(PersonTokenRequest), + response_model=PersonTokenAnswer, + responses=PROBLEM_RESPONSES, ) - async def person_token(request: Request) -> JSONResponse: - server = find(request) - return await _serve(request, server.endpoint_base, server.person_token) - - @router.post( + _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", - openapi_extra=_body(AuthTokenRequest), + response_model=AuthTokenAnswer, responses=PENDING_RESPONSE, ) - async def auth_token(request: Request) -> JSONResponse: - server = find(request) - return await _serve(request, server.endpoint_base, server.auth_token) - - @router.get( + _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` " @@ -265,19 +393,9 @@ async def auth_token(request: Request) -> JSONResponse: f"expires, `410` once collected. {SIGNED}" ), operation_id="aauth_person_server_poll", + response_model=AuthTokenAnswer | MissionAnswer | Empty, responses=PENDING_RESPONSE, ) - async def poll(request: Request, pending_path: str) -> JSONResponse: - server = find(request) - - async def read(signed: SignedRequest) -> Answer: - parts = server.pending_parts(pending_path) - if parts is None: - raise AAuthError(404, "not_found", "no such pending request") - return await server.poll(signed, *parts) - - return await _serve(request, server.endpoint_base, read) - return router @@ -288,27 +406,16 @@ def access_server_well_known_router( cache_control: str | None = None, ) -> APIRouter: """The Access Server's metadata and keys, to mount at the root.""" - router = APIRouter() if router is None else router - find = _access(server) - - @router.get( - "/.well-known/aauth-access.json", - summary="Access Server metadata", - description="Its AAuth discovery document: issuer, keys, token endpoint.", - operation_id="aauth_access_server_metadata", - ) - async def metadata(request: Request) -> JSONResponse: - return _cached(JSONResponse(find(request).metadata()), cache_control) - - @router.get( - "/.well-known/aauth-access-jwks.json", - summary="Access Server signing keys", - operation_id="aauth_access_server_jwks", + return _well_known( + 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, ) - async def jwks(request: Request) -> JSONResponse: - return _cached(JSONResponse(find(request).jwks()), cache_control) - - return router def access_server_router( @@ -317,17 +424,21 @@ def access_server_router( *, endpoint_path: str | None = None, ) -> APIRouter: - """The Access 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.""" + """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 - prefix = _under(router, _endpoint_path(server, endpoint_path)) - find = _access(server) if not router.prefix: access_server_well_known_router(server, router) - - @router.post( + prefix = _under(router, _endpoint_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 " @@ -335,25 +446,18 @@ def access_server_router( f"policy waits on someone. {SIGNED}" ), operation_id="aauth_access_server_token", - openapi_extra=_body(TokenRequest), + response_model=AuthTokenAnswer, responses=PENDING_RESPONSE, ) - async def token(request: Request) -> JSONResponse: - found = find(request) - return await _serve(request, found.endpoint_base, found.token) - - @router.get( + _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, ) - async def poll(request: Request, pending_id: str) -> JSONResponse: - found = find(request) - - async def read(signed: SignedRequest) -> Answer: - return await found.poll(signed, pending_id) - - return await _serve(request, found.endpoint_base, read) - return router diff --git a/packages/introspection-aauth/introspection_aauth/person_server/federation.py b/packages/introspection-aauth/introspection_aauth/person_server/federation.py index 5734676..10eade2 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/federation.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/federation.py @@ -10,12 +10,10 @@ """ import asyncio -import json from collections.abc import Awaitable, Callable -from dataclasses import dataclass from enum import StrEnum from functools import partial -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from urllib.parse import urljoin, urlsplit import http_sfv @@ -23,10 +21,17 @@ from pydantic import BaseModel, ValidationError from ..protocol import jwt -from ..protocol.errors import VerificationError -from ..protocol.keys import thumbprint +from ..protocol.claims import ( + ACCESS_DWK, + AuthTokenClaims, + DeliveryExpectation, + PersonTokenClaims, + ResourceTokenClaims, +) +from ..protocol.errors import Problem, VerificationError +from ..protocol.keys import Jwks from ..protocol.models import Budget -from ..protocol.server import ACCESS_DWK +from ..protocol.wire import AccessServerMetadata, AuthTokenAnswer, TokenRequest from .models import Approval, ApprovalStatus, Federation, Refusal from .store import Store @@ -61,11 +66,7 @@ class Outcome(BaseModel): refusal: Refusal | None = None -@dataclass(frozen=True) -class Metadata: - issuer: str - jwks_uri: str - auth_token_endpoint: str +Metadata = AccessServerMetadata def refused(status: int, error: str, detail: str) -> Outcome: @@ -87,9 +88,7 @@ def poll_delay(outcome: Outcome) -> int: return min(max(outcome.retry_after, MIN_DELAY), MAX_DELAY) -def _https(value: Any, endpoint: bool) -> bool: - if not isinstance(value, str): - return False +def _https(value: str, endpoint: bool) -> bool: parts = urlsplit(value) return ( parts.scheme == "https" @@ -120,10 +119,6 @@ def _requirement(header: str) -> str | None: return str(item.value) if item is not None else None -def _scopes(value: Any) -> set[str]: - return set(value.split()) if isinstance(value, str) else set() - - class AccessServerClient: """Talks to the Access Server a verified resource token names, signed as the Person Server.""" @@ -133,42 +128,32 @@ def __init__(self, ps: "PersonServer"): async def discover(self, access_server: str) -> Metadata | Outcome: """`{aud}/.well-known/aauth-access.json`, whose `issuer` is the `aud`.""" try: - document = await self._ps.discovery.get_json( - f"{access_server}/.well-known/{ACCESS_DWK}" + document = await self._ps.discovery.get( + f"{access_server}/.well-known/{ACCESS_DWK}", + AccessServerMetadata, ) except VerificationError as exc: return unreachable(f"its metadata could not be read: {exc.detail}") - if document.get("issuer") != access_server: + if document.issuer != access_server: return unreachable("its metadata names another issuer") - if not _https(document.get("auth_token_endpoint"), True) or not _https( - document.get("jwks_uri"), False + if not _https(document.auth_token_endpoint, True) or not _https( + document.jwks_uri, False ): return unreachable( "its metadata lacks an https auth_token_endpoint and jwks_uri" ) - return Metadata( - access_server, - str(document["jwks_uri"]), - str(document["auth_token_endpoint"]), - ) + return document async def request( self, federation: Federation, agent_jkt: str, metadata: Metadata ) -> Outcome: """The token request, with the budget the Person Server allows.""" - body = json.dumps( - { - "resource_token": federation.resource_token, - "agent_token": federation.agent_token, - "presented_token": federation.presented_token, - **( - {"budget": federation.budget.model_dump()} - if federation.budget - else {} - ), - }, - separators=(",", ":"), - ).encode() + 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._ps.identity.signed("POST", url, body, self._ps.clock()) response = await self._ps.http.post(url, content=body, headers=headers) @@ -202,13 +187,11 @@ async def _outcome( # noqa: PLR0911 status = response.status_code if status == 200: try: - auth_token = response.json().get("auth_token") - except (ValueError, AttributeError): - auth_token = None - if not isinstance(auth_token, str): + answer = AuthTokenAnswer.model_validate_json(response.content) + except ValidationError: return unreachable("it answered 200 without an auth_token") return await self._delivered( - auth_token, federation, agent_jkt, metadata + answer.auth_token, federation, agent_jkt, metadata ) if status == 202: return self._waiting(response, requested) @@ -227,18 +210,14 @@ async def _outcome( # noqa: PLR0911 retry_after=retry + 5 if status == 429 else retry, ) try: - problem = response.json() - except ValueError: + problem = Problem.model_validate_json(response.content) + except ValidationError: problem = None - error = problem.get("error") if isinstance(problem, dict) else None - if 400 <= status < 500 and isinstance(error, str) and error: - detail = ( - problem.get("detail") if isinstance(problem, dict) else None - ) + if 400 <= status < 500 and problem is not None and problem.error: return refused( status, - error, - f"the Access Server refused: {detail if isinstance(detail, str) else error}", + problem.error, + f"the Access Server refused: {problem.detail or problem.error}", ) return unreachable(f"it answered {status}") @@ -276,48 +255,34 @@ async def _delivered( metadata: Metadata, ) -> Outcome: """`#auth-token-delivery`, and the budget granted no more than was sent.""" - rt = jwt.decode(federation.resource_token).claims - presented = jwt.decode(federation.presented_token).claims try: - keys = await self._ps.discovery.get_json(metadata.jwks_uri) - claims = jwt.verify( - auth_token, keys, jwt.AUTH_TOKEN, self._ps.clock() + rt = ResourceTokenClaims.model_validate( + jwt.decode(federation.resource_token).claims ) - granted = ( - Budget.model_validate(claims["budget"]) - if "budget" in claims - else None + presented = PersonTokenClaims.model_validate( + jwt.decode(federation.presented_token).claims ) - except (VerificationError, ValidationError) as exc: - return unreachable(f"its auth token does not verify: {exc}") - cnf = claims.get("cnf") - jwk = cnf.get("jwk") if isinstance(cnf, dict) else None - sent = federation.budget - checks = { - "dwk": claims.get("dwk") != ACCESS_DWK, - "iss": claims.get("iss") != metadata.issuer, - "aud": claims.get("aud") != rt.get("iss"), - "cnf": not isinstance(jwk, dict) or _thumbprint(jwk) != agent_jkt, - "sub": claims.get("sub") != rt.get("sub"), - "ps": claims.get("ps") != self._ps.issuer, - "mission_s256": claims.get("mission_s256") - != rt.get("mission_s256"), - "scope": not _scopes(claims.get("scope")) - <= _scopes(rt.get("scope")), - "exp": claims["exp"] > presented.get("exp", 0), - "lifetime": claims["exp"] - claims["iat"] - > jwt.MAX_TOKEN_LIFETIME_SECONDS, - "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 - ) - ), - } - wrong = [name for name, failed in checks.items() if failed] + except (VerificationError, ValidationError): + return unreachable("its request's tokens cannot be read back") + try: + keys = await self._ps.discovery.get(metadata.jwks_uri, Jwks) + claims = jwt.verify( + auth_token, + keys, + jwt.AUTH_TOKEN, + self._ps.clock(), + AuthTokenClaims, + ) + except VerificationError as exc: + return unreachable(f"its auth token does not verify: {exc.detail}") + wrong = DeliveryExpectation( + issuer=metadata.issuer, + ps=self._ps.issuer, + rt=rt, + agent_jkt=agent_jkt, + not_after=presented.exp, + sent=federation.budget, + ).failures(claims) if wrong: return unreachable( f"its auth token has the wrong {', '.join(wrong)}" @@ -325,18 +290,11 @@ async def _delivered( return Outcome( step=Step.ISSUED, auth_token=auth_token, - auth_token_expires_at=int(claims["exp"]), - granted=granted, + auth_token_expires_at=claims.exp, + granted=claims.budget, ) -def _thumbprint(jwk: dict[str, Any]) -> str | None: - try: - return thumbprint(jwk) - except VerificationError: - return None - - async def due( ps: "PersonServer", tenant: str, approval_id: str ) -> tuple[Federation, str] | None: diff --git a/packages/introspection-aauth/introspection_aauth/person_server/models.py b/packages/introspection-aauth/introspection_aauth/person_server/models.py index 49baeca..ac5df12 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/models.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/models.py @@ -1,11 +1,13 @@ """What a Person Server keeps, and the requests it takes.""" from enum import StrEnum -from typing import Any from pydantic import BaseModel, ConfigDict, Field +from ..protocol.claims import AuthGrant +from ..protocol.keys import Jwk from ..protocol.models import Budget +from ..protocol.wire import Wire class ApprovalKind(StrEnum): @@ -80,6 +82,15 @@ def settled(self) -> bool: ) +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.""" @@ -90,7 +101,7 @@ class Approval(BaseModel): version: int = 0 agent_id: str agent_jkt: str - agent_jwk: dict[str, Any] + agent_jwk: Jwk person: str mission_s256: str | None = None #: The mission's description, the request's scope, or the completion summary. @@ -99,8 +110,10 @@ class Approval(BaseModel): summary: str | None = None resource: str | None = None budget: Budget | None = None - #: The claims an approved auth token is issued with. - grant: dict[str, Any] = Field(default_factory=dict) + #: 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 decided_budget: Budget | None = None decided_by: str | None = None @@ -145,3 +158,37 @@ class AuthTokenRequest(BaseModel): 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 diff --git a/packages/introspection-aauth/introspection_aauth/person_server/server.py b/packages/introspection-aauth/introspection_aauth/person_server/server.py index 15d1356..9198902 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/server.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/server.py @@ -6,31 +6,46 @@ import asyncio import hashlib import hmac -import json import logging -from collections.abc import Awaitable, Callable, Sequence +from collections.abc import Awaitable, Sequence from datetime import UTC, datetime -from typing import Any, Unpack +from typing import Protocol, Unpack from urllib.parse import quote, unquote import httpx2 as httpx from ..protocol import jwt +from ..protocol.claims import ( + PERSON_DWK, + RESOURCE_DWK, + AuthGrant, + AuthTokenClaims, + Confirmation, + PersonTokenClaims, + PresentedExpectation, + ResourceTokenClaims, + ResourceTokenExpectation, +) from ..protocol.errors import AAuthError, Answer, VerificationError from ..protocol.httpsig import SignedRequest from ..protocol.keys import SigningKey, b64url, s256 from ..protocol.models import Agent, Budget from ..protocol.server import ( - PERSON_DWK, - RESOURCE_DWK, Server, ServerOptions, https_origin, parse, pending, verify_agent, + verify_resource_token, ) -from .federation import run +from ..protocol.wire import ( + AuthTokenAnswer, + Empty, + PersonServerMetadata, + PersonTokenAnswer, +) +from .federation import Step, run from .models import ( Approval, ApprovalKind, @@ -40,6 +55,10 @@ LogEntry, Mission, MissionAction, + MissionAnswer, + MissionDocument, + MissionGrant, + MissionLogged, MissionProposal, PersonTokenRequest, ) @@ -47,20 +66,14 @@ logger = logging.getLogger(__name__) -Hook = Callable[[str, Approval], Awaitable[None]] +class Hook(Protocol): + """Told of an approval in `tenant`: one a person must decide, or one + its resource's Access Server must.""" -def _budget(claims: dict[str, Any]) -> Budget | None: - try: - return ( - Budget.model_validate(claims["budget"]) - if claims.get("budget") is not None - else None - ) - except ValueError: - raise AAuthError( - 400, "invalid_resource_token", "budget is not a budget object" - ) from None + def __call__( + self, tenant: str, approval: Approval, / + ) -> Awaitable[None]: ... def _fits(mission: Mission, budget: Budget | None) -> bool: @@ -110,19 +123,19 @@ def __init__( self._token_ttl, self._mission_ttl = token_ttl, mission_ttl self._approval_ttl = approval_ttl self._on_approval, self._on_federate = on_approval, on_federate - self._running: set[asyncio.Task[Any]] = set() + self._running: set[asyncio.Task[Step | None]] = set() # region documents - def metadata(self) -> dict[str, Any]: - return { - "issuer": self.issuer, - **({"name": self.name} if self.name else {}), - "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 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 pending_url(self, tenant: str, approval_id: str) -> str: return f"{self.endpoint_base}/pending/{quote(tenant, safe='')}/{approval_id}" @@ -189,24 +202,6 @@ def directed_subject(self, person: str, audience: str) -> str: hmac.new(self._subject_secret, message, hashlib.sha256).digest() ) - def _issue( - self, typ: str, claims: dict[str, Any], expires_at: int, tenant: str - ) -> tuple[str, int]: - now = self.clock() - token = self.identity.token( - { - "iss": self.issuer, - "dwk": PERSON_DWK, - "tenant": tenant, - "jti": self.new_id(), - "iat": now, - "exp": expires_at, - **claims, - }, - typ, - ) - return token, expires_at - now - 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) @@ -218,39 +213,53 @@ def _person_token( person: str, resource: str, mission: Mission | None, - ) -> dict[str, Any]: - token, expires_in = self._issue( - jwt.PERSON_TOKEN, - { - "aud": resource, - "sub": self.directed_subject(person, resource), - "cnf": {"jwk": agent.jwk}, - **({"mission_s256": mission.s256} if mission else {}), - }, - self._expiry(agent, mission.expires_at if mission else None), - tenant, + ) -> 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, ) - return {"person_token": token, "expires_in": expires_in} def _auth_token( self, tenant: str, agent: Agent, - grant: dict[str, Any], + grant: AuthGrant, mission: Mission, - ) -> dict[str, Any]: - token, expires_in = self._issue( - jwt.AUTH_TOKEN, - { - "ps": self.issuer, - "cnf": {"jwk": agent.jwk}, - "mission_s256": mission.s256, - **grant, - }, - self._expiry(agent, mission.expires_at), - tenant, + ) -> 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, ) - return {"auth_token": token, "expires_in": expires_in} # endregion @@ -279,7 +288,7 @@ async def propose_mission(self, request: SignedRequest) -> Answer: agent, person, proposal.description, - grant={"resources": resources}, + grant=MissionGrant(resources=resources), ) await self.store.put_approval(tenant, approval) return await self._ask(tenant, approval) @@ -305,7 +314,7 @@ async def mission_action( raise AAuthError( 403, "mission_terminated", "the mission has ended" ) - return Answer(200, {"s256": s256(entry.encode())}) + return Answer(200, MissionLogged(s256=s256(entry.encode()))) if action.action != "completion": raise AAuthError( 400, "invalid_request", "action is update or completion" @@ -345,11 +354,11 @@ async def person_token(self, request: SignedRequest) -> Answer: # endregion # region auth tokens - def _presented(self, token: str) -> dict[str, Any]: + 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() + token, jwt.PERSON_TOKEN, self.clock(), PersonTokenClaims ) except VerificationError as exc: code = ( @@ -363,38 +372,34 @@ def _presented(self, token: str) -> dict[str, Any]: async def _resource_token( self, token: str, tenant: str, agent: Agent - ) -> tuple[dict[str, Any], Mission]: + ) -> 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: - decoded = jwt.decode(token) + header, unverified = jwt.peek(token) except VerificationError: raise AAuthError( 400, "invalid_resource_token", "not a JWT" ) from None - unverified = decoded.claims - if ( - decoded.header.get("typ") != jwt.RESOURCE_TOKEN - or unverified.get("dwk") != RESOURCE_DWK - ): + if header.typ != jwt.RESOURCE_TOKEN or unverified.dwk != RESOURCE_DWK: raise AAuthError( 400, "invalid_resource_token", "not a resource token" ) - if unverified.get("tenant") != tenant: + if unverified.tenant != tenant: raise AAuthError( 400, "invalid_resource_token", "tenant does not match" ) - if not isinstance(unverified.get("mission_s256"), str): + if unverified.mission_s256 is None: raise AAuthError( 403, "access_denied", "an auth token is issued only within a mission", ) mission = self._live( - await self.store.mission(tenant, unverified["mission_s256"]), + await self.store.mission(tenant, unverified.mission_s256), agent.id, ) - resource = str(unverified.get("iss", "")).rstrip("/") + resource = (unverified.iss or "").rstrip("/") if resource not in mission.resources: raise AAuthError( 403, @@ -402,39 +407,35 @@ async def _resource_token( "this mission does not cover that resource", ) try: - rt = await self.discovery.verified( - resource, - RESOURCE_DWK, - lambda keys: jwt.verify( - token, keys, jwt.RESOURCE_TOKEN, self.clock() - ), + rt = await verify_resource_token( + token, self.discovery, resource, self.clock() ) except VerificationError as exc: raise AAuthError( 400, "invalid_resource_token", f"does not verify: {exc.detail}" ) from None - if str(rt.get("ps", "")).rstrip("/") != self.issuer: + wrong = ResourceTokenExpectation( + ps=self.issuer, agent_jkt=agent.jkt + ).failures(rt) + if wrong: raise AAuthError( - 400, "invalid_resource_token", "not for this Person Server" - ) - if rt.get("agent_jkt") != agent.jkt: - raise AAuthError( - 400, "invalid_resource_token", "agent_jkt is not the signer" + 400, + "invalid_resource_token", + f"its {', '.join(wrong)} not this request's", ) return rt, mission - def _access_server(self, rt: dict[str, Any]) -> str | None: + def _access_server(self, rt: ResourceTokenClaims) -> str | None: """The resource's own Access Server, when `aud` is not this Person Server.""" - audience = rt.get("aud") - if audience == self.issuer: + if rt.aud == self.issuer: return None - if not https_origin(audience): + if not https_origin(rt.aud): raise AAuthError( 400, "invalid_resource_token", "aud is neither this Person Server nor an Access Server", ) - return str(audience).rstrip("/") + return rt.aud.rstrip("/") async def auth_token(self, request: SignedRequest) -> Answer: """`POST /token`: a call to a resource, weighed against the mission's budget.""" @@ -442,32 +443,24 @@ async def auth_token(self, request: SignedRequest) -> Answer: body = parse(AuthTokenRequest, request.body) tenant = self._tenant(body.tenant) presented = self._presented(body.presented_token) - if presented.get("tenant") != tenant: + if presented.tenant != tenant: raise AAuthError( 400, "invalid_resource_token", "tenant does not match" ) rt, mission = await self._resource_token( body.resource_token, tenant, agent ) - resource = str(rt["iss"]).rstrip("/") - if ( - presented.get("jti") != rt.get("presented_jti") - or presented.get("sub") != rt.get("sub") - or presented.get("mission_s256") != rt.get("mission_s256") - or presented.get("aud") != resource - ): + resource = rt.iss.rstrip("/") + if PresentedExpectation(ps=self.issuer, rt=rt).failures(presented): raise AAuthError( 400, "invalid_resource_token", "does not match the presented token", ) - budget = _budget(rt) - grant = { - "aud": resource, - "sub": rt["sub"], - **{k: rt[k] for k in ("scope", "r3_uri", "r3_s256") if k in rt}, - **({"budget": budget.model_dump()} if budget else {}), - } + if rt.sub is None: + raise AAuthError(400, "invalid_resource_token", "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 ( @@ -504,7 +497,7 @@ async def auth_token(self, request: SignedRequest) -> Answer: ApprovalKind.AUTH_TOKEN, agent, mission.person, - str(rt.get("scope") or "a request"), + rt.scope or "a request", mission_s256=mission.s256, resource=resource, budget=budget, @@ -518,7 +511,7 @@ async def _federated( tenant: str, agent: Agent, mission: Mission, - grant: dict[str, Any], + grant: AuthGrant, federation: Federation, ) -> Answer: """The mission and budget are weighed first, as for any resource: a @@ -528,9 +521,9 @@ async def _federated( ApprovalKind.AUTH_TOKEN, agent, mission.person, - str(grant.get("scope") or "a request"), + grant.scope or "a request", mission_s256=mission.s256, - resource=grant["aud"], + resource=grant.aud, budget=federation.budget, grant=grant, federation=federation, @@ -557,7 +550,12 @@ def _new_approval( agent: Agent, person: str, description: str, - **fields: Any, + *, + 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( @@ -568,9 +566,13 @@ def _new_approval( agent_jwk=agent.jwk, 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, - **fields, ) async def _ask(self, tenant: str, approval: Approval) -> Answer: @@ -608,25 +610,29 @@ async def poll( tenant, agent, approval, federation, mission ) if approval.kind is ApprovalKind.COMPLETION: - return Answer(200, {}) + return Answer(200, Empty()) if mission is None: raise AAuthError(404, "mission_not_found", "no such mission") if approval.kind is ApprovalKind.MISSION: person_tokens = { r: self._person_token( tenant, agent, mission.person, r, mission - )["person_token"] + ).person_token for r in mission.resources } return Answer( 200, - { - "s256": mission.s256, - "mission": b64url(mission.mission.encode()), - "person_tokens": person_tokens, - }, + MissionAnswer( + s256=mission.s256, + mission=b64url(mission.mission.encode()), + person_tokens=person_tokens, + ), ) self._live(mission, agent.id) + if not isinstance(approval.grant, AuthGrant): + raise AAuthError( + 500, "server_error", "the approval grants nothing" + ) return Answer( 200, self._auth_token(tenant, agent, approval.grant, mission) ) @@ -683,7 +689,9 @@ async def _collect( ) return Answer( 200, - {"auth_token": federation.auth_token, "expires_in": expires_in}, + AuthTokenAnswer( + auth_token=federation.auth_token, expires_in=expires_in + ), ) async def decide( @@ -797,26 +805,20 @@ async def _carry_out( ) -> str | None: if approval.kind is ApprovalKind.MISSION: expires_at = now + self._mission_ttl - resources = list(approval.grant.get("resources", [])) - # The approval and the person make the blob, and so its `s256`, the - # mission's own: two alike approvals in one second must not share it. - blob = json.dumps( - { - "agent": approval.agent_id, - "approval": approval.id, - "person": approval.person, - "approved_at": datetime.fromtimestamp( - now, UTC - ).isoformat(), - "expires_at": datetime.fromtimestamp( - expires_at, UTC - ).isoformat(), - "description": approval.description, - "approved_resources": resources, - **({"budget": budget.model_dump()} if budget else {}), - }, - separators=(",", ":"), - ) + 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, @@ -877,23 +879,23 @@ async def decided(self, tenant: str, approval: Approval) -> None: 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.approval(tenant, approval_id) - if approval is None or not isinstance( - approval.grant.get("r3_uri"), str + 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 proposal = await self.identity.read_proposal( - self.http, - approval.grant["r3_uri"], - str(approval.grant.get("r3_s256")), - self.clock(), + self.http, grant.r3_uri, grant.r3_s256, self.clock() ) - display = proposal.get("display") - summary = display.get("summary") if isinstance(display, dict) else None + summary = proposal.display.summary if proposal.display else None if not summary: return approval changed = approval.model_copy( update={ - "summary": str(summary)[:2_000], + "summary": summary[:2_000], "version": approval.version + 1, } ) diff --git a/packages/introspection-aauth/introspection_aauth/protocol/__init__.py b/packages/introspection-aauth/introspection_aauth/protocol/__init__.py index d8f618d..cad1472 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/__init__.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/__init__.py @@ -1,10 +1,25 @@ """What a Person Server and an Access Server share: errors, keys, RFC 9421 -signatures, tokens, discovery, budgets, and the plumbing of a server.""" +signatures, tokens and their claims, discovery, budgets, the bodies they +exchange, and the plumbing of a server.""" +from .claims import ( + AgentExpectation, + AgentTokenClaims, + AuthGrant, + AuthTokenClaims, + Claims, + Confirmation, + DeliveryExpectation, + PersonTokenClaims, + PresentedExpectation, + ResourceTokenClaims, + ResourceTokenExpectation, +) from .discovery import Discovery -from .errors import AAuthError, Answer, VerificationError +from .errors import AAuthError, Answer, Problem, VerificationError from .httpsig import SignedRequest, Verified -from .keys import SigningKey, jwks, s256, thumbprint +from .json import JsonObject, JsonValue +from .keys import Jwk, Jwks, SigningKey, jwks, s256, thumbprint from .models import Agent, Budget from .server import ( Clock, @@ -18,12 +33,28 @@ __all__ = [ "AAuthError", "Agent", + "AgentExpectation", + "AgentTokenClaims", "Answer", + "AuthGrant", + "AuthTokenClaims", "Budget", + "Claims", "Clock", + "Confirmation", + "DeliveryExpectation", "Discovery", "IdFactory", "Identity", + "JsonObject", + "JsonValue", + "Jwk", + "Jwks", + "PersonTokenClaims", + "PresentedExpectation", + "Problem", + "ResourceTokenClaims", + "ResourceTokenExpectation", "SignedRequest", "SigningKey", "VerificationError", 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..1bd20fd --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/protocol/claims.py @@ -0,0 +1,299 @@ +"""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 Literal, Self + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from .errors import VerificationError +from .keys import Jwk, thumbprint +from .models import Budget + +AGENT_DWK = "aauth-agent.json" +PERSON_DWK = "aauth-person.json" +ACCESS_DWK = "aauth-access.json" +RESOURCE_DWK = "aauth-resource.json" +#: The longest an agent or auth token may claim to live, so a forged one +#: cannot outlive the revocation of the key that signed it. +MAX_TOKEN_LIFETIME_SECONDS = 3_600 + + +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.""" + + model_config = ConfigDict(frozen=True, extra="ignore") + + iss: str + iat: int + exp: int + jti: str | None = None + + @model_validator(mode="after") + def _exp_after_iat(self) -> Self: + if self.exp <= self.iat: + raise ValueError("exp is not after iat") + return self + + +class ShortLived(Claims): + """A token that may live no longer than `MAX_TOKEN_LIFETIME_SECONDS`.""" + + @model_validator(mode="after") + def _short_lived(self) -> Self: + if self.exp - self.iat > MAX_TOKEN_LIFETIME_SECONDS: + raise ValueError("the token claims too long a life") + return self + + +class AgentTokenClaims(ShortLived): + """`aa-agent+jwt` (`#agent-token`): the agent, and the key it signs with.""" + + dwk: Literal["aauth-agent.json"] = AGENT_DWK + #: `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.""" + + dwk: Literal["aauth-person.json"] = PERSON_DWK + 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`.""" + + dwk: Literal["aauth-resource.json"] = RESOURCE_DWK + 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) -> "AuthGrant": + """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(ShortLived, Carried): + """`aa-auth+jwt` (`#auth-token`): a grant for one call, from a Person + Server (`dwk` its document) or a resource's Access Server.""" + + dwk: Literal["aauth-person.json", "aauth-access.json"] + 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: Literal["aauth-person.json", "aauth-access.json"], + ps: str, + jwk: Jwk, + jti: str, + iat: int, + exp: int, + ) -> "AuthTokenClaims": + 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, + ) + + +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 presented person 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 != rt.iss, + "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, + "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/discovery.py b/packages/introspection-aauth/introspection_aauth/protocol/discovery.py index 72908a8..5490cd4 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/discovery.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/discovery.py @@ -3,19 +3,29 @@ import time from collections.abc import Callable -from typing import Any, TypeVar +from typing import Protocol, TypeVar import httpx2 as httpx +from pydantic import BaseModel, ValidationError from .errors import VerificationError +from .keys import Jwk, Jwks +from .wire import ServerMetadata T = TypeVar("T") +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 +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`.""" @@ -25,18 +35,20 @@ def __init__( *, ttl_seconds: int = KEYS_TTL_SECONDS, refresh_min_seconds: int = REFRESH_MIN_SECONDS, - clock: Callable[[], float] = time.monotonic, + 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, dict[str, Any]]] = {} + self._cache: dict[tuple[str, str], tuple[float, Jwks]] = {} - async def metadata(self, issuer: str, dwk: str) -> dict[str, Any]: + async def metadata(self, issuer: str, dwk: str) -> ServerMetadata: """The issuer's metadata document, which must name it as `issuer`.""" - document = await self.get_json(f"{issuer}/.well-known/{dwk}") - if str(document.get("issuer", "")).rstrip("/") != issuer: + document = await self.get( + f"{issuer}/.well-known/{dwk}", ServerMetadata + ) + if document.issuer.rstrip("/") != issuer: raise VerificationError( "issuer_mismatch", "metadata names another issuer" ) @@ -44,7 +56,7 @@ async def metadata(self, issuer: str, dwk: str) -> dict[str, Any]: async def jwks( self, issuer: str, dwk: str, *, refresh: bool = False - ) -> dict[str, Any]: + ) -> 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)) @@ -54,12 +66,19 @@ async def jwks( if not refresh or now - fetched_at < self._refresh_min: return cached[1] document = await self.metadata(issuer, dwk) - keys = await self.get_json(str(document.get("jwks_uri", ""))) + 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: Callable[[dict[str, Any]], T] + self, issuer: str, dwk: str, check: Callable[[Jwks], T] ) -> T: """`check` against the issuer's keys, and once more against fresh ones.""" try: @@ -67,24 +86,20 @@ async def verified( except VerificationError: return check(await self.jwks(issuer, dwk, refresh=True)) - async def key(self, issuer: str, dwk: str, kid: str) -> dict[str, Any]: + 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.get("keys", []): - if isinstance(key, dict) and key.get("kid") == kid: + for key in keys.keys: + if key.kid == kid: return key raise VerificationError("unknown_key", f"no key {kid} for {issuer}") - async def get_json(self, url: str) -> dict[str, Any]: + async def get(self, url: str, model: type[Document]) -> Document: + """The JSON document at `url`, read into `model`.""" try: response = await self.http.get(url) response.raise_for_status() - document = response.json() - except (httpx.HTTPError, ValueError) as exc: + return model.model_validate_json(response.content) + except (httpx.HTTPError, ValidationError, ValueError) as exc: raise VerificationError("unknown_key", f"{url}: {exc}") from None - if not isinstance(document, dict): - raise VerificationError( - "unknown_key", f"{url} is not a JSON object" - ) - return document diff --git a/packages/introspection-aauth/introspection_aauth/protocol/errors.py b/packages/introspection-aauth/introspection_aauth/protocol/errors.py index 35b4630..b4747b4 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/errors.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/errors.py @@ -2,17 +2,32 @@ from collections.abc import Mapping from dataclasses import dataclass, field -from typing import Any + +from pydantic import BaseModel, ConfigDict + + +class Problem(BaseModel): + """`application/problem+json` (`#error-responses`).""" + + model_config = ConfigDict(frozen=True, extra="ignore") + + error: str + detail: str | None = None @dataclass(frozen=True) class Answer: - """What a protocol request is answered with, for any web framework.""" + """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: dict[str, Any] + body: BaseModel headers: dict[str, str] = field(default_factory=dict) + def content(self) -> bytes: + return self.body.model_dump_json(exclude_none=True).encode() + class AAuthError(Exception): """A refusal: `status`, an AAuth `error` code and a `detail`.""" @@ -31,12 +46,9 @@ def __init__( self.headers = dict(headers or {}) def answer(self) -> Answer: - body: dict[str, Any] = {"error": self.error} - if self.detail: - body["detail"] = self.detail return Answer( self.status, - body, + Problem(error=self.error, detail=self.detail or None), {"Content-Type": "application/problem+json", **self.headers}, ) diff --git a/packages/introspection-aauth/introspection_aauth/protocol/httpsig.py b/packages/introspection-aauth/introspection_aauth/protocol/httpsig.py index 2156f87..21fddf2 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/httpsig.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/httpsig.py @@ -10,16 +10,19 @@ import base64 import hashlib -from collections.abc import Awaitable, Callable, Mapping +from collections.abc import Awaitable, Mapping from dataclasses import dataclass -from typing import Any +from typing import Protocol from urllib.parse import urlsplit import http_sfv +from http_sfv.item import Parameters +from pydantic import ValidationError from . import jwt +from .claims import Confirmation from .errors import VerificationError -from .keys import SigningKey, thumbprint +from .keys import Jwk, SigningKey, thumbprint from .keys import verify as verify_with LABEL = "sig" @@ -35,8 +38,22 @@ } ) -#: Finds a published key: `(id, dwk, kid)` to its JWK. -KeyResolver = Callable[[str, str, str], Awaitable[dict[str, Any]]] + +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 _Named: + """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 def _quoted(value: str) -> str: @@ -152,7 +169,7 @@ class Verified: """A signature that verified, and how its key was named.""" scheme: str - jwk: dict[str, Any] + jwk: Jwk thumbprint: str created: int #: The presented token, for the `jwt` scheme: decoded, not verified. @@ -197,8 +214,8 @@ def _covered(signature_input: http_sfv.InnerList) -> list[str]: async def _key( - scheme: str, params: dict[str, Any], resolve: KeyResolver | None -): + scheme: str, params: Parameters, resolve: KeyResolver | None +) -> tuple[Jwk, _Named]: if scheme == "jwt": token = params.get("jwt") if not isinstance(token, str): @@ -206,13 +223,20 @@ async def _key( "invalid_key", "the jwt scheme carries a jwt" ) cnf = jwt.decode(token).claims.get("cnf") - jwk = cnf.get("jwk") if isinstance(cnf, dict) else None - if not isinstance(jwk, dict): - raise VerificationError("invalid_jwt", "the token has no cnf.jwk") - return jwk, {"token": token} + try: + jwk = Confirmation.model_validate(cnf).jwk + except ValidationError: + raise VerificationError( + "invalid_jwt", "the token has no cnf.jwk" + ) from None + return jwk, _Named(token=token) if scheme == "jwks_uri": server, dwk, kid = (params.get(n) for n in ("id", "dwk", "kid")) - if not all(isinstance(v, str) for v in (server, 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" ) @@ -220,10 +244,7 @@ async def _key( raise VerificationError( "invalid_key", "not a server this verifier reads" ) - return await resolve(str(server), str(dwk), str(kid)), { - "server": server, - "dwk": dwk, - } + return await resolve(server, dwk, kid), _Named(server=server, dwk=dwk) raise VerificationError("unsupported_scheme", f"the {scheme} scheme") @@ -275,7 +296,7 @@ async def verify( 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), dict(item.params), resolve) + jwk, named = await _key(str(item.value), item.params, resolve) if body and require_content_digest and "content-digest" not in names: raise VerificationError( "invalid_input", "content-digest must be covered" @@ -299,4 +320,12 @@ async def verify( raise VerificationError( "invalid_signature", "the signature does not verify" ) - return Verified(str(item.value), jwk, thumbprint(jwk), created, **named) + 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/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 index 7613851..c1b7ce3 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/jwt.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/jwt.py @@ -1,12 +1,15 @@ """Compact JWS for AAuth's tokens, signed and verified under fully-specified algorithms (`#token-format`).""" -import json from dataclasses import dataclass -from typing import Any +from typing import TypeVar +from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError + +from .claims import MAX_TOKEN_LIFETIME_SECONDS, Claims from .errors import VerificationError -from .keys import SigningKey, b64url, b64url_decode +from .json import JsonObject +from .keys import Jwks, SigningKey, b64url, b64url_decode from .keys import verify as verify_signature AGENT_TOKEN = "aa-agent+jwt" @@ -15,15 +18,53 @@ AUTH_TOKEN = "aa-auth+jwt" #: How far a token's `iat` may be ahead of this clock. CLOCK_SKEW_SECONDS = 60 -#: The longest an agent or auth token may claim to live, so a forged one -#: cannot outlive the revocation of the key that signed it. -MAX_TOKEN_LIFETIME_SECONDS = 3_600 +__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) +_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 encode(claims: dict[str, Any], key: SigningKey, typ: str) -> str: - header = {"alg": "Ed25519", "typ": typ, "kid": key.kid} + +def encode(claims: Claims, key: SigningKey, typ: str) -> str: + header = Header(alg="Ed25519", typ=typ, kid=key.kid) signing_input = ".".join( - b64url(json.dumps(part, separators=(",", ":")).encode()) + b64url(part.model_dump_json(exclude_none=True).encode()) for part in (header, claims) ) return f"{signing_input}.{b64url(key.sign(signing_input.encode()))}" @@ -33,8 +74,8 @@ def encode(claims: dict[str, Any], key: SigningKey, typ: str) -> str: class Decoded: """A JWT split and decoded; nothing about it is verified yet.""" - header: dict[str, Any] - claims: dict[str, Any] + header: Header + claims: JsonObject signing_input: bytes signature: bytes @@ -44,56 +85,60 @@ def decode(token: str) -> Decoded: if len(parts) != 3: raise VerificationError("invalid_jwt", "not a compact JWT") try: - header, claims = (json.loads(b64url_decode(p)) for p in parts[:2]) + header = Header.model_validate_json(b64url_decode(parts[0])) + claims = _object.validate_json(b64url_decode(parts[1])) signature = b64url_decode(parts[2]) - except ValueError: + except (ValueError, ValidationError): raise VerificationError("invalid_jwt", "not a JWT") from None - if not isinstance(header, dict) or not isinstance(claims, dict): - raise VerificationError("invalid_jwt", "not a JWT") 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: dict[str, Any], + keys: Jwks, typ: str, now: int, - *, - max_lifetime: int | None = None, -) -> dict[str, Any]: - """The claims of `token`, signed by one of `keys` (a JWKS), of type `typ`, - unexpired at `now`; `max_lifetime` caps `exp - iat`.""" + 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.get("typ") != typ: + if decoded.header.typ != typ: raise VerificationError("invalid_jwt", f"not an {typ}") - kid = decoded.header.get("kid") - candidates = [ - k - for k in keys.get("keys", []) - if isinstance(k, dict) and (kid is None or k.get("kid") == kid) - ] + 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( - k.get("alg") == decoded.header.get("alg") + k.alg == 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") - claims = decoded.claims - exp, iat = claims.get("exp"), claims.get("iat") - if not isinstance(exp, int) or not isinstance(iat, int): - raise VerificationError("invalid_jwt", "exp and iat are required") - if exp <= now: + 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 iat > now + CLOCK_SKEW_SECONDS: + if claims.iat > now + CLOCK_SKEW_SECONDS: raise VerificationError( "clock_skew", "the token's iat is in the future" ) - if max_lifetime is not None and exp - iat > max_lifetime: - raise VerificationError( - "invalid_jwt", "the token claims too long a life" - ) return claims diff --git a/packages/introspection-aauth/introspection_aauth/protocol/keys.py b/packages/introspection-aauth/introspection_aauth/protocol/keys.py index 904a9db..ed2d5eb 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/keys.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/keys.py @@ -8,7 +8,6 @@ import hashlib import json from collections.abc import Sequence -from typing import Any from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives import hashes, serialization @@ -20,6 +19,7 @@ from cryptography.hazmat.primitives.asymmetric.utils import ( encode_dss_signature, ) +from pydantic import BaseModel, ConfigDict from .errors import VerificationError @@ -34,50 +34,75 @@ 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: dict[str, Any]) -> str: +def thumbprint(jwk: Jwk) -> str: """RFC 7638 thumbprint of an OKP or EC public key.""" - members = {"OKP": ("crv", "kty", "x"), "EC": ("crv", "kty", "x", "y")} - names = members.get(jwk.get("kty", "")) - if names is None or any(not isinstance(jwk.get(n), str) for n in names): + 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( - {n: jwk[n] for n in names}, separators=(",", ":"), sort_keys=True - ) + canonical = json.dumps(members, separators=(",", ":"), sort_keys=True) return s256(canonical.encode()) -def algorithm(jwk: dict[str, Any]) -> str: +def algorithm(jwk: Jwk) -> str: """The key's algorithm, from its `alg`, which must match its type.""" - alg = jwk.get("alg") - if alg not in ALGORITHMS: + alg = jwk.alg + if alg is None or alg not in ALGORITHMS: raise VerificationError( "unsupported_algorithm", "the key's alg is not Ed25519 or ES256" ) - if (jwk.get("kty"), jwk.get("crv")) != ALGORITHMS[alg]: + if (jwk.kty, jwk.crv) != ALGORITHMS[alg]: raise VerificationError( "invalid_key", "the key's type is not its alg's" ) return alg -def verify(jwk: dict[str, Any], data: bytes, signature: bytes) -> bool: +def verify(jwk: Jwk, data: bytes, signature: bytes) -> bool: """Whether `jwk` made `signature` over `data`.""" try: - if algorithm(jwk) == "Ed25519": - Ed25519PublicKey.from_public_bytes(b64url_decode(jwk["x"])).verify( + 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: + 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"), + int.from_bytes(b64url_decode(jwk.x), "big"), + int.from_bytes(b64url_decode(jwk.y), "big"), ec.SECP256R1(), ).public_key() der = encode_dss_signature( @@ -98,14 +123,15 @@ def __init__(self, private_key: Ed25519PrivateKey): raw = private_key.public_key().public_bytes( serialization.Encoding.Raw, serialization.PublicFormat.Raw ) - public = {"kty": "OKP", "crv": "Ed25519", "x": b64url(raw)} - self.kid = thumbprint(public) - self.public_jwk = { - **public, - "alg": "Ed25519", - "kid": self.kid, - "use": "sig", - } + 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) -> "SigningKey": @@ -128,6 +154,6 @@ def sign(self, data: bytes) -> bytes: return self._private.sign(data) -def jwks(keys: Sequence[SigningKey]) -> dict[str, Any]: +def jwks(keys: Sequence[SigningKey]) -> Jwks: """A JWKS publishing `keys`: the current key and any previous ones.""" - return {"keys": [k.public_jwk for k in keys]} + 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 index 29363e8..917c96b 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/memory.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/memory.py @@ -1,21 +1,24 @@ """What both servers' in-memory stores share.""" -from typing import Any, Protocol, TypeVar +from collections.abc import Hashable +from enum import StrEnum +from typing import Protocol, TypeVar class Versioned(Protocol): @property - def status(self) -> Any: ... + 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[Any, Record], key: Any, current: Record, changed: Record + 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.""" diff --git a/packages/introspection-aauth/introspection_aauth/protocol/models.py b/packages/introspection-aauth/introspection_aauth/protocol/models.py index c969e1f..cf63637 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/models.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/models.py @@ -1,9 +1,9 @@ """Values both servers exchange.""" -from typing import Any - from pydantic import BaseModel, ConfigDict, Field +from .keys import Jwk + #: draft-hardt-aauth-budgets caps an amount at 15 digits. MAX_BUDGET_AMOUNT = 999_999_999_999_999 @@ -32,7 +32,7 @@ class Agent(BaseModel): provider: str #: RFC 7638 thumbprint of the key it signs with. jkt: str - jwk: dict[str, Any] + jwk: Jwk expires_at: int #: The Person Server its agent token names, when it names one. person_server: str | None = None diff --git a/packages/introspection-aauth/introspection_aauth/protocol/server.py b/packages/introspection-aauth/introspection_aauth/protocol/server.py index 8ea4425..8df7323 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/server.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/server.py @@ -5,23 +5,49 @@ import time import uuid from collections.abc import Sequence -from typing import Any, NotRequired, Protocol, TypedDict, TypeVar, Unpack +from typing import NotRequired, Protocol, TypedDict, TypeVar, Unpack from urllib.parse import urlsplit import httpx2 as httpx from pydantic import BaseModel, ValidationError from . import httpsig, jwt +from .claims import ( + ACCESS_DWK, + AGENT_DWK, + PERSON_DWK, + RESOURCE_DWK, + AgentTokenClaims, + Claims, + ResourceTokenClaims, +) from .discovery import Discovery from .errors import AAuthError, Answer, VerificationError -from .keys import SigningKey, jwks, s256, thumbprint +from .keys import Jwks, SigningKey, jwks, s256 from .models import Agent +from .wire import Pending, R3Proposal + +__all__ = [ + "ACCESS_DWK", + "AGENT_DWK", + "PERSON_DWK", + "RESOURCE_DWK", + "Clock", + "IdFactory", + "Identity", + "Server", + "ServerOptions", + "https_origin", + "now_seconds", + "parse", + "pending", + "verify_agent", + "verify_agent_token", + "verify_server", +] -AGENT_DWK = "aauth-agent.json" -PERSON_DWK = "aauth-person.json" -ACCESS_DWK = "aauth-access.json" -RESOURCE_DWK = "aauth-resource.json" Model = TypeVar("Model", bound=BaseModel) +ClaimsModel = TypeVar("ClaimsModel", bound=Claims) def now_seconds() -> int: @@ -58,7 +84,7 @@ def pending( """`202`: poll `location` (`#deferred-responses`).""" return Answer( 202, - {"status": "pending"}, + Pending(), { "Location": location, "Retry-After": str(retry_after), @@ -86,15 +112,17 @@ def __init__( self.key = signing_key self.keys = [signing_key, *previous_keys] - def jwks(self) -> dict[str, Any]: + def jwks(self) -> Jwks: return jwks(self.keys) - def token(self, claims: dict[str, Any], typ: str) -> str: + 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) -> dict[str, Any]: - claims = jwt.verify(token, self.jwks(), typ, now) - if claims.get("iss") != self.issuer: + 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 @@ -107,7 +135,7 @@ def signed( async def read_proposal( self, http: httpx.AsyncClient, r3_uri: str, r3_s256: str, now: int - ) -> dict[str, Any]: + ) -> R3Proposal: """A resource's R3 proposal, fetched signed and checked against its hash.""" response = await http.get( r3_uri, headers=self.signed("GET", r3_uri, None, now) @@ -115,8 +143,7 @@ async def read_proposal( response.raise_for_status() if s256(response.content) != r3_s256: raise ValueError("the proposal does not match its hash") - document = response.json() - return document if isinstance(document, dict) else {} + return R3Proposal.model_validate_json(response.content) def new_hex_id() -> str: @@ -173,40 +200,44 @@ def __init__( self.new_id: IdFactory = options.get("new_id", new_hex_id) self.name = options.get("name") - def jwks(self) -> dict[str, Any]: + def jwks(self) -> Jwks: return self.identity.jwks() + def metadata(self) -> BaseModel: + """Its `/.well-known` metadata document.""" + raise NotImplementedError + + +async def verify_resource_token( + token: str, discovery: Discovery, resource: str, now: int +) -> ResourceTokenClaims: + """A resource token, against its resource's keys (`#resource-token`).""" + return await discovery.verified( + resource, + RESOURCE_DWK, + lambda keys: jwt.verify( + token, keys, jwt.RESOURCE_TOKEN, now, ResourceTokenClaims + ), + ) + async def verify_agent_token( token: str, discovery: Discovery, providers: set[str], now: int -) -> dict[str, Any]: +) -> AgentTokenClaims: """An agent token from a trusted agent provider (`#agent-token`).""" - provider = str(jwt.decode(token).claims.get("iss", "")).rstrip("/") + _, 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" ) - claims = await discovery.verified( + return await discovery.verified( provider, AGENT_DWK, lambda keys: jwt.verify( - token, - keys, - jwt.AGENT_TOKEN, - now, - max_lifetime=jwt.MAX_TOKEN_LIFETIME_SECONDS, + token, keys, jwt.AGENT_TOKEN, now, AgentTokenClaims ), ) - cnf = claims.get("cnf") - if claims.get("dwk") != AGENT_DWK or not isinstance( - claims.get("sub"), str - ): - raise VerificationError("invalid_jwt", "not an agent token") - if not isinstance(cnf, dict) or not isinstance(cnf.get("jwk"), dict): - raise VerificationError( - "invalid_jwt", "the agent token has no cnf.jwk" - ) - return claims async def verify_agent( @@ -223,7 +254,7 @@ async def verify_agent( claims = await verify_agent_token( verified.token, discovery, providers, now ) - if thumbprint(claims["cnf"]["jwk"]) != verified.thumbprint: + if claims.cnf.thumbprint != verified.thumbprint: raise VerificationError( "invalid_signature", "not signed by the token's key" ) @@ -235,14 +266,12 @@ async def verify_agent( ) raise AAuthError(401, exc.code, exc.detail, headers) from None return Agent( - id=claims["sub"], - provider=str(claims["iss"]).rstrip("/"), + id=claims.sub, + provider=claims.iss.rstrip("/"), jkt=verified.thumbprint, - jwk=claims["cnf"]["jwk"], - expires_at=int(claims["exp"]), - person_server=str(claims["ps"]).rstrip("/") - if "ps" in claims - else None, + jwk=claims.cnf.jwk, + expires_at=claims.exp, + person_server=claims.ps.rstrip("/") if claims.ps else None, token=verified.token, ) 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..adf0576 --- /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 .errors import Problem +from .keys import Jwks +from .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/tests/test_federation.py b/packages/introspection-aauth/tests/test_federation.py index b103c65..3404ba4 100644 --- a/packages/introspection-aauth/tests/test_federation.py +++ b/packages/introspection-aauth/tests/test_federation.py @@ -3,18 +3,33 @@ from urllib.parse import urlsplit +import httpx2 as httpx + from introspection_aauth.access_server import Verdict -from introspection_aauth.person_server import ApprovalStatus, federation -from introspection_aauth.protocol.jwt import decode +from introspection_aauth.person_server import ( + Approval, + ApprovalStatus, + federation, +) +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 + -from .world import AS, PS, RESOURCE, TENANT, World, 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) -> None: + async def run(tenant: str, approval: Approval) -> None: ran.append(approval.id) async def no_wait(_: float) -> None: @@ -28,11 +43,12 @@ async def no_wait(_: float) -> None: async def test_the_access_server_publishes_its_metadata(): world = World() - metadata = ( - await world.http.get(f"{AS}/.well-known/aauth-access.json") - ).json() - assert metadata["auth_token_endpoint"] == f"{AS}/token" - keys = (await world.http.get(metadata["jwks_uri"])).json() + 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() @@ -43,17 +59,13 @@ async def test_an_allowed_call_is_issued_by_the_access_server_and_collected_once asked = await world.ask(mission, 60_000, aud=AS) issued = await world.follow(asked) assert issued.status_code == 200, issued.text - claims = decode(issued.json()["auth_token"]).claims - assert ( - claims["iss"] == AS - and claims["aud"] == RESOURCE - and claims["ps"] == PS - ) - assert claims["cnf"]["jwk"]["x"] == world.agent_key.public_jwk["x"] - assert await world.spent(mission["s256"]) == 60_000 + 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 again.json()["error"] == "invalid_code" + assert problem(again).error == "invalid_code" async def test_a_refusal_is_relayed_and_nothing_is_spent(): @@ -64,9 +76,9 @@ async def test_a_refusal_is_relayed_and_nothing_is_spent(): 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 refused.json()["error"] == "denied" - assert "no Flight Sector account" in refused.json()["detail"] - assert await world.spent(mission["s256"]) == 0 + 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(): @@ -75,11 +87,8 @@ async def test_a_smaller_grant_is_all_that_is_spent(): 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 ( - decode(issued.json()["auth_token"]).claims["budget"]["amount"] - == 40_000 - ) - assert await world.spent(mission["s256"]) == 40_000 + 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(): @@ -87,7 +96,7 @@ async def test_the_access_servers_wait_is_journaled_step_by_step(): world = World(policy=lambda request: Verdict.approval()) kicked: list[str] = [] - async def kick(tenant: str, approval) -> None: + async def kick(tenant: str, approval: Approval) -> None: kicked.append(approval.id) world.ps._on_federate = kick @@ -116,7 +125,7 @@ async def kick(tenant: str, approval) -> None: assert outcome.step is federation.Step.ISSUED assert not await federation.record(world.ps, TENANT, approval_id, outcome) assert (await world.follow(asked)).status_code == 200 - assert await world.spent(mission["s256"]) == 60_000 + assert await world.spent(mission.s256) == 60_000 async def test_a_call_over_budget_goes_to_a_person_before_the_access_server(): @@ -136,7 +145,7 @@ async def test_a_call_over_budget_goes_to_a_person_before_the_access_server(): assert ran == [approval_id] issued = await world.follow(asked) assert issued.status_code == 200, issued.text - assert await world.spent(mission["s256"]) == 60_000 + assert await world.spent(mission.s256) == 60_000 async def test_sub_agents_are_not_federated(): @@ -153,12 +162,12 @@ async def test_sub_agents_are_not_federated(): }, ) assert refused.status_code == 400 - assert refused.json()["error"] == "invalid_request" + 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 metadata.json()["auth_token_endpoint"] + return body(metadata, AccessServerMetadata).auth_token_endpoint async def test_an_access_server_that_cannot_be_reached_ends_as_unreachable(): @@ -169,8 +178,8 @@ async def test_an_access_server_that_cannot_be_reached_ends_as_unreachable(): asked = await world.ask(mission, 60_000, aud=AS) answered = await world.follow(asked) assert answered.status_code == 502, answered.text - assert answered.json()["error"] == "as_unreachable" - assert await world.spent(mission["s256"]) == 0 + assert problem(answered).error == "as_unreachable" + assert await world.spent(mission.s256) == 0 async def test_a_dropped_connection_is_retried(): @@ -181,4 +190,4 @@ async def test_a_dropped_connection_is_retried(): asked = await world.ask(mission, 60_000, aud=AS) issued = await world.follow(asked) assert issued.status_code == 200, issued.text - assert await world.spent(mission["s256"]) == 60_000 + assert await world.spent(mission.s256) == 60_000 diff --git a/packages/introspection-aauth/tests/test_person_server.py b/packages/introspection-aauth/tests/test_person_server.py index f94a193..5be08d4 100644 --- a/packages/introspection-aauth/tests/test_person_server.py +++ b/packages/introspection-aauth/tests/test_person_server.py @@ -3,6 +3,7 @@ from urllib.parse import urlsplit +import httpx2 as httpx import pytest from introspection_aauth.person_server import ( @@ -10,24 +11,49 @@ MemoryStore, Mission, ) +from introspection_aauth.person_server.models import MissionAnswer from introspection_aauth.protocol import AAuthError, SigningKey -from introspection_aauth.protocol.jwt import AGENT_TOKEN, decode, encode +from introspection_aauth.protocol.claims import ( + 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, usd +from .world import ( + AP, + PS, + RESOURCE, + T0, + TENANT, + World, + body, + problem, + read, + usd, +) -def approval_id(response) -> str: +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 = ( - await world.http.get(f"{PS}/.well-known/aauth-person.json") - ).json() - assert metadata["issuer"] == PS - assert metadata["auth_token_endpoint"] == f"{PS}/aauth/token" - keys = (await world.http.get(metadata["jwks_uri"])).json() + 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() @@ -48,9 +74,9 @@ async def test_a_mission_goes_to_a_person_who_sets_its_budget(): assert waiting.status_code == 202 mission = await world.approved_mission(budget=100_000) - blob = decode(mission["person_tokens"][RESOURCE]).claims - assert blob["aud"] == RESOURCE and blob["mission_s256"] == mission["s256"] - assert blob["tenant"] == TENANT + 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(): @@ -58,10 +84,10 @@ async def test_a_call_within_budget_is_issued_unasked_and_spent(): mission = await world.approved_mission(budget=100_000) issued = await world.ask(mission, 60_000) assert issued.status_code == 200, issued.text - claims = decode(issued.json()["auth_token"]).claims - assert claims["iss"] == PS and claims["aud"] == RESOURCE - assert claims["budget"]["amount"] == 60_000 - assert await world.spent(mission["s256"]) == 60_000 + 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(): @@ -76,7 +102,7 @@ async def test_a_call_over_budget_waits_on_a_person_then_is_issued(): ) issued = await world.follow(over) assert issued.status_code == 200, issued.text - assert await world.spent(mission["s256"]) == 120_000 + assert await world.spent(mission.s256) == 120_000 async def test_a_declined_call_is_refused(): @@ -88,8 +114,8 @@ async def test_a_declined_call_is_refused(): ) refused = await world.follow(over) assert refused.status_code == 403 - assert refused.json()["error"] == "denied" - assert await world.spent(mission["s256"]) == 0 + assert problem(refused).error == "denied" + assert await world.spent(mission.s256) == 0 async def test_only_the_missions_person_accepts_its_completion(): @@ -97,7 +123,7 @@ async def test_only_the_missions_person_accepts_its_completion(): mission = await world.approved_mission(budget=1_000) done = await world.call( "POST", - f"{PS}/aauth/mission/{mission['s256']}", + f"{PS}/aauth/mission/{mission.s256}", {"action": "completion", "summary": "Booked"}, ) assert done.status_code == 202 @@ -113,11 +139,11 @@ async def test_only_the_missions_person_accepts_its_completion(): TENANT, approval_id(done), ApprovalStatus.APPROVED, decided_by="sam" ) assert (await world.follow(done)).status_code == 200 - stored = await world.ps_store.mission(TENANT, mission["s256"]) + stored = await world.ps_store.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 after.json()["error"] == "mission_terminated" + assert problem(after).error == "mission_terminated" async def test_a_mission_logs_updates(): @@ -125,11 +151,11 @@ async def test_a_mission_logs_updates(): mission = await world.approved_mission(budget=1_000) logged = await world.call( "POST", - f"{PS}/aauth/mission/{mission['s256']}", + f"{PS}/aauth/mission/{mission.s256}", {"action": "update", "description": "Found two flights"}, ) assert logged.status_code == 200 - stored = await world.ps_store.mission(TENANT, mission["s256"]) + stored = await world.ps_store.mission(TENANT, mission.s256) assert stored is not None and stored.log[0].entry == "Found two flights" @@ -150,7 +176,7 @@ async def test_an_agent_from_an_untrusted_provider_is_refused(): {"description": "x", "login_hint": "sam"}, ) assert refused.status_code == 401 - assert refused.json()["error"] == "invalid_jwt" + assert problem(refused).error == "invalid_jwt" async def test_a_key_acts_for_one_person(): @@ -166,8 +192,10 @@ async def test_a_key_acts_for_one_person(): "POST", f"{PS}/aauth/person-token", {"resource": RESOURCE} ) assert same.status_code == 200 - claims = decode(same.json()["person_token"]).claims - assert claims["sub"] == world.ps.directed_subject("sam", RESOURCE) + 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(): @@ -177,15 +205,14 @@ async def test_identical_missions_for_two_people_in_one_second_stay_apart(): # in the same second: the blob must still name a mission of Kim's own. world.agent_key = SigningKey.generate() world.agent_token = encode( - { - "iss": AP, - "dwk": "aauth-agent.json", - "sub": "aauth:travel@agents.example", - "jti": "agent-2", - "iat": T0, - "exp": T0 + 3_600, - "cnf": {"jwk": world.agent_key.public_jwk}, - }, + AgentTokenClaims( + 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, ) @@ -205,12 +232,12 @@ async def test_identical_missions_for_two_people_in_one_second_stay_apart(): decided_by="dana", budget=usd(100_000), ) - kim = (await world.follow(proposed)).json() - assert kim["s256"] != sam["s256"] - sam_sub = decode(sam["person_tokens"][RESOURCE]).claims["sub"] - kim_sub = decode(kim["person_tokens"][RESOURCE]).claims["sub"] + 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.mission(TENANT, kim["s256"]) + stored = await world.ps_store.mission(TENANT, kim.s256) assert stored is not None and stored.person == "kim" diff --git a/packages/introspection-aauth/tests/test_protocol.py b/packages/introspection-aauth/tests/test_protocol.py index 2bbf03b..fdce60c 100644 --- a/packages/introspection-aauth/tests/test_protocol.py +++ b/packages/introspection-aauth/tests/test_protocol.py @@ -23,13 +23,22 @@ jwks, jwt, ) +from introspection_aauth.protocol.claims import ( + AgentTokenClaims, + Claims, + Confirmation, + ShortLived, +) 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() ) @@ -75,15 +84,14 @@ def test_what_this_package_signs_verifies_under_the_data_planes_verifier(): provider, agent = SigningKey.generate(), SigningKey.generate() now = int(time.time()) token = jwt.encode( - { - "iss": "https://agents.example", - "dwk": "aauth-agent.json", - "sub": "aauth:travel@agents.example", - "jti": "a1", - "iat": now, - "exp": now + 600, - "cnf": {"jwk": agent.public_jwk}, - }, + AgentTokenClaims( + 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, ) @@ -92,7 +100,7 @@ def test_what_this_package_signs_verifies_under_the_data_planes_verifier(): headers = httpsig.sign( agent, httpsig.jwt_key(token), "POST", url, body=body, created=now ) - keys = jwks([provider]) + keys = jwks([provider]).model_dump(exclude_none=True) assert ( verify_agent_claims(token, lambda *_: keys)["sub"] == "aauth:travel@agents.example" @@ -120,7 +128,7 @@ def test_what_this_package_signs_verifies_under_the_data_planes_verifier(): ], ) issued = jwt.encode( - {"iss": "https://ps.example", "iat": now, "exp": now + 60}, + Claims(iss="https://ps.example", iat=now, exp=now + 60), provider, jwt.PERSON_TOKEN, ) @@ -130,7 +138,13 @@ def test_what_this_package_signs_verifies_under_the_data_planes_verifier(): async def test_a_tampered_or_stale_request_does_not_verify(): key = SigningKey.generate() token = jwt.encode( - {"cnf": {"jwk": key.public_jwk}, "exp": 2, "iat": 1}, + AgentTokenClaims( + iss="https://agents.example", + sub="aauth:a@agents.example", + cnf=Confirmation(jwk=key.public_jwk), + iat=1, + exp=2, + ), key, jwt.AGENT_TOKEN, ) @@ -176,7 +190,7 @@ async def test_a_server_signature_resolves_its_published_key(): httpsig.named_server(headers["signature-key"]) == "https://ps.example" ) - async def resolve(issuer: str, dwk: str, kid: str) -> dict: + async def resolve(issuer: str, dwk: str, kid: str) -> Jwk: assert (issuer, dwk, kid) == ( "https://ps.example", "aauth-person.json", @@ -196,52 +210,63 @@ async def resolve(issuer: str, dwk: str, kid: str) -> dict: def test_tokens_verify_only_as_issued(): key = SigningKey.generate() - token = jwt.encode({"iat": 100, "exp": 200}, key, jwt.AUTH_TOKEN) - assert jwt.verify(token, jwks([key]), jwt.AUTH_TOKEN, 150)["exp"] == 200 + token = jwt.encode( + Claims(iss="https://ps.example", 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) + 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) - assert other_key.value.code == "unknown_key" - long = jwt.encode({"iat": 100, "exp": 100 + 7_200}, key, jwt.AGENT_TOKEN) - with pytest.raises(VerificationError): jwt.verify( - long, - jwks([key]), - jwt.AGENT_TOKEN, - 150, - max_lifetime=jwt.MAX_TOKEN_LIFETIME_SECONDS, + token, jwks([SigningKey.generate()]), jwt.AUTH_TOKEN, 150, Claims ) + assert other_key.value.code == "unknown_key" + long = forge( + {"iss": "https://ps.example", "iat": 100, "exp": 100 + 7_200}, + key, + jwt.AGENT_TOKEN, + ) + with pytest.raises(VerificationError) as too_long: + jwt.verify(long, jwks([key]), jwt.AGENT_TOKEN, 150, ShortLived) + assert too_long.value.code == "invalid_jwt" + assert "too long a life" in too_long.value.detail def test_a_previous_key_still_verifies_what_it_signed(): old, new = SigningKey.generate(), SigningKey.generate() - token = jwt.encode({"iat": 1, "exp": 10}, old, jwt.PERSON_TOKEN) - assert jwt.verify(token, jwks([new, old]), jwt.PERSON_TOKEN, 5) + token = jwt.encode( + Claims(iss="https://ps.example", 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 = { - "kty": "EC", - "crv": "P-256", - "alg": "ES256", - "x": b64url(numbers.x.to_bytes(32, "big")), - "y": b64url(numbers.y.to_bytes(32, "big")), - } + 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, "alg": None, "kid": "x"}) + assert thumbprint(jwk) == thumbprint( + jwk.model_copy(update={"alg": None, "kid": "x"}) + ) def test_a_key_loads_from_pem(): @@ -252,7 +277,7 @@ def test_a_key_loads_from_pem(): serialization.NoEncryption(), ) assert SigningKey.from_pem(pem).kid == key.kid - assert base64.urlsafe_b64decode(key.public_jwk["x"] + "=") + assert base64.urlsafe_b64decode(f"{key.public_jwk.x}=") async def test_discovery_caches_keys_and_refetches_a_failing_set_sparingly(): diff --git a/packages/introspection-aauth/tests/test_refusals.py b/packages/introspection-aauth/tests/test_refusals.py index 081ec92..6d8a080 100644 --- a/packages/introspection-aauth/tests/test_refusals.py +++ b/packages/introspection-aauth/tests/test_refusals.py @@ -1,22 +1,31 @@ """Refusals, expiries and summaries, on both servers.""" -import json from urllib.parse import urlsplit import httpx2 as httpx import pytest from introspection_aauth.access_server import Verdict -from introspection_aauth.person_server import ApprovalStatus, federation +from introspection_aauth.person_server import ( + Approval, + ApprovalStatus, + federation, +) 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, decode, encode +from introspection_aauth.protocol.jwt import RESOURCE_TOKEN, encode +from introspection_aauth.protocol.wire import ( + PersonTokenAnswer, + R3Display, + R3Proposal, + TokenRequest, +) from .test_federation import inline -from .world import AS, PS, RESOURCE, TENANT, World, usd +from .world import AS, PS, RESOURCE, TENANT, World, body, problem, usd -def approval_id(response) -> str: +def approval_id(response: httpx.Response) -> str: return urlsplit(response.headers["location"]).path.rsplit("/", 1)[1] @@ -76,8 +85,10 @@ async def test_a_decision_is_made_once_and_only_a_mission_takes_a_budget(): 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) - token, person_token = world.resource_token(mission, 10) - claims = decode(token).claims | {"iss": "https://elsewhere.example"} + _, 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", @@ -100,11 +111,11 @@ async def test_a_resource_token_not_matching_the_presented_token_is_refused(): f"{PS}/aauth/token", { "resource_token": token, - "presented_token": other.json()["person_token"], + "presented_token": body(other, PersonTokenAnswer).person_token, }, ) assert refused.status_code == 400 - assert refused.json()["error"] == "invalid_resource_token" + assert problem(refused).error == "invalid_resource_token" async def test_an_expired_presented_token_is_never_federated(): @@ -118,7 +129,7 @@ async def test_an_expired_presented_token_is_never_federated(): f"{PS}/aauth/token", {"resource_token": token, "presented_token": person_token}, ) - assert refused.json()["error"] == "expired_presented_token" + assert problem(refused).error == "expired_presented_token" async def test_an_access_server_that_cannot_be_reached_is_as_unreachable(): @@ -128,8 +139,8 @@ async def test_an_access_server_that_cannot_be_reached_is_as_unreachable(): 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 refused.json()["error"] == "as_unreachable" - assert await world.spent(mission["s256"]) == 0 + assert problem(refused).error == "as_unreachable" + assert await world.spent(mission.s256) == 0 async def test_an_access_server_asking_for_claims_is_refused(): @@ -138,7 +149,9 @@ async def test_an_access_server_asking_for_claims_is_refused(): app = world.net.apps[AS] class AsksForClaims(httpx.AsyncBaseTransport): - async def handle_async_request(self, request): + async def handle_async_request( + self, request: httpx.Request + ) -> httpx.Response: if request.url.path == "/token": return httpx.Response( 202, @@ -149,7 +162,7 @@ async def handle_async_request(self, request): ) return await app.handle_async_request(request) - world.net.apps[AS] = AsksForClaims() # type: ignore[assignment] + world.net.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 @@ -159,7 +172,7 @@ 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, approval) -> None: + async def kick(tenant: str, approval: Approval) -> None: kicked.append(approval.id) world.ps._on_federate = kick @@ -174,12 +187,14 @@ async def kick(tenant, approval) -> None: app = world.net.apps[AS] class Busy(httpx.AsyncBaseTransport): - async def handle_async_request(self, request): + 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.net.apps[AS] = Busy() # type: ignore[assignment] + world.net.apps[AS] = Busy() assert first.location is not None busy = await client.poll(first.location, due[0], due[1], metadata) assert ( @@ -192,15 +207,13 @@ 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) - body = json.dumps( - { - "resource_token": token, - "agent_token": world.agent_token, - "presented_token": person_token, - } - ).encode() + 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=body) + unsigned = await world.http.post(url, content=content) assert unsigned.status_code == 401 stranger = SigningKey.generate() headers = sign( @@ -210,17 +223,17 @@ async def test_the_access_server_refuses_what_it_does_not_decide(): ), "POST", url, - body=body, + body=content, created=world.now, ) - refused = await world.http.post(url, content=body, headers=headers) + refused = await world.http.post(url, content=content, headers=headers) assert refused.status_code == 401 - ps_signed = world.ps.identity.signed("POST", url, body, world.now) + ps_signed = world.ps.identity.signed("POST", url, content, world.now) wrong_audience = await world.http.post( - url, content=body, headers=ps_signed + url, content=content, headers=ps_signed ) assert wrong_audience.status_code == 400 - assert wrong_audience.json()["error"] == "invalid_resource_token" + assert problem(wrong_audience).error == "invalid_resource_token" async def test_an_interaction_is_relayed_and_a_decision_settles_once(): @@ -231,7 +244,7 @@ async def test_an_interaction_is_relayed_and_a_decision_settles_once(): ) kicked: list[str] = [] - async def kick(tenant, approval) -> None: + async def kick(tenant: str, approval: Approval) -> None: kicked.append(approval.id) world.ps._on_federate = kick @@ -267,18 +280,17 @@ async def test_a_policy_may_not_grant_more_than_was_asked(): async def test_a_calls_r3_proposal_is_read_into_its_approval(): world = World() - proposal = json.dumps( - {"display": {"summary": "Book QF1 for $600"}} - ).encode() + proposal = R3Proposal( + display=R3Display(summary="Book QF1 for $600") + ).to_json() r3_uri = f"{RESOURCE}/r3/1" world.net.documents[r3_uri] = proposal mission = await world.approved_mission(budget=1_000) - token, person_token = world.resource_token(mission, 60_000) - claims = decode(token).claims | { - "r3_uri": r3_uri, - "r3_s256": s256(proposal), - } + _, 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", diff --git a/packages/introspection-aauth/tests/world.py b/packages/introspection-aauth/tests/world.py index 60bf7e9..c7ba81c 100644 --- a/packages/introspection-aauth/tests/world.py +++ b/packages/introspection-aauth/tests/world.py @@ -3,13 +3,14 @@ One transport stands in for the internet, routing each origin to its party.""" import json -from typing import Any +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, Verdict +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, @@ -20,14 +21,27 @@ MemoryStore, PersonServer, ) +from introspection_aauth.person_server.models import MissionAnswer from introspection_aauth.protocol import Budget, SigningKey, jwks +from introspection_aauth.protocol.claims import ( + 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" @@ -35,19 +49,49 @@ AS = "https://as.booking.example" TENANT = "acme" T0 = 1_791_000_000 -USD = {"unit": "USD", "decimals": 2} +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 Net(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.ASGITransport] = {} - self.documents: dict[str, Any] = {} + 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] = {} @@ -64,7 +108,11 @@ async def handle_async_request( document = self.documents[url] if isinstance(document, bytes): return httpx.Response(200, content=document) - return httpx.Response(200, json=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) @@ -73,7 +121,7 @@ async def handle_async_request( class World: def __init__( - self, policy: Any = None, ps_store: MemoryStore | None = None + self, policy: Policy | None = None, ps_store: MemoryStore | None = None ) -> None: self.now = T0 self.net = Net() @@ -82,15 +130,13 @@ def __init__( SigningKey.generate() for _ in range(3) ) self.net.documents |= { - f"{AP}/.well-known/aauth-agent.json": { - "issuer": AP, - "jwks_uri": f"{AP}/jwks.json", - }, + 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": { - "issuer": RESOURCE, - "jwks_uri": f"{RESOURCE}/jwks.json", - }, + 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() @@ -105,7 +151,7 @@ def __init__( default_tenant=TENANT, clock=lambda: self.now, ) - self.policy = policy or (lambda request: Verdict.allow()) + self.policy: Policy = policy or (lambda request: Verdict.allow()) self.access_store = AccessStore() self.access = AccessServer( AS, @@ -126,24 +172,30 @@ def __init__( app.include_router(router) self.net.apps[origin] = httpx.ASGITransport(app=app) self.agent_token = encode( - { - "iss": AP, - "dwk": "aauth-agent.json", - "sub": "aauth:travel@agents.example", - "jti": "agent-1", - "iat": T0, - "exp": T0 + 3_600, - "cnf": {"jwk": self.agent_key.public_jwk}, - }, + AgentTokenClaims( + iss=AP, + 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: dict[str, Any] | None = None + self, + method: str, + url: str, + body: BaseModel | JsonObject | None = None, ) -> httpx.Response: - """A request the agent signs (`sig=jwt`, its agent token).""" - content = json.dumps(body).encode() if body is not None else None + """A request the agent signs (`sig=jwt`, its agent token); a raw + object is for a body no request model would build.""" + if isinstance(body, BaseModel): + content = body.model_dump_json(exclude_none=True).encode() + else: + content = json.dumps(body).encode() if body is not None else None headers = sign( self.agent_key, jwt_key(self.agent_token), @@ -160,7 +212,7 @@ 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) -> dict[str, Any]: + async def approved_mission(self, budget: int) -> MissionAnswer: proposed = await self.call( "POST", f"{PS}/aauth/mission", @@ -182,41 +234,41 @@ async def approved_mission(self, budget: int) -> dict[str, Any]: ) done = await self.follow(proposed) assert done.status_code == 200, done.text - return done.json() + 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, + 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: dict[str, Any], amount: int | None, aud: str = PS + 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.""" - person_token = mission["person_tokens"][RESOURCE] - presented = decode(person_token).claims token = encode( - { - "iss": RESOURCE, - "dwk": "aauth-resource.json", - "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": {"amount": amount, **USD}} - if amount is not None - else {} - ), - }, + self.resource_claims(mission, amount, aud), self.resource_key, RESOURCE_TOKEN, ) - return token, person_token + return token, mission.person_tokens[RESOURCE] async def ask( - self, mission: dict[str, Any], amount: int | None, aud: str = PS + self, mission: MissionAnswer, amount: int | None, aud: str = PS ) -> httpx.Response: token, person_token = self.resource_token(mission, amount, aud) return await self.call( From 9e10e6348953a5608e3d31dd414edc37ea2d92d1 Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 14:03:19 -0700 Subject: [PATCH 08/34] refactor(aauth): the example builds each server with its own builder, over models Co-Authored-By: Claude Opus 5.5 --- .../aauth/four_party.py | 181 ++++++++++-------- 1 file changed, 104 insertions(+), 77 deletions(-) diff --git a/examples/introspection_examples/aauth/four_party.py b/examples/introspection_examples/aauth/four_party.py index 0cce39c..bf7841f 100644 --- a/examples/introspection_examples/aauth/four_party.py +++ b/examples/introspection_examples/aauth/four_party.py @@ -26,9 +26,7 @@ from __future__ import annotations import asyncio -import json import os -from typing import Any import httpx2 as httpx from fastapi import FastAPI @@ -46,7 +44,19 @@ 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 ( + AgentTokenClaims, + AuthTokenClaims, + Confirmation, + PersonTokenClaims, + ResourceTokenClaims, +) from introspection_aauth.protocol.httpsig import jwt_key, sign from introspection_aauth.protocol.jwt import ( AGENT_TOKEN, @@ -54,13 +64,21 @@ 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" -USD = {"unit": "USD", "decimals": 2} + + +def usd(amount: int) -> Budget: + return Budget(amount=amount, unit="USD", decimals=2) # #region docs:aauth-servers @@ -71,8 +89,8 @@ def flight_sector(request: AccessRequest) -> Verdict: return Verdict.deny("no Flight Sector account") -def servers(http: httpx.AsyncClient) -> tuple[PersonServer, AccessServer]: - person_server = PersonServer( +def person_server(http: httpx.AsyncClient) -> PersonServer: + return PersonServer( PS, SigningKey.generate(), MemoryStore(), @@ -82,7 +100,10 @@ def servers(http: httpx.AsyncClient) -> tuple[PersonServer, AccessServer]: endpoint_base=f"{PS}/aauth", default_tenant=TENANT, ) - access_server = AccessServer( + + +def access_server(http: httpx.AsyncClient) -> AccessServer: + return AccessServer( AS, SigningKey.generate(), flight_sector, @@ -91,21 +112,15 @@ def servers(http: httpx.AsyncClient) -> tuple[PersonServer, AccessServer]: agent_providers=[AP], http=http, ) - return person_server, access_server - - -def app(router: Any) -> FastAPI: - served = FastAPI() - served.include_router(router) - return served # #endregion docs:aauth-servers _http = httpx.AsyncClient() -_person_server, _access_server = servers(_http) -person_server_app = app(person_server_router(_person_server)) -access_server_app = app(access_server_router(_access_server)) +person_server_app = FastAPI() +person_server_app.include_router(person_server_router(person_server(_http))) +access_server_app = FastAPI() +access_server_app.include_router(access_server_router(access_server(_http))) class Internet(httpx.AsyncBaseTransport): @@ -113,13 +128,18 @@ class Internet(httpx.AsyncBaseTransport): def __init__(self) -> None: self.parties: dict[str, httpx.AsyncBaseTransport] = {} - self.documents: dict[str, Any] = {} + self.documents: dict[str, BaseModel] = {} async def handle_async_request( self, request: httpx.Request ) -> httpx.Response: - if str(request.url) in self.documents: - return httpx.Response(200, json=self.documents[str(request.url)]) + 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) @@ -128,54 +148,55 @@ async def handle_async_request( async def main() -> None: internet = Internet() http = httpx.AsyncClient(transport=internet) - person_server, access_server = servers(http) - internet.parties[PS] = httpx.ASGITransport( - app=app(person_server_router(person_server)) - ) - internet.parties[AS] = httpx.ASGITransport( - app=app(access_server_router(access_server)) - ) + ps = person_server(http) + ps_app = FastAPI() + ps_app.include_router(person_server_router(ps)) + as_app = FastAPI() + as_app.include_router(access_server_router(access_server(http))) + internet.parties[PS] = httpx.ASGITransport(app=ps_app) + internet.parties[AS] = httpx.ASGITransport(app=as_app) # The agent provider and the airline's booking API, as fixed documents. provider, airline, agent = (SigningKey.generate() for _ in range(3)) internet.documents |= { - f"{AP}/.well-known/aauth-agent.json": { - "issuer": AP, - "jwks_uri": f"{AP}/jwks.json", - }, + 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": { - "issuer": RESOURCE, - "jwks_uri": f"{RESOURCE}/jwks.json", - }, + f"{RESOURCE}/.well-known/aauth-resource.json": ServerMetadata( + issuer=RESOURCE, jwks_uri=f"{RESOURCE}/jwks.json" + ), f"{RESOURCE}/jwks.json": jwks([airline]), } - now = person_server.clock() + now = ps.clock() agent_token = encode( - { - "iss": AP, - "dwk": "aauth-agent.json", - "sub": "aauth:travel@agents.example", - "jti": "agent-1", - "iat": now, - "exp": now + 3_600, - "cnf": {"jwk": agent.public_jwk}, - }, + AgentTokenClaims( + 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: dict[str, Any] | None = None + method: str, url: str, body: BaseModel | None = None ) -> httpx.Response: - content = json.dumps(body).encode() if body is not None else None + 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=person_server.clock(), + created=ps.clock(), ) return await http.request( method, url, content=content, headers=headers @@ -185,42 +206,42 @@ async def call( proposed = await call( "POST", f"{PS}/aauth/mission", - { - "description": "Sam to Sydney, up to $2,000", - "resources": [RESOURCE], - "login_hint": "sam@acme.example", - }, + 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 person_server.decide( + await ps.decide( TENANT, approval_id, ApprovalStatus.APPROVED, decided_by="dana@acme.example", - budget=Budget(amount=200_000, **USD), + budget=usd(200_000), ) - mission = (await call("GET", proposed.headers["location"])).json() + 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 = decode(person_token).claims + person_token = mission.person_tokens[RESOURCE] + presented = PersonTokenClaims.model_validate(decode(person_token).claims) resource_token = encode( - { - "iss": RESOURCE, - "dwk": "aauth-resource.json", - "aud": AS, - "ps": PS, - "agent_jkt": agent.kid, - "presented_jti": presented["jti"], - "sub": presented["sub"], - "mission_s256": mission["s256"], - "tenant": TENANT, - "scope": "book", - "budget": {"amount": 145_000, **USD}, - "iat": now, - "exp": now + 300, - }, + ResourceTokenClaims( + 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, ) @@ -228,7 +249,9 @@ async def call( asked = await call( "POST", f"{PS}/aauth/token", - {"resource_token": resource_token, "presented_token": person_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): @@ -236,12 +259,16 @@ async def call( if answered.status_code != 202: break await asyncio.sleep(0.05) - claims = decode(answered.json()["auth_token"]).claims + 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']}, ${claims['budget']['amount'] / 100:,.2f}" + f"4. {answered.status_code}: an auth token from {claims.iss}" + f" for {claims.aud}, ${amount / 100:,.2f}" ) - stored = await person_server.store.mission(TENANT, mission["s256"]) + stored = await ps.store.mission(TENANT, mission.s256) assert stored is not None print(f"5. The trip has spent ${stored.spent / 100:,.2f} of its budget") From 1335811df0b2cc74dbefa854a0280d88f7cba815 Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 14:05:18 -0700 Subject: [PATCH 09/34] feat(aauth): discovery reads only AAuth identifiers over https, follows no redirect, and a signed Authorization is covered Discovery refuses an issuer that is not an AAuth server identifier and a jwks_uri that is not https, and every request the library makes passes follow_redirects=False. A signed request that carries Authorization must cover it, and sign() covers one when given. An Access Server's auth token must carry the resource token's tenant. Tests for each expectation's named failures, malformed claims and bodies, stored grant shapes, the mission blob's encoding, and a guard against typing.Any. Co-Authored-By: Claude Opus 5.5 --- packages/introspection-aauth/README.md | 14 +- .../person_server/federation.py | 8 +- .../introspection_aauth/protocol/claims.py | 1 + .../introspection_aauth/protocol/discovery.py | 45 +- .../introspection_aauth/protocol/httpsig.py | 13 +- .../introspection_aauth/protocol/server.py | 4 +- .../introspection-aauth/tests/test_models.py | 392 ++++++++++++++++++ 7 files changed, 468 insertions(+), 9 deletions(-) create mode 100644 packages/introspection-aauth/tests/test_models.py diff --git a/packages/introspection-aauth/README.md b/packages/introspection-aauth/README.md index ccfcdb6..718d3b0 100644 --- a/packages/introspection-aauth/README.md +++ b/packages/introspection-aauth/README.md @@ -22,6 +22,16 @@ pip install 'introspection-aauth[fastapi]' # and FastAPI routers for them 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. + ## Dependencies ``` @@ -94,7 +104,9 @@ mission only if it fits (atomically), log and end missions, read and save approvals, and replace 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. Whether a store encrypts what it keeps is its own +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 three methods over pending requests. ## Durable federation diff --git a/packages/introspection-aauth/introspection_aauth/person_server/federation.py b/packages/introspection-aauth/introspection_aauth/person_server/federation.py index 10eade2..7d5b858 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/federation.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/federation.py @@ -156,7 +156,9 @@ async def request( ).to_json() url = metadata.auth_token_endpoint headers = self._ps.identity.signed("POST", url, body, self._ps.clock()) - response = await self._ps.http.post(url, content=body, headers=headers) + response = await self._ps.http.post( + url, content=body, headers=headers, follow_redirects=False + ) return await self._outcome( response, url, federation, agent_jkt, metadata ) @@ -171,7 +173,9 @@ async def poll( headers = self._ps.identity.signed( "GET", location, None, self._ps.clock() ) - response = await self._ps.http.get(location, headers=headers) + response = await self._ps.http.get( + location, headers=headers, follow_redirects=False + ) return await self._outcome( response, location, federation, agent_jkt, metadata ) diff --git a/packages/introspection-aauth/introspection_aauth/protocol/claims.py b/packages/introspection-aauth/introspection_aauth/protocol/claims.py index 1bd20fd..ce8f810 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/claims.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/claims.py @@ -215,6 +215,7 @@ def failures(self, claims: AuthTokenClaims) -> list[str]: "sub": claims.sub != rt.sub, "ps": claims.ps != self.ps, "mission_s256": claims.mission_s256 != rt.mission_s256, + "tenant": claims.tenant != rt.tenant, "scope": not claims.scopes() <= rt.scopes(), "exp": claims.exp > self.not_after, "budget": (sent is None) != (granted is None) diff --git a/packages/introspection-aauth/introspection_aauth/protocol/discovery.py b/packages/introspection-aauth/introspection_aauth/protocol/discovery.py index 5490cd4..ecd3076 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/discovery.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/discovery.py @@ -4,6 +4,7 @@ import time from collections.abc import Callable from typing import Protocol, TypeVar +from urllib.parse import urlsplit import httpx2 as httpx from pydantic import BaseModel, ValidationError @@ -20,6 +21,32 @@ 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 Monotonic(Protocol): """Seconds on a clock that never goes back, for timing the cache.""" @@ -44,14 +71,23 @@ def __init__( 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`.""" + """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.rstrip("/") != issuer: + 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( @@ -96,9 +132,10 @@ async def key(self, issuer: str, dwk: str, kid: str) -> Jwk: 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`.""" + """The JSON document at `url`, read into `model`; a redirect is not + followed.""" try: - response = await self.http.get(url) + 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: diff --git a/packages/introspection-aauth/introspection_aauth/protocol/httpsig.py b/packages/introspection-aauth/introspection_aauth/protocol/httpsig.py index 21fddf2..5263b50 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/httpsig.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/httpsig.py @@ -115,9 +115,11 @@ def sign( *, 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`.""" + """The headers that sign a request to `url` with `key`, covering its + `Authorization` when it carries one.""" parts = urlsplit(url) covered = [ ("@method", method.upper()), @@ -132,6 +134,9 @@ def sign( 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) @@ -301,6 +306,12 @@ async def verify( raise VerificationError( "invalid_input", "content-digest 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""): diff --git a/packages/introspection-aauth/introspection_aauth/protocol/server.py b/packages/introspection-aauth/introspection_aauth/protocol/server.py index 8df7323..9feb531 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/server.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/server.py @@ -138,7 +138,9 @@ async def read_proposal( ) -> R3Proposal: """A resource's R3 proposal, fetched signed and checked against its hash.""" response = await http.get( - r3_uri, headers=self.signed("GET", r3_uri, None, now) + r3_uri, + headers=self.signed("GET", r3_uri, None, now), + follow_redirects=False, ) response.raise_for_status() if s256(response.content) != r3_s256: diff --git a/packages/introspection-aauth/tests/test_models.py b/packages/introspection-aauth/tests/test_models.py new file mode 100644 index 0000000..f36f565 --- /dev/null +++ b/packages/introspection-aauth/tests/test_models.py @@ -0,0 +1,392 @@ +"""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 ( + AgentExpectation, + AgentTokenClaims, + AuthGrant, + AuthTokenClaims, + Confirmation, + DeliveryExpectation, + PersonTokenClaims, + PresentedExpectation, + ResourceTokenClaims, + ResourceTokenExpectation, +) +from introspection_aauth.protocol.discovery import is_identifier +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() + + +def test_no_module_uses_typing_any(): + """Bodies and claims are models; `JsonValue` is for arbitrary JSON.""" + found = [] + for path in [*PACKAGE.rglob("*.py"), *TESTS.rglob("*.py")]: + for node in ast.walk(ast.parse(path.read_text())): + if ( + isinstance(node, ast.ImportFrom) + and node.module == "typing" + and any(alias.name == "Any" for alias in node.names) + ) or ( + isinstance(node, ast.Attribute) + and node.attr == "Any" + and isinstance(node.value, ast.Name) + and node.value.id == "typing" + ): + found.append(f"{path.name}:{node.lineno}") + assert found == [] + + +def resource_claims(**update: str | None) -> ResourceTokenClaims: + claims = ResourceTokenClaims( + iss=RESOURCE, + aud=AS, + ps=PS, + sub="person-1", + agent_jkt=AGENT.kid, + presented_jti="pt-1", + mission_s256="m-1", + tenant="acme", + 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_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"]), + ], +) +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 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( + 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( + 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 + 7_200}, "too long a life"), + ({"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)}, + "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), + "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( + 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"}) From 10035ea82f25771b81352cfc817e39adcad3e704 Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 14:18:49 -0700 Subject: [PATCH 10/34] feat(aauth): export MissionGrant from person_server Co-Authored-By: Claude Opus 5.5 --- .../introspection_aauth/person_server/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/introspection-aauth/introspection_aauth/person_server/__init__.py b/packages/introspection-aauth/introspection_aauth/person_server/__init__.py index f3069d3..d739e08 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/__init__.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/__init__.py @@ -9,6 +9,7 @@ Federation, LogEntry, Mission, + MissionGrant, ) from .server import PersonServer from .store import AnyoneDirectory, Directory, MemoryStore, Store @@ -23,6 +24,7 @@ "LogEntry", "MemoryStore", "Mission", + "MissionGrant", "PersonServer", "Store", "federation", From 4b1c0ef9f2ed8ebf138f5b5352b7a4efce32d7df Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 14:19:05 -0700 Subject: [PATCH 11/34] refactor(aauth)!: stores insert approvals and pending requests, as they insert missions BREAKING CHANGE: Store.put_approval is insert_approval, and the Access Server's Store.put_pending is insert_pending. Co-Authored-By: Claude Opus 5.5 --- .../introspection_aauth/access_server/server.py | 2 +- .../introspection_aauth/access_server/store.py | 4 ++-- .../introspection_aauth/person_server/server.py | 10 +++++----- .../introspection_aauth/person_server/store.py | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/introspection-aauth/introspection_aauth/access_server/server.py b/packages/introspection-aauth/introspection_aauth/access_server/server.py index fd42df9..ea813af 100644 --- a/packages/introspection-aauth/introspection_aauth/access_server/server.py +++ b/packages/introspection-aauth/introspection_aauth/access_server/server.py @@ -304,7 +304,7 @@ async def token(self, request: SignedRequest) -> Answer: "denied", verdict.detail or "this resource's policy refuses it", ) - await self.store.put_pending(record) + await self.store.insert_pending(record) if self._on_pending is not None: await self._on_pending(record) return self._waiting(record) diff --git a/packages/introspection-aauth/introspection_aauth/access_server/store.py b/packages/introspection-aauth/introspection_aauth/access_server/store.py index a1f76cd..3df484b 100644 --- a/packages/introspection-aauth/introspection_aauth/access_server/store.py +++ b/packages/introspection-aauth/introspection_aauth/access_server/store.py @@ -9,7 +9,7 @@ class Store(Protocol): async def pending(self, pending_id: str) -> PendingAccess | None: ... - async def put_pending(self, pending: PendingAccess) -> None: ... + async def insert_pending(self, pending: PendingAccess) -> None: ... async def transition_pending( self, current: PendingAccess, changed: PendingAccess @@ -28,7 +28,7 @@ def __init__(self) -> None: async def pending(self, pending_id: str) -> PendingAccess | None: return self.requests.get(pending_id) - async def put_pending(self, pending: PendingAccess) -> None: + async def insert_pending(self, pending: PendingAccess) -> None: self.requests[pending.id] = pending async def transition_pending( diff --git a/packages/introspection-aauth/introspection_aauth/person_server/server.py b/packages/introspection-aauth/introspection_aauth/person_server/server.py index 9198902..c65a33a 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/server.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/server.py @@ -290,7 +290,7 @@ async def propose_mission(self, request: SignedRequest) -> Answer: proposal.description, grant=MissionGrant(resources=resources), ) - await self.store.put_approval(tenant, approval) + await self.store.insert_approval(tenant, approval) return await self._ask(tenant, approval) async def mission_action( @@ -326,7 +326,7 @@ async def mission_action( action.summary or "The agent says the mission is done", mission_s256=mission.s256, ) - await self.store.put_approval(tenant, approval) + await self.store.insert_approval(tenant, approval) return await self._ask(tenant, approval) async def person_token(self, request: SignedRequest) -> Answer: @@ -503,7 +503,7 @@ async def auth_token(self, request: SignedRequest) -> Answer: budget=budget, grant=grant, ) - await self.store.put_approval(tenant, approval) + await self.store.insert_approval(tenant, approval) return await self._ask(tenant, approval) async def _federated( @@ -529,7 +529,7 @@ async def _federated( federation=federation, ) if not federation.within_budget: - await self.store.put_approval(tenant, approval) + await self.store.insert_approval(tenant, approval) return await self._ask(tenant, approval) approval = approval.model_copy( update={ @@ -537,7 +537,7 @@ async def _federated( "decided_at": self.clock(), } ) - await self.store.put_approval(tenant, approval) + await self.store.insert_approval(tenant, approval) await self.decided(tenant, approval) return pending(self.pending_url(tenant, approval.id), self.retry_after) diff --git a/packages/introspection-aauth/introspection_aauth/person_server/store.py b/packages/introspection-aauth/introspection_aauth/person_server/store.py index 414524d..3f2802b 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/store.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/store.py @@ -64,7 +64,7 @@ async def approval( self, tenant: str, approval_id: str ) -> Approval | None: ... - async def put_approval(self, tenant: str, approval: Approval) -> None: + async def insert_approval(self, tenant: str, approval: Approval) -> None: """Save a new approval.""" ... @@ -166,7 +166,7 @@ async def end_mission( async def approval(self, tenant: str, approval_id: str) -> Approval | None: return self.approvals.get((tenant, approval_id)) - async def put_approval(self, tenant: str, approval: Approval) -> None: + async def insert_approval(self, tenant: str, approval: Approval) -> None: self.approvals[(tenant, approval.id)] = approval async def transition_approval( From 45f8310742a1e214c8094dae2518f692fcf7fe09 Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 14:47:43 -0700 Subject: [PATCH 12/34] fix(aauth): collect an approved auth token once, read proposals only from their resource, and check each token as its type requires - An approved auth token is collected once (compare-and-set on the approval's `collected`), then `410 invalid_code`; one not collected before the approval expires is `408`. A mission approval polled after its mission ended is `mission_terminated`. - An R3 proposal is read only from the resource's own https origin, with no credentials in its URL and no redirect. - Lifetimes are capped per type: person and auth tokens at an hour, agent tokens at a day; `dwk` and `jti` are required. - Delivery checks `account`, `r3_uri` and `r3_s256`, bounds `exp` by the agent token too, and compares audiences without a trailing slash. - A throttled first request to an Access Server is sent again. - A body's signature must cover `content-type` with `content-digest`; a list-valued Signature is `invalid_signature`; keys of an unsupported alg are skipped. - An agent request in any scheme but `sig=jwt`, and a server request naming another server or `dwk`, is refused before anything is fetched. - Store methods take CRUD verbs; `Approval` carries `agent_expires_at`; `Policy`, `Sleep`, `Exchange` and discovery's check are protocols; no `object` annotations (the guard test checks); `py.typed` ships. Co-Authored-By: Claude Opus 5.5 --- .../aauth/four_party.py | 7 +- packages/introspection-aauth/README.md | 20 +- .../access_server/server.py | 67 ++-- .../access_server/store.py | 6 +- .../introspection_aauth/fastapi.py | 36 +- .../person_server/federation.py | 52 ++- .../person_server/models.py | 7 +- .../person_server/server.py | 112 ++++--- .../person_server/store.py | 32 +- .../introspection_aauth/protocol/claims.py | 55 +-- .../introspection_aauth/protocol/discovery.py | 12 +- .../introspection_aauth/protocol/httpsig.py | 35 +- .../introspection_aauth/protocol/keys.py | 5 +- .../introspection_aauth/protocol/models.py | 4 + .../introspection_aauth/protocol/server.py | 147 ++++++-- .../introspection_aauth/py.typed | 0 .../tests/test_boundaries.py | 314 ++++++++++++++++++ .../tests/test_federation.py | 13 +- .../introspection-aauth/tests/test_models.py | 96 +++++- .../tests/test_person_server.py | 81 +++-- .../tests/test_protocol.py | 24 +- .../tests/test_refusals.py | 27 +- packages/introspection-aauth/tests/world.py | 47 ++- 23 files changed, 919 insertions(+), 280 deletions(-) create mode 100644 packages/introspection-aauth/introspection_aauth/py.typed create mode 100644 packages/introspection-aauth/tests/test_boundaries.py diff --git a/examples/introspection_examples/aauth/four_party.py b/examples/introspection_examples/aauth/four_party.py index bf7841f..7069fc7 100644 --- a/examples/introspection_examples/aauth/four_party.py +++ b/examples/introspection_examples/aauth/four_party.py @@ -51,6 +51,8 @@ ) from introspection_aauth.protocol import Budget, SigningKey, jwks from introspection_aauth.protocol.claims import ( + AGENT_DWK, + RESOURCE_DWK, AgentTokenClaims, AuthTokenClaims, Confirmation, @@ -171,6 +173,7 @@ async def main() -> None: now = ps.clock() agent_token = encode( AgentTokenClaims( + dwk=AGENT_DWK, iss=AP, sub="aauth:travel@agents.example", jti="agent-1", @@ -229,6 +232,8 @@ async def call( presented = PersonTokenClaims.model_validate(decode(person_token).claims) resource_token = encode( ResourceTokenClaims( + dwk=RESOURCE_DWK, + jti="quote-1", iss=RESOURCE, aud=AS, ps=PS, @@ -268,7 +273,7 @@ async def call( f"4. {answered.status_code}: an auth token from {claims.iss}" f" for {claims.aud}, ${amount / 100:,.2f}" ) - stored = await ps.store.mission(TENANT, mission.s256) + 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") diff --git a/packages/introspection-aauth/README.md b/packages/introspection-aauth/README.md index 718d3b0..e7a4233 100644 --- a/packages/introspection-aauth/README.md +++ b/packages/introspection-aauth/README.md @@ -30,7 +30,11 @@ 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. +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 @@ -98,16 +102,18 @@ app.include_router(access_server_router(access_server)) ## Bring your own store -`person_server.Store` is the Person Server's state in logical operations: -bind an agent key to a person, read and insert missions, spend from a -mission only if it fits (atomically), log and end missions, read and save -approvals, and replace an approval only if its status and version have not -changed. `transaction()` groups the writes that commit together: a decision +`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 three methods over pending requests. +choice. `access_server.Store` is `select_pending`, `insert_pending` and `transition_pending`. ## Durable federation diff --git a/packages/introspection-aauth/introspection_aauth/access_server/server.py b/packages/introspection-aauth/introspection_aauth/access_server/server.py index ea813af..1112211 100644 --- a/packages/introspection-aauth/introspection_aauth/access_server/server.py +++ b/packages/introspection-aauth/introspection_aauth/access_server/server.py @@ -4,7 +4,7 @@ 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.""" -from collections.abc import Awaitable, Callable, Sequence +from collections.abc import Awaitable, Sequence from typing import Protocol, Unpack import httpx2 as httpx @@ -31,6 +31,8 @@ ServerOptions, parse, pending, + presented_token_error, + requirement, verify_agent_token, verify_resource_token, verify_server, @@ -45,7 +47,13 @@ ) from .store import MemoryStore, Store -Policy = Callable[[AccessRequest], Verdict | Awaitable[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): @@ -75,11 +83,7 @@ def _lowered(asked: Budget | None, granted: Budget | None) -> Budget | None: """`granted`, which may be no more than what was asked.""" if granted is None: return asked - if ( - asked is None - or not asked.same_scale(granted) - or granted.amount > asked.amount - ): + if asked is None or not granted.at_most(asked): raise AAuthError( 500, "server_error", "the policy granted more than was asked" ) @@ -142,20 +146,15 @@ async def _resource_token( "resource", f"this Access Server does not decide for {resource}", ) - try: - rt = await verify_resource_token( - token, self.discovery, resource, self.clock() - ) - except VerificationError as exc: - raise _bad("resource", exc.detail) from None - wrong = ResourceTokenExpectation( - ps=ps, aud=self.issuer, requires_presented=True - ).failures(rt) - if wrong: - raise _bad( - "resource", f"its {', '.join(wrong)} not for this request" - ) - return rt + 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 @@ -200,12 +199,7 @@ async def _presented_token( ), ) except VerificationError as exc: - code = ( - "expired_presented_token" - if exc.code == "expired_jwt" - else "invalid_presented_token" - ) - raise AAuthError(400, code, exc.detail) from None + raise presented_token_error(exc) from None wrong = PresentedExpectation(ps=ps, rt=rt).failures(presented) if wrong: raise _bad( @@ -219,7 +213,7 @@ async def _summary(self, rt: ResourceTokenClaims) -> str | None: return None try: proposal = await self.identity.read_proposal( - self.http, rt.r3_uri, rt.r3_s256, self.clock() + self.http, rt.iss, rt.r3_uri, rt.r3_s256, self.clock() ) except (httpx.HTTPError, ValueError): return None @@ -250,10 +244,7 @@ async def token(self, request: SignedRequest) -> Answer: if ( sent is not None and asked_budget is not None - and ( - not sent.same_scale(asked_budget) - or sent.amount > asked_budget.amount - ) + and not sent.at_most(asked_budget) ): raise AAuthError( 400, "invalid_request", "budget is above the resource token's" @@ -311,13 +302,13 @@ async def token(self, request: SignedRequest) -> Answer: def _waiting(self, record: PendingAccess) -> Answer: verdict = record.verdict - requirement = ( - f'requirement=interaction;url="{verdict.url}";code="{verdict.code}"' + waiting_on = ( + requirement("interaction", url=verdict.url, code=verdict.code) if verdict.decision == "interaction" - else "requirement=approval" + else None ) return pending( - self.pending_url(record.id), self.retry_after, requirement + self.pending_url(record.id), self.retry_after, waiting_on ) def _issue( @@ -352,7 +343,7 @@ async def poll(self, request: SignedRequest, pending_id: str) -> Answer: PERSON_DWK, self.clock(), ) - record = await self.store.pending(pending_id) + record = await self.store.select_pending(pending_id) if record is None or record.request.ps != ps: raise AAuthError(404, "not_found", "no such pending request") if record.status is PendingStatus.DENIED: @@ -390,7 +381,7 @@ async def decide( ) -> PendingAccess: """Settle a request the policy waited on, once; approving may narrow the scope or lower the budget.""" - record = await self.store.pending(pending_id) + record = await self.store.select_pending(pending_id) if record is None: raise AAuthError(404, "not_found", "no such request") if record.status is not PendingStatus.PENDING: diff --git a/packages/introspection-aauth/introspection_aauth/access_server/store.py b/packages/introspection-aauth/introspection_aauth/access_server/store.py index 3df484b..a7e8283 100644 --- a/packages/introspection-aauth/introspection_aauth/access_server/store.py +++ b/packages/introspection-aauth/introspection_aauth/access_server/store.py @@ -7,7 +7,9 @@ class Store(Protocol): - async def pending(self, pending_id: str) -> PendingAccess | None: ... + async def select_pending( + self, pending_id: str + ) -> PendingAccess | None: ... async def insert_pending(self, pending: PendingAccess) -> None: ... @@ -25,7 +27,7 @@ class MemoryStore: def __init__(self) -> None: self.requests: dict[str, PendingAccess] = {} - async def pending(self, pending_id: str) -> PendingAccess | None: + async def select_pending(self, pending_id: str) -> PendingAccess | None: return self.requests.get(pending_id) async def insert_pending(self, pending: PendingAccess) -> None: diff --git a/packages/introspection-aauth/introspection_aauth/fastapi.py b/packages/introspection-aauth/introspection_aauth/fastapi.py index 54b2192..ac54c73 100644 --- a/packages/introspection-aauth/introspection_aauth/fastapi.py +++ b/packages/introspection-aauth/introspection_aauth/fastapi.py @@ -19,11 +19,12 @@ 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 +from pydantic import BaseModel, JsonValue from .access_server.server import AccessServer from .person_server.models import ( @@ -37,6 +38,7 @@ from .person_server.server import PersonServer from .protocol.errors import AAuthError, Answer, Problem from .protocol.httpsig import SignedRequest +from .protocol.json import JsonObject from .protocol.keys import Jwks from .protocol.server import Server from .protocol.wire import ( @@ -54,13 +56,23 @@ _Acting = TypeVar("_Acting", bound=Server, contravariant=True) +class _Response(TypedDict, total=False): + """One of a route's documented responses.""" + + model: type[BaseModel] + description: str + content: JsonObject + + +Responses = dict[int | str, _Response] + + class _Docs(TypedDict, total=False): summary: str description: str operation_id: str status_code: int - response_model: object - responses: dict[int | str, dict[str, object]] + response_model: type[BaseModel] | UnionType class PersonServerResolver(Protocol): @@ -85,14 +97,14 @@ def __call__(self, request: SignedRequest, /) -> Awaitable[Answer]: ... "Signed: the request carries an RFC 9421 signature with `Signature-Key`, " "and errors are AAuth problem+json." ) -PROBLEM_RESPONSES: dict[int | str, dict[str, object]] = { +PROBLEM_RESPONSES: Responses = { "4XX": { "model": Problem, "description": "Refused", "content": {"application/problem+json": {}}, }, } -PENDING_RESPONSE: dict[int | str, dict[str, object]] = { +PENDING_RESPONSE: Responses = { HTTPStatus.ACCEPTED.value: { "model": Pending, "description": "Put to whoever decides; poll the `Location` it names", @@ -101,7 +113,7 @@ def __call__(self, request: SignedRequest, /) -> Awaitable[Answer]: ... } -def _body(model: type[BaseModel]) -> dict[str, object]: +def _body(model: type[BaseModel]) -> JsonObject: """The signed JSON body, read raw so the signature covers its bytes.""" return { "requestBody": { @@ -210,7 +222,11 @@ async def jwks(request: Request) -> Response: def _endpoint_path( - source: PersonServer | AccessServer | object, endpoint_path: str | None + source: PersonServer + | AccessServer + | PersonServerResolver + | AccessServerResolver, + endpoint_path: str | None, ) -> str: if isinstance(source, (PersonServer, AccessServer)): return urlsplit(source.endpoint_base).path @@ -244,7 +260,7 @@ def __call__( ) -> Awaitable[Answer]: ... -def _parameters(path: str) -> list[dict[str, object]]: +def _parameters(path: str) -> list[JsonValue]: names = [ segment.strip("{}").split(":")[0] for segment in path.split("/") @@ -269,10 +285,11 @@ def _signed_route( act: _Act[_Server], *, body: type[BaseModel] | None = None, + responses: Responses | None = None, **docs: Unpack[_Docs], ) -> None: """A signed protocol route: the server `find` resolves answers it by `act`.""" - extra: dict[str, object] = _body(body) if body is not None else {} + extra: JsonObject = _body(body) if body is not None else {} if parameters := _parameters(path): extra["parameters"] = parameters @@ -289,6 +306,7 @@ async def handle(signed: SignedRequest) -> Answer: 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/federation.py b/packages/introspection-aauth/introspection_aauth/person_server/federation.py index 7d5b858..8b3aa98 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/federation.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/federation.py @@ -10,10 +10,10 @@ """ import asyncio -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable from enum import StrEnum from functools import partial -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Protocol from urllib.parse import urljoin, urlsplit import http_sfv @@ -23,6 +23,7 @@ from ..protocol import jwt from ..protocol.claims import ( ACCESS_DWK, + AgentTokenClaims, AuthTokenClaims, DeliveryExpectation, PersonTokenClaims, @@ -46,6 +47,18 @@ DEFAULT_DELAY, MIN_DELAY, MAX_DELAY = 5, 1, 300 +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"]: ... + + class Step(StrEnum): ISSUED = "issued" PENDING = "pending" @@ -60,6 +73,7 @@ class Outcome(BaseModel): 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 @@ -205,11 +219,14 @@ async def _outcome( # noqa: PLR0911 "denied", "the Access Server asks for payment, which this Person Server does not make", ) - if status in (429, 503) and requested != metadata.auth_token_endpoint: + if status in (429, 503): + # Slow down and ask again: poll again, or send the request again. retry = _retry_after(response) or 0 return Outcome( step=Step.PENDING, - location=requested, + 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, ) @@ -266,6 +283,9 @@ async def _delivered( presented = PersonTokenClaims.model_validate( jwt.decode(federation.presented_token).claims ) + agent = AgentTokenClaims.model_validate( + jwt.decode(federation.agent_token).claims + ) except (VerificationError, ValidationError): return unreachable("its request's tokens cannot be read back") try: @@ -284,7 +304,7 @@ async def _delivered( ps=self._ps.issuer, rt=rt, agent_jkt=agent_jkt, - not_after=presented.exp, + not_after=min(presented.exp, agent.exp), sent=federation.budget, ).failures(claims) if wrong: @@ -303,7 +323,7 @@ async def due( ps: "PersonServer", 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 ps.store.approval(tenant, approval_id) + approval = await ps.store.select_approval(tenant, approval_id) if ( approval is None or approval.federation is None @@ -329,7 +349,7 @@ async def _record( ps: "PersonServer", tenant: str, approval_id: str, outcome: Outcome ) -> bool: async with ps.store.transaction() as tx: - approval = await tx.approval(tenant, approval_id) + approval = await tx.select_approval(tenant, approval_id) federation = approval.federation if approval else None if ( approval is None @@ -399,7 +419,7 @@ async def _spend( return Refusal( status=404, error="mission_not_found", detail="no such mission" ) - mission = await tx.mission(tenant, mission_s256) + mission = await tx.select_mission(tenant, mission_s256) granted = outcome.granted spend = ( granted.amount @@ -409,7 +429,7 @@ async def _spend( and mission.budget.same_scale(granted) else None ) - if await tx.spend( + if await tx.update_spend( tenant, mission_s256, spend, @@ -438,7 +458,7 @@ async def run( ps: "PersonServer", tenant: str, approval_id: str, - sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, + sleep: Sleep = asyncio.sleep, ) -> Step | None: """Redeem an approved request at its Access Server, polling while it decides.""" found = await due(ps, tenant, approval_id) @@ -448,7 +468,7 @@ async def run( client = AccessServerClient(ps) metadata = await client.discover(federation.access_server) - async def attempt(call: Callable[[], Awaitable[Outcome]]) -> Outcome: + async def attempt(call: Exchange) -> Outcome: """`call`, retried while the Access Server cannot be reached, then `as_unreachable`.""" for delay in TRANSPORT_RETRY_DELAYS: try: @@ -469,18 +489,16 @@ async def attempt(call: Callable[[], Awaitable[Outcome]]) -> Outcome: ) while True: waiting = await record(ps, tenant, approval_id, outcome) - if ( - not waiting - or outcome.location is None - or isinstance(metadata, Outcome) - ): + if not waiting or isinstance(metadata, Outcome): return outcome.step await sleep(poll_delay(outcome)) federation = federation.model_copy( update={"requirement": outcome.requirement} ) outcome = await attempt( - partial( + partial(client.request, federation, agent_jkt, metadata) + if outcome.location is None + else partial( client.poll, outcome.location, federation, agent_jkt, metadata ) ) diff --git a/packages/introspection-aauth/introspection_aauth/person_server/models.py b/packages/introspection-aauth/introspection_aauth/person_server/models.py index ac5df12..c780379 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/models.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/models.py @@ -1,6 +1,7 @@ """What a Person Server keeps, and the requests it takes.""" from enum import StrEnum +from typing import Literal from pydantic import BaseModel, ConfigDict, Field @@ -102,6 +103,8 @@ class Approval(BaseModel): 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. @@ -115,6 +118,8 @@ class Approval(BaseModel): 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 @@ -134,7 +139,7 @@ class MissionProposal(BaseModel): class MissionAction(BaseModel): model_config = ConfigDict(extra="ignore") - action: str + 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 diff --git a/packages/introspection-aauth/introspection_aauth/person_server/server.py b/packages/introspection-aauth/introspection_aauth/person_server/server.py index c65a33a..44e199b 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/server.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/server.py @@ -36,6 +36,7 @@ https_origin, parse, pending, + presented_token_error, verify_agent, verify_resource_token, ) @@ -78,11 +79,14 @@ def __call__( def _fits(mission: Mission, budget: Budget | None) -> bool: """Whether a request is within what is left of its mission's budget.""" - return budget is None or ( - mission.budget is not None - and mission.budget.same_scale(budget) - and mission.budget.amount - mission.spent >= budget.amount + if budget is None: + return True + if mission.budget is None: + return False + left = mission.budget.model_copy( + update={"amount": mission.budget.amount - mission.spent} ) + return budget.at_most(left) class PersonServer(Server): @@ -120,7 +124,8 @@ def __init__( self._subject_secret = subject_secret self._providers = {p.rstrip("/") for p in agent_providers} self._default_tenant = default_tenant - self._token_ttl, self._mission_ttl = token_ttl, mission_ttl + 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() @@ -176,7 +181,7 @@ async def _person( ) -> 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.binding(tenant, agent.jkt) + bound = await self.store.select_binding(tenant, agent.jkt) if bound is None: raise AAuthError( 400, "invalid_request", "name the person: login_hint" @@ -189,7 +194,10 @@ async def _person( "access_denied", "this Person Server does not speak for them", ) - if await self.store.bind(tenant, agent.jkt, person) != person: + if ( + await self.store.insert_binding(tenant, agent.jkt, person) + != person + ): raise AAuthError( 403, "invalid_request", "this agent acts for another person" ) @@ -301,11 +309,11 @@ async def mission_action( action = parse(MissionAction, request.body) tenant = self._tenant(action.tenant) mission = self._live( - await self.store.mission(tenant, mission_s256), agent.id + await self.store.select_mission(tenant, mission_s256), agent.id ) if action.action == "update": entry = action.description or "" - if not await self.store.log_mission( + if not await self.store.update_mission_log( tenant, mission.s256, LogEntry(at=self.clock(), entry=entry), @@ -315,10 +323,6 @@ async def mission_action( 403, "mission_terminated", "the mission has ended" ) return Answer(200, MissionLogged(s256=s256(entry.encode()))) - if action.action != "completion": - raise AAuthError( - 400, "invalid_request", "action is update or completion" - ) approval = self._new_approval( ApprovalKind.COMPLETION, agent, @@ -343,7 +347,8 @@ async def person_token(self, request: SignedRequest) -> Answer: mission = None if body.mission_s256 is not None: mission = self._live( - await self.store.mission(tenant, body.mission_s256), agent.id + await self.store.select_mission(tenant, body.mission_s256), + agent.id, ) if mission.person != person: raise AAuthError(404, "mission_not_found", "no such mission") @@ -361,14 +366,7 @@ def _presented(self, token: str) -> PersonTokenClaims: token, jwt.PERSON_TOKEN, self.clock(), PersonTokenClaims ) except VerificationError as exc: - code = ( - "expired_presented_token" - if exc.code == "expired_jwt" - else "invalid_presented_token" - ) - raise AAuthError( - 400, code, "not a live token this Person Server issued" - ) from None + raise presented_token_error(exc) from None async def _resource_token( self, token: str, tenant: str, agent: Agent @@ -396,7 +394,7 @@ async def _resource_token( "an auth token is issued only within a mission", ) mission = self._live( - await self.store.mission(tenant, unverified.mission_s256), + await self.store.select_mission(tenant, unverified.mission_s256), agent.id, ) resource = (unverified.iss or "").rstrip("/") @@ -406,23 +404,13 @@ async def _resource_token( "access_denied", "this mission does not cover that resource", ) - try: - rt = await verify_resource_token( - token, self.discovery, resource, self.clock() - ) - except VerificationError as exc: - raise AAuthError( - 400, "invalid_resource_token", f"does not verify: {exc.detail}" - ) from None - wrong = ResourceTokenExpectation( - ps=self.issuer, agent_jkt=agent.jkt - ).failures(rt) - if wrong: - raise AAuthError( - 400, - "invalid_resource_token", - f"its {', '.join(wrong)} not this request's", - ) + 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: @@ -486,7 +474,7 @@ async def auth_token(self, request: SignedRequest) -> Answer: 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.spend( + spent = await self.store.update_spend( tenant, mission.s256, budget.amount, True, self.clock() ) if spent is not None: @@ -564,6 +552,7 @@ def _new_approval( 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, @@ -585,11 +574,11 @@ async def poll( ) -> Answer: """`GET /pending/{tenant}/{id}`: only the key that asked may read it.""" agent = await self._agent(request) - approval = await self.store.approval(tenant, approval_id) + approval = await self.store.select_approval(tenant, approval_id) if approval is None or approval.agent_jkt != agent.jkt: raise AAuthError(404, "not_found", "no such pending request") mission = ( - await self.store.mission(tenant, approval.mission_s256) + await self.store.select_mission(tenant, approval.mission_s256) if approval.mission_s256 else None ) @@ -611,8 +600,7 @@ async def poll( ) if approval.kind is ApprovalKind.COMPLETION: return Answer(200, Empty()) - if mission is None: - raise AAuthError(404, "mission_not_found", "no such mission") + mission = self._live(mission, agent.id) if approval.kind is ApprovalKind.MISSION: person_tokens = { r: self._person_token( @@ -628,15 +616,33 @@ async def poll( person_tokens=person_tokens, ), ) - self._live(mission, agent.id) if not isinstance(approval.grant, AuthGrant): raise AAuthError( 500, "server_error", "the approval grants nothing" ) + await self._collect_once(tenant, approval) return Answer( 200, self._auth_token(tenant, agent, approval.grant, mission) ) + async def _collect_once(self, tenant: str, approval: Approval) -> None: + """Mark an approved auth token collected, or refuse a second collect.""" + if approval.expires_at <= self.clock(): + raise AAuthError( + 408, "expired", "the auth token was not collected in time" + ) + collected = approval.model_copy( + update={"collected": True, "version": approval.version + 1} + ) + if approval.collected or not await self.store.transition_approval( + tenant, approval, collected + ): + raise AAuthError( + 410, + "invalid_code", + "this request's auth token was already collected", + ) + async def _collect( self, tenant: str, @@ -746,7 +752,7 @@ async def _decide( decided_by: str, budget: Budget | None, ) -> Approval | None: - approval = await tx.approval(tenant, approval_id) + 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: @@ -837,9 +843,11 @@ async def _carry_out( return mission.s256 if approval.mission_s256 is None: return None - current = await tx.mission(tenant, approval.mission_s256) + current = await tx.select_mission(tenant, approval.mission_s256) if approval.kind is ApprovalKind.COMPLETION: - ended = await tx.end_mission(tenant, approval.mission_s256, now) + 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. @@ -852,7 +860,7 @@ async def _carry_out( and current.budget.same_scale(approval.budget) else None ) - ended = await tx.spend( + ended = await tx.update_spend( tenant, approval.mission_s256, spend, False, now ) if ended is None: @@ -878,7 +886,7 @@ async def decided(self, tenant: str, approval: Approval) -> None: 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.approval(tenant, approval_id) + approval = await self.store.select_approval(tenant, approval_id) grant = approval.grant if approval is not None else None if ( approval is None @@ -888,7 +896,7 @@ async def describe(self, tenant: str, approval_id: str) -> Approval | None: ): return approval proposal = await self.identity.read_proposal( - self.http, grant.r3_uri, grant.r3_s256, self.clock() + self.http, grant.aud, grant.r3_uri, grant.r3_s256, self.clock() ) summary = proposal.display.summary if proposal.display else None if not summary: diff --git a/packages/introspection-aauth/introspection_aauth/person_server/store.py b/packages/introspection-aauth/introspection_aauth/person_server/store.py index 3f2802b..b117921 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/store.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/store.py @@ -21,21 +21,23 @@ class Store(Protocol): spend it pays for. """ - async def binding(self, tenant: str, jkt: str) -> str | None: + async def select_binding(self, tenant: str, jkt: str) -> str | None: """The person an agent key is bound to.""" ... - async def bind(self, tenant: str, jkt: str, person: str) -> str: + 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 mission(self, tenant: str, s256: str) -> Mission | None: ... + 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 spend( + async def update_spend( self, tenant: str, s256: str, @@ -48,19 +50,19 @@ async def spend( not fit. `amount` None checks only that it is live.""" ... - async def log_mission( + 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 end_mission( + 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 approval( + async def select_approval( self, tenant: str, approval_id: str ) -> Approval | None: ... @@ -111,13 +113,13 @@ def __init__(self) -> None: self.approvals: dict[tuple[str, str], Approval] = {} self._lock = asyncio.Lock() - async def binding(self, tenant: str, jkt: str) -> str | None: + async def select_binding(self, tenant: str, jkt: str) -> str | None: return self.bindings.get((tenant, jkt)) - async def bind(self, tenant: str, jkt: str, person: str) -> str: + async def insert_binding(self, tenant: str, jkt: str, person: str) -> str: return self.bindings.setdefault((tenant, jkt), person) - async def mission(self, tenant: str, s256: str) -> Mission | None: + 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: @@ -126,7 +128,7 @@ async def insert_mission(self, tenant: str, mission: Mission) -> bool: self.missions[(tenant, mission.s256)] = mission return True - async def spend( + async def update_spend( self, tenant: str, s256: str, @@ -147,7 +149,7 @@ async def spend( mission.spent += amount return mission - async def log_mission( + async def update_mission_log( self, tenant: str, s256: str, entry: LogEntry, now: int ) -> bool: mission = _live(self.missions.get((tenant, s256)), now) @@ -155,7 +157,7 @@ async def log_mission( mission.log.append(entry) return mission is not None - async def end_mission( + async def update_mission_end( self, tenant: str, s256: str, at: int ) -> Mission | None: mission = _live(self.missions.get((tenant, s256)), at) @@ -163,7 +165,9 @@ async def end_mission( mission.completed_at = at return mission - async def approval(self, tenant: str, approval_id: str) -> Approval | None: + 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: diff --git a/packages/introspection-aauth/introspection_aauth/protocol/claims.py b/packages/introspection-aauth/introspection_aauth/protocol/claims.py index ce8f810..1aacaf7 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/claims.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/claims.py @@ -3,7 +3,7 @@ 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 Literal, Self +from typing import ClassVar, Literal, Self from pydantic import BaseModel, ConfigDict, Field, model_validator @@ -15,9 +15,10 @@ PERSON_DWK = "aauth-person.json" ACCESS_DWK = "aauth-access.json" RESOURCE_DWK = "aauth-resource.json" -#: The longest an agent or auth token may claim to live, so a forged one -#: cannot outlive the revocation of the key that signed it. +#: 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): @@ -41,36 +42,33 @@ def thumbprint(self) -> str: class Claims(BaseModel): - """What every AAuth token carries.""" + """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 | None = None + jti: str = Field(min_length=1) @model_validator(mode="after") - def _exp_after_iat(self) -> Self: + 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 ShortLived(Claims): - """A token that may live no longer than `MAX_TOKEN_LIFETIME_SECONDS`.""" - - @model_validator(mode="after") - def _short_lived(self) -> Self: - if self.exp - self.iat > MAX_TOKEN_LIFETIME_SECONDS: - raise ValueError("the token claims too long a life") - return self - - -class AgentTokenClaims(ShortLived): +class AgentTokenClaims(Claims): """`aa-agent+jwt` (`#agent-token`): the agent, and the key it signs with.""" - dwk: Literal["aauth-agent.json"] = AGENT_DWK + 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 @@ -81,7 +79,9 @@ class AgentTokenClaims(ShortLived): class PersonTokenClaims(Claims): """`aa-person+jwt` (`#person-token`): who the agent acts for, at one resource.""" - dwk: Literal["aauth-person.json"] = PERSON_DWK + 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) @@ -109,9 +109,10 @@ def scopes(self) -> set[str]: class ResourceTokenClaims(Claims, Carried): """`aa-resource+jwt` (`#resource-token`): a call a resource asks to be - authorized, for the server named in `aud`.""" + 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"] = RESOURCE_DWK + dwk: Literal["aauth-resource.json"] aud: str ps: str | None = None sub: str | None = None @@ -145,10 +146,12 @@ def requested(cls, rt: ResourceTokenClaims, sub: str) -> "AuthGrant": ) -class AuthTokenClaims(ShortLived, Carried): +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: Literal["aauth-person.json", "aauth-access.json"] aud: str sub: str = Field(min_length=1) @@ -200,7 +203,8 @@ class DeliveryExpectation(BaseModel): ps: str rt: ResourceTokenClaims agent_jkt: str - #: The presented person token's `exp`, which the auth token may not outlive. + #: 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 @@ -210,12 +214,15 @@ def failures(self, claims: AuthTokenClaims) -> list[str]: checks = { "dwk": claims.dwk != ACCESS_DWK, "iss": claims.iss != self.issuer, - "aud": claims.aud != rt.iss, + "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) diff --git a/packages/introspection-aauth/introspection_aauth/protocol/discovery.py b/packages/introspection-aauth/introspection_aauth/protocol/discovery.py index ecd3076..660bd68 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/discovery.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/discovery.py @@ -2,7 +2,6 @@ (`#metadata-documents`), cached for a bounded time.""" import time -from collections.abc import Callable from typing import Protocol, TypeVar from urllib.parse import urlsplit @@ -14,6 +13,7 @@ from .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 @@ -47,6 +47,12 @@ def is_https(value: str) -> bool: 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.""" @@ -113,9 +119,7 @@ async def jwks( self._cache[(issuer, dwk)] = (self._clock() + self._ttl, keys) return keys - async def verified( - self, issuer: str, dwk: str, check: Callable[[Jwks], T] - ) -> T: + 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)) diff --git a/packages/introspection-aauth/introspection_aauth/protocol/httpsig.py b/packages/introspection-aauth/introspection_aauth/protocol/httpsig.py index 5263b50..0152b79 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/httpsig.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/httpsig.py @@ -75,9 +75,7 @@ def jwks_uri_key(server: str, dwk: str, kid: str) -> str: ) -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.""" +def _signature_key(signature_key: str) -> http_sfv.Item | None: parsed = http_sfv.Dictionary() try: parsed.parse(signature_key.encode("ascii")) @@ -86,7 +84,20 @@ def named_server(signature_key: str) -> str | None: if len(parsed) != 1: return None item = next(iter(parsed.values())) - if not isinstance(item, http_sfv.Item) or str(item.value) != "jwks_uri": + 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 @@ -272,8 +283,6 @@ async def verify( request: SignedRequest, now: int, resolve: KeyResolver | None = None, - *, - require_content_digest: bool = True, ) -> Verified: """Verify `request`'s signature; `resolve` finds a `jwks_uri` key.""" headers, body = request.headers, request.body @@ -302,10 +311,12 @@ async def verify( 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 and require_content_digest and "content-digest" not in names: - raise VerificationError( - "invalid_input", "content-digest must be covered" - ) + 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: @@ -323,7 +334,9 @@ async def verify( + [f'"@signature-params": {signature_input}'] ) signature = _dictionary(headers, "signature").get(label) - if signature is None or not isinstance(signature.value, bytes): + if not isinstance(signature, http_sfv.Item) or not isinstance( + signature.value, bytes + ): raise VerificationError( "invalid_signature", "no Signature for the key" ) diff --git a/packages/introspection-aauth/introspection_aauth/protocol/keys.py b/packages/introspection-aauth/introspection_aauth/protocol/keys.py index ed2d5eb..a15d5dd 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/keys.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/keys.py @@ -62,7 +62,8 @@ def s256(data: bytes) -> str: def thumbprint(jwk: Jwk) -> str: - """RFC 7638 thumbprint of an OKP or EC public key.""" + """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: @@ -111,7 +112,7 @@ def verify(jwk: Jwk, data: bytes, signature: bytes) -> bool: ) public.verify(der, data, ec.ECDSA(hashes.SHA256())) return True - except (InvalidSignature, ValueError): + except (InvalidSignature, ValueError, VerificationError): return False diff --git a/packages/introspection-aauth/introspection_aauth/protocol/models.py b/packages/introspection-aauth/introspection_aauth/protocol/models.py index cf63637..37a5369 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/models.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/models.py @@ -20,6 +20,10 @@ class Budget(BaseModel): def same_scale(self, other: "Budget") -> bool: return self.unit == other.unit and self.decimals == other.decimals + def at_most(self, limit: "Budget") -> 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.""" diff --git a/packages/introspection-aauth/introspection_aauth/protocol/server.py b/packages/introspection-aauth/introspection_aauth/protocol/server.py index 9feb531..445c4f4 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/server.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/server.py @@ -8,6 +8,7 @@ from typing import NotRequired, Protocol, TypedDict, TypeVar, Unpack from urllib.parse import urlsplit +import http_sfv import httpx2 as httpx from pydantic import BaseModel, ValidationError @@ -20,10 +21,11 @@ AgentTokenClaims, Claims, ResourceTokenClaims, + ResourceTokenExpectation, ) from .discovery import Discovery from .errors import AAuthError, Answer, VerificationError -from .keys import Jwks, SigningKey, jwks, s256 +from .keys import Jwk, Jwks, SigningKey, jwks, s256 from .models import Agent from .wire import Pending, R3Proposal @@ -41,8 +43,12 @@ "now_seconds", "parse", "pending", + "presented_token_error", + "requirement", + "same_origin", "verify_agent", "verify_agent_token", + "verify_resource_token", "verify_server", ] @@ -54,10 +60,12 @@ def now_seconds() -> int: return int(time.time()) -def https_origin(value: object) -> bool: +#: How a `401` names the scheme an agent should sign with. +ACCEPT_JWT_SCHEME = {"Accept-Signature-Scheme": "jwt"} + + +def https_origin(value: str) -> bool: """A server identifier: `https://host`, with no path, query or fragment.""" - if not isinstance(value, str): - return False parts = urlsplit(value) return ( parts.scheme == "https" @@ -78,6 +86,51 @@ def parse(model: type[Model], body: bytes | None) -> Model: ) from None +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) + + +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 presented_token_error(exc: VerificationError) -> AAuthError: + """The refusal of a presented person token that does not verify.""" + code = ( + "expired_presented_token" + if exc.code == "expired_jwt" + else "invalid_presented_token" + ) + return AAuthError(400, code, exc.detail) + + def pending( location: str, retry_after: int, requirement: str | None = None ) -> Answer: @@ -89,11 +142,14 @@ def pending( "Location": location, "Retry-After": str(retry_after), "Cache-Control": "no-store", - "AAuth-Requirement": requirement or "requirement=approval", + "AAuth-Requirement": requirement or APPROVAL_REQUIREMENT, }, ) +APPROVAL_REQUIREMENT = "requirement=approval" + + class Identity: """A server's identifier and keys: the current key signs, and the previous ones stay published until what they signed has expired.""" @@ -134,9 +190,18 @@ def signed( return httpsig.sign(self.key, key, method, url, body=body, created=now) async def read_proposal( - self, http: httpx.AsyncClient, r3_uri: str, r3_s256: str, now: int + 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.""" + """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.""" + 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), @@ -175,6 +240,8 @@ class ServerOptions(TypedDict): 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: @@ -193,7 +260,7 @@ def __init__( ) self.issuer = self.identity.issuer self.http = http - self.discovery = Discovery(http) + self.discovery = options.get("discovery") or Discovery(http) self.endpoint_base = options.get("endpoint_base", self.issuer).rstrip( "/" ) @@ -211,16 +278,35 @@ def metadata(self) -> BaseModel: async def verify_resource_token( - token: str, discovery: Discovery, resource: str, now: int + token: str, + discovery: Discovery, + resource: str, + now: int, + expectation: ResourceTokenExpectation, ) -> ResourceTokenClaims: - """A resource token, against its resource's keys (`#resource-token`).""" - return await discovery.verified( - resource, - RESOURCE_DWK, - lambda keys: jwt.verify( - token, keys, jwt.RESOURCE_TOKEN, now, 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 AAuthError( + 400, "invalid_resource_token", f"does not verify: {exc.detail}" + ) from None + wrong = expectation.failures(rt) + if wrong: + raise AAuthError( + 400, + "invalid_resource_token", + f"its {', '.join(wrong)} not this request's", + ) + return rt async def verify_agent_token( @@ -248,8 +334,14 @@ async def verify_agent( providers: set[str], now: int, ) -> Agent: - """An agent's signed request (`sig=jwt`, its agent token), or a `401`.""" + """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") @@ -261,12 +353,8 @@ async def verify_agent( "invalid_signature", "not signed by the token's key" ) except VerificationError as exc: - headers = ( - {"Accept-Signature-Scheme": "jwt"} - if exc.code == "unsupported_scheme" - else {} - ) - raise AAuthError(401, exc.code, exc.detail, headers) from None + hint = ACCEPT_JWT_SCHEME if exc.code == "unsupported_scheme" else None + raise AAuthError(401, exc.code, exc.detail, hint) from None return Agent( id=claims.sub, provider=claims.iss.rstrip("/"), @@ -294,8 +382,17 @@ async def verify_server( "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, discovery.key) + verified = await httpsig.verify(request, now, resolve) except VerificationError as exc: raise AAuthError(401, exc.code, exc.detail) from None if verified.scheme != "jwks_uri" or verified.dwk != dwk: 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/tests/test_boundaries.py b/packages/introspection-aauth/tests/test_boundaries.py new file mode 100644 index 0000000..8725a6c --- /dev/null +++ b/packages/introspection-aauth/tests/test_boundaries.py @@ -0,0 +1,314 @@ +"""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 Verdict +from introspection_aauth.person_server import ( + Approval, + ApprovalStatus, + federation, +) +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.wire import 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), + ) + assert (await world.follow(proposed)).status_code == 200 + world.now += 120 + ended = await world.follow(proposed) + assert ended.status_code == 403 + assert problem(ended).error == "mission_terminated" + + +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.net.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.net.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.net.calls == [f"POST {url}"], world.net.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.net.apps[AS] = Scripted(world.net.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.net.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 federation.due(world.ps, TENANT, approval) + assert due is not None + client = federation.AccessServerClient(world.ps) + metadata = await client.discover(due[0].access_server) + assert isinstance(metadata, federation.Metadata) + world.net.apps[AS] = Scripted( + world.net.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 federation.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"}' + ) diff --git a/packages/introspection-aauth/tests/test_federation.py b/packages/introspection-aauth/tests/test_federation.py index 3404ba4..12950d1 100644 --- a/packages/introspection-aauth/tests/test_federation.py +++ b/packages/introspection-aauth/tests/test_federation.py @@ -11,6 +11,9 @@ ApprovalStatus, federation, ) +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 ( @@ -155,11 +158,11 @@ async def test_sub_agents_are_not_federated(): refused = await world.call( "POST", f"{PS}/aauth/token", - { - "resource_token": token, - "presented_token": person_token, - "subagent_token": "x", - }, + AuthTokenRequest( + resource_token=token, + presented_token=person_token, + subagent_token="x", + ), ) assert refused.status_code == 400 assert problem(refused).error == "invalid_request" diff --git a/packages/introspection-aauth/tests/test_models.py b/packages/introspection-aauth/tests/test_models.py index f36f565..8fd07f8 100644 --- a/packages/introspection-aauth/tests/test_models.py +++ b/packages/introspection-aauth/tests/test_models.py @@ -24,10 +24,14 @@ jwt, ) from introspection_aauth.protocol.claims import ( + AGENT_DWK, + PERSON_DWK, + RESOURCE_DWK, AgentExpectation, AgentTokenClaims, AuthGrant, AuthTokenClaims, + Claims, Confirmation, DeliveryExpectation, PersonTokenClaims, @@ -48,11 +52,25 @@ OTHER = SigningKey.generate() -def test_no_module_uses_typing_any(): +def _annotations(tree: ast.AST) -> list[ast.expr]: + 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) + return found + + +def test_no_module_types_anything_as_any_or_object(): """Bodies and claims are models; `JsonValue` is for arbitrary JSON.""" found = [] for path in [*PACKAGE.rglob("*.py"), *TESTS.rglob("*.py")]: - for node in ast.walk(ast.parse(path.read_text())): + tree = ast.parse(path.read_text()) + for node in ast.walk(tree): if ( isinstance(node, ast.ImportFrom) and node.module == "typing" @@ -64,11 +82,19 @@ def test_no_module_uses_typing_any(): and node.value.id == "typing" ): found.append(f"{path.name}:{node.lineno}") + for annotation in _annotations(tree): + if any( + isinstance(n, ast.Name) and n.id == "object" + for n in ast.walk(annotation) + ): + found.append(f"{path.name}:{annotation.lineno}") assert found == [] def resource_claims(**update: str | None) -> ResourceTokenClaims: claims = ResourceTokenClaims( + dwk=RESOURCE_DWK, + jti="rt-1", iss=RESOURCE, aud=AS, ps=PS, @@ -77,6 +103,9 @@ def resource_claims(**update: str | None) -> ResourceTokenClaims: 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, @@ -109,6 +138,13 @@ def delivery(rt: ResourceTokenClaims) -> DeliveryExpectation: ) +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 ( @@ -128,6 +164,9 @@ def test_an_auth_token_for_exactly_the_call_passes(): ({"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( @@ -158,6 +197,7 @@ def test_resource_presented_and_agent_expectations_name_their_failures(): 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", @@ -176,6 +216,8 @@ def test_resource_presented_and_agent_expectations_name_their_failures(): 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), @@ -193,7 +235,7 @@ def test_resource_presented_and_agent_expectations_name_their_failures(): ("payload", "detail"), [ ({"iat": 200, "exp": 100}, "exp is not after iat"), - ({"iat": 100, "exp": 100 + 7_200}, "too long a life"), + ({"iat": 100, "exp": 100 + 86_401}, "lifetime is over 86400"), ({"cnf": {"jwk": {"kty": "oct"}}}, "cnf"), ({"cnf": {}}, "cnf.jwk"), ({"sub": ""}, "sub"), @@ -209,6 +251,7 @@ def test_malformed_agent_token_claims_do_not_verify( "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 @@ -245,6 +288,7 @@ def test_a_stored_grant_of_any_shape_decodes(): "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, @@ -353,6 +397,8 @@ 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), @@ -390,3 +436,47 @@ async def test_an_authorization_header_must_be_signed_when_present(): 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 index 5be08d4..6fdfda3 100644 --- a/packages/introspection-aauth/tests/test_person_server.py +++ b/packages/introspection-aauth/tests/test_person_server.py @@ -11,9 +11,15 @@ MemoryStore, Mission, ) -from introspection_aauth.person_server.models import MissionAnswer +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, @@ -62,11 +68,11 @@ async def test_a_mission_goes_to_a_person_who_sets_its_budget(): proposed = await world.call( "POST", f"{PS}/aauth/mission", - { - "description": "Book Sydney", - "resources": [RESOURCE], - "login_hint": "sam", - }, + MissionProposal( + description="Book Sydney", + resources=[RESOURCE], + login_hint="sam", + ), ) assert proposed.status_code == 202 assert proposed.headers["aauth-requirement"] == "requirement=approval" @@ -124,7 +130,7 @@ async def test_only_the_missions_person_accepts_its_completion(): done = await world.call( "POST", f"{PS}/aauth/mission/{mission.s256}", - {"action": "completion", "summary": "Booked"}, + MissionAction(action="completion", summary="Booked"), ) assert done.status_code == 202 with pytest.raises(AAuthError) as refused: @@ -139,7 +145,7 @@ async def test_only_the_missions_person_accepts_its_completion(): TENANT, approval_id(done), ApprovalStatus.APPROVED, decided_by="sam" ) assert (await world.follow(done)).status_code == 200 - stored = await world.ps_store.mission(TENANT, mission.s256) + 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 @@ -152,10 +158,10 @@ async def test_a_mission_logs_updates(): logged = await world.call( "POST", f"{PS}/aauth/mission/{mission.s256}", - {"action": "update", "description": "Found two flights"}, + MissionAction(action="update", description="Found two flights"), ) assert logged.status_code == 200 - stored = await world.ps_store.mission(TENANT, mission.s256) + stored = await world.ps_store.select_mission(TENANT, mission.s256) assert stored is not None and stored.log[0].entry == "Found two flights" @@ -173,7 +179,7 @@ async def test_an_agent_from_an_untrusted_provider_is_refused(): refused = await world.call( "POST", f"{PS}/aauth/mission", - {"description": "x", "login_hint": "sam"}, + MissionProposal(description="x", login_hint="sam"), ) assert refused.status_code == 401 assert problem(refused).error == "invalid_jwt" @@ -185,11 +191,13 @@ async def test_a_key_acts_for_one_person(): other = await world.call( "POST", f"{PS}/aauth/person-token", - {"resource": RESOURCE, "login_hint": "someone-else"}, + PersonTokenRequest(resource=RESOURCE, login_hint="someone-else"), ) assert other.status_code == 403 same = await world.call( - "POST", f"{PS}/aauth/person-token", {"resource": RESOURCE} + "POST", + f"{PS}/aauth/person-token", + PersonTokenRequest(resource=RESOURCE), ) assert same.status_code == 200 claims = read( @@ -206,6 +214,7 @@ async def test_identical_missions_for_two_people_in_one_second_stay_apart(): world.agent_key = SigningKey.generate() world.agent_token = encode( AgentTokenClaims( + dwk=AGENT_DWK, iss=AP, sub="aauth:travel@agents.example", jti="agent-2", @@ -219,11 +228,11 @@ async def test_identical_missions_for_two_people_in_one_second_stay_apart(): proposed = await world.call( "POST", f"{PS}/aauth/mission", - { - "description": "Book Sydney", - "resources": [RESOURCE], - "login_hint": "kim", - }, + MissionProposal( + description="Book Sydney", + resources=[RESOURCE], + login_hint="kim", + ), ) await world.ps.decide( TENANT, @@ -237,7 +246,7 @@ async def test_identical_missions_for_two_people_in_one_second_stay_apart(): 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.mission(TENANT, kim.s256) + stored = await world.ps_store.select_mission(TENANT, kim.s256) assert stored is not None and stored.person == "kim" @@ -253,11 +262,11 @@ async def test_a_mission_whose_hash_is_taken_is_refused_not_reused(): proposed = await world.call( "POST", f"{PS}/aauth/mission", - { - "description": "Book Sydney", - "resources": [RESOURCE], - "login_hint": "sam", - }, + MissionProposal( + description="Book Sydney", + resources=[RESOURCE], + login_hint="sam", + ), ) with pytest.raises(AAuthError) as refused: await world.ps.decide( @@ -268,7 +277,9 @@ async def test_a_mission_whose_hash_is_taken_is_refused_not_reused(): budget=usd(100_000), ) assert refused.value.status == 409 - approval = await world.ps_store.approval(TENANT, approval_id(proposed)) + approval = await world.ps_store.select_approval( + TENANT, approval_id(proposed) + ) assert approval is not None and approval.status is ApprovalStatus.PENDING @@ -276,11 +287,11 @@ async def propose(world: World) -> str: proposed = await world.call( "POST", f"{PS}/aauth/mission", - { - "description": "Book Sydney", - "resources": [RESOURCE], - "login_hint": "sam", - }, + MissionProposal( + description="Book Sydney", + resources=[RESOURCE], + login_hint="sam", + ), ) return approval_id(proposed) @@ -300,7 +311,8 @@ async def test_a_decision_in_the_callers_unit_of_work_commits_with_it(): assert decided is not None and decided.mission_s256 is not None await world.ps.decided(TENANT, decided) assert ( - await world.ps_store.mission(TENANT, decided.mission_s256) is not None + await world.ps_store.select_mission(TENANT, decided.mission_s256) + is not None ) @@ -319,6 +331,9 @@ async def test_a_decision_in_the_callers_unit_of_work_rolls_back_with_it(): ) 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.mission(TENANT, decided.mission_s256) is None - approval = await world.ps_store.approval(TENANT, pending) + 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 index fdce60c..cfe3dbb 100644 --- a/packages/introspection-aauth/tests/test_protocol.py +++ b/packages/introspection-aauth/tests/test_protocol.py @@ -24,10 +24,11 @@ jwt, ) from introspection_aauth.protocol.claims import ( + AGENT_DWK, AgentTokenClaims, Claims, Confirmation, - ShortLived, + PersonTokenClaims, ) from introspection_aauth.protocol.keys import ( Jwk, @@ -85,6 +86,7 @@ def test_what_this_package_signs_verifies_under_the_data_planes_verifier(): now = int(time.time()) token = jwt.encode( AgentTokenClaims( + dwk=AGENT_DWK, iss="https://agents.example", sub="aauth:travel@agents.example", jti="a1", @@ -128,7 +130,7 @@ def test_what_this_package_signs_verifies_under_the_data_planes_verifier(): ], ) issued = jwt.encode( - Claims(iss="https://ps.example", iat=now, exp=now + 60), + Claims(iss="https://ps.example", jti="c1", iat=now, exp=now + 60), provider, jwt.PERSON_TOKEN, ) @@ -139,6 +141,8 @@ 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), @@ -211,7 +215,9 @@ async def resolve(issuer: str, dwk: str, kid: str) -> Jwk: def test_tokens_verify_only_as_issued(): key = SigningKey.generate() token = jwt.encode( - Claims(iss="https://ps.example", iat=100, exp=200), key, jwt.AUTH_TOKEN + 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 @@ -230,20 +236,22 @@ def test_tokens_verify_only_as_issued(): ) assert other_key.value.code == "unknown_key" long = forge( - {"iss": "https://ps.example", "iat": 100, "exp": 100 + 7_200}, + {"iss": "https://ps.example", "jti": "c1", "iat": 100, "exp": 7_300}, key, - jwt.AGENT_TOKEN, + 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.AGENT_TOKEN, 150, ShortLived) + jwt.verify(long, jwks([key]), jwt.AUTH_TOKEN, 150, PersonTokenClaims) assert too_long.value.code == "invalid_jwt" - assert "too long a life" in too_long.value.detail def test_a_previous_key_still_verifies_what_it_signed(): old, new = SigningKey.generate(), SigningKey.generate() token = jwt.encode( - Claims(iss="https://ps.example", iat=1, exp=10), old, jwt.PERSON_TOKEN + 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) diff --git a/packages/introspection-aauth/tests/test_refusals.py b/packages/introspection-aauth/tests/test_refusals.py index 6d8a080..7f4eea8 100644 --- a/packages/introspection-aauth/tests/test_refusals.py +++ b/packages/introspection-aauth/tests/test_refusals.py @@ -11,6 +11,11 @@ ApprovalStatus, federation, ) +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 @@ -35,7 +40,7 @@ async def test_an_approval_nobody_answers_expires(): proposed = await world.call( "POST", f"{PS}/aauth/mission", - {"description": "x", "login_hint": "sam"}, + MissionProposal(description="x", login_hint="sam"), ) world.now += 120 expired = await world.follow(proposed) @@ -93,7 +98,7 @@ async def test_a_resource_the_mission_does_not_cover_is_refused_before_its_keys_ refused = await world.call( "POST", f"{PS}/aauth/token", - {"resource_token": forged, "presented_token": person_token}, + AuthTokenRequest(resource_token=forged, presented_token=person_token), ) assert refused.status_code == 403 assert not any("elsewhere" in c for c in world.net.calls) @@ -104,15 +109,17 @@ async def test_a_resource_token_not_matching_the_presented_token_is_refused(): mission = await world.approved_mission(budget=1_000) token, _ = world.resource_token(mission, 10) other = await world.call( - "POST", f"{PS}/aauth/person-token", {"resource": RESOURCE} + "POST", + f"{PS}/aauth/person-token", + PersonTokenRequest(resource=RESOURCE), ) refused = await world.call( "POST", f"{PS}/aauth/token", - { - "resource_token": token, - "presented_token": body(other, PersonTokenAnswer).person_token, - }, + AuthTokenRequest( + resource_token=token, + presented_token=body(other, PersonTokenAnswer).person_token, + ), ) assert refused.status_code == 400 assert problem(refused).error == "invalid_resource_token" @@ -127,7 +134,7 @@ async def test_an_expired_presented_token_is_never_federated(): refused = await world.call( "POST", f"{PS}/aauth/token", - {"resource_token": token, "presented_token": person_token}, + AuthTokenRequest(resource_token=token, presented_token=person_token), ) assert problem(refused).error == "expired_presented_token" @@ -295,7 +302,9 @@ async def test_a_calls_r3_proposal_is_read_into_its_approval(): over = await world.call( "POST", f"{PS}/aauth/token", - {"resource_token": with_proposal, "presented_token": person_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" diff --git a/packages/introspection-aauth/tests/world.py b/packages/introspection-aauth/tests/world.py index c7ba81c..fba116d 100644 --- a/packages/introspection-aauth/tests/world.py +++ b/packages/introspection-aauth/tests/world.py @@ -21,9 +21,15 @@ MemoryStore, PersonServer, ) -from introspection_aauth.person_server.models import MissionAnswer +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, @@ -174,6 +180,7 @@ def __init__( self.agent_token = encode( AgentTokenClaims( iss=AP, + dwk=AGENT_DWK, sub="aauth:travel@agents.example", jti="agent-1", iat=T0, @@ -188,14 +195,20 @@ async def call( self, method: str, url: str, - body: BaseModel | JsonObject | None = None, + 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 (`sig=jwt`, its agent token); a raw - object is for a body no request model would build.""" - if isinstance(body, BaseModel): - content = body.model_dump_json(exclude_none=True).encode() - else: - content = json.dumps(body).encode() if body is not None else None + """A request the agent signs, with a body no request model would build.""" headers = sign( self.agent_key, jwt_key(self.agent_token), @@ -216,11 +229,11 @@ async def approved_mission(self, budget: int) -> MissionAnswer: proposed = await self.call( "POST", f"{PS}/aauth/mission", - { - "description": "Book Sydney", - "resources": [RESOURCE], - "login_hint": "sam", - }, + MissionProposal( + description="Book Sydney", + resources=[RESOURCE], + login_hint="sam", + ), ) approval_id = urlsplit(proposed.headers["location"]).path.rsplit( "/", 1 @@ -243,6 +256,8 @@ def resource_claims( 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, @@ -274,10 +289,12 @@ async def ask( return await self.call( "POST", f"{PS}/aauth/token", - {"resource_token": token, "presented_token": person_token}, + AuthTokenRequest( + resource_token=token, presented_token=person_token + ), ) async def spent(self, s256: str) -> int: - mission = await self.ps_store.mission(TENANT, s256) + mission = await self.ps_store.select_mission(TENANT, s256) assert mission is not None return mission.spent From ef3c239eba09031e0b862c11596f234bc4c994e0 Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 15:00:01 -0700 Subject: [PATCH 13/34] fix(aauth): verify a signed request at the path its signer sent, still percent-encoded A tenant that needs escaping in a pending URL (`org:proj`) made every poll fail its signature: the routers rebuilt the URL from the decoded path. They now use the raw path and query, and read the pending parts from it once. Co-Authored-By: Claude Opus 5.5 --- .../introspection_aauth/fastapi.py | 22 +++++++++++++--- .../tests/test_boundaries.py | 25 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/introspection-aauth/introspection_aauth/fastapi.py b/packages/introspection-aauth/introspection_aauth/fastapi.py index ac54c73..912b133 100644 --- a/packages/introspection-aauth/introspection_aauth/fastapi.py +++ b/packages/introspection-aauth/introspection_aauth/fastapi.py @@ -141,9 +141,23 @@ def _respond(answer: Answer) -> Response: 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: - path = request.url.path.removeprefix(urlsplit(base).path) - query = f"?{request.url.query}" if request.url.query else "" + 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}", @@ -314,7 +328,9 @@ async def handle(signed: SignedRequest) -> Answer: async def _poll_person_server( server: PersonServer, signed: SignedRequest, path: dict[str, str] ) -> Answer: - parts = server.pending_parts(path["pending_path"]) + # 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 AAuthError(404, "not_found", "no such pending request") return await server.poll(signed, *parts) diff --git a/packages/introspection-aauth/tests/test_boundaries.py b/packages/introspection-aauth/tests/test_boundaries.py index 8725a6c..f662adb 100644 --- a/packages/introspection-aauth/tests/test_boundaries.py +++ b/packages/introspection-aauth/tests/test_boundaries.py @@ -312,3 +312,28 @@ async def test_a_token_request_body_is_its_model(): ).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 From 83b2cac552ce0f07b47d88784b7f9b7c80de1bcd Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 15:01:38 -0700 Subject: [PATCH 14/34] fix(aauth): a pending URL gives its terminal answer once, then answers 410 Per #pending-url-security: an approved mission's person tokens, a completion, a refusal and an expiry are each answered once, at the Person Server and the Access Server alike; the URL then answers 410 invalid_code. Co-Authored-By: Claude Opus 5.5 --- .../access_server/models.py | 3 +- .../access_server/server.py | 24 +++---- .../person_server/server.py | 34 ++++++---- .../tests/test_boundaries.py | 64 ++++++++++++++++++- 4 files changed, 98 insertions(+), 27 deletions(-) diff --git a/packages/introspection-aauth/introspection_aauth/access_server/models.py b/packages/introspection-aauth/introspection_aauth/access_server/models.py index 3332287..92133ce 100644 --- a/packages/introspection-aauth/introspection_aauth/access_server/models.py +++ b/packages/introspection-aauth/introspection_aauth/access_server/models.py @@ -86,7 +86,8 @@ class PendingStatus(StrEnum): PENDING = "pending" APPROVED = "approved" DENIED = "denied" - #: Approved, and its auth token handed out: an auth token is collected once. + #: Its terminal answer given, which is given once: an auth token, a + #: refusal or an expiry. COLLECTED = "collected" diff --git a/packages/introspection-aauth/introspection_aauth/access_server/server.py b/packages/introspection-aauth/introspection_aauth/access_server/server.py index 1112211..ad4d6ce 100644 --- a/packages/introspection-aauth/introspection_aauth/access_server/server.py +++ b/packages/introspection-aauth/introspection_aauth/access_server/server.py @@ -346,28 +346,30 @@ async def poll(self, request: SignedRequest, pending_id: str) -> Answer: record = await self.store.select_pending(pending_id) if record is None or record.request.ps != ps: raise AAuthError(404, "not_found", "no such pending request") - if record.status is PendingStatus.DENIED: - raise AAuthError( - 403, "denied", "this resource's policy refuses it" - ) if record.status is PendingStatus.COLLECTED: raise AAuthError( - 410, "invalid_code", "its auth token was issued already" + 410, "invalid_code", "this request was already answered" ) - if self.clock() >= record.expires_at: - raise AAuthError(408, "expired", "nobody decided in time") - if record.status is PendingStatus.PENDING: + expired = self.clock() >= record.expires_at + if record.status is PendingStatus.PENDING and not expired: return self._waiting(record) - collected = record.model_copy( + # A terminal answer is given once; the pending URL then answers 410. + answered = record.model_copy( update={ "status": PendingStatus.COLLECTED, "version": record.version + 1, } ) - if not await self.store.transition_pending(record, collected): + if not await self.store.transition_pending(record, answered): raise AAuthError( - 410, "invalid_code", "its auth token was issued already" + 410, "invalid_code", "this request was already answered" ) + if record.status is PendingStatus.DENIED: + raise AAuthError( + 403, "denied", "this resource's policy refuses it" + ) + if expired: + raise AAuthError(408, "expired", "nobody decided in time") return self._issue(record, record.scope, record.budget) async def decide( diff --git a/packages/introspection-aauth/introspection_aauth/person_server/server.py b/packages/introspection-aauth/introspection_aauth/person_server/server.py index 44e199b..431dc91 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/server.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/server.py @@ -582,14 +582,20 @@ async def poll( if approval.mission_s256 else None ) + if approval.collected: + raise AAuthError( + 410, "invalid_code", "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 AAuthError(403, "denied", "the approver declined") if approval.status is ApprovalStatus.PENDING: if approval.expires_at <= self.clock(): + await self._answered(tenant, approval) raise AAuthError(408, "expired", "nobody answered in time") return pending( self.pending_url(tenant, approval.id), self.retry_after @@ -598,10 +604,15 @@ async def poll( return await self._collect( tenant, agent, approval, federation, mission ) + if approval.expires_at <= self.clock(): + await self._answered(tenant, approval) + raise AAuthError(408, "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 @@ -620,27 +631,22 @@ async def poll( raise AAuthError( 500, "server_error", "the approval grants nothing" ) - await self._collect_once(tenant, approval) + await self._answered(tenant, approval) return Answer( 200, self._auth_token(tenant, agent, approval.grant, mission) ) - async def _collect_once(self, tenant: str, approval: Approval) -> None: - """Mark an approved auth token collected, or refuse a second collect.""" - if approval.expires_at <= self.clock(): - raise AAuthError( - 408, "expired", "the auth token was not collected in time" - ) - collected = approval.model_copy( + 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 approval.collected or not await self.store.transition_approval( - tenant, approval, collected + if not await self.store.transition_approval( + tenant, approval, answered ): raise AAuthError( - 410, - "invalid_code", - "this request's auth token was already collected", + 410, "invalid_code", "this request was already answered" ) async def _collect( @@ -661,6 +667,7 @@ async def _collect( ) if federation.auth_token is None: if approval.expires_at <= now: + await self._answered(tenant, approval) raise AAuthError( 408, "expired", "the Access Server did not answer in time" ) @@ -672,6 +679,7 @@ async def _collect( 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 AAuthError( 408, "expired", diff --git a/packages/introspection-aauth/tests/test_boundaries.py b/packages/introspection-aauth/tests/test_boundaries.py index f662adb..9a4e717 100644 --- a/packages/introspection-aauth/tests/test_boundaries.py +++ b/packages/introspection-aauth/tests/test_boundaries.py @@ -80,13 +80,71 @@ async def test_a_mission_approval_polled_after_the_mission_ended_is_refused(): decided_by="dana", budget=usd(1_000), ) - assert (await world.follow(proposed)).status_code == 200 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" + + +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 federation.due(world.ps, TENANT, kicked[0]) + assert due is not None + client = federation.AccessServerClient(world.ps) + metadata = await client.discover(due[0].access_server) + assert isinstance(metadata, federation.Metadata) + 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 + + async def test_spends_against_one_mission_at_once_never_exceed_its_budget(): world = World() mission = await world.approved_mission(budget=100_000) @@ -315,7 +373,9 @@ async def test_a_token_request_body_is_its_model(): @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): +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( From 450137392e1cfe235965b848c02cf90080ad6c8b Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 15:02:51 -0700 Subject: [PATCH 15/34] fix(aauth): a proposal's summary is written without making a decision or federation stale Store.update_approval_summary sets the summary and leaves the version, and transition_approval keeps the stored summary. Both servers read a proposal the same way: one that cannot be read leaves no summary, and is logged. Co-Authored-By: Claude Opus 5.5 --- .../access_server/server.py | 10 +- .../person_server/server.py | 19 +--- .../person_server/store.py | 24 ++++- .../introspection_aauth/protocol/server.py | 30 ++++++ .../tests/test_refusals.py | 94 +++++++++++++++++++ 5 files changed, 155 insertions(+), 22 deletions(-) diff --git a/packages/introspection-aauth/introspection_aauth/access_server/server.py b/packages/introspection-aauth/introspection_aauth/access_server/server.py index ad4d6ce..f7d949c 100644 --- a/packages/introspection-aauth/introspection_aauth/access_server/server.py +++ b/packages/introspection-aauth/introspection_aauth/access_server/server.py @@ -211,13 +211,9 @@ async def _presented_token( async def _summary(self, rt: ResourceTokenClaims) -> str | None: if rt.r3_uri is None or rt.r3_s256 is None: return None - try: - proposal = await self.identity.read_proposal( - self.http, rt.iss, rt.r3_uri, rt.r3_s256, self.clock() - ) - except (httpx.HTTPError, ValueError): - return None - return proposal.display.summary if proposal.display else None + return await self.identity.summary( + self.http, rt.iss, rt.r3_uri, rt.r3_s256, self.clock() + ) # endregion diff --git a/packages/introspection-aauth/introspection_aauth/person_server/server.py b/packages/introspection-aauth/introspection_aauth/person_server/server.py index 431dc91..ceea5a2 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/server.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/server.py @@ -903,22 +903,13 @@ async def describe(self, tenant: str, approval_id: str) -> Approval | None: or grant.r3_s256 is None ): return approval - proposal = await self.identity.read_proposal( + summary = await self.identity.summary( self.http, grant.aud, grant.r3_uri, grant.r3_s256, self.clock() ) - summary = proposal.display.summary if proposal.display else None - if not summary: + if summary is None or not await self.store.update_approval_summary( + tenant, approval.id, summary + ): return approval - changed = approval.model_copy( - update={ - "summary": summary[:2_000], - "version": approval.version + 1, - } - ) - return ( - changed - if await self.store.transition_approval(tenant, approval, changed) - else approval - ) + return approval.model_copy(update={"summary": summary}) # endregion diff --git a/packages/introspection-aauth/introspection_aauth/person_server/store.py b/packages/introspection-aauth/introspection_aauth/person_server/store.py index b117921..431f338 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/store.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/store.py @@ -74,7 +74,15 @@ 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.""" + `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"]: ... @@ -176,10 +184,24 @@ async def insert_approval(self, tenant: str, approval: Approval) -> None: 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() diff --git a/packages/introspection-aauth/introspection_aauth/protocol/server.py b/packages/introspection-aauth/introspection_aauth/protocol/server.py index 445c4f4..3d8c86f 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/server.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/server.py @@ -2,6 +2,7 @@ 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 @@ -52,6 +53,8 @@ "verify_server", ] +logger = logging.getLogger(__name__) + Model = TypeVar("Model", bound=BaseModel) ClaimsModel = TypeVar("ClaimsModel", bound=Claims) @@ -212,6 +215,33 @@ async def read_proposal( 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 + def new_hex_id() -> str: return uuid.uuid4().hex diff --git a/packages/introspection-aauth/tests/test_refusals.py b/packages/introspection-aauth/tests/test_refusals.py index 7f4eea8..760965e 100644 --- a/packages/introspection-aauth/tests/test_refusals.py +++ b/packages/introspection-aauth/tests/test_refusals.py @@ -308,3 +308,97 @@ async def test_a_calls_r3_proposal_is_read_into_its_approval(): ) 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.net.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.net.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 federation.due(world.ps, TENANT, kicked[0]) + assert due is not None + client = federation.AccessServerClient(world.ps) + metadata = await client.discover(due[0].access_server) + assert isinstance(metadata, federation.Metadata) + first = await client.request(due[0], due[1], metadata) + assert await federation.record(world.ps, 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 federation.record(world.ps, 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 From 1afb891f67f18be85532925b64d002da0e35b587 Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 15:03:34 -0700 Subject: [PATCH 16/34] fix(aauth): an Access Server's token endpoint and keys must be on its own origin Co-Authored-By: Claude Opus 5.5 --- .../person_server/federation.py | 10 +++++-- .../tests/test_boundaries.py | 30 ++++++++++++++++++- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/introspection-aauth/introspection_aauth/person_server/federation.py b/packages/introspection-aauth/introspection_aauth/person_server/federation.py index 8b3aa98..d03553b 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/federation.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/federation.py @@ -32,6 +32,7 @@ from ..protocol.errors import Problem, VerificationError from ..protocol.keys import Jwks from ..protocol.models import Budget +from ..protocol.server import same_origin from ..protocol.wire import AccessServerMetadata, AuthTokenAnswer, TokenRequest from .models import Approval, ApprovalStatus, Federation, Refusal from .store import Store @@ -150,11 +151,14 @@ async def discover(self, access_server: str) -> Metadata | Outcome: return unreachable(f"its metadata could not be read: {exc.detail}") if document.issuer != access_server: return unreachable("its metadata names another issuer") - if not _https(document.auth_token_endpoint, True) or not _https( - document.jwks_uri, False + if not ( + _https(document.auth_token_endpoint, True) + and same_origin(document.auth_token_endpoint, access_server) + and same_origin(document.jwks_uri, access_server) ): return unreachable( - "its metadata lacks an https auth_token_endpoint and jwks_uri" + "its metadata keeps its auth_token_endpoint and jwks_uri " + "off its own https origin" ) return document diff --git a/packages/introspection-aauth/tests/test_boundaries.py b/packages/introspection-aauth/tests/test_boundaries.py index 9a4e717..4559860 100644 --- a/packages/introspection-aauth/tests/test_boundaries.py +++ b/packages/introspection-aauth/tests/test_boundaries.py @@ -25,7 +25,10 @@ Confirmation, ) from introspection_aauth.protocol.jwt import AGENT_TOKEN, encode -from introspection_aauth.protocol.wire import TokenRequest +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 @@ -397,3 +400,28 @@ async def test_a_tenant_that_needs_escaping_polls_through_the_router( ) 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.net.documents[f"{AS}/.well-known/aauth-access.json"] = ( + AccessServerMetadata( + issuer=AS, + jwks_uri=jwks_uri, + auth_token_endpoint=auth_token_endpoint, + ) + ) + found = await federation.AccessServerClient(world.ps).discover(AS) + assert isinstance(found, federation.Outcome) + assert found.refusal is not None + assert found.refusal.error == "as_unreachable" From db5b7216fdfcebda0dae655c526548abd23ea5b7 Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 15:04:05 -0700 Subject: [PATCH 17/34] fix(aauth): a key without alg takes its algorithm from its type RFC 7800 does not require `alg` on a confirmation key: OKP/Ed25519 is Ed25519 and EC/P-256 is ES256, and any other key type is refused. JWT verification reads a published key's algorithm the same way. Co-Authored-By: Claude Opus 5.5 --- .../introspection_aauth/protocol/jwt.py | 12 +++++- .../introspection_aauth/protocol/keys.py | 20 +++++++--- .../tests/test_boundaries.py | 38 +++++++++++++++++++ 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/packages/introspection-aauth/introspection_aauth/protocol/jwt.py b/packages/introspection-aauth/introspection_aauth/protocol/jwt.py index c1b7ce3..516317b 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/jwt.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/jwt.py @@ -9,7 +9,7 @@ from .claims import MAX_TOKEN_LIFETIME_SECONDS, Claims from .errors import VerificationError from .json import JsonObject -from .keys import Jwks, SigningKey, b64url, b64url_decode +from .keys import Jwk, Jwks, SigningKey, algorithm, b64url, b64url_decode from .keys import verify as verify_signature AGENT_TOKEN = "aa-agent+jwt" @@ -61,6 +61,14 @@ class Unverified(BaseModel): 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( @@ -121,7 +129,7 @@ def verify( if not candidates: raise VerificationError("unknown_key", "signed by a key not published") if not any( - k.alg == decoded.header.alg + _algorithm(k) == decoded.header.alg and verify_signature(k, decoded.signing_input, decoded.signature) for k in candidates ): diff --git a/packages/introspection-aauth/introspection_aauth/protocol/keys.py b/packages/introspection-aauth/introspection_aauth/protocol/keys.py index a15d5dd..a3678a0 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/keys.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/keys.py @@ -75,17 +75,25 @@ def thumbprint(jwk: Jwk) -> str: def algorithm(jwk: Jwk) -> str: - """The key's algorithm, from its `alg`, which must match its type.""" - alg = jwk.alg - if alg is None or alg not in ALGORITHMS: + """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's alg is not Ed25519 or ES256" + "unsupported_algorithm", "the key is not Ed25519 or ES256" ) - if (jwk.kty, jwk.crv) != ALGORITHMS[alg]: + if (jwk.kty, jwk.crv) != ALGORITHMS[named]: raise VerificationError( "invalid_key", "the key's type is not its alg's" ) - return alg + return named def verify(jwk: Jwk, data: bytes, signature: bytes) -> bool: diff --git a/packages/introspection-aauth/tests/test_boundaries.py b/packages/introspection-aauth/tests/test_boundaries.py index 4559860..4f1624e 100644 --- a/packages/introspection-aauth/tests/test_boundaries.py +++ b/packages/introspection-aauth/tests/test_boundaries.py @@ -25,6 +25,7 @@ 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, @@ -425,3 +426,40 @@ async def test_access_server_metadata_keeps_its_endpoints_on_its_origin( assert isinstance(found, federation.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"), + ) From 14a56f1319b254885446186285bc89740881db8e Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 15:04:48 -0700 Subject: [PATCH 18/34] test(aauth): the type guard reads the AST for every way of writing Any, object or a bare dict Co-Authored-By: Claude Opus 5.5 --- .../introspection-aauth/tests/test_fastapi.py | 3 +- .../introspection-aauth/tests/test_models.py | 95 +++++++++++++++---- 2 files changed, 76 insertions(+), 22 deletions(-) diff --git a/packages/introspection-aauth/tests/test_fastapi.py b/packages/introspection-aauth/tests/test_fastapi.py index 07d07e2..802559c 100644 --- a/packages/introspection-aauth/tests/test_fastapi.py +++ b/packages/introspection-aauth/tests/test_fastapi.py @@ -8,11 +8,12 @@ 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]: +def paths(app: FastAPI) -> dict[str, dict[str, JsonObject]]: return app.openapi()["paths"] diff --git a/packages/introspection-aauth/tests/test_models.py b/packages/introspection-aauth/tests/test_models.py index 8fd07f8..296d399 100644 --- a/packages/introspection-aauth/tests/test_models.py +++ b/packages/introspection-aauth/tests/test_models.py @@ -52,7 +52,12 @@ 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: @@ -62,35 +67,83 @@ def _annotations(tree: ast.AST) -> list[ast.expr]: 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 = [] - for path in [*PACKAGE.rglob("*.py"), *TESTS.rglob("*.py")]: - tree = ast.parse(path.read_text()) - for node in ast.walk(tree): - if ( - isinstance(node, ast.ImportFrom) - and node.module == "typing" - and any(alias.name == "Any" for alias in node.names) - ) or ( - isinstance(node, ast.Attribute) - and node.attr == "Any" - and isinstance(node.value, ast.Name) - and node.value.id == "typing" - ): - found.append(f"{path.name}:{node.lineno}") - for annotation in _annotations(tree): - if any( - isinstance(n, ast.Name) and n.id == "object" - for n in ast.walk(annotation) - ): - found.append(f"{path.name}:{annotation.lineno}") + 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, From 60d12157e65155cb74d7392d6375de7a9d64dc7b Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 15:05:12 -0700 Subject: [PATCH 19/34] fix(aauth): a delivered answer is recorded apart from its decision An Access Server's pending request keeps its decided status once its answer is given; `collected` records the delivery, as the Person Server's approvals do, so listings by decision still find it. Co-Authored-By: Claude Opus 5.5 --- .../introspection_aauth/access_server/models.py | 6 +++--- .../introspection_aauth/access_server/server.py | 4 ++-- .../introspection-aauth/tests/test_boundaries.py | 13 ++++++++++++- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/introspection-aauth/introspection_aauth/access_server/models.py b/packages/introspection-aauth/introspection_aauth/access_server/models.py index 92133ce..ea0d677 100644 --- a/packages/introspection-aauth/introspection_aauth/access_server/models.py +++ b/packages/introspection-aauth/introspection_aauth/access_server/models.py @@ -86,9 +86,6 @@ class PendingStatus(StrEnum): PENDING = "pending" APPROVED = "approved" DENIED = "denied" - #: Its terminal answer given, which is given once: an auth token, a - #: refusal or an expiry. - COLLECTED = "collected" class PendingAccess(BaseModel): @@ -108,5 +105,8 @@ class PendingAccess(BaseModel): 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 index f7d949c..8f15d38 100644 --- a/packages/introspection-aauth/introspection_aauth/access_server/server.py +++ b/packages/introspection-aauth/introspection_aauth/access_server/server.py @@ -342,7 +342,7 @@ async def poll(self, request: SignedRequest, pending_id: str) -> Answer: record = await self.store.select_pending(pending_id) if record is None or record.request.ps != ps: raise AAuthError(404, "not_found", "no such pending request") - if record.status is PendingStatus.COLLECTED: + if record.collected: raise AAuthError( 410, "invalid_code", "this request was already answered" ) @@ -352,7 +352,7 @@ async def poll(self, request: SignedRequest, pending_id: str) -> Answer: # A terminal answer is given once; the pending URL then answers 410. answered = record.model_copy( update={ - "status": PendingStatus.COLLECTED, + "collected": True, "version": record.version + 1, } ) diff --git a/packages/introspection-aauth/tests/test_boundaries.py b/packages/introspection-aauth/tests/test_boundaries.py index 4f1624e..58a781f 100644 --- a/packages/introspection-aauth/tests/test_boundaries.py +++ b/packages/introspection-aauth/tests/test_boundaries.py @@ -8,7 +8,7 @@ import httpx2 as httpx import pytest -from introspection_aauth.access_server import Verdict +from introspection_aauth.access_server import PendingStatus, Verdict from introspection_aauth.person_server import ( Approval, ApprovalStatus, @@ -122,6 +122,15 @@ async def test_a_pending_url_answers_once_then_is_gone(decision: str): 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(): @@ -147,6 +156,8 @@ async def kick(tenant: str, approval: Approval) -> None: 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(): From 8f306f694ec9e1c7f22dda9cc763c2c2b2eb4e42 Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 16:21:09 -0700 Subject: [PATCH 20/34] refactor(aauth)!: federation steps on PersonServer, typed error classes, retries on the http client - due/record/run become PersonServer.federation_due, record_federation and federate; Step and Outcome move to person_server.models, with Outcome.refused, Outcome.unreachable and Outcome.poll_delay(). - AAuthError gains a subclass per AAuth error code (InvalidRequest, InvalidToken, InvalidCode, Expired, NotFound, MissionNotFound, MissionTerminated, AccessDenied, Denied, ServerError, Unauthorized), raised at each call site; presented_token_error and the access server's _bad go. - AAuth-Requirement and Retry-After parsing and the https endpoint check move to protocol.server beside their counterparts; federation's own copies of the origin check go. - The in-process retry loop goes: retrying a dropped connection is the injected http client's transport's. - Private methods sit at the bottom of each class. BREAKING CHANGE: the federation module's due, record, run, refused, unreachable and poll_delay functions are removed, and so are presented_token_error and TRANSPORT_RETRY_DELAYS. Co-Authored-By: Claude Opus 5.5 --- packages/introspection-aauth/README.md | 17 +- .../access_server/server.py | 304 +++--- .../introspection_aauth/fastapi.py | 9 +- .../person_server/__init__.py | 4 + .../person_server/federation.py | 345 +----- .../person_server/models.py | 43 + .../person_server/server.py | 991 ++++++++++-------- .../introspection_aauth/protocol/__init__.py | 29 +- .../introspection_aauth/protocol/claims.py | 12 +- .../introspection_aauth/protocol/errors.py | 83 ++ .../introspection_aauth/protocol/server.py | 78 +- .../tests/test_boundaries.py | 4 +- .../tests/test_federation.py | 21 +- .../tests/test_refusals.py | 19 +- 14 files changed, 1010 insertions(+), 949 deletions(-) diff --git a/packages/introspection-aauth/README.md b/packages/introspection-aauth/README.md index e7a4233..190e3ad 100644 --- a/packages/introspection-aauth/README.md +++ b/packages/introspection-aauth/README.md @@ -15,7 +15,7 @@ pip install 'introspection-aauth[fastapi]' # and FastAPI routers for them | Module | Holds | | --- | --- | -| `introspection_aauth.protocol` | What both servers share, written once: `AAuthError` and its 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.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 `federation`: the client for a resource's Access Server and the loop around it. | | `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`. | @@ -118,11 +118,16 @@ choice. `access_server.Store` is `select_pending`, `insert_pending` and `transit ## Durable federation `PersonServer(on_federate=...)` is told of each approved request a -resource's Access Server must decide. Unset, `federation.run` drives it in -this process. A host with a durable executor journals the steps itself: -`federation.due`, `AccessServerClient.discover`, `.request`, -`federation.record`, wait `federation.poll_delay`, `.poll`, `record`, and so -on until `record` returns False. +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. ## Not served diff --git a/packages/introspection-aauth/introspection_aauth/access_server/server.py b/packages/introspection-aauth/introspection_aauth/access_server/server.py index 8f15d38..ac27091 100644 --- a/packages/introspection-aauth/introspection_aauth/access_server/server.py +++ b/packages/introspection-aauth/introspection_aauth/access_server/server.py @@ -22,7 +22,17 @@ ResourceTokenClaims, ResourceTokenExpectation, ) -from ..protocol.errors import AAuthError, Answer, VerificationError +from ..protocol.errors import ( + Answer, + Denied, + Expired, + InvalidCode, + InvalidRequest, + InvalidToken, + NotFound, + ServerError, + VerificationError, +) from ..protocol.httpsig import SignedRequest from ..protocol.keys import SigningKey from ..protocol.models import Budget @@ -31,7 +41,6 @@ ServerOptions, parse, pending, - presented_token_error, requirement, verify_agent_token, verify_resource_token, @@ -62,20 +71,12 @@ class PendingHook(Protocol): def __call__(self, pending: PendingAccess, /) -> Awaitable[None]: ... -def _bad(token: str, detail: str) -> AAuthError: - return AAuthError(400, f"invalid_{token}_token", detail) - - def _narrowed(asked: str | None, scope: str | None) -> str | None: """`scope`, which may be no broader than what was asked.""" if scope is None: return asked if not set(scope.split()) <= set((asked or "").split()): - raise AAuthError( - 500, - "server_error", - "the policy granted a scope that was not asked for", - ) + raise ServerError("the policy granted a scope that was not asked for") return scope @@ -84,9 +85,7 @@ def _lowered(asked: Budget | None, granted: Budget | None) -> Budget | None: if granted is None: return asked if asked is None or not granted.at_most(asked): - raise AAuthError( - 500, "server_error", "the policy granted more than was asked" - ) + raise ServerError("the policy granted more than was asked") return granted @@ -131,92 +130,6 @@ def metadata(self) -> AccessServerMetadata: def pending_url(self, pending_id: str) -> str: return f"{self.endpoint_base}/pending/{pending_id}" - # region verification - async def _resource_token( - self, token: str, ps: str - ) -> ResourceTokenClaims: - try: - _, unverified = jwt.peek(token) - except VerificationError: - raise _bad("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 _bad( - "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 AAuthError( - 400, "invalid_request", f"agent_token: {exc.detail}" - ) from None - wrong = AgentExpectation(agent_jkt=rt.agent_jkt or "", ps=ps).failures( - claims - ) - if "cnf" in wrong: - raise _bad("resource", "agent_jkt is not the agent token's key") - if wrong: - raise AAuthError( - 400, - "invalid_request", - "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 presented_token_error(exc) from None - wrong = PresentedExpectation(ps=ps, rt=rt).failures(presented) - if wrong: - raise _bad( - "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() - ) - - # endregion - async def token(self, request: SignedRequest) -> Answer: """`POST /token`: a Person Server's signed request for an auth token.""" ps = await verify_server( @@ -228,10 +141,8 @@ async def token(self, request: SignedRequest) -> Answer: ) body = parse(TokenRequest, request.body) if body.upstream_token is not None or body.subagent_token is not None: - raise AAuthError( - 400, - "invalid_request", - "call chaining and sub-agents are not served here", + 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) @@ -242,9 +153,7 @@ async def token(self, request: SignedRequest) -> Answer: and asked_budget is not None and not sent.at_most(asked_budget) ): - raise AAuthError( - 400, "invalid_request", "budget is above the resource token's" - ) + 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( @@ -286,50 +195,12 @@ async def token(self, request: SignedRequest) -> Answer: _lowered(asked.budget, verdict.budget), ) if verdict.decision == "deny": - raise AAuthError( - 403, - "denied", - verdict.detail or "this resource's policy refuses it", - ) + 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) - 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) - ) - 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( @@ -341,11 +212,9 @@ async def poll(self, request: SignedRequest, pending_id: str) -> Answer: ) record = await self.store.select_pending(pending_id) if record is None or record.request.ps != ps: - raise AAuthError(404, "not_found", "no such pending request") + raise NotFound("no such pending request") if record.collected: - raise AAuthError( - 410, "invalid_code", "this request was already answered" - ) + 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) @@ -357,15 +226,11 @@ async def poll(self, request: SignedRequest, pending_id: str) -> Answer: } ) if not await self.store.transition_pending(record, answered): - raise AAuthError( - 410, "invalid_code", "this request was already answered" - ) + raise InvalidCode("this request was already answered") if record.status is PendingStatus.DENIED: - raise AAuthError( - 403, "denied", "this resource's policy refuses it" - ) + raise Denied("this resource's policy refuses it") if expired: - raise AAuthError(408, "expired", "nobody decided in time") + raise Expired("nobody decided in time") return self._issue(record, record.scope, record.budget) async def decide( @@ -381,11 +246,11 @@ async def decide( the scope or lower the budget.""" record = await self.store.select_pending(pending_id) if record is None: - raise AAuthError(404, "not_found", "no such request") + raise NotFound("no such request") if record.status is not PendingStatus.PENDING: - raise AAuthError(409, "invalid_request", "already decided") + raise InvalidRequest("already decided", status=409) if self.clock() >= record.expires_at: - raise AAuthError(410, "expired", "this request has expired") + raise Expired("this request has expired", status=410) decided = record.model_copy( update={ "status": PendingStatus.APPROVED @@ -398,5 +263,122 @@ async def decide( } ) if not await self.store.transition_pending(record, decided): - raise AAuthError(409, "invalid_request", "already 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/fastapi.py b/packages/introspection-aauth/introspection_aauth/fastapi.py index 912b133..0d203fa 100644 --- a/packages/introspection-aauth/introspection_aauth/fastapi.py +++ b/packages/introspection-aauth/introspection_aauth/fastapi.py @@ -36,7 +36,12 @@ PersonTokenRequest, ) from .person_server.server import PersonServer -from .protocol.errors import AAuthError, Answer, Problem +from .protocol.errors import ( + AAuthError, + Answer, + NotFound, + Problem, +) from .protocol.httpsig import SignedRequest from .protocol.json import JsonObject from .protocol.keys import Jwks @@ -332,7 +337,7 @@ async def _poll_person_server( under = f"{urlsplit(server.endpoint_base).path}/pending/" parts = server.pending_parts(urlsplit(signed.url).path.removeprefix(under)) if parts is None: - raise AAuthError(404, "not_found", "no such pending request") + raise NotFound("no such pending request") return await server.poll(signed, *parts) diff --git a/packages/introspection-aauth/introspection_aauth/person_server/__init__.py b/packages/introspection-aauth/introspection_aauth/person_server/__init__.py index d739e08..79f0392 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/__init__.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/__init__.py @@ -10,6 +10,8 @@ LogEntry, Mission, MissionGrant, + Outcome, + Step, ) from .server import PersonServer from .store import AnyoneDirectory, Directory, MemoryStore, Store @@ -25,7 +27,9 @@ "MemoryStore", "Mission", "MissionGrant", + "Outcome", "PersonServer", + "Step", "Store", "federation", ] diff --git a/packages/introspection-aauth/introspection_aauth/person_server/federation.py b/packages/introspection-aauth/introspection_aauth/person_server/federation.py index d03553b..71159ab 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/federation.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/federation.py @@ -5,20 +5,17 @@ the deferred loop (`#deferred-responses`), and checks the auth token before the agent gets it (`#auth-token-delivery`). -The loop is steps a host can journal: `discover`, `request`, `record`, wait -`poll_delay`, `poll`, `record`, and so on; `run` is that loop in-process. +The loop is steps a host can journal: `PersonServer.federation_due`, then +`discover`, `request`, `PersonServer.record_federation`, wait `poll_delay`, +`poll`, `record_federation`, and so on; `PersonServer.federate` is that loop +in-process. """ -import asyncio -from collections.abc import Awaitable -from enum import StrEnum -from functools import partial -from typing import TYPE_CHECKING, Protocol -from urllib.parse import urljoin, urlsplit +from typing import TYPE_CHECKING +from urllib.parse import urljoin -import http_sfv import httpx2 as httpx -from pydantic import BaseModel, ValidationError +from pydantic import ValidationError from ..protocol import jwt from ..protocol.claims import ( @@ -31,11 +28,14 @@ ) from ..protocol.errors import Problem, VerificationError from ..protocol.keys import Jwks -from ..protocol.models import Budget -from ..protocol.server import same_origin +from ..protocol.server import ( + https_endpoint, + requirement_name, + retry_after, + same_origin, +) from ..protocol.wire import AccessServerMetadata, AuthTokenAnswer, TokenRequest -from .models import Approval, ApprovalStatus, Federation, Refusal -from .store import Store +from .models import Federation, Outcome, Step if TYPE_CHECKING: from .server import PersonServer @@ -43,97 +43,11 @@ SERVED = frozenset({"approval", "interaction"}) #: Requirements draft -11 defines that this Person Server does not serve. NOT_SERVED = frozenset({"claims", "clarification"}) -#: Seconds to wait before each retry of a request that could not connect. -TRANSPORT_RETRY_DELAYS = (1.0, 2.0, 4.0) -DEFAULT_DELAY, MIN_DELAY, MAX_DELAY = 5, 1, 300 - - -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"]: ... - - -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 Metadata = AccessServerMetadata -def refused(status: int, error: str, detail: str) -> Outcome: - return Outcome( - step=Step.REFUSED, - refusal=Refusal(status=status, error=error, detail=detail), - ) - - -def unreachable(detail: str) -> Outcome: - """`as_unreachable`: no verifiable answer from the Access Server.""" - return refused(502, "as_unreachable", detail) - - -def poll_delay(outcome: Outcome) -> int: - """`Retry-After`, 5 seconds when absent; never a hot loop, never a stall.""" - if outcome.retry_after is None: - return DEFAULT_DELAY - return min(max(outcome.retry_after, MIN_DELAY), MAX_DELAY) - - -def _https(value: str, endpoint: bool) -> bool: - parts = urlsplit(value) - return ( - parts.scheme == "https" - and bool(parts.hostname) - and not (endpoint and (parts.query or parts.fragment)) - ) - - -def _origin(url: str) -> tuple[str, str | None, int | None]: - parts = urlsplit(url) - return parts.scheme, parts.hostname, parts.port - - -def _retry_after(response: httpx.Response) -> int | None: - try: - return max(int(response.headers.get("retry-after", "")), 0) - except ValueError: - return None - - -def _requirement(header: str) -> str | None: - 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 - - class AccessServerClient: """Talks to the Access Server a verified resource token names, signed as the Person Server.""" @@ -148,15 +62,17 @@ async def discover(self, access_server: str) -> Metadata | Outcome: AccessServerMetadata, ) except VerificationError as exc: - return unreachable(f"its metadata could not be read: {exc.detail}") + return Outcome.unreachable( + f"its metadata could not be read: {exc.detail}" + ) if document.issuer != access_server: - return unreachable("its metadata names another issuer") + return Outcome.unreachable("its metadata names another issuer") if not ( - _https(document.auth_token_endpoint, True) + https_endpoint(document.auth_token_endpoint) and same_origin(document.auth_token_endpoint, access_server) and same_origin(document.jwks_uri, access_server) ): - return unreachable( + return Outcome.unreachable( "its metadata keeps its auth_token_endpoint and jwks_uri " "off its own https origin" ) @@ -211,21 +127,23 @@ async def _outcome( # noqa: PLR0911 try: answer = AuthTokenAnswer.model_validate_json(response.content) except ValidationError: - return unreachable("it answered 200 without an auth_token") + 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 refused( + 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) or 0 + retry = retry_after(response.headers) or 0 return Outcome( step=Step.PENDING, location=None @@ -239,29 +157,31 @@ async def _outcome( # noqa: PLR0911 except ValidationError: problem = None if 400 <= status < 500 and problem is not None and problem.error: - return refused( + return Outcome.refused( status, problem.error, f"the Access Server refused: {problem.detail or problem.error}", ) - return unreachable(f"it answered {status}") + return Outcome.unreachable(f"it answered {status}") @staticmethod def _waiting(response: httpx.Response, requested: str) -> Outcome: header = response.headers.get("aauth-requirement") - requirement = _requirement(header) if header else "approval" + requirement = requirement_name(header) if header else "approval" if requirement is None: - return unreachable("its AAuth-Requirement is malformed") + return Outcome.unreachable("its AAuth-Requirement is malformed") if requirement in NOT_SERVED: - return refused( + 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 _origin(polled) != _origin(requested): - return unreachable("its pending Location is not on its own origin") + 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, @@ -269,7 +189,7 @@ def _waiting(response: httpx.Response, requested: str) -> Outcome: requirement=header if header and requirement in SERVED else "requirement=approval", - retry_after=_retry_after(response), + retry_after=retry_after(response.headers), ) async def _delivered( @@ -291,7 +211,9 @@ async def _delivered( jwt.decode(federation.agent_token).claims ) except (VerificationError, ValidationError): - return unreachable("its request's tokens cannot be read back") + return Outcome.unreachable( + "its request's tokens cannot be read back" + ) try: keys = await self._ps.discovery.get(metadata.jwks_uri, Jwks) claims = jwt.verify( @@ -302,7 +224,9 @@ async def _delivered( AuthTokenClaims, ) except VerificationError as exc: - return unreachable(f"its auth token does not verify: {exc.detail}") + return Outcome.unreachable( + f"its auth token does not verify: {exc.detail}" + ) wrong = DeliveryExpectation( issuer=metadata.issuer, ps=self._ps.issuer, @@ -312,7 +236,7 @@ async def _delivered( sent=federation.budget, ).failures(claims) if wrong: - return unreachable( + return Outcome.unreachable( f"its auth token has the wrong {', '.join(wrong)}" ) return Outcome( @@ -321,188 +245,3 @@ async def _delivered( auth_token_expires_at=claims.exp, granted=claims.budget, ) - - -async def due( - ps: "PersonServer", 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 ps.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( - ps: "PersonServer", 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: - return await _record(ps, tenant, approval_id, outcome) - except _Stale: - return False - - -async def _record( - ps: "PersonServer", tenant: str, approval_id: str, outcome: Outcome -) -> bool: - async with ps.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 = ps.clock() - status = 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 _spend(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 _Stale - return outcome.step is Step.PENDING - - -class _Stale(Exception): - """Rolls a `record` back: the approval changed or lapsed meanwhile.""" - - -async def _spend( - 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", - ) - - -async def run( - ps: "PersonServer", - tenant: str, - approval_id: str, - sleep: Sleep = asyncio.sleep, -) -> Step | None: - """Redeem an approved request at its Access Server, polling while it decides.""" - found = await due(ps, tenant, approval_id) - if found is None: - return None - federation, agent_jkt = found - client = AccessServerClient(ps) - metadata = await client.discover(federation.access_server) - - async def attempt(call: Exchange) -> Outcome: - """`call`, retried while the Access Server cannot be reached, then `as_unreachable`.""" - for delay in TRANSPORT_RETRY_DELAYS: - try: - return await call() - except httpx.TransportError: - await sleep(delay) - try: - return await call() - except httpx.TransportError as exc: - return 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 record(ps, tenant, approval_id, outcome) - if not waiting or isinstance(metadata, Outcome): - return outcome.step - await sleep(poll_delay(outcome)) - 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 - ) - ) diff --git a/packages/introspection-aauth/introspection_aauth/person_server/models.py b/packages/introspection-aauth/introspection_aauth/person_server/models.py index c780379..628710e 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/models.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/models.py @@ -197,3 +197,46 @@ 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) -> "Outcome": + return cls( + step=Step.REFUSED, + refusal=Refusal(status=status, error=error, detail=detail), + ) + + @classmethod + def unreachable(cls, detail: str) -> "Outcome": + """`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 index ceea5a2..779e1ec 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/server.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/server.py @@ -9,6 +9,7 @@ import logging from collections.abc import Awaitable, Sequence from datetime import UTC, datetime +from functools import partial from typing import Protocol, Unpack from urllib.parse import quote, unquote @@ -26,7 +27,21 @@ ResourceTokenClaims, ResourceTokenExpectation, ) -from ..protocol.errors import AAuthError, Answer, VerificationError +from ..protocol.errors import ( + AAuthError, + AccessDenied, + Answer, + Denied, + Expired, + InvalidCode, + InvalidRequest, + InvalidToken, + MissionNotFound, + MissionTerminated, + NotFound, + ServerError, + VerificationError, +) from ..protocol.httpsig import SignedRequest from ..protocol.keys import SigningKey, b64url, s256 from ..protocol.models import Agent, Budget @@ -36,7 +51,6 @@ https_origin, parse, pending, - presented_token_error, verify_agent, verify_resource_token, ) @@ -46,7 +60,9 @@ PersonServerMetadata, PersonTokenAnswer, ) -from .federation import Step, run +from .federation import ( + AccessServerClient, +) from .models import ( Approval, ApprovalKind, @@ -61,7 +77,10 @@ MissionGrant, MissionLogged, MissionProposal, + Outcome, PersonTokenRequest, + Refusal, + Step, ) from .store import AnyoneDirectory, Directory, Store @@ -77,6 +96,18 @@ def __call__( ) -> 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]: ... + + def _fits(mission: Mission, budget: Budget | None) -> bool: """Whether a request is within what is left of its mission's budget.""" if budget is None: @@ -89,6 +120,10 @@ def _fits(mission: Mission, budget: Budget | None) -> bool: return budget.at_most(left) +class _Stale(Exception): + """Rolls a `record` back: the approval changed or lapsed meanwhile.""" + + class PersonServer(Server): """Speaks for the people its `Directory` names, keeping its state in a `Store`. @@ -96,7 +131,7 @@ class PersonServer(Server): 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 `federation.run` or journals step by step. Unset, it is + the host runs with `federate` or journals step by step. Unset, it is run in this process. """ @@ -157,52 +192,6 @@ def pending_parts(self, path: str) -> tuple[str, str] | None: # endregion # region people and tokens - def _tenant(self, named: str | None) -> str: - tenant = named or self._default_tenant - if not tenant: - raise AAuthError(400, "invalid_request", "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 AAuthError( - 403, "invalid_request", "this agent's Person Server is another" - ) - 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 AAuthError( - 400, "invalid_request", "name the person: login_hint" - ) - return bound - person = self.directory.person(login_hint, tenant) - if person is None: - raise AAuthError( - 403, - "access_denied", - "this Person Server does not speak for them", - ) - if ( - await self.store.insert_binding(tenant, agent.jkt, person) - != person - ): - raise AAuthError( - 403, "invalid_request", "this agent acts for another person" - ) - return person - 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() @@ -210,78 +199,9 @@ def directed_subject(self, person: str, audience: str) -> str: hmac.new(self._subject_secret, message, hashlib.sha256).digest() ) - 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, - ) - # endregion # region missions - 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 AAuthError(404, "mission_not_found", "no such mission") - if mission.completed_at is not None: - raise AAuthError(403, "mission_terminated", "mission completed") - if mission.expires_at <= self.clock(): - raise AAuthError(403, "mission_terminated", "mission expired") - return mission - async def propose_mission(self, request: SignedRequest) -> Answer: """`POST /mission`: always put to a person, who sets the budget.""" agent = await self._agent(request) @@ -319,9 +239,7 @@ async def mission_action( LogEntry(at=self.clock(), entry=entry), self.clock(), ): - raise AAuthError( - 403, "mission_terminated", "the mission has ended" - ) + raise MissionTerminated("the mission has ended") return Answer(200, MissionLogged(s256=s256(entry.encode()))) approval = self._new_approval( ApprovalKind.COMPLETION, @@ -340,9 +258,7 @@ async def person_token(self, request: SignedRequest) -> Answer: tenant = self._tenant(body.tenant) resource = body.resource.rstrip("/") if not https_origin(resource): - raise AAuthError( - 400, "invalid_request", "resource is an https origin" - ) + 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: @@ -351,7 +267,7 @@ async def person_token(self, request: SignedRequest) -> Answer: agent.id, ) if mission.person != person: - raise AAuthError(404, "mission_not_found", "no such mission") + raise MissionNotFound("no such mission") return Answer( 200, self._person_token(tenant, agent, person, resource, mission) ) @@ -359,72 +275,6 @@ async def person_token(self, request: SignedRequest) -> Answer: # endregion # region auth tokens - 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 presented_token_error(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 AAuthError( - 400, "invalid_resource_token", "not a JWT" - ) from None - if header.typ != jwt.RESOURCE_TOKEN or unverified.dwk != RESOURCE_DWK: - raise AAuthError( - 400, "invalid_resource_token", "not a resource token" - ) - if unverified.tenant != tenant: - raise AAuthError( - 400, "invalid_resource_token", "tenant does not match" - ) - if unverified.mission_s256 is None: - raise AAuthError( - 403, - "access_denied", - "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 AAuthError( - 403, - "access_denied", - "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 AAuthError( - 400, - "invalid_resource_token", - "aud is neither this Person Server nor an Access Server", - ) - return rt.aud.rstrip("/") - 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) @@ -432,21 +282,17 @@ async def auth_token(self, request: SignedRequest) -> Answer: tenant = self._tenant(body.tenant) presented = self._presented(body.presented_token) if presented.tenant != tenant: - raise AAuthError( - 400, "invalid_resource_token", "tenant does not match" - ) + 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 AAuthError( - 400, - "invalid_resource_token", - "does not match the presented token", + raise InvalidToken( + "resource", "does not match the presented token" ) if rt.sub is None: - raise AAuthError(400, "invalid_resource_token", "sub is required") + raise InvalidToken("resource", "sub is required") budget = rt.budget grant = AuthGrant.requested(rt, rt.sub) access_server = self._access_server(rt) @@ -455,10 +301,8 @@ async def auth_token(self, request: SignedRequest) -> Answer: body.upstream_token is not None or body.subagent_token is not None ): - raise AAuthError( - 400, - "invalid_request", - "call chaining and sub-agents are not federated here", + raise InvalidRequest( + "call chaining and sub-agents are not federated here" ) federation = Federation( access_server=access_server, @@ -494,81 +338,9 @@ async def auth_token(self, request: SignedRequest) -> Answer: await self.store.insert_approval(tenant, approval) return await self._ask(tenant, approval) - 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) - # endregion # region approvals - 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 poll( self, request: SignedRequest, tenant: str, approval_id: str ) -> Answer: @@ -576,27 +348,25 @@ async def poll( 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 AAuthError(404, "not_found", "no such pending request") + 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 AAuthError( - 410, "invalid_code", "this request was already answered" - ) + 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 AAuthError(403, "denied", "the approver declined") + raise Denied("the approver declined") if approval.status is ApprovalStatus.PENDING: if approval.expires_at <= self.clock(): await self._answered(tenant, approval) - raise AAuthError(408, "expired", "nobody answered in time") + raise Expired("nobody answered in time") return pending( self.pending_url(tenant, approval.id), self.retry_after ) @@ -606,7 +376,7 @@ async def poll( ) if approval.expires_at <= self.clock(): await self._answered(tenant, approval) - raise AAuthError(408, "expired", "it was not collected in time") + raise Expired("it was not collected in time") if approval.kind is ApprovalKind.COMPLETION: await self._answered(tenant, approval) return Answer(200, Empty()) @@ -628,87 +398,13 @@ async def poll( ), ) if not isinstance(approval.grant, AuthGrant): - raise AAuthError( - 500, "server_error", "the approval grants nothing" - ) + raise ServerError("the approval grants nothing") await self._answered(tenant, approval) return Answer( 200, self._auth_token(tenant, agent, approval.grant, mission) ) - 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 AAuthError( - 410, "invalid_code", "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 AAuthError( - 410, - "invalid_code", - "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 AAuthError( - 408, "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 AAuthError( - 408, - "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 AAuthError( - 410, - "invalid_code", - "this request's auth token was already collected", - ) - return Answer( - 200, - AuthTokenAnswer( - auth_token=federation.auth_token, expires_in=expires_in - ), - ) - - async def decide( + async def decide( self, tenant: str, approval_id: str, @@ -725,9 +421,7 @@ async def decide( caller commits and then calls `decided`; otherwise in a transaction of its own, which `decided` follows.""" if status is ApprovalStatus.PENDING: - raise AAuthError( - 422, "invalid_request", "status is approved or denied" - ) + raise InvalidRequest("status is approved or denied", status=422) if store is not None: return await self._decide( store, @@ -750,6 +444,416 @@ async def decide( 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: + return await self._record_federation(tenant, approval_id, outcome) + except _Stale: + return False + + 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.""" + found = await self.federation_due(tenant, approval_id) + if found is None: + return None + federation, agent_jkt = found + client = AccessServerClient(self) + 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, @@ -764,27 +868,21 @@ async def _decide( if approval is None: return None if approval.kind is not ApprovalKind.MISSION and budget is not None: - raise AAuthError( - 422, "invalid_request", "only a mission takes a budget" - ) + raise InvalidRequest("only a mission takes a budget", status=422) if ( approval.kind is ApprovalKind.COMPLETION and decided_by != approval.person ): - raise AAuthError( - 403, - "access_denied", - "a completion is accepted by the person the mission is for", + 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 AAuthError( - 409, - "invalid_request", - "this approval is already decided or has expired", + raise InvalidRequest( + "this approval is already decided or has expired", status=409 ) mission_s256 = approval.mission_s256 if status is ApprovalStatus.APPROVED: @@ -802,10 +900,8 @@ async def _decide( } ) if not await tx.transition_approval(tenant, approval, decided): - raise AAuthError( - 409, - "invalid_request", - "this approval is already decided or has expired", + raise InvalidRequest( + "this approval is already decided or has expired", status=409 ) return decided @@ -843,10 +939,8 @@ async def _carry_out( expires_at=expires_at, ) if not await tx.insert_mission(tenant, mission): - raise AAuthError( - 409, - "invalid_request", - "a mission with this hash exists already", + raise InvalidRequest( + "a mission with this hash exists already", status=409 ) return mission.s256 if approval.mission_s256 is None: @@ -872,44 +966,113 @@ async def _carry_out( tenant, approval.mission_s256, spend, False, now ) if ended is None: - raise AAuthError( - 409, "mission_terminated", "the mission has ended" - ) + raise MissionTerminated("the mission has ended", status=409) return approval.mission_s256 - 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(run(self, tenant, approval.id)) - self._running.add(task) - task.add_done_callback(self._running.discard) + 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 _Stale + return outcome.step is Step.PENDING - 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 + 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 ( - approval is None - or not isinstance(grant, AuthGrant) - or grant.r3_uri is None - or grant.r3_s256 is None + mission is None + or mission.completed_at is not None + or mission.expires_at <= now ): - return approval - summary = await self.identity.summary( - self.http, grant.aud, grant.r3_uri, grant.r3_s256, self.clock() + 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", ) - 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 diff --git a/packages/introspection-aauth/introspection_aauth/protocol/__init__.py b/packages/introspection-aauth/introspection_aauth/protocol/__init__.py index cad1472..0e29fad 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/__init__.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/__init__.py @@ -16,7 +16,23 @@ ResourceTokenExpectation, ) from .discovery import Discovery -from .errors import AAuthError, Answer, Problem, VerificationError +from .errors import ( + AAuthError, + AccessDenied, + Answer, + Denied, + Expired, + InvalidCode, + InvalidRequest, + InvalidToken, + MissionNotFound, + MissionTerminated, + NotFound, + Problem, + ServerError, + Unauthorized, + VerificationError, +) from .httpsig import SignedRequest, Verified from .json import JsonObject, JsonValue from .keys import Jwk, Jwks, SigningKey, jwks, s256, thumbprint @@ -32,6 +48,7 @@ __all__ = [ "AAuthError", + "AccessDenied", "Agent", "AgentExpectation", "AgentTokenClaims", @@ -43,20 +60,30 @@ "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", diff --git a/packages/introspection-aauth/introspection_aauth/protocol/claims.py b/packages/introspection-aauth/introspection_aauth/protocol/claims.py index 1aacaf7..6149804 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/claims.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/claims.py @@ -3,7 +3,7 @@ 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, Literal, Self +from typing import ClassVar, Final, Literal, Self from pydantic import BaseModel, ConfigDict, Field, model_validator @@ -12,8 +12,10 @@ from .models import Budget AGENT_DWK = "aauth-agent.json" -PERSON_DWK = "aauth-person.json" -ACCESS_DWK = "aauth-access.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 @@ -152,7 +154,7 @@ class AuthTokenClaims(Claims, Carried): max_lifetime: ClassVar[int | None] = MAX_TOKEN_LIFETIME_SECONDS - dwk: Literal["aauth-person.json", "aauth-access.json"] + dwk: ServerDwk aud: str sub: str = Field(min_length=1) #: The Person Server the person is at. @@ -165,7 +167,7 @@ def issue( grant: AuthGrant, *, iss: str, - dwk: Literal["aauth-person.json", "aauth-access.json"], + dwk: ServerDwk, ps: str, jwk: Jwk, jti: str, diff --git a/packages/introspection-aauth/introspection_aauth/protocol/errors.py b/packages/introspection-aauth/introspection_aauth/protocol/errors.py index b4747b4..65df07b 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/errors.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/errors.py @@ -53,6 +53,89 @@ def answer(self) -> Answer: ) +class _Coded(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(_Coded): + code, default_status = "invalid_request", 400 + + +class InvalidCode(_Coded): + """A pending URL that has already answered, or never existed (`#pending-url-security`).""" + + code, default_status = "invalid_code", 410 + + +class Expired(_Coded): + code, default_status = "expired", 408 + + +class NotFound(_Coded): + code, default_status = "not_found", 404 + + +class MissionNotFound(_Coded): + code, default_status = "mission_not_found", 404 + + +class MissionTerminated(_Coded): + code, default_status = "mission_terminated", 403 + + +class AccessDenied(_Coded): + code, default_status = "access_denied", 403 + + +class Denied(_Coded): + code, default_status = "denied", 403 + + +class ServerError(_Coded): + 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 + ) -> "Unauthorized": + 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") -> "InvalidToken": + return cls(token, exc.detail, expired=exc.code == "expired_jwt") + + class VerificationError(Exception): """A token or signature that failed a check, with the AAuth code for it. diff --git a/packages/introspection-aauth/introspection_aauth/protocol/server.py b/packages/introspection-aauth/introspection_aauth/protocol/server.py index 3d8c86f..7f8fc6f 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/server.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/server.py @@ -5,7 +5,7 @@ import logging import time import uuid -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from typing import NotRequired, Protocol, TypedDict, TypeVar, Unpack from urllib.parse import urlsplit @@ -23,9 +23,16 @@ Claims, ResourceTokenClaims, ResourceTokenExpectation, + ServerDwk, ) from .discovery import Discovery -from .errors import AAuthError, Answer, VerificationError +from .errors import ( + Answer, + InvalidRequest, + InvalidToken, + Unauthorized, + VerificationError, +) from .keys import Jwk, Jwks, SigningKey, jwks, s256 from .models import Agent from .wire import Pending, R3Proposal @@ -44,7 +51,6 @@ "now_seconds", "parse", "pending", - "presented_token_error", "requirement", "same_origin", "verify_agent", @@ -79,14 +85,23 @@ def https_origin(value: str) -> bool: ) +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 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 AAuthError( - 400, "invalid_request", exc.errors()[0]["msg"] - ) from None + raise InvalidRequest(exc.errors()[0]["msg"]) from None def _origin(url: str) -> str | None: @@ -124,14 +139,24 @@ def requirement( return str(header) -def presented_token_error(exc: VerificationError) -> AAuthError: - """The refusal of a presented person token that does not verify.""" - code = ( - "expired_presented_token" - if exc.code == "expired_jwt" - else "invalid_presented_token" - ) - return AAuthError(400, code, exc.detail) +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( @@ -160,7 +185,7 @@ class Identity: def __init__( self, issuer: str, - dwk: str, + dwk: ServerDwk, signing_key: SigningKey, previous_keys: Sequence[SigningKey] = (), ): @@ -280,7 +305,7 @@ class Server: def __init__( self, issuer: str, - dwk: str, + dwk: ServerDwk, signing_key: SigningKey, http: httpx.AsyncClient, **options: Unpack[ServerOptions], @@ -326,15 +351,13 @@ async def verify_resource_token( ), ) except VerificationError as exc: - raise AAuthError( - 400, "invalid_resource_token", f"does not verify: {exc.detail}" + raise InvalidToken( + "resource", f"does not verify: {exc.detail}" ) from None wrong = expectation.failures(rt) if wrong: - raise AAuthError( - 400, - "invalid_resource_token", - f"its {', '.join(wrong)} not this request's", + raise InvalidToken( + "resource", f"its {', '.join(wrong)} not this request's" ) return rt @@ -384,7 +407,7 @@ async def verify_agent( ) except VerificationError as exc: hint = ACCEPT_JWT_SCHEME if exc.code == "unsupported_scheme" else None - raise AAuthError(401, exc.code, exc.detail, hint) from None + raise Unauthorized.rejected(exc, hint) from None return Agent( id=claims.sub, provider=claims.iss.rstrip("/"), @@ -407,8 +430,7 @@ async def verify_server( `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 AAuthError( - 401, + raise Unauthorized( "invalid_signature", "sign as a server this one takes requests from", ) @@ -424,9 +446,9 @@ async def resolve(issuer: str, signed_dwk: str, kid: str) -> Jwk: try: verified = await httpsig.verify(request, now, resolve) except VerificationError as exc: - raise AAuthError(401, exc.code, exc.detail) from None + raise Unauthorized.rejected(exc) from None if verified.scheme != "jwks_uri" or verified.dwk != dwk: - raise AAuthError( - 401, "unsupported_scheme", f"sign with sig=jwks_uri and {dwk}" + raise Unauthorized( + "unsupported_scheme", f"sign with sig=jwks_uri and {dwk}" ) return str(verified.server) diff --git a/packages/introspection-aauth/tests/test_boundaries.py b/packages/introspection-aauth/tests/test_boundaries.py index 58a781f..70b3662 100644 --- a/packages/introspection-aauth/tests/test_boundaries.py +++ b/packages/introspection-aauth/tests/test_boundaries.py @@ -143,7 +143,7 @@ async def kick(tenant: str, approval: Approval) -> None: world.ps._on_federate = kick mission = await world.approved_mission(budget=100_000) await world.ask(mission, 10, aud=AS) - due = await federation.due(world.ps, TENANT, kicked[0]) + due = await world.ps.federation_due(TENANT, kicked[0]) assert due is not None client = federation.AccessServerClient(world.ps) metadata = await client.discover(due[0].access_server) @@ -341,7 +341,7 @@ async def kick(tenant: str, approval: Approval) -> None: 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 federation.due(world.ps, TENANT, approval) + due = await world.ps.federation_due(TENANT, approval) assert due is not None client = federation.AccessServerClient(world.ps) metadata = await client.discover(due[0].access_server) diff --git a/packages/introspection-aauth/tests/test_federation.py b/packages/introspection-aauth/tests/test_federation.py index 12950d1..a0d5790 100644 --- a/packages/introspection-aauth/tests/test_federation.py +++ b/packages/introspection-aauth/tests/test_federation.py @@ -38,7 +38,7 @@ async def run(tenant: str, approval: Approval) -> None: async def no_wait(_: float) -> None: return None - await federation.run(world.ps, tenant, approval.id, sleep=no_wait) + await world.ps.federate(tenant, approval.id, sleep=no_wait) world.ps._on_federate = run return ran @@ -107,7 +107,7 @@ async def kick(tenant: str, approval: Approval) -> None: asked = await world.ask(mission, 60_000, aud=AS) approval_id = kicked[0] - due = await federation.due(world.ps, TENANT, approval_id) + due = await world.ps.federation_due(TENANT, approval_id) assert due is not None fed, jkt = due client = federation.AccessServerClient(world.ps) @@ -115,7 +115,7 @@ async def kick(tenant: str, approval: Approval) -> None: assert isinstance(metadata, federation.Metadata) outcome = await client.request(fed, jkt, metadata) assert outcome.step is federation.Step.PENDING - assert await federation.record(world.ps, TENANT, approval_id, outcome) + 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" @@ -126,7 +126,7 @@ async def kick(tenant: str, approval: Approval) -> None: assert outcome.location is not None outcome = await client.poll(outcome.location, fed, jkt, metadata) assert outcome.step is federation.Step.ISSUED - assert not await federation.record(world.ps, TENANT, approval_id, outcome) + 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 @@ -177,20 +177,9 @@ 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.net.failures[await token_endpoint(world)] = 100 + world.net.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 - - -async def test_a_dropped_connection_is_retried(): - world = World() - inline(world) - mission = await world.approved_mission(budget=100_000) - world.net.failures[await token_endpoint(world)] = 1 - asked = await world.ask(mission, 60_000, aud=AS) - issued = await world.follow(asked) - assert issued.status_code == 200, issued.text - assert await world.spent(mission.s256) == 60_000 diff --git a/packages/introspection-aauth/tests/test_refusals.py b/packages/introspection-aauth/tests/test_refusals.py index 760965e..d5ec354 100644 --- a/packages/introspection-aauth/tests/test_refusals.py +++ b/packages/introspection-aauth/tests/test_refusals.py @@ -185,7 +185,7 @@ async def kick(tenant: str, approval: Approval) -> None: world.ps._on_federate = kick mission = await world.approved_mission(budget=100_000) await world.ask(mission, 10, aud=AS) - due = await federation.due(world.ps, TENANT, kicked[0]) + due = await world.ps.federation_due(TENANT, kicked[0]) assert due is not None client = federation.AccessServerClient(world.ps) metadata = await client.discover(due[0].access_server) @@ -204,10 +204,7 @@ async def handle_async_request( world.net.apps[AS] = Busy() assert first.location is not None busy = await client.poll(first.location, due[0], due[1], metadata) - assert ( - busy.step is federation.Step.PENDING - and federation.poll_delay(busy) == 7 - ) + assert busy.step is federation.Step.PENDING and busy.poll_delay() == 7 async def test_the_access_server_refuses_what_it_does_not_decide(): @@ -257,13 +254,13 @@ async def kick(tenant: str, approval: Approval) -> None: world.ps._on_federate = kick mission = await world.approved_mission(budget=100_000) asked = await world.ask(mission, 10, aud=AS) - due = await federation.due(world.ps, TENANT, kicked[0]) + due = await world.ps.federation_due(TENANT, kicked[0]) assert due is not None client = federation.AccessServerClient(world.ps) metadata = await client.discover(due[0].access_server) assert isinstance(metadata, federation.Metadata) outcome = await client.request(due[0], due[1], metadata) - await federation.record(world.ps, TENANT, kicked[0], outcome) + 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()) @@ -273,7 +270,7 @@ async def kick(tenant: str, approval: Approval) -> None: assert outcome.location is not None denied = await client.poll(outcome.location, due[0], due[1], metadata) assert denied.step is federation.Step.REFUSED - await federation.record(world.ps, TENANT, kicked[0], denied) + await world.ps.record_federation(TENANT, kicked[0], denied) assert (await world.follow(asked)).status_code == 403 @@ -372,19 +369,19 @@ async def kick(tenant: str, approval: Approval) -> None: presented_token=person_token, ), ) - due = await federation.due(world.ps, TENANT, kicked[0]) + due = await world.ps.federation_due(TENANT, kicked[0]) assert due is not None client = federation.AccessServerClient(world.ps) metadata = await client.discover(due[0].access_server) assert isinstance(metadata, federation.Metadata) first = await client.request(due[0], due[1], metadata) - assert await federation.record(world.ps, TENANT, kicked[0], first) + 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 federation.record(world.ps, TENANT, kicked[0], issued) + 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 From 1eda313cbbb3b2b43ccfeba9bbee0ffd0890eec6 Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 16:32:05 -0700 Subject: [PATCH 21/34] refactor(aauth)!: the Access Server client takes what it uses; every check has a test - AccessServerClient takes the Person Server's identity, discovery, http client and clock instead of the PersonServer, so federation no longer imports the server under TYPE_CHECKING; PersonServer.access_server_client() builds one. - Classmethods returning their own class annotate Self; errors.py defines VerificationError before the errors that read it. - A test per presented- and resource-token expectation check, and end-to-end tests that the Access Server refuses an agent or presented token it did not expect and the Person Server refuses an auth token it did not ask for: disabling any check, or any call to one, fails a test. BREAKING CHANGE: AccessServerClient(ps) is now ps.access_server_client(). Co-Authored-By: Claude Opus 5.5 --- .../access_server/models.py | 10 +- .../person_server/federation.py | 38 +++-- .../person_server/models.py | 6 +- .../person_server/server.py | 8 +- .../person_server/store.py | 4 +- .../introspection_aauth/protocol/claims.py | 4 +- .../introspection_aauth/protocol/errors.py | 33 ++-- .../introspection_aauth/protocol/keys.py | 7 +- .../introspection_aauth/protocol/models.py | 6 +- .../tests/test_boundaries.py | 6 +- .../tests/test_expectations_enforced.py | 154 ++++++++++++++++++ .../tests/test_federation.py | 2 +- .../introspection-aauth/tests/test_models.py | 60 +++++++ .../tests/test_refusals.py | 6 +- 14 files changed, 288 insertions(+), 56 deletions(-) create mode 100644 packages/introspection-aauth/tests/test_expectations_enforced.py diff --git a/packages/introspection-aauth/introspection_aauth/access_server/models.py b/packages/introspection-aauth/introspection_aauth/access_server/models.py index ea0d677..78b3103 100644 --- a/packages/introspection-aauth/introspection_aauth/access_server/models.py +++ b/packages/introspection-aauth/introspection_aauth/access_server/models.py @@ -1,7 +1,7 @@ """What an Access Server decides on, and what it keeps while it waits.""" from enum import StrEnum -from typing import Literal +from typing import Literal, Self from pydantic import BaseModel, ConfigDict @@ -66,19 +66,19 @@ class Verdict(BaseModel): @classmethod def allow( cls, scope: str | None = None, budget: Budget | None = None - ) -> "Verdict": + ) -> Self: return cls(decision="allow", scope=scope, budget=budget) @classmethod - def deny(cls, detail: str | None = None) -> "Verdict": + def deny(cls, detail: str | None = None) -> Self: return cls(decision="deny", detail=detail) @classmethod - def approval(cls) -> "Verdict": + def approval(cls) -> Self: return cls(decision="approval") @classmethod - def interaction(cls, url: str, code: str) -> "Verdict": + def interaction(cls, url: str, code: str) -> Self: return cls(decision="interaction", url=url, code=code) diff --git a/packages/introspection-aauth/introspection_aauth/person_server/federation.py b/packages/introspection-aauth/introspection_aauth/person_server/federation.py index 71159ab..5acd956 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/federation.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/federation.py @@ -11,7 +11,6 @@ in-process. """ -from typing import TYPE_CHECKING from urllib.parse import urljoin import httpx2 as httpx @@ -26,9 +25,12 @@ PersonTokenClaims, ResourceTokenClaims, ) +from ..protocol.discovery import Discovery from ..protocol.errors import Problem, VerificationError from ..protocol.keys import Jwks from ..protocol.server import ( + Clock, + Identity, https_endpoint, requirement_name, retry_after, @@ -37,9 +39,6 @@ from ..protocol.wire import AccessServerMetadata, AuthTokenAnswer, TokenRequest from .models import Federation, Outcome, Step -if TYPE_CHECKING: - from .server import PersonServer - SERVED = frozenset({"approval", "interaction"}) #: Requirements draft -11 defines that this Person Server does not serve. NOT_SERVED = frozenset({"claims", "clarification"}) @@ -51,13 +50,22 @@ class AccessServerClient: """Talks to the Access Server a verified resource token names, signed as the Person Server.""" - def __init__(self, ps: "PersonServer"): - self._ps = ps + 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) -> Metadata | Outcome: """`{aud}/.well-known/aauth-access.json`, whose `issuer` is the `aud`.""" try: - document = await self._ps.discovery.get( + document = await self._discovery.get( f"{access_server}/.well-known/{ACCESS_DWK}", AccessServerMetadata, ) @@ -89,8 +97,8 @@ async def request( budget=federation.budget, ).to_json() url = metadata.auth_token_endpoint - headers = self._ps.identity.signed("POST", url, body, self._ps.clock()) - response = await self._ps.http.post( + 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( @@ -104,10 +112,8 @@ async def poll( agent_jkt: str, metadata: Metadata, ) -> Outcome: - headers = self._ps.identity.signed( - "GET", location, None, self._ps.clock() - ) - response = await self._ps.http.get( + headers = self._identity.signed("GET", location, None, self._clock()) + response = await self._http.get( location, headers=headers, follow_redirects=False ) return await self._outcome( @@ -215,12 +221,12 @@ async def _delivered( "its request's tokens cannot be read back" ) try: - keys = await self._ps.discovery.get(metadata.jwks_uri, Jwks) + keys = await self._discovery.get(metadata.jwks_uri, Jwks) claims = jwt.verify( auth_token, keys, jwt.AUTH_TOKEN, - self._ps.clock(), + self._clock(), AuthTokenClaims, ) except VerificationError as exc: @@ -229,7 +235,7 @@ async def _delivered( ) wrong = DeliveryExpectation( issuer=metadata.issuer, - ps=self._ps.issuer, + ps=self._identity.issuer, rt=rt, agent_jkt=agent_jkt, not_after=min(presented.exp, agent.exp), diff --git a/packages/introspection-aauth/introspection_aauth/person_server/models.py b/packages/introspection-aauth/introspection_aauth/person_server/models.py index 628710e..f46822d 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/models.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/models.py @@ -1,7 +1,7 @@ """What a Person Server keeps, and the requests it takes.""" from enum import StrEnum -from typing import Literal +from typing import Literal, Self from pydantic import BaseModel, ConfigDict, Field @@ -224,14 +224,14 @@ class Outcome(BaseModel): refusal: Refusal | None = None @classmethod - def refused(cls, status: int, error: str, detail: str) -> "Outcome": + 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) -> "Outcome": + def unreachable(cls, detail: str) -> Self: """`as_unreachable`: no verifiable answer from the Access Server.""" return cls.refused(502, "as_unreachable", detail) diff --git a/packages/introspection-aauth/introspection_aauth/person_server/server.py b/packages/introspection-aauth/introspection_aauth/person_server/server.py index 779e1ec..c2fccf8 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/server.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/server.py @@ -177,6 +177,12 @@ def metadata(self) -> PersonServerMetadata: 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}" @@ -518,7 +524,7 @@ async def federate( if found is None: return None federation, agent_jkt = found - client = AccessServerClient(self) + client = self.access_server_client() metadata = await client.discover(federation.access_server) async def attempt(call: Exchange) -> Outcome: diff --git a/packages/introspection-aauth/introspection_aauth/person_server/store.py b/packages/introspection-aauth/introspection_aauth/person_server/store.py index 431f338..65605af 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/store.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/store.py @@ -1,6 +1,8 @@ """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 @@ -85,7 +87,7 @@ async def update_approval_summary( decision or federation in flight is not made stale; whether it did.""" ... - def transaction(self) -> AbstractAsyncContextManager["Store"]: ... + def transaction(self) -> AbstractAsyncContextManager[Store]: ... class Directory(Protocol): diff --git a/packages/introspection-aauth/introspection_aauth/protocol/claims.py b/packages/introspection-aauth/introspection_aauth/protocol/claims.py index 6149804..0e52d93 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/claims.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/claims.py @@ -133,7 +133,7 @@ class AuthGrant(Carried): sub: str = Field(min_length=1) @classmethod - def requested(cls, rt: ResourceTokenClaims, sub: str) -> "AuthGrant": + def requested(cls, rt: ResourceTokenClaims, sub: str) -> Self: """What `rt` asks for, for the person `sub`.""" return cls( aud=rt.iss.rstrip("/"), @@ -173,7 +173,7 @@ def issue( jti: str, iat: int, exp: int, - ) -> "AuthTokenClaims": + ) -> Self: return cls( iss=iss, dwk=dwk, diff --git a/packages/introspection-aauth/introspection_aauth/protocol/errors.py b/packages/introspection-aauth/introspection_aauth/protocol/errors.py index 65df07b..101a215 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/errors.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/errors.py @@ -2,6 +2,7 @@ from collections.abc import Mapping from dataclasses import dataclass, field +from typing import Self from pydantic import BaseModel, ConfigDict @@ -53,6 +54,19 @@ def answer(self) -> Answer: ) +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 _Coded(AAuthError): """An `AAuthError` whose code is fixed by its class.""" @@ -119,8 +133,8 @@ def __init__( @classmethod def rejected( - cls, exc: "VerificationError", headers: Mapping[str, str] | None = None - ) -> "Unauthorized": + cls, exc: VerificationError, headers: Mapping[str, str] | None = None + ) -> Self: return cls(exc.code, exc.detail, headers) @@ -132,18 +146,5 @@ def __init__(self, token: str, detail: str, *, expired: bool = False): super().__init__(400, f"{prefix}_{token}_token", detail) @classmethod - def rejected(cls, token: str, exc: "VerificationError") -> "InvalidToken": + def rejected(cls, token: str, exc: VerificationError) -> Self: return cls(token, exc.detail, expired=exc.code == "expired_jwt") - - -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 diff --git a/packages/introspection-aauth/introspection_aauth/protocol/keys.py b/packages/introspection-aauth/introspection_aauth/protocol/keys.py index a3678a0..9e30843 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/keys.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/keys.py @@ -8,6 +8,7 @@ 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 @@ -143,11 +144,11 @@ def __init__(self, private_key: Ed25519PrivateKey): ) @classmethod - def generate(cls) -> "SigningKey": + def generate(cls) -> Self: return cls(Ed25519PrivateKey.generate()) @classmethod - def from_pem(cls, pem: str | bytes) -> "SigningKey": + def from_pem(cls, pem: str | bytes) -> Self: key = serialization.load_pem_private_key( pem.encode() if isinstance(pem, str) else pem, password=None ) @@ -156,7 +157,7 @@ def from_pem(cls, pem: str | bytes) -> "SigningKey": return cls(key) @classmethod - def from_seed(cls, seed: bytes) -> "SigningKey": + def from_seed(cls, seed: bytes) -> Self: return cls(Ed25519PrivateKey.from_private_bytes(seed)) def sign(self, data: bytes) -> bytes: diff --git a/packages/introspection-aauth/introspection_aauth/protocol/models.py b/packages/introspection-aauth/introspection_aauth/protocol/models.py index 37a5369..5b6808a 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/models.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/models.py @@ -1,5 +1,7 @@ """Values both servers exchange.""" +from typing import Self + from pydantic import BaseModel, ConfigDict, Field from .keys import Jwk @@ -17,10 +19,10 @@ class Budget(BaseModel): unit: str = Field(min_length=1, max_length=32) decimals: int = Field(ge=0, le=18) - def same_scale(self, other: "Budget") -> bool: + def same_scale(self, other: Self) -> bool: return self.unit == other.unit and self.decimals == other.decimals - def at_most(self, limit: "Budget") -> bool: + 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 diff --git a/packages/introspection-aauth/tests/test_boundaries.py b/packages/introspection-aauth/tests/test_boundaries.py index 70b3662..80fdf06 100644 --- a/packages/introspection-aauth/tests/test_boundaries.py +++ b/packages/introspection-aauth/tests/test_boundaries.py @@ -145,7 +145,7 @@ async def kick(tenant: str, approval: Approval) -> None: await world.ask(mission, 10, aud=AS) due = await world.ps.federation_due(TENANT, kicked[0]) assert due is not None - client = federation.AccessServerClient(world.ps) + client = world.ps.access_server_client() metadata = await client.discover(due[0].access_server) assert isinstance(metadata, federation.Metadata) first = await client.request(due[0], due[1], metadata) @@ -343,7 +343,7 @@ async def kick(tenant: str, approval: Approval) -> None: approval = urlsplit(asked.headers["location"]).path.rsplit("/", 1)[1] due = await world.ps.federation_due(TENANT, approval) assert due is not None - client = federation.AccessServerClient(world.ps) + client = world.ps.access_server_client() metadata = await client.discover(due[0].access_server) assert isinstance(metadata, federation.Metadata) world.net.apps[AS] = Scripted( @@ -433,7 +433,7 @@ async def test_access_server_metadata_keeps_its_endpoints_on_its_origin( auth_token_endpoint=auth_token_endpoint, ) ) - found = await federation.AccessServerClient(world.ps).discover(AS) + found = await world.ps.access_server_client().discover(AS) assert isinstance(found, federation.Outcome) assert found.refusal is not None assert found.refusal.error == "as_unreachable" 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..02368f2 --- /dev/null +++ b/packages/introspection-aauth/tests/test_expectations_enforced.py @@ -0,0 +1,154 @@ +"""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.net.apps[AS] = Tampering(world, world.net.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_federation.py b/packages/introspection-aauth/tests/test_federation.py index a0d5790..f7d4845 100644 --- a/packages/introspection-aauth/tests/test_federation.py +++ b/packages/introspection-aauth/tests/test_federation.py @@ -110,7 +110,7 @@ async def kick(tenant: str, approval: Approval) -> None: due = await world.ps.federation_due(TENANT, approval_id) assert due is not None fed, jkt = due - client = federation.AccessServerClient(world.ps) + client = world.ps.access_server_client() metadata = await client.discover(fed.access_server) assert isinstance(metadata, federation.Metadata) outcome = await client.request(fed, jkt, metadata) diff --git a/packages/introspection-aauth/tests/test_models.py b/packages/introspection-aauth/tests/test_models.py index 296d399..be82188 100644 --- a/packages/introspection-aauth/tests/test_models.py +++ b/packages/introspection-aauth/tests/test_models.py @@ -244,6 +244,66 @@ def test_an_auth_token_from_elsewhere_bound_elsewhere_or_outliving_is_named(): 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( diff --git a/packages/introspection-aauth/tests/test_refusals.py b/packages/introspection-aauth/tests/test_refusals.py index d5ec354..701da41 100644 --- a/packages/introspection-aauth/tests/test_refusals.py +++ b/packages/introspection-aauth/tests/test_refusals.py @@ -187,7 +187,7 @@ async def kick(tenant: str, approval: Approval) -> None: await world.ask(mission, 10, aud=AS) due = await world.ps.federation_due(TENANT, kicked[0]) assert due is not None - client = federation.AccessServerClient(world.ps) + client = world.ps.access_server_client() metadata = await client.discover(due[0].access_server) assert isinstance(metadata, federation.Metadata) first = await client.request(due[0], due[1], metadata) @@ -256,7 +256,7 @@ async def kick(tenant: str, approval: Approval) -> None: asked = await world.ask(mission, 10, aud=AS) due = await world.ps.federation_due(TENANT, kicked[0]) assert due is not None - client = federation.AccessServerClient(world.ps) + client = world.ps.access_server_client() metadata = await client.discover(due[0].access_server) assert isinstance(metadata, federation.Metadata) outcome = await client.request(due[0], due[1], metadata) @@ -371,7 +371,7 @@ async def kick(tenant: str, approval: Approval) -> None: ) due = await world.ps.federation_due(TENANT, kicked[0]) assert due is not None - client = federation.AccessServerClient(world.ps) + client = world.ps.access_server_client() metadata = await client.discover(due[0].access_server) assert isinstance(metadata, federation.Metadata) first = await client.request(due[0], due[1], metadata) From b8bab8212efd23fb4feca2fcf4e06a3db3f99e64 Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 16:34:16 -0700 Subject: [PATCH 22/34] build(aauth): ban relative imports, type-check the package's tests, raise its coverage floor - ruff TID252 (ban-relative-imports = all) for library code; a test suite keeps importing its own helpers relatively. introspection-aauth's 103 relative imports become absolute, and so does the examples package's one. - The introspection-aauth CI job runs ty over its tests as well as its source. - Its coverage floor rises from 84% to 88%; it is at 90%. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- examples/introspection_examples/__init__.py | 2 +- .../access_server/__init__.py | 11 +++- .../access_server/models.py | 6 +- .../access_server/server.py | 34 +++++----- .../access_server/store.py | 4 +- .../introspection_aauth/fastapi.py | 18 +++--- .../person_server/__init__.py | 13 ++-- .../person_server/federation.py | 20 +++--- .../person_server/models.py | 8 +-- .../person_server/server.py | 64 ++++++++++--------- .../person_server/store.py | 8 ++- .../introspection_aauth/protocol/__init__.py | 23 ++++--- .../introspection_aauth/protocol/claims.py | 6 +- .../introspection_aauth/protocol/discovery.py | 6 +- .../introspection_aauth/protocol/httpsig.py | 10 +-- .../introspection_aauth/protocol/jwt.py | 20 ++++-- .../introspection_aauth/protocol/keys.py | 2 +- .../introspection_aauth/protocol/models.py | 2 +- .../introspection_aauth/protocol/server.py | 14 ++-- .../introspection_aauth/protocol/wire.py | 6 +- packages/introspection-aauth/pyproject.toml | 2 +- .../tests/test_federation.py | 12 +++- pyproject.toml | 6 ++ 24 files changed, 178 insertions(+), 121 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09638a0..c01e56d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -242,7 +242,7 @@ jobs: run: uv run ruff check . - name: Run type checking - run: uv run ty check introspection_aauth + run: uv run ty check introspection_aauth tests - name: Run tests with coverage run: uv run pytest tests/ -v --cov=introspection_aauth --cov-report=term-missing 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/packages/introspection-aauth/introspection_aauth/access_server/__init__.py b/packages/introspection-aauth/introspection_aauth/access_server/__init__.py index de4b834..6666495 100644 --- a/packages/introspection-aauth/introspection_aauth/access_server/__init__.py +++ b/packages/introspection-aauth/introspection_aauth/access_server/__init__.py @@ -1,9 +1,14 @@ """An AAuth Access Server: a resource's own policy over the auth tokens a Person Server redeems for it.""" -from .models import AccessRequest, PendingAccess, PendingStatus, Verdict -from .server import AccessServer, Policy -from .store import MemoryStore, Store +from introspection_aauth.access_server.models import ( + AccessRequest, + PendingAccess, + PendingStatus, + Verdict, +) +from introspection_aauth.access_server.server import AccessServer, Policy +from introspection_aauth.access_server.store import MemoryStore, Store __all__ = [ "AccessRequest", diff --git a/packages/introspection-aauth/introspection_aauth/access_server/models.py b/packages/introspection-aauth/introspection_aauth/access_server/models.py index 78b3103..18a1a8f 100644 --- a/packages/introspection-aauth/introspection_aauth/access_server/models.py +++ b/packages/introspection-aauth/introspection_aauth/access_server/models.py @@ -5,14 +5,14 @@ from pydantic import BaseModel, ConfigDict -from ..protocol.claims import ( +from introspection_aauth.protocol.claims import ( AgentTokenClaims, AuthGrant, PersonTokenClaims, ResourceTokenClaims, ) -from ..protocol.keys import Jwk -from ..protocol.models import Budget +from introspection_aauth.protocol.keys import Jwk +from introspection_aauth.protocol.models import Budget class VerifiedTokens(BaseModel): diff --git a/packages/introspection-aauth/introspection_aauth/access_server/server.py b/packages/introspection-aauth/introspection_aauth/access_server/server.py index ac27091..6cd6773 100644 --- a/packages/introspection-aauth/introspection_aauth/access_server/server.py +++ b/packages/introspection-aauth/introspection_aauth/access_server/server.py @@ -9,8 +9,16 @@ import httpx2 as httpx -from ..protocol import jwt -from ..protocol.claims import ( +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.claims import ( ACCESS_DWK, PERSON_DWK, AgentExpectation, @@ -22,7 +30,7 @@ ResourceTokenClaims, ResourceTokenExpectation, ) -from ..protocol.errors import ( +from introspection_aauth.protocol.errors import ( Answer, Denied, Expired, @@ -33,10 +41,10 @@ ServerError, VerificationError, ) -from ..protocol.httpsig import SignedRequest -from ..protocol.keys import SigningKey -from ..protocol.models import Budget -from ..protocol.server import ( +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.server import ( Server, ServerOptions, parse, @@ -46,15 +54,11 @@ verify_resource_token, verify_server, ) -from ..protocol.wire import AccessServerMetadata, AuthTokenAnswer, TokenRequest -from .models import ( - AccessRequest, - PendingAccess, - PendingStatus, - Verdict, - VerifiedTokens, +from introspection_aauth.protocol.wire import ( + AccessServerMetadata, + AuthTokenAnswer, + TokenRequest, ) -from .store import MemoryStore, Store class Policy(Protocol): diff --git a/packages/introspection-aauth/introspection_aauth/access_server/store.py b/packages/introspection-aauth/introspection_aauth/access_server/store.py index a7e8283..9e3230d 100644 --- a/packages/introspection-aauth/introspection_aauth/access_server/store.py +++ b/packages/introspection-aauth/introspection_aauth/access_server/store.py @@ -2,8 +2,8 @@ from typing import Protocol -from ..protocol.memory import compare_and_set -from .models import PendingAccess +from introspection_aauth.access_server.models import PendingAccess +from introspection_aauth.protocol.memory import compare_and_set class Store(Protocol): diff --git a/packages/introspection-aauth/introspection_aauth/fastapi.py b/packages/introspection-aauth/introspection_aauth/fastapi.py index 0d203fa..dd8b783 100644 --- a/packages/introspection-aauth/introspection_aauth/fastapi.py +++ b/packages/introspection-aauth/introspection_aauth/fastapi.py @@ -26,8 +26,8 @@ from fastapi import APIRouter, HTTPException, Request, Response from pydantic import BaseModel, JsonValue -from .access_server.server import AccessServer -from .person_server.models import ( +from introspection_aauth.access_server.server import AccessServer +from introspection_aauth.person_server.models import ( AuthTokenRequest, MissionAction, MissionAnswer, @@ -35,18 +35,18 @@ MissionProposal, PersonTokenRequest, ) -from .person_server.server import PersonServer -from .protocol.errors import ( +from introspection_aauth.person_server.server import PersonServer +from introspection_aauth.protocol.errors import ( AAuthError, Answer, NotFound, Problem, ) -from .protocol.httpsig import SignedRequest -from .protocol.json import JsonObject -from .protocol.keys import Jwks -from .protocol.server import Server -from .protocol.wire import ( +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 ( AccessServerMetadata, AuthTokenAnswer, Empty, diff --git a/packages/introspection-aauth/introspection_aauth/person_server/__init__.py b/packages/introspection-aauth/introspection_aauth/person_server/__init__.py index 79f0392..6412e1d 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/__init__.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/__init__.py @@ -1,8 +1,8 @@ """An AAuth Person Server: missions, person and auth tokens, budgets, approvals, and federation with a resource's own Access Server.""" -from . import federation -from .models import ( +from introspection_aauth.person_server import federation +from introspection_aauth.person_server.models import ( Approval, ApprovalKind, ApprovalStatus, @@ -13,8 +13,13 @@ Outcome, Step, ) -from .server import PersonServer -from .store import AnyoneDirectory, Directory, MemoryStore, Store +from introspection_aauth.person_server.server import PersonServer +from introspection_aauth.person_server.store import ( + AnyoneDirectory, + Directory, + MemoryStore, + Store, +) __all__ = [ "AnyoneDirectory", diff --git a/packages/introspection-aauth/introspection_aauth/person_server/federation.py b/packages/introspection-aauth/introspection_aauth/person_server/federation.py index 5acd956..725305d 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/federation.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/federation.py @@ -16,8 +16,9 @@ import httpx2 as httpx from pydantic import ValidationError -from ..protocol import jwt -from ..protocol.claims import ( +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, @@ -25,10 +26,10 @@ PersonTokenClaims, ResourceTokenClaims, ) -from ..protocol.discovery import Discovery -from ..protocol.errors import Problem, VerificationError -from ..protocol.keys import Jwks -from ..protocol.server import ( +from introspection_aauth.protocol.discovery import Discovery +from introspection_aauth.protocol.errors import Problem, VerificationError +from introspection_aauth.protocol.keys import Jwks +from introspection_aauth.protocol.server import ( Clock, Identity, https_endpoint, @@ -36,8 +37,11 @@ retry_after, same_origin, ) -from ..protocol.wire import AccessServerMetadata, AuthTokenAnswer, TokenRequest -from .models import Federation, Outcome, Step +from introspection_aauth.protocol.wire import ( + AccessServerMetadata, + AuthTokenAnswer, + TokenRequest, +) SERVED = frozenset({"approval", "interaction"}) #: Requirements draft -11 defines that this Person Server does not serve. diff --git a/packages/introspection-aauth/introspection_aauth/person_server/models.py b/packages/introspection-aauth/introspection_aauth/person_server/models.py index f46822d..4d2eadc 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/models.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/models.py @@ -5,10 +5,10 @@ from pydantic import BaseModel, ConfigDict, Field -from ..protocol.claims import AuthGrant -from ..protocol.keys import Jwk -from ..protocol.models import Budget -from ..protocol.wire import Wire +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): diff --git a/packages/introspection-aauth/introspection_aauth/person_server/server.py b/packages/introspection-aauth/introspection_aauth/person_server/server.py index c2fccf8..40ff6ce 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/server.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/server.py @@ -15,8 +15,33 @@ import httpx2 as httpx -from ..protocol import jwt -from ..protocol.claims import ( +from introspection_aauth.person_server.federation import AccessServerClient +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 ( + AnyoneDirectory, + Directory, + Store, +) +from introspection_aauth.protocol import jwt +from introspection_aauth.protocol.claims import ( PERSON_DWK, RESOURCE_DWK, AuthGrant, @@ -27,7 +52,7 @@ ResourceTokenClaims, ResourceTokenExpectation, ) -from ..protocol.errors import ( +from introspection_aauth.protocol.errors import ( AAuthError, AccessDenied, Answer, @@ -42,10 +67,10 @@ ServerError, VerificationError, ) -from ..protocol.httpsig import SignedRequest -from ..protocol.keys import SigningKey, b64url, s256 -from ..protocol.models import Agent, Budget -from ..protocol.server import ( +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.server import ( Server, ServerOptions, https_origin, @@ -54,35 +79,12 @@ verify_agent, verify_resource_token, ) -from ..protocol.wire import ( +from introspection_aauth.protocol.wire import ( AuthTokenAnswer, Empty, PersonServerMetadata, PersonTokenAnswer, ) -from .federation import ( - AccessServerClient, -) -from .models import ( - Approval, - ApprovalKind, - ApprovalStatus, - AuthTokenRequest, - Federation, - LogEntry, - Mission, - MissionAction, - MissionAnswer, - MissionDocument, - MissionGrant, - MissionLogged, - MissionProposal, - Outcome, - PersonTokenRequest, - Refusal, - Step, -) -from .store import AnyoneDirectory, Directory, Store logger = logging.getLogger(__name__) diff --git a/packages/introspection-aauth/introspection_aauth/person_server/store.py b/packages/introspection-aauth/introspection_aauth/person_server/store.py index 65605af..1d83033 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/store.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/store.py @@ -9,8 +9,12 @@ from contextlib import AbstractAsyncContextManager, asynccontextmanager from typing import Protocol -from ..protocol.memory import compare_and_set -from .models import Approval, LogEntry, Mission +from introspection_aauth.person_server.models import ( + Approval, + LogEntry, + Mission, +) +from introspection_aauth.protocol.memory import compare_and_set class Store(Protocol): diff --git a/packages/introspection-aauth/introspection_aauth/protocol/__init__.py b/packages/introspection-aauth/introspection_aauth/protocol/__init__.py index 0e29fad..5a0b4c1 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/__init__.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/__init__.py @@ -2,7 +2,7 @@ signatures, tokens and their claims, discovery, budgets, the bodies they exchange, and the plumbing of a server.""" -from .claims import ( +from introspection_aauth.protocol.claims import ( AgentExpectation, AgentTokenClaims, AuthGrant, @@ -15,8 +15,8 @@ ResourceTokenClaims, ResourceTokenExpectation, ) -from .discovery import Discovery -from .errors import ( +from introspection_aauth.protocol.discovery import Discovery +from introspection_aauth.protocol.errors import ( AAuthError, AccessDenied, Answer, @@ -33,11 +33,18 @@ Unauthorized, VerificationError, ) -from .httpsig import SignedRequest, Verified -from .json import JsonObject, JsonValue -from .keys import Jwk, Jwks, SigningKey, jwks, s256, thumbprint -from .models import Agent, Budget -from .server import ( +from introspection_aauth.protocol.httpsig import SignedRequest, Verified +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.server import ( Clock, Identity, IdFactory, diff --git a/packages/introspection-aauth/introspection_aauth/protocol/claims.py b/packages/introspection-aauth/introspection_aauth/protocol/claims.py index 0e52d93..cd0d5fc 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/claims.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/claims.py @@ -7,9 +7,9 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator -from .errors import VerificationError -from .keys import Jwk, thumbprint -from .models import Budget +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. diff --git a/packages/introspection-aauth/introspection_aauth/protocol/discovery.py b/packages/introspection-aauth/introspection_aauth/protocol/discovery.py index 660bd68..e094308 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/discovery.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/discovery.py @@ -8,9 +8,9 @@ import httpx2 as httpx from pydantic import BaseModel, ValidationError -from .errors import VerificationError -from .keys import Jwk, Jwks -from .wire import ServerMetadata +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) diff --git a/packages/introspection-aauth/introspection_aauth/protocol/httpsig.py b/packages/introspection-aauth/introspection_aauth/protocol/httpsig.py index 0152b79..5caa8f2 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/httpsig.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/httpsig.py @@ -19,11 +19,11 @@ from http_sfv.item import Parameters from pydantic import ValidationError -from . import jwt -from .claims import Confirmation -from .errors import VerificationError -from .keys import Jwk, SigningKey, thumbprint -from .keys import verify as verify_with +from introspection_aauth.protocol import jwt +from introspection_aauth.protocol.claims import Confirmation +from introspection_aauth.protocol.errors import VerificationError +from introspection_aauth.protocol.keys import Jwk, SigningKey, thumbprint +from introspection_aauth.protocol.keys import verify as verify_with LABEL = "sig" REQUIRED = ("@method", "@authority", "@path", "signature-key") diff --git a/packages/introspection-aauth/introspection_aauth/protocol/jwt.py b/packages/introspection-aauth/introspection_aauth/protocol/jwt.py index 516317b..c328d39 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/jwt.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/jwt.py @@ -6,11 +6,21 @@ from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError -from .claims import MAX_TOKEN_LIFETIME_SECONDS, Claims -from .errors import VerificationError -from .json import JsonObject -from .keys import Jwk, Jwks, SigningKey, algorithm, b64url, b64url_decode -from .keys import verify as verify_signature +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" diff --git a/packages/introspection-aauth/introspection_aauth/protocol/keys.py b/packages/introspection-aauth/introspection_aauth/protocol/keys.py index 9e30843..606df12 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/keys.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/keys.py @@ -22,7 +22,7 @@ ) from pydantic import BaseModel, ConfigDict -from .errors import VerificationError +from introspection_aauth.protocol.errors import VerificationError ALGORITHMS = {"Ed25519": ("OKP", "Ed25519"), "ES256": ("EC", "P-256")} diff --git a/packages/introspection-aauth/introspection_aauth/protocol/models.py b/packages/introspection-aauth/introspection_aauth/protocol/models.py index 5b6808a..a0d134e 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/models.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/models.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, ConfigDict, Field -from .keys import Jwk +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 diff --git a/packages/introspection-aauth/introspection_aauth/protocol/server.py b/packages/introspection-aauth/introspection_aauth/protocol/server.py index 7f8fc6f..492ae02 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/server.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/server.py @@ -13,8 +13,8 @@ import httpx2 as httpx from pydantic import BaseModel, ValidationError -from . import httpsig, jwt -from .claims import ( +from introspection_aauth.protocol import httpsig, jwt +from introspection_aauth.protocol.claims import ( ACCESS_DWK, AGENT_DWK, PERSON_DWK, @@ -25,17 +25,17 @@ ResourceTokenExpectation, ServerDwk, ) -from .discovery import Discovery -from .errors import ( +from introspection_aauth.protocol.discovery import Discovery +from introspection_aauth.protocol.errors import ( Answer, InvalidRequest, InvalidToken, Unauthorized, VerificationError, ) -from .keys import Jwk, Jwks, SigningKey, jwks, s256 -from .models import Agent -from .wire import Pending, R3Proposal +from introspection_aauth.protocol.keys import Jwk, Jwks, SigningKey, jwks, s256 +from introspection_aauth.protocol.models import Agent +from introspection_aauth.protocol.wire import Pending, R3Proposal __all__ = [ "ACCESS_DWK", diff --git a/packages/introspection-aauth/introspection_aauth/protocol/wire.py b/packages/introspection-aauth/introspection_aauth/protocol/wire.py index adf0576..6c9734e 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/wire.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/wire.py @@ -6,9 +6,9 @@ from pydantic import BaseModel, ConfigDict -from .errors import Problem -from .keys import Jwks -from .models import Budget +from introspection_aauth.protocol.errors import Problem +from introspection_aauth.protocol.keys import Jwks +from introspection_aauth.protocol.models import Budget class Wire(BaseModel): diff --git a/packages/introspection-aauth/pyproject.toml b/packages/introspection-aauth/pyproject.toml index 5c24773..5bcba1e 100644 --- a/packages/introspection-aauth/pyproject.toml +++ b/packages/introspection-aauth/pyproject.toml @@ -44,7 +44,7 @@ source = ["introspection_aauth"] branch = true [tool.coverage.report] -fail_under = 84 +fail_under = 88 show_missing = true precision = 1 diff --git a/packages/introspection-aauth/tests/test_federation.py b/packages/introspection-aauth/tests/test_federation.py index f7d4845..0ad3044 100644 --- a/packages/introspection-aauth/tests/test_federation.py +++ b/packages/introspection-aauth/tests/test_federation.py @@ -21,7 +21,17 @@ AuthTokenAnswer, ) -from .world import AS, PS, RESOURCE, TENANT, World, body, problem, read, usd +from .world import ( + AS, + PS, + RESOURCE, + TENANT, + World, + body, + problem, + read, + usd, +) def auth_token(response: httpx.Response) -> AuthTokenClaims: diff --git a/pyproject.toml b/pyproject.toml index 50ea1b9..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" From 6116c5a34d89c95a83a57af5651f2f2e50891c04 Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 16:34:38 -0700 Subject: [PATCH 23/34] build(aauth): jscpd skips the re-export __init__ modules Co-Authored-By: Claude Opus 5.5 --- packages/introspection-aauth/.jscpd.json | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/introspection-aauth/.jscpd.json b/packages/introspection-aauth/.jscpd.json index f5e0edb..0a9a7b0 100644 --- a/packages/introspection-aauth/.jscpd.json +++ b/packages/introspection-aauth/.jscpd.json @@ -1,14 +1,27 @@ { - "path": ["introspection_aauth"], + "path": [ + "introspection_aauth" + ], "pattern": "**/*.py", - "format": ["python"], - "ignore": ["**/.venv/**", "**/__pycache__/**"], + "format": [ + "python" + ], + "ignore": [ + "**/.venv/**", + "**/__pycache__/**", + "**/__init__.py" + ], "gitignore": true, "minTokens": 50, "minLines": 5, "mode": "weak", - "kind": ["exact", "renamed"], + "kind": [ + "exact", + "renamed" + ], "ignoreIdentifiers": true, "threshold": 0, - "reporters": ["console"] + "reporters": [ + "console" + ] } From bfe588f8ba5dff659f791d2610ddca4ef4382c48 Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 16:42:50 -0700 Subject: [PATCH 24/34] refactor(aauth): no underscore on classes, constants or type variables Private classes, constants and type variables drop their leading underscore and stay private by being left out of their module's __all__: fastapi, protocol.httpsig, protocol.errors and person_server.server now define one. AGENTS.md records the rule, and that imports are absolute. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 20 ++++++-- .../introspection_aauth/fastapi.py | 51 ++++++++++++------- .../person_server/server.py | 13 +++-- .../introspection_aauth/protocol/errors.py | 38 ++++++++++---- .../introspection_aauth/protocol/httpsig.py | 28 ++++++++-- .../introspection_aauth/protocol/jwt.py | 4 +- 6 files changed, 113 insertions(+), 41 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6e94fe2..a1fbda9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,6 +122,20 @@ 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. + +**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 +296,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/packages/introspection-aauth/introspection_aauth/fastapi.py b/packages/introspection-aauth/introspection_aauth/fastapi.py index dd8b783..07bd45b 100644 --- a/packages/introspection-aauth/introspection_aauth/fastapi.py +++ b/packages/introspection-aauth/introspection_aauth/fastapi.py @@ -56,12 +56,21 @@ TokenRequest, ) -_Found = TypeVar("_Found", bound=Server, covariant=True) -_Server = TypeVar("_Server", bound=Server) -_Acting = TypeVar("_Acting", bound=Server, contravariant=True) +__all__ = [ + "AccessServerResolver", + "PersonServerResolver", + "access_server_router", + "access_server_well_known_router", + "person_server_router", + "person_server_well_known_router", +] +FoundServer = TypeVar("FoundServer", bound=Server, covariant=True) +AnyServer = TypeVar("AnyServer", bound=Server) +ActingServer = TypeVar("ActingServer", bound=Server, contravariant=True) -class _Response(TypedDict, total=False): + +class DocumentedResponse(TypedDict, total=False): """One of a route's documented responses.""" model: type[BaseModel] @@ -69,10 +78,10 @@ class _Response(TypedDict, total=False): content: JsonObject -Responses = dict[int | str, _Response] +Responses = dict[int | str, DocumentedResponse] -class _Docs(TypedDict, total=False): +class RouteDocs(TypedDict, total=False): summary: str description: str operation_id: str @@ -185,20 +194,20 @@ def _under(router: APIRouter, path: str) -> str: return path.removeprefix(router.prefix) -class _Resolver(Protocol[_Found]): - def __call__(self, request: Request) -> _Found | None: ... +class Resolver(Protocol[FoundServer]): + def __call__(self, request: Request) -> FoundServer | None: ... -class _Find(Protocol[_Found]): - def __call__(self, request: Request) -> _Found: ... +class Finder(Protocol[FoundServer]): + def __call__(self, request: Request) -> FoundServer: ... -def _fixed(server: _Server) -> _Resolver[_Server]: +def _fixed(server: AnyServer) -> Resolver[AnyServer]: return lambda request: server -def _finder(resolve: _Resolver[_Server]) -> _Find[_Server]: - def find(request: 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) @@ -209,7 +218,7 @@ def find(request: Request) -> _Server: def _well_known( router: APIRouter, - find: _Find[Server], + find: Finder[Server], *, role: str, title: str, @@ -271,11 +280,15 @@ def person_server_well_known_router( ) -class _Act(Protocol[_Acting]): +class Action(Protocol[ActingServer]): """A server's answer to one signed request, given its path parameters.""" def __call__( - self, server: _Acting, signed: SignedRequest, path: dict[str, str], / + self, + server: ActingServer, + signed: SignedRequest, + path: dict[str, str], + /, ) -> Awaitable[Answer]: ... @@ -300,12 +313,12 @@ def _signed_route( router: APIRouter, method: str, path: str, - find: _Find[_Server], - act: _Act[_Server], + find: Finder[AnyServer], + act: Action[AnyServer], *, body: type[BaseModel] | None = None, responses: Responses | None = None, - **docs: Unpack[_Docs], + **docs: Unpack[RouteDocs], ) -> None: """A signed protocol route: the server `find` resolves answers it by `act`.""" extra: JsonObject = _body(body) if body is not None else {} diff --git a/packages/introspection-aauth/introspection_aauth/person_server/server.py b/packages/introspection-aauth/introspection_aauth/person_server/server.py index 40ff6ce..c59776e 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/server.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/server.py @@ -86,6 +86,13 @@ PersonTokenAnswer, ) +__all__ = [ + "Exchange", + "Hook", + "PersonServer", + "Sleep", +] + logger = logging.getLogger(__name__) @@ -122,7 +129,7 @@ def _fits(mission: Mission, budget: Budget | None) -> bool: return budget.at_most(left) -class _Stale(Exception): +class StaleRecord(Exception): """Rolls a `record` back: the approval changed or lapsed meanwhile.""" @@ -512,7 +519,7 @@ async def record_federation( spend lands only when it issues, in the same transaction as its token.""" try: return await self._record_federation(tenant, approval_id, outcome) - except _Stale: + except StaleRecord: return False async def federate( @@ -1029,7 +1036,7 @@ async def _record_federation( if approval.expires_at <= now or not await tx.transition_approval( tenant, approval, changed ): - raise _Stale + raise StaleRecord return outcome.step is Step.PENDING async def _spend_granted( diff --git a/packages/introspection-aauth/introspection_aauth/protocol/errors.py b/packages/introspection-aauth/introspection_aauth/protocol/errors.py index 101a215..a1af5a9 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/errors.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/errors.py @@ -6,6 +6,24 @@ from pydantic import BaseModel, ConfigDict +__all__ = [ + "AAuthError", + "AccessDenied", + "Answer", + "Denied", + "Expired", + "InvalidCode", + "InvalidRequest", + "InvalidToken", + "MissionNotFound", + "MissionTerminated", + "NotFound", + "Problem", + "ServerError", + "Unauthorized", + "VerificationError", +] + class Problem(BaseModel): """`application/problem+json` (`#error-responses`).""" @@ -67,7 +85,7 @@ def __init__(self, code: str, detail: str): self.detail = detail -class _Coded(AAuthError): +class CodedError(AAuthError): """An `AAuthError` whose code is fixed by its class.""" code: str @@ -85,41 +103,41 @@ def __init__( ) -class InvalidRequest(_Coded): +class InvalidRequest(CodedError): code, default_status = "invalid_request", 400 -class InvalidCode(_Coded): +class InvalidCode(CodedError): """A pending URL that has already answered, or never existed (`#pending-url-security`).""" code, default_status = "invalid_code", 410 -class Expired(_Coded): +class Expired(CodedError): code, default_status = "expired", 408 -class NotFound(_Coded): +class NotFound(CodedError): code, default_status = "not_found", 404 -class MissionNotFound(_Coded): +class MissionNotFound(CodedError): code, default_status = "mission_not_found", 404 -class MissionTerminated(_Coded): +class MissionTerminated(CodedError): code, default_status = "mission_terminated", 403 -class AccessDenied(_Coded): +class AccessDenied(CodedError): code, default_status = "access_denied", 403 -class Denied(_Coded): +class Denied(CodedError): code, default_status = "denied", 403 -class ServerError(_Coded): +class ServerError(CodedError): code, default_status = "server_error", 500 diff --git a/packages/introspection-aauth/introspection_aauth/protocol/httpsig.py b/packages/introspection-aauth/introspection_aauth/protocol/httpsig.py index 5caa8f2..a061c25 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/httpsig.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/httpsig.py @@ -25,6 +25,24 @@ from introspection_aauth.protocol.keys import Jwk, SigningKey, thumbprint from introspection_aauth.protocol.keys import verify as verify_with +__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", +] + LABEL = "sig" REQUIRED = ("@method", "@authority", "@path", "signature-key") MAX_CLOCK_SKEW_SECONDS = 60 @@ -48,7 +66,7 @@ def __call__( @dataclass(frozen=True) -class _Named: +class KeyNaming: """How a key was named: the token presented, or the server and document.""" token: str | None = None @@ -231,7 +249,7 @@ def _covered(signature_input: http_sfv.InnerList) -> list[str]: async def _key( scheme: str, params: Parameters, resolve: KeyResolver | None -) -> tuple[Jwk, _Named]: +) -> tuple[Jwk, KeyNaming]: if scheme == "jwt": token = params.get("jwt") if not isinstance(token, str): @@ -245,7 +263,7 @@ async def _key( raise VerificationError( "invalid_jwt", "the token has no cnf.jwk" ) from None - return jwk, _Named(token=token) + return jwk, KeyNaming(token=token) if scheme == "jwks_uri": server, dwk, kid = (params.get(n) for n in ("id", "dwk", "kid")) if not ( @@ -260,7 +278,9 @@ async def _key( raise VerificationError( "invalid_key", "not a server this verifier reads" ) - return await resolve(server, dwk, kid), _Named(server=server, dwk=dwk) + return await resolve(server, dwk, kid), KeyNaming( + server=server, dwk=dwk + ) raise VerificationError("unsupported_scheme", f"the {scheme} scheme") diff --git a/packages/introspection-aauth/introspection_aauth/protocol/jwt.py b/packages/introspection-aauth/introspection_aauth/protocol/jwt.py index c328d39..14ea0fd 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/jwt.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/jwt.py @@ -46,7 +46,7 @@ ] ClaimsModel = TypeVar("ClaimsModel", bound=Claims) -_object = TypeAdapter(JsonObject) +JSON_OBJECT = TypeAdapter(JsonObject) class Header(BaseModel): @@ -104,7 +104,7 @@ def decode(token: str) -> Decoded: raise VerificationError("invalid_jwt", "not a compact JWT") try: header = Header.model_validate_json(b64url_decode(parts[0])) - claims = _object.validate_json(b64url_decode(parts[1])) + 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 From a7e3ca81ab0af2e75a4a7a21b505d2ff9720ecfa Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 16:45:56 -0700 Subject: [PATCH 25/34] refactor(aauth)!: person_server.federation becomes access_server_client The module holds only AccessServerClient since the federation steps moved onto PersonServer, so it is named for it, and person_server exports AccessServerClient. Its Metadata alias for AccessServerMetadata goes. BREAKING CHANGE: import AccessServerClient from introspection_aauth.person_server; person_server.federation and federation.Metadata are removed. Co-Authored-By: Claude Opus 5.5 --- .../person_server/__init__.py | 6 ++-- ...{federation.py => access_server_client.py} | 35 ++++++++----------- .../person_server/server.py | 4 ++- .../tests/test_boundaries.py | 11 +++--- .../tests/test_federation.py | 8 ++--- .../tests/test_refusals.py | 13 +++---- 6 files changed, 39 insertions(+), 38 deletions(-) rename packages/introspection-aauth/introspection_aauth/person_server/{federation.py => access_server_client.py} (90%) diff --git a/packages/introspection-aauth/introspection_aauth/person_server/__init__.py b/packages/introspection-aauth/introspection_aauth/person_server/__init__.py index 6412e1d..88bfb83 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/__init__.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/__init__.py @@ -1,7 +1,9 @@ """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 import federation +from introspection_aauth.person_server.access_server_client import ( + AccessServerClient, +) from introspection_aauth.person_server.models import ( Approval, ApprovalKind, @@ -22,6 +24,7 @@ ) __all__ = [ + "AccessServerClient", "AnyoneDirectory", "Approval", "ApprovalKind", @@ -36,5 +39,4 @@ "PersonServer", "Step", "Store", - "federation", ] diff --git a/packages/introspection-aauth/introspection_aauth/person_server/federation.py b/packages/introspection-aauth/introspection_aauth/person_server/access_server_client.py similarity index 90% rename from packages/introspection-aauth/introspection_aauth/person_server/federation.py rename to packages/introspection-aauth/introspection_aauth/person_server/access_server_client.py index 725305d..f74df35 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/federation.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/access_server_client.py @@ -1,15 +1,8 @@ -"""The Person Server's side of four-party access (`#access-server-federation`). - -A resource token whose `aud` is the resource's own Access Server is redeemed -there: the Person Server signs as itself (`#ps-to-as-token-request`), follows -the deferred loop (`#deferred-responses`), and checks the auth token before -the agent gets it (`#auth-token-delivery`). - -The loop is steps a host can journal: `PersonServer.federation_due`, then -`discover`, `request`, `PersonServer.record_federation`, wait `poll_delay`, -`poll`, `record_federation`, and so on; `PersonServer.federate` is that loop -in-process. -""" +"""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.""" from urllib.parse import urljoin @@ -48,9 +41,6 @@ NOT_SERVED = frozenset({"claims", "clarification"}) -Metadata = AccessServerMetadata - - class AccessServerClient: """Talks to the Access Server a verified resource token names, signed as the Person Server.""" @@ -66,7 +56,9 @@ def __init__( self._http = http self._clock = clock - async def discover(self, access_server: str) -> Metadata | Outcome: + async def discover( + self, access_server: str + ) -> AccessServerMetadata | Outcome: """`{aud}/.well-known/aauth-access.json`, whose `issuer` is the `aud`.""" try: document = await self._discovery.get( @@ -91,7 +83,10 @@ async def discover(self, access_server: str) -> Metadata | Outcome: return document async def request( - self, federation: Federation, agent_jkt: str, metadata: Metadata + self, + federation: Federation, + agent_jkt: str, + metadata: AccessServerMetadata, ) -> Outcome: """The token request, with the budget the Person Server allows.""" body = TokenRequest( @@ -114,7 +109,7 @@ async def poll( location: str, federation: Federation, agent_jkt: str, - metadata: Metadata, + metadata: AccessServerMetadata, ) -> Outcome: headers = self._identity.signed("GET", location, None, self._clock()) response = await self._http.get( @@ -130,7 +125,7 @@ async def _outcome( # noqa: PLR0911 requested: str, federation: Federation, agent_jkt: str, - metadata: Metadata, + metadata: AccessServerMetadata, ) -> Outcome: status = response.status_code if status == 200: @@ -207,7 +202,7 @@ async def _delivered( auth_token: str, federation: Federation, agent_jkt: str, - metadata: Metadata, + metadata: AccessServerMetadata, ) -> Outcome: """`#auth-token-delivery`, and the budget granted no more than was sent.""" try: diff --git a/packages/introspection-aauth/introspection_aauth/person_server/server.py b/packages/introspection-aauth/introspection_aauth/person_server/server.py index c59776e..ff369ed 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/server.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/server.py @@ -15,7 +15,9 @@ import httpx2 as httpx -from introspection_aauth.person_server.federation import AccessServerClient +from introspection_aauth.person_server.access_server_client import ( + AccessServerClient, +) from introspection_aauth.person_server.models import ( Approval, ApprovalKind, diff --git a/packages/introspection-aauth/tests/test_boundaries.py b/packages/introspection-aauth/tests/test_boundaries.py index 80fdf06..a84143c 100644 --- a/packages/introspection-aauth/tests/test_boundaries.py +++ b/packages/introspection-aauth/tests/test_boundaries.py @@ -12,7 +12,8 @@ from introspection_aauth.person_server import ( Approval, ApprovalStatus, - federation, + Outcome, + Step, ) from introspection_aauth.person_server.models import ( MissionAction, @@ -147,7 +148,7 @@ async def kick(tenant: str, approval: Approval) -> None: assert due is not None client = world.ps.access_server_client() metadata = await client.discover(due[0].access_server) - assert isinstance(metadata, federation.Metadata) + 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) @@ -345,7 +346,7 @@ async def kick(tenant: str, approval: Approval) -> None: assert due is not None client = world.ps.access_server_client() metadata = await client.discover(due[0].access_server) - assert isinstance(metadata, federation.Metadata) + assert isinstance(metadata, AccessServerMetadata) world.net.apps[AS] = Scripted( world.net.apps[AS], httpx.Response( @@ -357,7 +358,7 @@ async def kick(tenant: str, approval: Approval) -> None: ), ) outcome = await client.request(due[0], due[1], metadata) - assert outcome.step is federation.Step.PENDING + assert outcome.step is Step.PENDING assert outcome.requirement == "requirement=approval" @@ -434,7 +435,7 @@ async def test_access_server_metadata_keeps_its_endpoints_on_its_origin( ) ) found = await world.ps.access_server_client().discover(AS) - assert isinstance(found, federation.Outcome) + assert isinstance(found, Outcome) assert found.refusal is not None assert found.refusal.error == "as_unreachable" diff --git a/packages/introspection-aauth/tests/test_federation.py b/packages/introspection-aauth/tests/test_federation.py index 0ad3044..1413f72 100644 --- a/packages/introspection-aauth/tests/test_federation.py +++ b/packages/introspection-aauth/tests/test_federation.py @@ -9,7 +9,7 @@ from introspection_aauth.person_server import ( Approval, ApprovalStatus, - federation, + Step, ) from introspection_aauth.person_server.models import ( AuthTokenRequest, @@ -122,9 +122,9 @@ async def kick(tenant: str, approval: Approval) -> None: fed, jkt = due client = world.ps.access_server_client() metadata = await client.discover(fed.access_server) - assert isinstance(metadata, federation.Metadata) + assert isinstance(metadata, AccessServerMetadata) outcome = await client.request(fed, jkt, metadata) - assert outcome.step is federation.Step.PENDING + 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 @@ -135,7 +135,7 @@ async def kick(tenant: str, approval: Approval) -> None: 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 federation.Step.ISSUED + 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 diff --git a/packages/introspection-aauth/tests/test_refusals.py b/packages/introspection-aauth/tests/test_refusals.py index 701da41..a95f837 100644 --- a/packages/introspection-aauth/tests/test_refusals.py +++ b/packages/introspection-aauth/tests/test_refusals.py @@ -9,7 +9,7 @@ from introspection_aauth.person_server import ( Approval, ApprovalStatus, - federation, + Step, ) from introspection_aauth.person_server.models import ( AuthTokenRequest, @@ -20,6 +20,7 @@ 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, @@ -189,7 +190,7 @@ async def kick(tenant: str, approval: Approval) -> None: assert due is not None client = world.ps.access_server_client() metadata = await client.discover(due[0].access_server) - assert isinstance(metadata, federation.Metadata) + assert isinstance(metadata, AccessServerMetadata) first = await client.request(due[0], due[1], metadata) app = world.net.apps[AS] @@ -204,7 +205,7 @@ async def handle_async_request( world.net.apps[AS] = Busy() assert first.location is not None busy = await client.poll(first.location, due[0], due[1], metadata) - assert busy.step is federation.Step.PENDING and busy.poll_delay() == 7 + assert busy.step is Step.PENDING and busy.poll_delay() == 7 async def test_the_access_server_refuses_what_it_does_not_decide(): @@ -258,7 +259,7 @@ async def kick(tenant: str, approval: Approval) -> None: assert due is not None client = world.ps.access_server_client() metadata = await client.discover(due[0].access_server) - assert isinstance(metadata, federation.Metadata) + 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) @@ -269,7 +270,7 @@ async def kick(tenant: str, approval: Approval) -> None: 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 federation.Step.REFUSED + assert denied.step is Step.REFUSED await world.ps.record_federation(TENANT, kicked[0], denied) assert (await world.follow(asked)).status_code == 403 @@ -373,7 +374,7 @@ async def kick(tenant: str, approval: Approval) -> None: assert due is not None client = world.ps.access_server_client() metadata = await client.discover(due[0].access_server) - assert isinstance(metadata, federation.Metadata) + 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()) From 94f396b53a0ea9711035c7b12bb5d52f94ddaf0e Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 16:49:31 -0700 Subject: [PATCH 26/34] refactor(aauth): one concept per module - protocol: Answer, Identity, the expectations, verification, URLs and the deferred-response headers each get their own module (answer, identity, expectations, verification, urls, pending); protocol.server keeps Server and its options. - person_server: Directory and AnyoneDirectory move to directory, the hooks to hooks, StaleRecord to store; a mission's budget check is Mission.fits. server.py holds PersonServer alone. - access_server: Policy and PendingHook move to hooks; narrowing a grant is AccessRequest.narrowed and AccessRequest.lowered. server.py holds AccessServer alone. - Each module defines its own logger. Co-Authored-By: Claude Opus 5.5 --- packages/introspection-aauth/README.md | 2 +- .../access_server/__init__.py | 7 +- .../access_server/hooks.py | 25 ++ .../access_server/models.py | 19 + .../access_server/server.py | 69 ++-- .../introspection_aauth/fastapi.py | 4 +- .../person_server/__init__.py | 6 +- .../person_server/access_server_client.py | 16 +- .../person_server/directory.py | 19 + .../person_server/hooks.py | 31 ++ .../person_server/models.py | 11 + .../person_server/server.py | 78 ++-- .../person_server/store.py | 18 +- .../introspection_aauth/protocol/__init__.py | 24 +- .../introspection_aauth/protocol/answer.py | 19 + .../introspection_aauth/protocol/claims.py | 115 ------ .../introspection_aauth/protocol/errors.py | 18 +- .../protocol/expectations.py | 128 +++++++ .../introspection_aauth/protocol/identity.py | 117 ++++++ .../introspection_aauth/protocol/pending.py | 65 ++++ .../introspection_aauth/protocol/server.py | 341 +----------------- .../introspection_aauth/protocol/urls.py | 46 +++ .../protocol/verification.py | 152 ++++++++ .../introspection-aauth/tests/test_models.py | 10 +- 24 files changed, 742 insertions(+), 598 deletions(-) create mode 100644 packages/introspection-aauth/introspection_aauth/access_server/hooks.py create mode 100644 packages/introspection-aauth/introspection_aauth/person_server/directory.py create mode 100644 packages/introspection-aauth/introspection_aauth/person_server/hooks.py create mode 100644 packages/introspection-aauth/introspection_aauth/protocol/answer.py create mode 100644 packages/introspection-aauth/introspection_aauth/protocol/expectations.py create mode 100644 packages/introspection-aauth/introspection_aauth/protocol/identity.py create mode 100644 packages/introspection-aauth/introspection_aauth/protocol/pending.py create mode 100644 packages/introspection-aauth/introspection_aauth/protocol/urls.py create mode 100644 packages/introspection-aauth/introspection_aauth/protocol/verification.py diff --git a/packages/introspection-aauth/README.md b/packages/introspection-aauth/README.md index 190e3ad..ecfe41b 100644 --- a/packages/introspection-aauth/README.md +++ b/packages/introspection-aauth/README.md @@ -16,7 +16,7 @@ pip install 'introspection-aauth[fastapi]' # and FastAPI routers for them | 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 `federation`: the client for a resource's Access Server and the loop around it. | +| `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`. | diff --git a/packages/introspection-aauth/introspection_aauth/access_server/__init__.py b/packages/introspection-aauth/introspection_aauth/access_server/__init__.py index 6666495..aa3448d 100644 --- a/packages/introspection-aauth/introspection_aauth/access_server/__init__.py +++ b/packages/introspection-aauth/introspection_aauth/access_server/__init__.py @@ -1,13 +1,18 @@ """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, Policy +from introspection_aauth.access_server.server import ( + AccessServer, +) from introspection_aauth.access_server.store import MemoryStore, Store __all__ = [ 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 index 18a1a8f..f180af0 100644 --- a/packages/introspection-aauth/introspection_aauth/access_server/models.py +++ b/packages/introspection-aauth/introspection_aauth/access_server/models.py @@ -11,6 +11,7 @@ PersonTokenClaims, ResourceTokenClaims, ) +from introspection_aauth.protocol.errors import ServerError from introspection_aauth.protocol.keys import Jwk from introspection_aauth.protocol.models import Budget @@ -49,6 +50,24 @@ class AccessRequest(BaseModel): #: 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; diff --git a/packages/introspection-aauth/introspection_aauth/access_server/server.py b/packages/introspection-aauth/introspection_aauth/access_server/server.py index 6cd6773..8537428 100644 --- a/packages/introspection-aauth/introspection_aauth/access_server/server.py +++ b/packages/introspection-aauth/introspection_aauth/access_server/server.py @@ -4,11 +4,15 @@ 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.""" -from collections.abc import Awaitable, Sequence -from typing import Protocol, Unpack +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, @@ -18,38 +22,45 @@ ) 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, - AgentExpectation, AgentTokenClaims, AuthGrant, AuthTokenClaims, PersonTokenClaims, - PresentedExpectation, ResourceTokenClaims, - ResourceTokenExpectation, ) from introspection_aauth.protocol.errors import ( - Answer, Denied, Expired, InvalidCode, InvalidRequest, InvalidToken, NotFound, - ServerError, 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, - pending, - requirement, +) +from introspection_aauth.protocol.verification import ( verify_agent_token, verify_resource_token, verify_server, @@ -61,38 +72,6 @@ ) -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]: ... - - -def _narrowed(asked: str | None, scope: str | None) -> str | None: - """`scope`, which may be no broader than what was asked.""" - if scope is None: - return asked - if not set(scope.split()) <= set((asked or "").split()): - raise ServerError("the policy granted a scope that was not asked for") - return scope - - -def _lowered(asked: Budget | None, granted: Budget | None) -> Budget | None: - """`granted`, which may be no more than what was asked.""" - if granted is None: - return asked - if asked is None or not granted.at_most(asked): - raise ServerError("the policy granted more than was asked") - return granted - - class AccessServer(Server): """Decides, by `policy`, for the `resources` it serves, taking requests only from `person_servers` for agents of `agent_providers`.""" @@ -195,8 +174,8 @@ async def token(self, request: SignedRequest) -> Answer: if verdict.decision == "allow": return self._issue( record, - _narrowed(asked.scope, verdict.scope), - _lowered(asked.budget, verdict.budget), + asked.narrowed(verdict.scope), + asked.lowered(verdict.budget), ) if verdict.decision == "deny": raise Denied(verdict.detail or "this resource's policy refuses it") @@ -261,8 +240,8 @@ async def decide( if approved else PendingStatus.DENIED, "version": record.version + 1, - "scope": _narrowed(record.request.scope, scope), - "budget": _lowered(record.request.budget, budget), + "scope": record.request.narrowed(scope), + "budget": record.request.lowered(budget), "decided_by": decided_by, } ) diff --git a/packages/introspection-aauth/introspection_aauth/fastapi.py b/packages/introspection-aauth/introspection_aauth/fastapi.py index 07bd45b..4d8505e 100644 --- a/packages/introspection-aauth/introspection_aauth/fastapi.py +++ b/packages/introspection-aauth/introspection_aauth/fastapi.py @@ -36,9 +36,11 @@ PersonTokenRequest, ) from introspection_aauth.person_server.server import PersonServer +from introspection_aauth.protocol.answer import ( + Answer, +) from introspection_aauth.protocol.errors import ( AAuthError, - Answer, NotFound, Problem, ) diff --git a/packages/introspection-aauth/introspection_aauth/person_server/__init__.py b/packages/introspection-aauth/introspection_aauth/person_server/__init__.py index 88bfb83..ae6b170 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/__init__.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/__init__.py @@ -4,6 +4,10 @@ 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, @@ -17,8 +21,6 @@ ) from introspection_aauth.person_server.server import PersonServer from introspection_aauth.person_server.store import ( - AnyoneDirectory, - Directory, MemoryStore, 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 index f74df35..7a0b465 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/access_server_client.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/access_server_client.py @@ -15,19 +15,27 @@ ACCESS_DWK, AgentTokenClaims, AuthTokenClaims, - DeliveryExpectation, 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, - Identity, +) +from introspection_aauth.protocol.urls import ( https_endpoint, - requirement_name, - retry_after, same_origin, ) from introspection_aauth.protocol.wire import ( 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 index 4d2eadc..e5f20a3 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/models.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/models.py @@ -46,6 +46,17 @@ class Mission(BaseModel): 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.""" diff --git a/packages/introspection-aauth/introspection_aauth/person_server/server.py b/packages/introspection-aauth/introspection_aauth/person_server/server.py index ff369ed..0a96c08 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/server.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/server.py @@ -7,10 +7,10 @@ import hashlib import hmac import logging -from collections.abc import Awaitable, Sequence +from collections.abc import Sequence from datetime import UTC, datetime from functools import partial -from typing import Protocol, Unpack +from typing import Unpack from urllib.parse import quote, unquote import httpx2 as httpx @@ -18,6 +18,15 @@ 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, @@ -38,11 +47,13 @@ Step, ) from introspection_aauth.person_server.store import ( - AnyoneDirectory, - Directory, + 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, @@ -50,14 +61,11 @@ AuthTokenClaims, Confirmation, PersonTokenClaims, - PresentedExpectation, ResourceTokenClaims, - ResourceTokenExpectation, ) from introspection_aauth.protocol.errors import ( AAuthError, AccessDenied, - Answer, Denied, Expired, InvalidCode, @@ -69,15 +77,25 @@ 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, - https_origin, parse, - pending, +) +from introspection_aauth.protocol.urls import ( + https_origin, +) +from introspection_aauth.protocol.verification import ( verify_agent, verify_resource_token, ) @@ -89,52 +107,12 @@ ) __all__ = [ - "Exchange", - "Hook", "PersonServer", - "Sleep", ] logger = logging.getLogger(__name__) -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]: ... - - -def _fits(mission: Mission, budget: Budget | None) -> bool: - """Whether a request is within what is left of its mission's budget.""" - if budget is None: - return True - if mission.budget is None: - return False - left = mission.budget.model_copy( - update={"amount": mission.budget.amount - mission.spent} - ) - return budget.at_most(left) - - -class StaleRecord(Exception): - """Rolls a `record` back: the approval changed or lapsed meanwhile.""" - - class PersonServer(Server): """Speaks for the people its `Directory` names, keeping its state in a `Store`. @@ -327,7 +305,7 @@ async def auth_token(self, request: SignedRequest) -> Answer: agent_token=agent.token, presented_token=body.presented_token, budget=budget, - within_budget=_fits(mission, budget), + within_budget=mission.fits(budget), ) return await self._federated( tenant, agent, mission, grant, federation diff --git a/packages/introspection-aauth/introspection_aauth/person_server/store.py b/packages/introspection-aauth/introspection_aauth/person_server/store.py index 1d83033..c464636 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/store.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/store.py @@ -94,20 +94,6 @@ async def update_approval_summary( def transaction(self) -> AbstractAsyncContextManager[Store]: ... -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 - - def _live(mission: Mission | None, now: int) -> Mission | None: if ( mission is None @@ -222,3 +208,7 @@ async def _transaction(self) -> AsyncGenerator[Store, None]: 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 index 5a0b4c1..4bc8f77 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/__init__.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/__init__.py @@ -2,24 +2,22 @@ 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 ( - AgentExpectation, AgentTokenClaims, AuthGrant, AuthTokenClaims, Claims, Confirmation, - DeliveryExpectation, PersonTokenClaims, - PresentedExpectation, ResourceTokenClaims, - ResourceTokenExpectation, ) from introspection_aauth.protocol.discovery import Discovery from introspection_aauth.protocol.errors import ( AAuthError, AccessDenied, - Answer, Denied, Expired, InvalidCode, @@ -33,7 +31,16 @@ 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, @@ -44,11 +51,14 @@ thumbprint, ) from introspection_aauth.protocol.models import Agent, Budget +from introspection_aauth.protocol.pending import ( + pending, +) from introspection_aauth.protocol.server import ( Clock, - Identity, IdFactory, - pending, +) +from introspection_aauth.protocol.verification import ( 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 index cd0d5fc..b052dbd 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/claims.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/claims.py @@ -192,118 +192,3 @@ def issue( r3_uri=grant.r3_uri, r3_s256=grant.r3_s256, ) - - -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/errors.py b/packages/introspection-aauth/introspection_aauth/protocol/errors.py index a1af5a9..f16fe75 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/errors.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/errors.py @@ -1,15 +1,15 @@ """AAuth errors, answered as `application/problem+json` (`#error-responses`).""" from collections.abc import Mapping -from dataclasses import dataclass, field from typing import Self from pydantic import BaseModel, ConfigDict +from introspection_aauth.protocol.answer import Answer + __all__ = [ "AAuthError", "AccessDenied", - "Answer", "Denied", "Expired", "InvalidCode", @@ -34,20 +34,6 @@ class Problem(BaseModel): detail: str | None = None -@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() - - class AAuthError(Exception): """A refusal: `status`, an AAuth `error` code and a `detail`.""" 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/identity.py b/packages/introspection-aauth/introspection_aauth/protocol/identity.py new file mode 100644 index 0000000..260f989 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/protocol/identity.py @@ -0,0 +1,117 @@ +"""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 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__) + + +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.""" + 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/pending.py b/packages/introspection-aauth/introspection_aauth/protocol/pending.py new file mode 100644 index 0000000..d4a48e1 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/protocol/pending.py @@ -0,0 +1,65 @@ +"""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 index 492ae02..8e1a601 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/server.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/server.py @@ -5,37 +5,25 @@ import logging import time import uuid -from collections.abc import Mapping, Sequence +from collections.abc import Sequence from typing import NotRequired, Protocol, TypedDict, TypeVar, Unpack -from urllib.parse import urlsplit -import http_sfv import httpx2 as httpx from pydantic import BaseModel, ValidationError -from introspection_aauth.protocol import httpsig, jwt from introspection_aauth.protocol.claims import ( ACCESS_DWK, AGENT_DWK, PERSON_DWK, RESOURCE_DWK, - AgentTokenClaims, - Claims, - ResourceTokenClaims, - ResourceTokenExpectation, ServerDwk, ) from introspection_aauth.protocol.discovery import Discovery from introspection_aauth.protocol.errors import ( - Answer, InvalidRequest, - InvalidToken, - Unauthorized, - VerificationError, ) -from introspection_aauth.protocol.keys import Jwk, Jwks, SigningKey, jwks, s256 -from introspection_aauth.protocol.models import Agent -from introspection_aauth.protocol.wire import Pending, R3Proposal +from introspection_aauth.protocol.identity import Identity +from introspection_aauth.protocol.keys import Jwks, SigningKey __all__ = [ "ACCESS_DWK", @@ -44,58 +32,21 @@ "RESOURCE_DWK", "Clock", "IdFactory", - "Identity", "Server", "ServerOptions", - "https_origin", "now_seconds", "parse", - "pending", - "requirement", - "same_origin", - "verify_agent", - "verify_agent_token", - "verify_resource_token", - "verify_server", ] logger = logging.getLogger(__name__) Model = TypeVar("Model", bound=BaseModel) -ClaimsModel = TypeVar("ClaimsModel", bound=Claims) def now_seconds() -> int: return int(time.time()) -#: How a `401` names the scheme an agent should sign with. -ACCEPT_JWT_SCHEME = {"Accept-Signature-Scheme": "jwt"} - - -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 parse(model: type[Model], body: bytes | None) -> Model: """A request body, or `400 invalid_request`.""" try: @@ -104,170 +55,6 @@ def parse(model: type[Model], body: bytes | None) -> Model: raise InvalidRequest(exc.errors()[0]["msg"]) from None -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) - - -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" - - -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.""" - 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 - - def new_hex_id() -> str: return uuid.uuid4().hex @@ -330,125 +117,3 @@ def jwks(self) -> Jwks: def metadata(self) -> BaseModel: """Its `/.well-known` metadata document.""" raise NotImplementedError - - -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/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..9d326c8 --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/protocol/verification.py @@ -0,0 +1,152 @@ +"""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/tests/test_models.py b/packages/introspection-aauth/tests/test_models.py index be82188..4fc0eb2 100644 --- a/packages/introspection-aauth/tests/test_models.py +++ b/packages/introspection-aauth/tests/test_models.py @@ -27,19 +27,21 @@ AGENT_DWK, PERSON_DWK, RESOURCE_DWK, - AgentExpectation, AgentTokenClaims, AuthGrant, AuthTokenClaims, Claims, Confirmation, - DeliveryExpectation, PersonTokenClaims, - PresentedExpectation, ResourceTokenClaims, - ResourceTokenExpectation, ) 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 aca284e335017b6381bf3125f7c8be8594f98e7c Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 16:51:10 -0700 Subject: [PATCH 27/34] feat(aauth): trace federation and log decisions as the platform does - Federation is traced as person_server.federate, with a span per exchange with the Access Server (.metadata, .request, .poll, .delivery), the names the data plane's own federation used; reading an R3 proposal is aauth.r3.read. The package depends on opentelemetry-api only. - The Person Server logs each federation outcome and the Access Server each decision, with identifiers and enumerated values as extra fields. - A test checks the spans and records, and that no token enters a record. - AGENTS.md: one file one concept, 100 to 300 lines, and the logging and tracing rules; the README says what the package emits. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 16 ++ packages/introspection-aauth/README.md | 12 ++ .../access_server/server.py | 12 ++ .../person_server/access_server_client.py | 193 ++++++++++-------- .../person_server/server.py | 104 ++++++---- .../introspection_aauth/protocol/identity.py | 29 ++- packages/introspection-aauth/pyproject.toml | 2 + .../tests/test_telemetry.py | 56 +++++ packages/introspection-aauth/uv.lock | 33 ++- 9 files changed, 320 insertions(+), 137 deletions(-) create mode 100644 packages/introspection-aauth/tests/test_telemetry.py diff --git a/AGENTS.md b/AGENTS.md index a1fbda9..c760c65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,6 +131,22 @@ 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 diff --git a/packages/introspection-aauth/README.md b/packages/introspection-aauth/README.md index ecfe41b..d2956e5 100644 --- a/packages/introspection-aauth/README.md +++ b/packages/introspection-aauth/README.md @@ -129,6 +129,18 @@ 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`, diff --git a/packages/introspection-aauth/introspection_aauth/access_server/server.py b/packages/introspection-aauth/introspection_aauth/access_server/server.py index 8537428..51886a5 100644 --- a/packages/introspection-aauth/introspection_aauth/access_server/server.py +++ b/packages/introspection-aauth/introspection_aauth/access_server/server.py @@ -4,6 +4,7 @@ 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 @@ -71,6 +72,8 @@ TokenRequest, ) +logger = logging.getLogger(__name__) + class AccessServer(Server): """Decides, by `policy`, for the `resources` it serves, taking requests @@ -159,6 +162,15 @@ async def token(self, request: SignedRequest) -> Answer: ) 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, 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 index 7a0b465..f14a166 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/access_server_client.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/access_server_client.py @@ -4,9 +4,11 @@ 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 @@ -44,6 +46,9 @@ 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"}) @@ -68,27 +73,31 @@ async def discover( self, access_server: str ) -> AccessServerMetadata | Outcome: """`{aud}/.well-known/aauth-access.json`, whose `issuer` is the `aud`.""" - 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) + with tracer.start_as_current_span( + "person_server.federate.metadata", + attributes={"aauth.access_server": access_server}, ): - return Outcome.unreachable( - "its metadata keeps its auth_token_endpoint and jwks_uri " - "off its own https origin" - ) - return document + 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, @@ -97,20 +106,24 @@ async def request( metadata: AccessServerMetadata, ) -> Outcome: """The token request, with the budget the Person Server allows.""" - 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 - ) + 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, @@ -119,13 +132,19 @@ async def poll( agent_jkt: str, metadata: AccessServerMetadata, ) -> Outcome: - 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 - ) + 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, @@ -213,48 +232,52 @@ async def _delivered( metadata: AccessServerMetadata, ) -> Outcome: """`#auth-token-delivery`, and the budget granted no more than was sent.""" - 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)}" + 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, ) - 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/server.py b/packages/introspection-aauth/introspection_aauth/person_server/server.py index 0a96c08..35f6298 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/server.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/server.py @@ -14,6 +14,7 @@ from urllib.parse import quote, unquote import httpx2 as httpx +from opentelemetry import trace from introspection_aauth.person_server.access_server_client import ( AccessServerClient, @@ -111,6 +112,7 @@ ] logger = logging.getLogger(__name__) +tracer = trace.get_tracer(__name__) class PersonServer(Server): @@ -498,9 +500,25 @@ async def record_federation( """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: - return await self._record_federation(tenant, approval_id, outcome) + 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, @@ -509,49 +527,55 @@ async def federate( sleep: Sleep = asyncio.sleep, ) -> Step | None: """Redeem an approved request at its Access Server, polling while it decides.""" - 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) + 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}") + 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, + 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 diff --git a/packages/introspection-aauth/introspection_aauth/protocol/identity.py b/packages/introspection-aauth/introspection_aauth/protocol/identity.py index 260f989..c623a1c 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/identity.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/identity.py @@ -5,6 +5,7 @@ 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 ( @@ -25,6 +26,7 @@ from introspection_aauth.protocol.wire import R3Proposal logger = logging.getLogger(__name__) +tracer = trace.get_tracer(__name__) class Identity: @@ -77,17 +79,22 @@ async def read_proposal( """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.""" - 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) + 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, diff --git a/packages/introspection-aauth/pyproject.toml b/packages/introspection-aauth/pyproject.toml index 5bcba1e..f45bc95 100644 --- a/packages/introspection-aauth/pyproject.toml +++ b/packages/introspection-aauth/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "cryptography>=44.0.0", "http-sfv>=0.9.9", "httpx2>=2.12.0", + "opentelemetry-api>=1.39.0", "pydantic>=2.10.0", ] @@ -22,6 +23,7 @@ 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", diff --git a/packages/introspection-aauth/tests/test_telemetry.py b/packages/introspection-aauth/tests/test_telemetry.py new file mode 100644 index 0000000..ab5b1d7 --- /dev/null +++ b/packages/introspection-aauth/tests/test_telemetry.py @@ -0,0 +1,56 @@ +"""Federation emits the spans and records the data plane reads, carrying +identifiers and never a token.""" + +import logging + +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 + +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"] + + 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/uv.lock b/packages/introspection-aauth/uv.lock index 36a2b5e..1ae66ff 100644 --- a/packages/introspection-aauth/uv.lock +++ b/packages/introspection-aauth/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 5 +revision = 3 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.12' and sys_platform == 'emscripten'", @@ -488,6 +488,7 @@ dependencies = [ { name = "cryptography" }, { name = "http-sfv" }, { name = "httpx2" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, ] @@ -497,6 +498,7 @@ fastapi = [ ] test = [ { name = "aauth-signing" }, + { name = "opentelemetry-sdk" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -516,6 +518,8 @@ requires-dist = [ { 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" }, @@ -542,6 +546,33 @@ 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" From da76e1c90a7bed63652609bb4e42711227e56d64 Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 16:51:51 -0700 Subject: [PATCH 28/34] test(aauth): no log field the platform's formatter would drop; jscpd skips imports Co-Authored-By: Claude Opus 5.5 --- packages/introspection-aauth/.jscpd.json | 8 +++-- .../access_server/__init__.py | 8 ++--- .../access_server/server.py | 4 +-- .../introspection_aauth/fastapi.py | 4 +-- .../person_server/access_server_client.py | 12 ++----- .../person_server/server.py | 12 ++----- .../introspection_aauth/protocol/__init__.py | 12 ++----- .../introspection_aauth/protocol/identity.py | 8 ++--- .../introspection_aauth/protocol/pending.py | 4 +-- .../introspection_aauth/protocol/server.py | 4 +-- .../protocol/verification.py | 4 +-- .../tests/test_federation.py | 4 +-- .../tests/test_telemetry.py | 31 +++++++++++++++++++ 13 files changed, 56 insertions(+), 59 deletions(-) diff --git a/packages/introspection-aauth/.jscpd.json b/packages/introspection-aauth/.jscpd.json index 0a9a7b0..d0e4a07 100644 --- a/packages/introspection-aauth/.jscpd.json +++ b/packages/introspection-aauth/.jscpd.json @@ -8,8 +8,7 @@ ], "ignore": [ "**/.venv/**", - "**/__pycache__/**", - "**/__init__.py" + "**/__pycache__/**" ], "gitignore": true, "minTokens": 50, @@ -23,5 +22,10 @@ "threshold": 0, "reporters": [ "console" + ], + "ignorePattern": [ + "from [\\w.]+ import \\([^)]*\\)", + "from [\\w.]+ import [^\\n(]+", + "import [\\w.]+( as \\w+)?" ] } diff --git a/packages/introspection-aauth/introspection_aauth/access_server/__init__.py b/packages/introspection-aauth/introspection_aauth/access_server/__init__.py index aa3448d..a70dfea 100644 --- a/packages/introspection-aauth/introspection_aauth/access_server/__init__.py +++ b/packages/introspection-aauth/introspection_aauth/access_server/__init__.py @@ -1,18 +1,14 @@ """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.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.server import AccessServer from introspection_aauth.access_server.store import MemoryStore, Store __all__ = [ diff --git a/packages/introspection-aauth/introspection_aauth/access_server/server.py b/packages/introspection-aauth/introspection_aauth/access_server/server.py index 51886a5..bc4592b 100644 --- a/packages/introspection-aauth/introspection_aauth/access_server/server.py +++ b/packages/introspection-aauth/introspection_aauth/access_server/server.py @@ -23,9 +23,7 @@ ) 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.answer import Answer from introspection_aauth.protocol.claims import ( ACCESS_DWK, PERSON_DWK, diff --git a/packages/introspection-aauth/introspection_aauth/fastapi.py b/packages/introspection-aauth/introspection_aauth/fastapi.py index 4d8505e..dffe1a8 100644 --- a/packages/introspection-aauth/introspection_aauth/fastapi.py +++ b/packages/introspection-aauth/introspection_aauth/fastapi.py @@ -36,9 +36,7 @@ PersonTokenRequest, ) from introspection_aauth.person_server.server import PersonServer -from introspection_aauth.protocol.answer import ( - Answer, -) +from introspection_aauth.protocol.answer import Answer from introspection_aauth.protocol.errors import ( AAuthError, NotFound, 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 index f14a166..d02f377 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/access_server_client.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/access_server_client.py @@ -22,20 +22,14 @@ ) 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.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.server import Clock from introspection_aauth.protocol.urls import ( https_endpoint, same_origin, diff --git a/packages/introspection-aauth/introspection_aauth/person_server/server.py b/packages/introspection-aauth/introspection_aauth/person_server/server.py index 35f6298..8214c69 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/server.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/server.py @@ -52,9 +52,7 @@ Store, ) from introspection_aauth.protocol import jwt -from introspection_aauth.protocol.answer import ( - Answer, -) +from introspection_aauth.protocol.answer import Answer from introspection_aauth.protocol.claims import ( PERSON_DWK, RESOURCE_DWK, @@ -85,17 +83,13 @@ 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.pending import pending from introspection_aauth.protocol.server import ( Server, ServerOptions, parse, ) -from introspection_aauth.protocol.urls import ( - https_origin, -) +from introspection_aauth.protocol.urls import https_origin from introspection_aauth.protocol.verification import ( verify_agent, verify_resource_token, diff --git a/packages/introspection-aauth/introspection_aauth/protocol/__init__.py b/packages/introspection-aauth/introspection_aauth/protocol/__init__.py index 4bc8f77..1763f5f 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/__init__.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/__init__.py @@ -2,9 +2,7 @@ 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.answer import Answer from introspection_aauth.protocol.claims import ( AgentTokenClaims, AuthGrant, @@ -38,9 +36,7 @@ ResourceTokenExpectation, ) from introspection_aauth.protocol.httpsig import SignedRequest, Verified -from introspection_aauth.protocol.identity import ( - Identity, -) +from introspection_aauth.protocol.identity import Identity from introspection_aauth.protocol.json import JsonObject, JsonValue from introspection_aauth.protocol.keys import ( Jwk, @@ -51,9 +47,7 @@ thumbprint, ) from introspection_aauth.protocol.models import Agent, Budget -from introspection_aauth.protocol.pending import ( - pending, -) +from introspection_aauth.protocol.pending import pending from introspection_aauth.protocol.server import ( Clock, IdFactory, diff --git a/packages/introspection-aauth/introspection_aauth/protocol/identity.py b/packages/introspection-aauth/introspection_aauth/protocol/identity.py index c623a1c..28de655 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/identity.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/identity.py @@ -12,17 +12,13 @@ Claims, ServerDwk, ) -from introspection_aauth.protocol.errors import ( - VerificationError, -) +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.verification import ClaimsModel from introspection_aauth.protocol.wire import R3Proposal logger = logging.getLogger(__name__) diff --git a/packages/introspection-aauth/introspection_aauth/protocol/pending.py b/packages/introspection-aauth/introspection_aauth/protocol/pending.py index d4a48e1..e88327e 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/pending.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/pending.py @@ -5,9 +5,7 @@ import http_sfv -from introspection_aauth.protocol.answer import ( - Answer, -) +from introspection_aauth.protocol.answer import Answer from introspection_aauth.protocol.wire import Pending diff --git a/packages/introspection-aauth/introspection_aauth/protocol/server.py b/packages/introspection-aauth/introspection_aauth/protocol/server.py index 8e1a601..b910720 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/server.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/server.py @@ -19,9 +19,7 @@ ServerDwk, ) from introspection_aauth.protocol.discovery import Discovery -from introspection_aauth.protocol.errors import ( - InvalidRequest, -) +from introspection_aauth.protocol.errors import InvalidRequest from introspection_aauth.protocol.identity import Identity from introspection_aauth.protocol.keys import Jwks, SigningKey diff --git a/packages/introspection-aauth/introspection_aauth/protocol/verification.py b/packages/introspection-aauth/introspection_aauth/protocol/verification.py index 9d326c8..c87395d 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/verification.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/verification.py @@ -17,9 +17,7 @@ Unauthorized, VerificationError, ) -from introspection_aauth.protocol.expectations import ( - ResourceTokenExpectation, -) +from introspection_aauth.protocol.expectations import ResourceTokenExpectation from introspection_aauth.protocol.keys import Jwk from introspection_aauth.protocol.models import Agent diff --git a/packages/introspection-aauth/tests/test_federation.py b/packages/introspection-aauth/tests/test_federation.py index 1413f72..701eb21 100644 --- a/packages/introspection-aauth/tests/test_federation.py +++ b/packages/introspection-aauth/tests/test_federation.py @@ -11,9 +11,7 @@ ApprovalStatus, Step, ) -from introspection_aauth.person_server.models import ( - AuthTokenRequest, -) +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 ( diff --git a/packages/introspection-aauth/tests/test_telemetry.py b/packages/introspection-aauth/tests/test_telemetry.py index ab5b1d7..35e42d0 100644 --- a/packages/introspection-aauth/tests/test_telemetry.py +++ b/packages/introspection-aauth/tests/test_telemetry.py @@ -2,6 +2,7 @@ identifiers and never a token.""" import logging +import re import pytest from opentelemetry import trace @@ -14,6 +15,32 @@ 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)) @@ -50,6 +77,10 @@ async def test_a_federation_is_traced_and_logged_by_identifier( ] 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()) From 8b07b8e96fb34b998c0d58759fe8e49c7fb8a21c Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 16:53:12 -0700 Subject: [PATCH 29/34] refactor(aauth): fastapi becomes a package, one module per server introspection_aauth.fastapi keeps its six public names; the shared plumbing is fastapi.routes and each server's routers fastapi.person_server and fastapi.access_server. Co-Authored-By: Claude Opus 5.5 --- .../introspection_aauth/fastapi.py | 515 ------------------ .../introspection_aauth/fastapi/__init__.py | 38 ++ .../fastapi/access_server.py | 91 ++++ .../fastapi/person_server.py | 172 ++++++ .../introspection_aauth/fastapi/routes.py | 272 +++++++++ 5 files changed, 573 insertions(+), 515 deletions(-) delete mode 100644 packages/introspection-aauth/introspection_aauth/fastapi.py create mode 100644 packages/introspection-aauth/introspection_aauth/fastapi/__init__.py create mode 100644 packages/introspection-aauth/introspection_aauth/fastapi/access_server.py create mode 100644 packages/introspection-aauth/introspection_aauth/fastapi/person_server.py create mode 100644 packages/introspection-aauth/introspection_aauth/fastapi/routes.py diff --git a/packages/introspection-aauth/introspection_aauth/fastapi.py b/packages/introspection-aauth/introspection_aauth/fastapi.py deleted file mode 100644 index dffe1a8..0000000 --- a/packages/introspection-aauth/introspection_aauth/fastapi.py +++ /dev/null @@ -1,515 +0,0 @@ -"""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 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.access_server.server import AccessServer -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 ( - AAuthError, - NotFound, - 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 ( - AccessServerMetadata, - AuthTokenAnswer, - Empty, - Pending, - PersonServerMetadata, - PersonTokenAnswer, - TokenRequest, -) - -__all__ = [ - "AccessServerResolver", - "PersonServerResolver", - "access_server_router", - "access_server_well_known_router", - "person_server_router", - "person_server_well_known_router", -] - -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 PersonServerResolver(Protocol): - """The Person Server that serves `request`, or None when there is none.""" - - def __call__(self, request: Request) -> PersonServer | None: ... - - -class AccessServerResolver(Protocol): - """The Access Server that serves `request`, or None when there is none.""" - - def __call__(self, request: Request) -> AccessServer | None: ... - - -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(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(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 _under(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: 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(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(find(request).jwks(), cache_control) - - return router - - -def _endpoint_path( - source: PersonServer - | AccessServer - | PersonServerResolver - | AccessServerResolver, - endpoint_path: str | None, -) -> str: - if isinstance(source, (PersonServer, AccessServer)): - 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("/") - - -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( - 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, - ) - - -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 _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(body) if body is not None else {} - if parameters := _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, - ) - - -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 = _under(router, _endpoint_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 - - -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( - 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 = _under(router, _endpoint_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/__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..a80947d --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/fastapi/access_server.py @@ -0,0 +1,91 @@ +"""FastAPI routers for an Access Server's protocol routes and its well-known +documents.""" + +from typing import Protocol + +from fastapi import APIRouter, Request + +from introspection_aauth.access_server.server import AccessServer +from introspection_aauth.fastapi.routes import ( + PENDING_RESPONSE, + SIGNED, + endpoint_base_path, + finder, + fixed, + relative_path, + signed_route, + well_known_router, +) +from introspection_aauth.protocol.wire import ( + AccessServerMetadata, + AuthTokenAnswer, + TokenRequest, +) + + +class AccessServerResolver(Protocol): + """The Access Server that serves `request`, or None when there is none.""" + + def __call__(self, request: Request) -> AccessServer | None: ... + + +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..39cc12a --- /dev/null +++ b/packages/introspection-aauth/introspection_aauth/fastapi/person_server.py @@ -0,0 +1,172 @@ +"""FastAPI routers for a Person Server's protocol routes and its well-known +documents.""" + +from http import HTTPStatus +from typing import Protocol +from urllib.parse import urlsplit + +from fastapi import APIRouter, Request + +from introspection_aauth.fastapi.routes import ( + PENDING_RESPONSE, + PROBLEM_RESPONSES, + SIGNED, + 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, +) + + +class PersonServerResolver(Protocol): + """The Person Server that serves `request`, or None when there is none.""" + + def __call__(self, request: Request) -> PersonServer | None: ... + + +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, + ) From 4cf1bb129b13d27d4b031068f0688046af6991fb Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 16:54:22 -0700 Subject: [PATCH 30/34] refactor(aauth): each server's resolver is the generic Resolver Co-Authored-By: Claude Opus 5.5 --- .../introspection_aauth/fastapi/access_server.py | 12 ++++-------- .../introspection_aauth/fastapi/person_server.py | 11 ++++------- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/packages/introspection-aauth/introspection_aauth/fastapi/access_server.py b/packages/introspection-aauth/introspection_aauth/fastapi/access_server.py index a80947d..728c09d 100644 --- a/packages/introspection-aauth/introspection_aauth/fastapi/access_server.py +++ b/packages/introspection-aauth/introspection_aauth/fastapi/access_server.py @@ -1,14 +1,13 @@ """FastAPI routers for an Access Server's protocol routes and its well-known documents.""" -from typing import Protocol - -from fastapi import APIRouter, Request +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, @@ -22,11 +21,8 @@ TokenRequest, ) - -class AccessServerResolver(Protocol): - """The Access Server that serves `request`, or None when there is none.""" - - def __call__(self, request: Request) -> AccessServer | None: ... +#: Finds the AccessServer that serves a request, or None when there is none. +AccessServerResolver = Resolver[AccessServer] def access_server_well_known_router( diff --git a/packages/introspection-aauth/introspection_aauth/fastapi/person_server.py b/packages/introspection-aauth/introspection_aauth/fastapi/person_server.py index 39cc12a..68f579d 100644 --- a/packages/introspection-aauth/introspection_aauth/fastapi/person_server.py +++ b/packages/introspection-aauth/introspection_aauth/fastapi/person_server.py @@ -2,15 +2,15 @@ documents.""" from http import HTTPStatus -from typing import Protocol from urllib.parse import urlsplit -from fastapi import APIRouter, Request +from fastapi import APIRouter from introspection_aauth.fastapi.routes import ( PENDING_RESPONSE, PROBLEM_RESPONSES, SIGNED, + Resolver, endpoint_base_path, finder, fixed, @@ -40,11 +40,8 @@ PersonTokenAnswer, ) - -class PersonServerResolver(Protocol): - """The Person Server that serves `request`, or None when there is none.""" - - def __call__(self, request: Request) -> PersonServer | None: ... +#: Finds the PersonServer that serves a request, or None when there is none. +PersonServerResolver = Resolver[PersonServer] def person_server_well_known_router( From b55fef6a74675552752087b8e02eb786bea34d1c Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 16:54:52 -0700 Subject: [PATCH 31/34] ci: write each suite's coverage table to the run summary Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c01e56d..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() @@ -245,7 +248,10 @@ jobs: run: uv run ty check introspection_aauth tests - name: Run tests with coverage - run: uv run pytest tests/ -v --cov=introspection_aauth --cov-report=term-missing + 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 . From f794426739b52d07afacded54d8cc13913060998 Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 16:56:08 -0700 Subject: [PATCH 32/34] refactor(aauth): httpsig becomes a package, signing and verification apart protocol.httpsig keeps its public names; signing is httpsig.signing and verification httpsig.verification. Co-Authored-By: Claude Opus 5.5 --- .../protocol/httpsig/__init__.py | 47 +++++ .../protocol/httpsig/signing.py | 132 ++++++++++++++ .../{httpsig.py => httpsig/verification.py} | 163 ++---------------- 3 files changed, 190 insertions(+), 152 deletions(-) create mode 100644 packages/introspection-aauth/introspection_aauth/protocol/httpsig/__init__.py create mode 100644 packages/introspection-aauth/introspection_aauth/protocol/httpsig/signing.py rename packages/introspection-aauth/introspection_aauth/protocol/{httpsig.py => httpsig/verification.py} (61%) 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.py b/packages/introspection-aauth/introspection_aauth/protocol/httpsig/verification.py similarity index 61% rename from packages/introspection-aauth/introspection_aauth/protocol/httpsig.py rename to packages/introspection-aauth/introspection_aauth/protocol/httpsig/verification.py index a061c25..a775288 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/httpsig.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/httpsig/verification.py @@ -1,15 +1,7 @@ -"""RFC 9421 HTTP Message Signatures under the AAuth profile, with the -signer's key named in `Signature-Key` (draft-hardt-httpbis-signature-key). +"""Verifying an RFC 9421 signed request with `Signature-Key`: which key signed +it, whether every required component is covered, and whether the signature +holds.""" -`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. -""" - -import base64 -import hashlib from collections.abc import Awaitable, Mapping from dataclasses import dataclass from typing import Protocol @@ -22,39 +14,15 @@ from introspection_aauth.protocol import jwt from introspection_aauth.protocol.claims import Confirmation from introspection_aauth.protocol.errors import VerificationError -from introspection_aauth.protocol.keys import Jwk, SigningKey, thumbprint -from introspection_aauth.protocol.keys import verify as verify_with - -__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", -] - -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", - } +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): @@ -74,115 +42,6 @@ class KeyNaming: dwk: str | None = None -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}:", - } - - @dataclass(frozen=True) class SignedRequest: """A request as its signer addressed it: `url` is the one it signed, From 6d72db770b687bc695fc5766f07f1aea98e13123 Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 16:57:46 -0700 Subject: [PATCH 33/34] docs(examples): build each AAuth app in a factory whose lifespan owns its http client No client is created at import: person_server_app and access_server_app mount a server's router with a lifespan that closes its http client, uvicorn runs them through --factory, and the in-process flow uses the same factories. Co-Authored-By: Claude Opus 5.5 --- .../aauth/four_party.py | 65 +++++++++++++++---- examples/uv.lock | 5 +- 2 files changed, 55 insertions(+), 15 deletions(-) diff --git a/examples/introspection_examples/aauth/four_party.py b/examples/introspection_examples/aauth/four_party.py index 7069fc7..95d2bed 100644 --- a/examples/introspection_examples/aauth/four_party.py +++ b/examples/introspection_examples/aauth/four_party.py @@ -17,16 +17,18 @@ (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 introspection_examples.aauth.four_party:person_server_app --port 8001 + 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 introspection_examples.aauth.four_party:access_server_app --port 8002 + 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 @@ -118,11 +120,44 @@ def access_server(http: httpx.AsyncClient) -> AccessServer: # #endregion docs:aauth-servers -_http = httpx.AsyncClient() -person_server_app = FastAPI() -person_server_app.include_router(person_server_router(person_server(_http))) -access_server_app = FastAPI() -access_server_app.include_router(access_server_router(access_server(_http))) + +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 Internet(httpx.AsyncBaseTransport): @@ -149,14 +184,16 @@ async def handle_async_request( async def main() -> None: internet = Internet() - http = httpx.AsyncClient(transport=internet) + async with httpx.AsyncClient(transport=internet) as http: + await run(internet, http) + + +async def run(internet: Internet, http: httpx.AsyncClient) -> None: ps = person_server(http) - ps_app = FastAPI() - ps_app.include_router(person_server_router(ps)) - as_app = FastAPI() - as_app.include_router(access_server_router(access_server(http))) - internet.parties[PS] = httpx.ASGITransport(app=ps_app) - internet.parties[AS] = httpx.ASGITransport(app=as_app) + internet.parties[PS] = httpx.ASGITransport(app=person_server_app(ps)) + internet.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)) diff --git a/examples/uv.lock b/examples/uv.lock index f7d6a86..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'", @@ -482,6 +482,7 @@ dependencies = [ { name = "cryptography" }, { name = "http-sfv" }, { name = "httpx2" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, ] @@ -497,6 +498,8 @@ requires-dist = [ { 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" }, From 3bf5cc056b6c506e2480c72b7fe66a7ff0e16957 Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 16:59:05 -0700 Subject: [PATCH 34/34] refactor(aauth): name the in-process transport for what it is Co-Authored-By: Claude Opus 5.5 --- .../aauth/four_party.py | 19 ++++++++++--------- .../tests/test_boundaries.py | 16 ++++++++-------- .../tests/test_expectations_enforced.py | 4 +++- .../tests/test_federation.py | 2 +- .../tests/test_refusals.py | 18 +++++++++--------- packages/introspection-aauth/tests/world.py | 12 ++++++------ 6 files changed, 37 insertions(+), 34 deletions(-) diff --git a/examples/introspection_examples/aauth/four_party.py b/examples/introspection_examples/aauth/four_party.py index 95d2bed..0b89cd4 100644 --- a/examples/introspection_examples/aauth/four_party.py +++ b/examples/introspection_examples/aauth/four_party.py @@ -160,8 +160,9 @@ def serve_access_server() -> FastAPI: return access_server_app(access_server(httpx.AsyncClient())) -class Internet(httpx.AsyncBaseTransport): - """Routes each origin to its party, in this process.""" +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] = {} @@ -183,21 +184,21 @@ async def handle_async_request( async def main() -> None: - internet = Internet() - async with httpx.AsyncClient(transport=internet) as http: - await run(internet, http) + transport = InProcessTransport() + async with httpx.AsyncClient(transport=transport) as http: + await run(transport, http) -async def run(internet: Internet, http: httpx.AsyncClient) -> None: +async def run(transport: InProcessTransport, http: httpx.AsyncClient) -> None: ps = person_server(http) - internet.parties[PS] = httpx.ASGITransport(app=person_server_app(ps)) - internet.parties[AS] = httpx.ASGITransport( + 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)) - internet.documents |= { + transport.documents |= { f"{AP}/.well-known/aauth-agent.json": ServerMetadata( issuer=AP, jwks_uri=f"{AP}/jwks.json" ), diff --git a/packages/introspection-aauth/tests/test_boundaries.py b/packages/introspection-aauth/tests/test_boundaries.py index a84143c..8226bf1 100644 --- a/packages/introspection-aauth/tests/test_boundaries.py +++ b/packages/introspection-aauth/tests/test_boundaries.py @@ -206,7 +206,7 @@ async def test_a_proposal_is_read_only_from_its_resources_https_origin( await world.ps.identity.read_proposal( world.http, RESOURCE, r3_uri, "x", world.now ) - assert world.net.calls == [] + assert world.transport.calls == [] async def test_a_signature_that_is_a_list_is_refused(): @@ -263,7 +263,7 @@ async def test_a_signature_key_naming_a_host_fetches_nothing(): f'sig=jwks_uri;id="{PS}";dwk="aauth-agent.json";kid="k"', ), ): - world.net.calls.clear() + world.transport.calls.clear() headers = httpsig.sign( SigningKey.generate(), signature_key, @@ -274,7 +274,7 @@ async def test_a_signature_key_naming_a_host_fetches_nothing(): ) refused = await world.http.post(url, content=b"{}", headers=headers) assert refused.status_code == 401 - assert world.net.calls == [f"POST {url}"], world.net.calls + assert world.transport.calls == [f"POST {url}"], world.transport.calls class Scripted(httpx.AsyncBaseTransport): @@ -297,7 +297,7 @@ async def handle_async_request( async def federate(world: World, *answers: httpx.Response) -> httpx.Response: inline(world) - world.net.apps[AS] = Scripted(world.net.apps[AS], *answers) + 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)) @@ -310,7 +310,7 @@ async def test_a_throttled_first_request_is_sent_again(status: int): ) assert issued.status_code == 200, issued.text assert auth_token(issued).iss == AS - posts = [c for c in world.net.calls if c == f"POST {AS}/token"] + posts = [c for c in world.transport.calls if c == f"POST {AS}/token"] assert len(posts) == 2 @@ -347,8 +347,8 @@ async def kick(tenant: str, approval: Approval) -> None: client = world.ps.access_server_client() metadata = await client.discover(due[0].access_server) assert isinstance(metadata, AccessServerMetadata) - world.net.apps[AS] = Scripted( - world.net.apps[AS], + world.transport.apps[AS] = Scripted( + world.transport.apps[AS], httpx.Response( 202, headers={ @@ -427,7 +427,7 @@ async def test_access_server_metadata_keeps_its_endpoints_on_its_origin( auth_token_endpoint: str, jwks_uri: str ): world = World() - world.net.documents[f"{AS}/.well-known/aauth-access.json"] = ( + world.transport.documents[f"{AS}/.well-known/aauth-access.json"] = ( AccessServerMetadata( issuer=AS, jwks_uri=jwks_uri, diff --git a/packages/introspection-aauth/tests/test_expectations_enforced.py b/packages/introspection-aauth/tests/test_expectations_enforced.py index 02368f2..41f1beb 100644 --- a/packages/introspection-aauth/tests/test_expectations_enforced.py +++ b/packages/introspection-aauth/tests/test_expectations_enforced.py @@ -146,7 +146,9 @@ async def test_the_person_server_refuses_an_auth_token_it_did_not_ask_for( ): world = World() inline(world) - world.net.apps[AS] = Tampering(world, world.net.apps[AS], update) + 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 diff --git a/packages/introspection-aauth/tests/test_federation.py b/packages/introspection-aauth/tests/test_federation.py index 701eb21..a84e5ce 100644 --- a/packages/introspection-aauth/tests/test_federation.py +++ b/packages/introspection-aauth/tests/test_federation.py @@ -185,7 +185,7 @@ 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.net.failures[await token_endpoint(world)] = 1 + 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 diff --git a/packages/introspection-aauth/tests/test_refusals.py b/packages/introspection-aauth/tests/test_refusals.py index a95f837..a8e4e45 100644 --- a/packages/introspection-aauth/tests/test_refusals.py +++ b/packages/introspection-aauth/tests/test_refusals.py @@ -102,7 +102,7 @@ async def test_a_resource_the_mission_does_not_cover_is_refused_before_its_keys_ AuthTokenRequest(resource_token=forged, presented_token=person_token), ) assert refused.status_code == 403 - assert not any("elsewhere" in c for c in world.net.calls) + 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(): @@ -143,7 +143,7 @@ async def test_an_expired_presented_token_is_never_federated(): async def test_an_access_server_that_cannot_be_reached_is_as_unreachable(): world = World() inline(world) - del world.net.apps[AS] + 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 @@ -154,7 +154,7 @@ async def test_an_access_server_that_cannot_be_reached_is_as_unreachable(): async def test_an_access_server_asking_for_claims_is_refused(): world = World() inline(world) - app = world.net.apps[AS] + app = world.transport.apps[AS] class AsksForClaims(httpx.AsyncBaseTransport): async def handle_async_request( @@ -170,7 +170,7 @@ async def handle_async_request( ) return await app.handle_async_request(request) - world.net.apps[AS] = AsksForClaims() + 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 @@ -192,7 +192,7 @@ async def kick(tenant: str, approval: Approval) -> None: metadata = await client.discover(due[0].access_server) assert isinstance(metadata, AccessServerMetadata) first = await client.request(due[0], due[1], metadata) - app = world.net.apps[AS] + app = world.transport.apps[AS] class Busy(httpx.AsyncBaseTransport): async def handle_async_request( @@ -202,7 +202,7 @@ async def handle_async_request( return httpx.Response(429, headers={"Retry-After": "2"}) return await app.handle_async_request(request) - world.net.apps[AS] = Busy() + 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 @@ -290,7 +290,7 @@ async def test_a_calls_r3_proposal_is_read_into_its_approval(): ).to_json() r3_uri = f"{RESOURCE}/r3/1" - world.net.documents[r3_uri] = proposal + 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( @@ -312,7 +312,7 @@ 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.net.documents[r3_uri] = proposal + 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( @@ -357,7 +357,7 @@ async def kick(tenant: str, approval: Approval) -> None: world.ps._on_federate = kick mission = await world.approved_mission(budget=100_000) r3_uri = f"{RESOURCE}/r3/1" - world.net.documents[r3_uri] = SUMMARY + 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)} diff --git a/packages/introspection-aauth/tests/world.py b/packages/introspection-aauth/tests/world.py index fba116d..21fded4 100644 --- a/packages/introspection-aauth/tests/world.py +++ b/packages/introspection-aauth/tests/world.py @@ -1,6 +1,6 @@ """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 transport stands in for the internet, routing each origin to its party.""" +One in-process transport routes each origin to its party.""" import json from typing import TypeVar @@ -92,7 +92,7 @@ def forge(claims: JsonObject, key: SigningKey, typ: str) -> str: return f"{signing_input}.{b64url(key.sign(signing_input.encode()))}" -class Net(httpx.AsyncBaseTransport): +class InProcessTransport(httpx.AsyncBaseTransport): """Routes a request by its origin: to an app, or to a fixed document.""" def __init__(self) -> None: @@ -130,12 +130,12 @@ def __init__( self, policy: Policy | None = None, ps_store: MemoryStore | None = None ) -> None: self.now = T0 - self.net = Net() - self.http = httpx.AsyncClient(transport=self.net) + 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.net.documents |= { + self.transport.documents |= { f"{AP}/.well-known/aauth-agent.json": ServerMetadata( issuer=AP, jwks_uri=f"{AP}/jwks.json" ), @@ -176,7 +176,7 @@ def __init__( ): app = FastAPI() app.include_router(router) - self.net.apps[origin] = httpx.ASGITransport(app=app) + self.transport.apps[origin] = httpx.ASGITransport(app=app) self.agent_token = encode( AgentTokenClaims( iss=AP,