Skip to content

feat(aauth): introspection-aauth, a Person Server and Access Server library - #193

Merged
brightsparc merged 34 commits into
mainfrom
feat/introspection-aauth
Oct 22, 2026
Merged

brightsparc merged 34 commits into
mainfrom
feat/introspection-aauth

Conversation

@brightsparc

@brightsparc brightsparc commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Implements the design in introspection-org/introspection-cloud#3526 (docs/design/connectors-aauth-b2b2c.md §23): the hosted AAuth Person Server as an open-source Python library, plus an Access Server, as a new distribution introspection-aauth in packages/introspection-aauth. Nothing in introspection_sdk changes.

Layout

Three layers, so the two servers share code without importing each other. The Rust crates copied their server plumbing into each crate and failed jscpd; this package's jscpd check is at 0 clones.

Module Holds
protocol One module per concept: errors (AAuthError and a subclass per AAuth error code: InvalidRequest, InvalidToken, InvalidCode, Expired, NotFound, MissionNotFound, MissionTerminated, AccessDenied, Denied, ServerError, Unauthorized), answer, keys (Ed25519 SigningKey, current and previous; Ed25519/ES256 verify; RFC 7638 thumbprints; Jwk/Jwks), httpsig (signing and verification, RFC 9421 with Signature-Key, jwt and jwks_uri), jwt (compact JWS), claims (a model per token type), expectations, wire (every body sent or read), discovery (metadata → JWKS, 300 s cache, rate-limited refetch), identity (a server's issuer, keys, signing and R3 reads), verification (verify_agent, verify_server, verify_agent_token, verify_resource_token), pending (the 202 answer, AAuth-Requirement, Retry-After), urls, models (Budget, Agent), and server (the Server base, Clock, IdFactory).
person_server server holds PersonServer alone: missions, person and auth tokens, budget spend-if-fits, approvals, decide, completion, poll, 410 invalid_code once collected, pending_parts, and the federation steps federation_due, record_federation and federate. access_server_client (AccessServerClient, built by ps.access_server_client()), models (including the federation Step and Outcome), store (Store, MemoryStore), directory (Directory, AnyoneDirectory), hooks (Hook, Sleep, Exchange).
access_server server holds AccessServer alone: policy → allow/deny/approval/interaction, pending requests, decide, auth tokens capped at an hour and at the agent/presented tokens' expiry. models, store, hooks (Policy, PendingHook).
fastapi (extra) A package: person_server_router, access_server_router and their *_well_known_routers, in fastapi.person_server and fastapi.access_server over the shared fastapi.routes. Each takes the server or a resolver (`request → server

protocol imports neither server nor FastAPI.

Models

  • Wire bodies are pydantic models in protocol.wire (and the request models in each server's models), serialised with model_dump_json(exclude_none=True) and parsed with model_validate_json. A body that does not validate is 400 invalid_request. Signatures cover exactly the bytes sent.
  • Claims are a model per token type in protocol.claims. Each checks what its token must be on its own: required members including dwk and jti, exp after iat, the lifetime its type allows (person and auth tokens an hour, agent tokens a day; a resource token's is bounded by its resource), a cnf.jwk that is a public key, a Budget of non-negative integers. jwt.verify(token, keys, typ, now, Model) returns the model. Every token the library mints is built as its claims model, and an auth token is built field by field from an AuthGrant.
  • Expectations (DeliveryExpectation, PresentedExpectation, ResourceTokenExpectation, AgentExpectation, in protocol.expectations) say whether a token that verified is the one a caller expected. failures(claims) names every failed check, e.g. its auth token has the wrong sub, tenant. They are explicit methods, not pydantic validation context, so a token's own validity and a caller's expectation stay separate and a token is parsed once. They return names only, since the names reach the error detail sent to the other party and the values hold sub, tenant and account.
  • Approval.grant is AuthGrant | MissionGrant (left to right), so stored rows of either shape, and {}, decode. Neither carries unknown members (extra="forbid").
  • No typing.Any, no object, and no bare dict/list in the package or its tests. The guard reads the AST, so it finds t.Any under an alias, typing_extensions.Any, string annotations and type aliases, and a test proves each; arbitrary JSON is JsonValue/JsonObject. Callables in the public API are protocols (Policy, Hook, Sleep, Clock, IdFactory). The package ships py.typed.

Behaviour

  • Discovery reads only AAuth server identifiers (https://, a lowercase host, no port, path, userinfo, query, fragment or trailing slash), requires an https jwks_uri, and every request the library makes passes follow_redirects=False.
  • A signed request that carries Authorization must cover it (required when present, per the AAuth-Access rule; the library does not issue AAuth-Access yet), and sign(..., authorization=...) covers it.
  • The mission blob is raw UTF-8 JSON with no floats, and names its approval, not its person.
  • An auth token delivered by an Access Server must match the resource token's aud (trailing slash aside), sub, ps, mission_s256, tenant, account, r3_uri, r3_s256 and scope, be bound to the agent's key, outlive neither the presented nor the agent token, and grant no more budget than was sent.
  • A pending URL answers once (#pending-url-security): an approved mission's person tokens, an auth token (federated or not), a completion, a refusal and an expiry are each the terminal answer, given once and recorded by a compare-and-set on collected; the URL then answers 410 invalid_code, at both servers. The decided status is left as it is, so listings by decision still find the request. A mission approval first polled after its mission ended is 403 mission_terminated.
  • Signed URLs are verified as sent: the routers rebuild the signed URL from the raw, still percent-encoded path and query, so a tenant that needs escaping (org:proj) polls, and the pending parts are decoded once.
  • Summaries don't make a decision stale: Store.update_approval_summary sets an approval's summary without moving its version, and transition_approval keeps the stored summary, so a concurrent decision or federation is neither refused nor loses its token. Both servers read a proposal the same way: one that cannot be read leaves no summary, and is logged.
  • A key without alg (RFC 7800) takes its algorithm from its type: OKP/Ed25519 is Ed25519, EC/P-256 is ES256; anything else is refused.
  • An Access Server's metadata must name it as issuer and keep its auth_token_endpoint and jwks_uri on its own https origin.
  • No fetch from a request's say-so: an agent request is signed sig=jwt and any other scheme is refused before anything is read; its provider must be in agent_providers before discovery. A server request must name an allowed server and the expected dwk before its key is fetched. An R3 proposal is read only from its resource's own https origin, with no credentials in the URL and no redirect.
  • A body's signature covers content-type with content-digest. A Signature that is not a byte sequence is invalid_signature. Keys of an unsupported alg are skipped.
  • A first request to an Access Server answered 429 or 503 is sent again after the back-off; a poll answered so is polled again.
  • AAuth-Requirement is serialised as an RFC 9651 Dictionary, its strings escaped.
  • Unit of work: Store.transaction() yields a transaction-scoped Store. decide(..., store=tx) makes a decision in the host's own unit of work; the host commits, then calls decided. Store methods are named by CRUD verb: select_binding, insert_binding, select_mission, insert_mission, update_spend, update_mission_log, update_mission_end, select_approval, insert_approval, transition_approval, update_approval_summary, and the Access Server's select_pending, insert_pending, transition_pending. Approval carries agent_expires_at and collected.

Dependency graph

uv tree --no-dev --package introspection-aauth --depth 2, trimmed to runtime:

introspection-aauth
├── cryptography v50.0.2        Ed25519 + ES256 (sign/verify), JWK thumbprints, PEM keys; JWS is done here on it
│   └── cffi
├── http-sfv v0.9.9             RFC 9651 structured fields: Signature-Input, Signature, Signature-Key, AAuth-Requirement
│   └── typing-extensions
├── httpx2 v2.16.0              discovery, federation, R3 fetches (the repo's HTTP rule)
│   ├── anyio, httpcore2, idna, truststore, typing-extensions
├── pydantic v2.14.1            models and request bodies
│   ├── annotated-types, pydantic-core, typing-extensions, typing-inspection
└── fastapi v0.158.2            (extra: fastapi) the routers
    ├── starlette, pydantic, annotated-doc, opentelemetry-api, …
  • No PyJWT. AAuth tokens use fully-specified alg: Ed25519 (RFC 9864), and every claim check is the protocol's own, so JWS is about 60 lines on cryptography.
  • No aauth-signing at runtime. It appears only in the test extra, to prove compatibility with the data plane (below). Through it the tests pull in pyjwt.
  • Shared with the data plane: all four runtime dependencies, and FastAPI, are already in dataplane-api. Adopting this package adds no new dependency there, and lets it drop aauth-signing.

Tests

163 tests, coverage 90.4% (floor 88). They run through the real FastAPI routers, with one in-process httpx2 transport routing each origin.

Every check is shown to matter: switching off each expectation check in turn, and each of the five places a server applies one, fails a test. test_expectations_enforced.py covers the Access Server refusing an agent token bound elsewhere or for another Person Server and a resource token not naming the presented token, and the Person Server refusing an auth token it did not ask for (a tampered sub, aud, scope, ps or cnf), each beside a control that the expected tokens pass.

Beyond the flows below: a tenant needing escaping polled through the router, each terminal answer given once then 410 with its status kept, the summary races against a decision and a federation, unreadable proposals, Access Server endpoints off its origin, a key without alg, collect-once and its expiry, concurrent spends against one mission, per-type lifetimes (an agent token of 12 hours is accepted), 402, a cross-origin Location, an unknown requirement, a throttled first request, a list-valued Signature, a body signed without its content type, a Signature-Key naming a host to fetch (no outbound request), proposals off their resource's origin, each expectation's named failures, malformed claims and bodies, stored grant shapes, the mission blob's encoding, malformed issuers, an http jwks_uri, a redirect, Authorization coverage, the routers' resolver, prefix, pending paths and Cache-Control, and the spans and log records federation emits.

  • Interop:
    • the hellocoop vectors (@hellocoop/httpsig 2.6.0, the spec author's library; copied from aauth-protocol): what this package signs is byte for byte what hellocoop verified, and what hellocoop signed verifies here;
    • cloud compatibility: a request this package signs verifies under the data plane's current verifier (aauth_signing.verify_signature, with the same base64url conversion signature_for_library does), its agent token passes verify_agent_claims, and its tokens pass aauth_signing's verify_jwt.
  • Person Server:
    • mission → approve with a budget → person tokens;
    • within budget, issued unasked and spent;
    • over budget, waits → decide → issued;
    • declined;
    • completion only by the mission's own person, then mission_terminated;
    • update log, key-to-person binding, untrusted provider, unsigned request, expiry (408), decide-once (409), budget only on a mission (422), uncovered resource refused before its keys are fetched;
    • R3 summary read into the approval.
  • Federation (four-party):
    • allowed → issued by the AS, collected once → 410 invalid_code;
    • a refusal is relayed and nothing is spent;
    • a smaller grant → only that is spent;
    • an approval wait journaled step by step (the shape of the data plane's Restate handler);
    • over budget → a person first, then the AS;
    • unreachable AS → 502 as_unreachable;
    • requirement=claims refused;
    • 429 back-off;
    • an interaction relayed, then denied;
    • a policy granting a broader scope is refused;
    • an expired presented token is never federated;
    • sub-agents refused.
  • Access Server: unsigned, an unknown Person Server, a resource token for another audience.

Also run locally: ruff format --check, ruff check (with the complexity ratchet), ty check over the package and its tests, deptry, jscpd (0 clones), scripts/check_examples.py, and actionlint.

Logging and tracing

The package configures neither logging nor a tracer provider, and depends on opentelemetry-api alone, so the host's formatter and exporter carry what it emits; under the platform's setup_structured_logging its records are JSON with trace_id and span_id. Federation is traced as person_server.federate, with .metadata, .request, .poll and .delivery per exchange (the names the data plane's own federation used); an R3 read is aauth.r3.read. The Person Server logs each federation outcome and the Access Server each decision, with a fixed message and identifiers (tenant, approval_id, resource, ps) and enumerated values (step, decision, error) as extra fields. A test checks the spans and records, that no token enters a record, and that no field is one the platform's formatter would drop.

Conventions

Recorded in AGENTS.md: one file one concept, named for it, 100 to 300 lines (a models.py, or a module holding one long class alone, excepted); no leading underscore on a class, constant or type variable, which stays internal by being left out of __all__; absolute imports (ruff TID252); the logging and tracing rules above.

Example

examples/introspection_examples/aauth/four_party.py: person_server(http) and access_server(http) build each server (the docs:aauth-servers region), mounted on FastAPI with in-memory stores. uv run -m introspection_examples.aauth.four_party drives the four-party flow in one process (propose → manager approves $2,000 → airline quotes $1,450 → federated to its AS → auth token → $1,450 spent). person_server_app(ps) and access_server_app(server) (the docs:aauth-apps region) mount each server's router with a lifespan that closes its http client on shutdown; serve_person_server / serve_access_server build one with its own client for uvicorn --factory, and the in-process run uses the same factories. It runs in examples.yml as its own step with no secrets.

Repo wiring

  • packages/introspection-aauth is its own uv project with its own lock (like examples/); root deptry excludes packages/.
  • CI: a new introspection-aauth job runs format, lint, ty over the package and its tests, tests with the coverage floor, deptry and jscpd. Both test jobs write their coverage table to the run summary.
  • release-please: a separate component introspection-aauth (tags introspection-aauth-v*), manifest at 0.0.0, so the first feat releases 0.1.0. Not wired: a PyPI publish for it. That needs a trusted publisher for the new project name first; publish.yml publishes only introspection-sdk on v* tags.

How the data plane plugs in

  • Store: its three repositories behind the protocol. The operations map 1:1 onto aauth_bindings get/upsert, aauth_missions get/create/guarded update.sql (within_budget, live_at, expected_updated_at), and aauth_approvals get/create/update (expected_status + expected_updated_at ≙ version). transaction() is one AsyncSession. Sealing stays in the repositories.
  • Directory: uuid5(org_id, login_hint); the tenant stays <org>:<project>. pending_url is a method to override for its /pending/{org}/{project}/{id} path.
  • Hooks: on_approval → person_server/describe; on_federate → person_server/federate, whose Restate handler wraps ps.federation_due, the client's discover/request/poll and ps.record_federation in ctx.run and sleeps outcome.poll_delay() with ctx.sleep, as it does now.
  • Settings become constructor arguments; clock and new_id (uuid7) are injected.
  • Its customer /v1/aauth/approvals and /missions routes stay its own, calling PersonServer.decide.

Deliberately out of scope

As in the hosted Person Server: requirement=claims/clarification, payment (402 → 403 denied), step-up with an auth token, sub-agents and call chaining, token revocation, the hwk scheme. Also: no SQL store, no Restate integration, no Settings object.

Missions and federation

  • A mission's blob carries its approval's id, so two identical approvals decided in the same second hash to two missions. A taken hash is refused with 409, never reused. Tests: test_identical_missions_for_two_people_in_one_second_stay_apart, test_a_mission_whose_hash_is_taken_is_refused_not_reused.
  • PersonServer.federate records a request or poll that cannot connect as as_unreachable (502), so the pending URL answers. Retrying a dropped connection is the injected httpx.AsyncClient's, through its transport's retries; a durable host retries each step itself (the data plane through Restate). Test: test_an_access_server_that_cannot_be_reached_ends_as_unreachable.

🤖 Generated with Claude Code

…ibrary

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 <noreply@anthropic.com>
…etries 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 <noreply@anthropic.com>
…method

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… tags, dependencies and schema

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brightsparc and others added 2 commits October 21, 2026 13:43
…aths back, document themselves, and cache well-known documents; Clock and IdFactory protocols

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…takes the host's

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
… over models

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ws 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 <noreply@anthropic.com>
@brightsparc
brightsparc marked this pull request as ready for review October 21, 2026 21:07
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ey 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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
…skips imports

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…apart

protocol.httpsig keeps its public names; signing is httpsig.signing and
verification httpsig.verification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brightsparc
brightsparc merged commit 46f26e8 into main Oct 22, 2026
10 checks passed
@brightsparc
brightsparc deleted the feat/introspection-aauth branch October 22, 2026 00:07
@introspection-ci introspection-ci Bot mentioned this pull request Oct 22, 2026

This branch was successfully deployed

1 active deployment
build — 3bf5cc05 Deployed Oct 21, 2026 by brightsparc via Unit Tests #553
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant