Repository navigation
feat(aauth): introspection-aauth, a Person Server and Access Server library - #193
Merged
Merged
Conversation
…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>
…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>
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>
Merged
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 distributionintrospection-aauthinpackages/introspection-aauth. Nothing inintrospection_sdkchanges.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.
protocolerrors(AAuthErrorand a subclass per AAuth error code:InvalidRequest,InvalidToken,InvalidCode,Expired,NotFound,MissionNotFound,MissionTerminated,AccessDenied,Denied,ServerError,Unauthorized),answer,keys(Ed25519SigningKey, current and previous; Ed25519/ES256 verify; RFC 7638 thumbprints;Jwk/Jwks),httpsig(signingandverification, RFC 9421 withSignature-Key,jwtandjwks_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(the202answer,AAuth-Requirement,Retry-After),urls,models(Budget,Agent), andserver(theServerbase,Clock,IdFactory).person_serverserverholdsPersonServeralone: missions, person and auth tokens, budget spend-if-fits, approvals,decide, completion, poll,410 invalid_codeonce collected,pending_parts, and the federation stepsfederation_due,record_federationandfederate.access_server_client(AccessServerClient, built byps.access_server_client()),models(including the federationStepandOutcome),store(Store,MemoryStore),directory(Directory,AnyoneDirectory),hooks(Hook,Sleep,Exchange).access_serverserverholdsAccessServeralone: 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)person_server_router,access_server_routerand their*_well_known_routers, infastapi.person_serverandfastapi.access_serverover the sharedfastapi.routes. Each takes the server or a resolver (`request → serverprotocolimports neither server nor FastAPI.Models
protocol.wire(and the request models in each server'smodels), serialised withmodel_dump_json(exclude_none=True)and parsed withmodel_validate_json. A body that does not validate is400 invalid_request. Signatures cover exactly the bytes sent.protocol.claims. Each checks what its token must be on its own: required members includingdwkandjti,expafteriat, the lifetime its type allows (person and auth tokens an hour, agent tokens a day; a resource token's is bounded by its resource), acnf.jwkthat is a public key, aBudgetof 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 anAuthGrant.DeliveryExpectation,PresentedExpectation,ResourceTokenExpectation,AgentExpectation, inprotocol.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 holdsub,tenantandaccount.Approval.grantisAuthGrant | MissionGrant(left to right), so stored rows of either shape, and{}, decode. Neither carries unknown members (extra="forbid").typing.Any, noobject, and no baredict/listin the package or its tests. The guard reads the AST, so it findst.Anyunder an alias,typing_extensions.Any, string annotations and type aliases, and a test proves each; arbitrary JSON isJsonValue/JsonObject. Callables in the public API are protocols (Policy,Hook,Sleep,Clock,IdFactory). The package shipspy.typed.Behaviour
https://, a lowercase host, no port, path, userinfo, query, fragment or trailing slash), requires an httpsjwks_uri, and every request the library makes passesfollow_redirects=False.Authorizationmust cover it (required when present, per the AAuth-Access rule; the library does not issue AAuth-Access yet), andsign(..., authorization=...)covers it.aud(trailing slash aside),sub,ps,mission_s256,tenant,account,r3_uri,r3_s256and scope, be bound to the agent's key, outlive neither the presented nor the agent token, and grant no more budget than was sent.#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 oncollected; the URL then answers410 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 is403 mission_terminated.org:proj) polls, and the pending parts are decoded once.Store.update_approval_summarysets an approval's summary without moving its version, andtransition_approvalkeeps 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.alg(RFC 7800) takes its algorithm from its type: OKP/Ed25519 is Ed25519, EC/P-256 is ES256; anything else is refused.auth_token_endpointandjwks_urion its own https origin.sig=jwtand any other scheme is refused before anything is read; its provider must be inagent_providersbefore discovery. A server request must name an allowed server and the expecteddwkbefore 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.content-typewithcontent-digest. A Signature that is not a byte sequence isinvalid_signature. Keys of an unsupportedalgare skipped.429or503is sent again after the back-off; a poll answered so is polled again.AAuth-Requirementis serialised as an RFC 9651 Dictionary, its strings escaped.Store.transaction()yields a transaction-scopedStore.decide(..., store=tx)makes a decision in the host's own unit of work; the host commits, then callsdecided. 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'sselect_pending,insert_pending,transition_pending.Approvalcarriesagent_expires_atandcollected.Dependency graph
uv tree --no-dev --package introspection-aauth --depth 2, trimmed to runtime:alg: Ed25519(RFC 9864), and every claim check is the protocol's own, so JWS is about 60 lines oncryptography.aauth-signingat runtime. It appears only in thetestextra, to prove compatibility with the data plane (below). Through it the tests pull inpyjwt.dataplane-api. Adopting this package adds no new dependency there, and lets it dropaauth-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.pycovers 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 tamperedsub,aud,scope,psorcnf), 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
410with its status kept, the summary races against a decision and a federation, unreadable proposals, Access Server endpoints off its origin, a key withoutalg, collect-once and its expiry, concurrent spends against one mission, per-type lifetimes (an agent token of 12 hours is accepted), 402, a cross-originLocation, an unknown requirement, a throttled first request, a list-valued Signature, a body signed without its content type, aSignature-Keynaming 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 httpjwks_uri, a redirect,Authorizationcoverage, the routers' resolver, prefix, pending paths andCache-Control, and the spans and log records federation emits.@hellocoop/httpsig2.6.0, the spec author's library; copied fromaauth-protocol): what this package signs is byte for byte what hellocoop verified, and what hellocoop signed verifies here;aauth_signing.verify_signature, with the same base64url conversionsignature_for_librarydoes), its agent token passesverify_agent_claims, and its tokens passaauth_signing'sverify_jwt.mission_terminated;408), decide-once (409), budget only on a mission (422), uncovered resource refused before its keys are fetched;410 invalid_code;502 as_unreachable;requirement=claimsrefused;429back-off;Also run locally:
ruff format --check,ruff check(with the complexity ratchet),ty checkover the package and its tests,deptry,jscpd(0 clones),scripts/check_examples.py, andactionlint.Logging and tracing
The package configures neither logging nor a tracer provider, and depends on
opentelemetry-apialone, so the host's formatter and exporter carry what it emits; under the platform'ssetup_structured_loggingits records are JSON withtrace_idandspan_id. Federation is traced asperson_server.federate, with.metadata,.request,.polland.deliveryper exchange (the names the data plane's own federation used); an R3 read isaauth.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) asextrafields. 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 (amodels.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 (ruffTID252); the logging and tracing rules above.Example
examples/introspection_examples/aauth/four_party.py:person_server(http)andaccess_server(http)build each server (thedocs:aauth-serversregion), mounted on FastAPI with in-memory stores.uv run -m introspection_examples.aauth.four_partydrives 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)andaccess_server_app(server)(thedocs:aauth-appsregion) mount each server's router with a lifespan that closes its http client on shutdown;serve_person_server/serve_access_serverbuild one with its own client foruvicorn --factory, and the in-process run uses the same factories. It runs inexamples.ymlas its own step with no secrets.Repo wiring
packages/introspection-aauthis its own uv project with its own lock (likeexamples/); rootdeptryexcludespackages/.introspection-aauthjob 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.introspection-aauth(tagsintrospection-aauth-v*), manifest at0.0.0, so the firstfeatreleases 0.1.0. Not wired: a PyPI publish for it. That needs a trusted publisher for the new project name first;publish.ymlpublishes onlyintrospection-sdkonv*tags.How the data plane plugs in
Store: its three repositories behind the protocol. The operations map 1:1 ontoaauth_bindingsget/upsert,aauth_missionsget/create/guardedupdate.sql(within_budget,live_at,expected_updated_at), andaauth_approvalsget/create/update(expected_status+expected_updated_at≙version).transaction()is oneAsyncSession. Sealing stays in the repositories.Directory:uuid5(org_id, login_hint); the tenant stays<org>:<project>.pending_urlis a method to override for its/pending/{org}/{project}/{id}path.on_approval→person_server/describe;on_federate→person_server/federate, whose Restate handler wrapsps.federation_due, the client'sdiscover/request/pollandps.record_federationinctx.runand sleepsoutcome.poll_delay()withctx.sleep, as it does now.clockandnew_id(uuid7) are injected./v1/aauth/approvalsand/missionsroutes stay its own, callingPersonServer.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, thehwkscheme. Also: no SQL store, no Restate integration, no Settings object.Missions and federation
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.federaterecords a request or poll that cannot connect asas_unreachable(502), so the pending URL answers. Retrying a dropped connection is the injectedhttpx.AsyncClient's, through its transport'sretries; 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