From 55aadadc6cf55e79908fe59f93ded3d185c285b5 Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 18:38:46 -0700 Subject: [PATCH 1/5] fix(aauth): accept the Person Server's own auth token as the presented token An agent presents the auth token it holds for a resource until the resource asks for another; the resource then names that auth token's jti as presented_jti. The Person Server accepted only a person token there, so every call after the first to one resource was refused ("not an aa-person+jwt"). It now also accepts an auth token it issued itself (its issuer, its dwk), checked against the same expectation. A federated call still presents the person token. Co-Authored-By: Claude Opus 5.5 --- .../person_server/server.py | 25 ++++++- .../protocol/expectations.py | 13 ++-- .../tests/test_person_server.py | 73 ++++++++++++++++++- packages/introspection-aauth/uv.lock | 2 +- uv.lock | 2 +- 5 files changed, 102 insertions(+), 13 deletions(-) diff --git a/packages/introspection-aauth/introspection_aauth/person_server/server.py b/packages/introspection-aauth/introspection_aauth/person_server/server.py index 8214c69d..5ff8a911 100644 --- a/packages/introspection-aauth/introspection_aauth/person_server/server.py +++ b/packages/introspection-aauth/introspection_aauth/person_server/server.py @@ -288,6 +288,10 @@ async def auth_token(self, request: SignedRequest) -> Answer: grant = AuthGrant.requested(rt, rt.sub) access_server = self._access_server(rt) if access_server is not None: + if isinstance(presented, AuthTokenClaims): + raise InvalidToken( + "presented", "a federated call presents the person token" + ) if ( body.upstream_token is not None or body.subagent_token is not None @@ -684,14 +688,27 @@ def _live(self, mission: Mission | None, agent_id: str) -> Mission: 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.""" + def _presented(self, token: str) -> PersonTokenClaims | AuthTokenClaims: + """The token the agent presented to the resource: a person token, or an + auth token this Person Server issued for an earlier call there, which + an agent presents until the resource asks for another. An expired one + is refused before anything is federated.""" try: - return self.identity.verify_own( - token, jwt.PERSON_TOKEN, self.clock(), PersonTokenClaims + header, _ = jwt.peek(token) + if header.typ != jwt.AUTH_TOKEN: + return self.identity.verify_own( + token, jwt.PERSON_TOKEN, self.clock(), PersonTokenClaims + ) + claims = self.identity.verify_own( + token, jwt.AUTH_TOKEN, self.clock(), AuthTokenClaims ) except VerificationError as exc: raise InvalidToken.rejected("presented", exc) from None + if claims.dwk != PERSON_DWK: + raise InvalidToken( + "presented", "not an auth token this Person Server issued" + ) + return claims async def _resource_token( self, token: str, tenant: str, agent: Agent diff --git a/packages/introspection-aauth/introspection_aauth/protocol/expectations.py b/packages/introspection-aauth/introspection_aauth/protocol/expectations.py index 39c8e83c..ab3a435b 100644 --- a/packages/introspection-aauth/introspection_aauth/protocol/expectations.py +++ b/packages/introspection-aauth/introspection_aauth/protocol/expectations.py @@ -60,20 +60,23 @@ def failures(self, claims: AuthTokenClaims) -> list[str]: 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.""" + """What the token a resource saw must be, read from the resource token + that names it: a person token, or an auth token `ps` issued for an earlier + call there, 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]: + def failures( + self, presented: PersonTokenClaims | AuthTokenClaims + ) -> list[str]: rt = self.rt checks = { "iss": presented.iss != self.ps, - "aud": presented.aud != rt.iss.rstrip("/"), + "aud": presented.aud.rstrip("/") != rt.iss.rstrip("/"), "cnf": presented.cnf.thumbprint != rt.agent_jkt, "jti": presented.jti != rt.presented_jti, "sub": presented.sub != rt.sub, diff --git a/packages/introspection-aauth/tests/test_person_server.py b/packages/introspection-aauth/tests/test_person_server.py index 6fdfda36..80bff965 100644 --- a/packages/introspection-aauth/tests/test_person_server.py +++ b/packages/introspection-aauth/tests/test_person_server.py @@ -12,12 +12,13 @@ Mission, ) from introspection_aauth.person_server.models import ( + AuthTokenRequest, MissionAction, MissionAnswer, MissionProposal, PersonTokenRequest, ) -from introspection_aauth.protocol import AAuthError, SigningKey +from introspection_aauth.protocol import AAuthError, SigningKey, jwt from introspection_aauth.protocol.claims import ( AGENT_DWK, AgentTokenClaims, @@ -25,7 +26,12 @@ Confirmation, PersonTokenClaims, ) -from introspection_aauth.protocol.jwt import AGENT_TOKEN, encode +from introspection_aauth.protocol.jwt import ( + AGENT_TOKEN, + AUTH_TOKEN, + RESOURCE_TOKEN, + encode, +) from introspection_aauth.protocol.keys import Jwks from introspection_aauth.protocol.wire import ( AuthTokenAnswer, @@ -96,6 +102,69 @@ async def test_a_call_within_budget_is_issued_unasked_and_spent(): assert await world.spent(mission.s256) == 60_000 +async def present( + world: World, + mission: MissionAnswer, + amount: int, + presented: str, + **update: str, +) -> httpx.Response: + """A call whose resource saw `presented`, with its resource token changed by `update`.""" + seen = str(jwt.decode(presented).claims["jti"]) + rt = world.resource_claims(mission, amount).model_copy( + update={ + "jti": f"rt-{seen}", + "presented_jti": seen, + **update, + } + ) + return await world.call( + "POST", + f"{PS}/aauth/token", + AuthTokenRequest( + resource_token=encode(rt, world.resource_key, RESOURCE_TOKEN), + presented_token=presented, + ), + ) + + +async def test_a_second_call_presents_the_first_calls_auth_token(): + world = World() + mission = await world.approved_mission(budget=100_000) + first = body(await world.ask(mission, 30_000), AuthTokenAnswer).auth_token + second = await present(world, mission, 40_000, first) + assert second.status_code == 200, second.text + claims = read(body(second, AuthTokenAnswer).auth_token, AuthTokenClaims) + assert claims.budget == usd(40_000) + assert await world.spent(mission.s256) == 70_000 + + +async def test_a_presented_auth_token_must_be_the_one_the_resource_saw(): + world = World() + mission = await world.approved_mission(budget=100_000) + first = body(await world.ask(mission, 30_000), AuthTokenAnswer).auth_token + refused = await present( + world, mission, 40_000, first, presented_jti="other" + ) + assert refused.status_code == 400 + assert problem(refused).error == "invalid_resource_token" + + +async def test_an_auth_token_this_person_server_did_not_issue_is_refused(): + world = World() + mission = await world.approved_mission(budget=100_000) + first = body(await world.ask(mission, 30_000), AuthTokenAnswer).auth_token + claims = read(first, AuthTokenClaims) + foreign = encode( + claims.model_copy(update={"jti": "at-foreign"}), + SigningKey.generate(), + AUTH_TOKEN, + ) + refused = await present(world, mission, 40_000, foreign) + assert refused.status_code == 400 + assert problem(refused).error == "invalid_presented_token" + + async def test_a_call_over_budget_waits_on_a_person_then_is_issued(): world = World() mission = await world.approved_mission(budget=100_000) diff --git a/packages/introspection-aauth/uv.lock b/packages/introspection-aauth/uv.lock index 1ae66ff3..e354a656 100644 --- a/packages/introspection-aauth/uv.lock +++ b/packages/introspection-aauth/uv.lock @@ -482,7 +482,7 @@ wheels = [ [[package]] name = "introspection-aauth" -version = "0.0.0" +version = "1.0.0" source = { editable = "." } dependencies = [ { name = "cryptography" }, diff --git a/uv.lock b/uv.lock index b9aac5e9..7342ecc8 100644 --- a/uv.lock +++ b/uv.lock @@ -816,7 +816,7 @@ black = [ [[package]] name = "introspection-sdk" -version = "0.29.1" +version = "0.30.0" source = { editable = "." } dependencies = [ { name = "ag-ui-protocol" }, From 09275dcbfe5276cf45c161b496abb4782d7f8bb6 Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 18:40:14 -0700 Subject: [PATCH 2/5] chore(aauth): release introspection-aauth as 0.2.0 It is pre-release; 1.0.0 came from the initial release's breaking-change footers and is yanked from PyPI. Co-Authored-By: Claude Opus 5.5 --- release-please-config.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/release-please-config.json b/release-please-config.json index 66f243b2..d03c9c5b 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -28,7 +28,8 @@ "path": "pyproject.toml", "jsonpath": "$.project.version" } - ] + ], + "release-as": "0.2.0" } } } From cfae7bf9b16c2aca5b4b6e096f673468e99932fb Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 18:40:26 -0700 Subject: [PATCH 3/5] chore(aauth): release introspection-aauth as 0.1.0 Co-Authored-By: Claude Opus 5.5 --- release-please-config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release-please-config.json b/release-please-config.json index d03c9c5b..62903790 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -29,7 +29,7 @@ "jsonpath": "$.project.version" } ], - "release-as": "0.2.0" + "release-as": "0.1.0" } } } From 7b879c0204bae0bbc7a789156d41e8d9af854b6c Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 18:41:31 -0700 Subject: [PATCH 4/5] chore(aauth): drop the 1.0.0 changelog entry; the first release is 0.1.0 Co-Authored-By: Claude Opus 5.5 --- packages/introspection-aauth/CHANGELOG.md | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/packages/introspection-aauth/CHANGELOG.md b/packages/introspection-aauth/CHANGELOG.md index 9999b3e5..825c32f0 100644 --- a/packages/introspection-aauth/CHANGELOG.md +++ b/packages/introspection-aauth/CHANGELOG.md @@ -1,12 +1 @@ # Changelog - -## 1.0.0 (2026-10-22) - - -### ⚠ BREAKING CHANGES - -* **aauth:** Store.put_approval is insert_approval, and the Access Server's Store.put_pending is insert_pending. - -### Features - -* **aauth:** introspection-aauth, a Person Server and Access Server library ([#193](https://github.com/introspection-org/introspection-python-sdk/issues/193)) ([46f26e8](https://github.com/introspection-org/introspection-python-sdk/commit/46f26e869417bf8e8df88687958266b25ece38c9)) From 4b1ed6f0152dd1b3a5ce432891b71976cec20092 Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 21 Oct 2026 18:42:39 -0700 Subject: [PATCH 5/5] chore(aauth): classify introspection-aauth as alpha Co-Authored-By: Claude Opus 5.5 --- packages/introspection-aauth/pyproject.toml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/introspection-aauth/pyproject.toml b/packages/introspection-aauth/pyproject.toml index d3fdf0a6..0a9a87f4 100644 --- a/packages/introspection-aauth/pyproject.toml +++ b/packages/introspection-aauth/pyproject.toml @@ -9,6 +9,14 @@ description = "An AAuth Person Server and Access Server (draft-hardt-oauth-aauth readme = "README.md" requires-python = ">=3.11" license = "Apache-2.0" +classifiers = [ + "Development Status :: 3 - Alpha", + "Framework :: FastAPI", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Topic :: Security", + "Typing :: Typed", +] dependencies = [ "cryptography>=44.0.0", "http-sfv>=0.9.9",