Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 0 additions & 11 deletions packages/introspection-aauth/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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))
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions packages/introspection-aauth/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
73 changes: 71 additions & 2 deletions packages/introspection-aauth/tests/test_person_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,26 @@
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,
AuthTokenClaims,
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,
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion packages/introspection-aauth/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
"path": "pyproject.toml",
"jsonpath": "$.project.version"
}
]
],
"release-as": "0.1.0"
}
}
}
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading