A Go implementation of the AAuth protocol: agent identity and authorization across trust domains, with no shared secrets and no per-server pre-registration. Every agent holds its own signing key (Ed25519 by default, or ES256) and a self-describing token that binds it; any party can verify the token and every request it signs.
The module covers every role: agent, resource, Person Server (PS), Access Server (AS), and hosted agent provider (AP). Each is a library you embed in your own services.
To our knowledge this is the first Go implementation of the protocol (the draft's Implementation Status section lists TypeScript, .NET, Python, and Java).
| Draft | Version | Constant |
|---|---|---|
| AAuth Protocol | draft-hardt-oauth-aauth-protocol-11 |
aauth.ProtocolDraft |
| HTTP Signature Keys | draft-hardt-httpbis-signature-key-09 |
aauth.SignatureKeyDraft |
| AAuth Bootstrap | draft-hardt-aauth-bootstrap-02 |
aauth.BootstrapDraft |
The drafts are works in progress, and each revision can change the wire format; the module's minor version moves with them until the protocol stabilizes. Upgrading from v0.1 (draft -09)? See the migration guide and the changelog. For an interactive tour of the protocol, see explorer.aauth.dev.
- Install
- Packages
- Quick start: agent
- Quick start: resource
- Quick start: hosted AP, PS, and AS
- Protocol coverage
- Design notes
- Security notes
- Sandbox
- Examples
- Testing
- License
go get github.com/aauth-dev/aauth-goRequires Go 1.26 or later. The full API reference is on pkg.go.dev.
import aauth "github.com/aauth-dev/aauth-go"| Package | Contents |
|---|---|
aauth |
The protocol vocabulary shared by every role: identifiers, keys, tokens (mint, issue, verify), HTTP message signatures, metadata, requirements, deferred responses, revocation, errors, and the agent-side PSClient and Transport. Resources use it directly. |
personserver |
Person Server as an http.Handler: person and auth token endpoints, pending requests, missions, permission, audit, interaction, revocation, and federation to access servers. |
accessserver |
Access Server as an http.Handler (AS token endpoint, revocation), plus Client (PS to AS over HTTP) and Server.Local (in-process PS-AS collapse). |
agentprovider |
Hosted agent provider: agent token issuance, two-key and single-key refresh, sub-agent tokens, and a Client for agents (Enroll, TokenSource). |
ratelimit |
In-memory token bucket implementing aauth.Limiter for single-instance deployments. |
interactioncode |
Interaction code generation and canonicalization (Crockford base32). |
sandbox |
Every role together for development, behind the aauth-sandbox command: development CA, consent pages, message trace and a signing helper. |
A self-hosted agent is its own agent provider (bootstrap §4.3): it holds a
key, mints its own agent tokens, and serves /.well-known/aauth-agent.json
and its JWKS (Agent.JWKS) at its issuer. Wrap an http.Client with
Transport and AAuth disappears from application code:
id, err := aauth.ParseAgentIdentifier("aauth:assistant@agent.example")
if err != nil {
return err
}
agent, err := aauth.NewAgent(id,
aauth.WithIssuer("https://agent.example"), // serves aauth-agent.json and the JWKS
aauth.WithPersonServer("https://ps.example"), // the ps claim
)
if err != nil {
return err
}
ps := aauth.NewPSClient("https://ps.example", agent)
if _, err := ps.Discover(ctx); err != nil { // endpoints from aauth-person.json
return err
}
hc := &http.Client{Transport: aauth.NewTransport(agent, ps)}
res, err := hc.Get("https://files.example/files")For each request the transport signs with the agent's key and answers
requirement=agent-token, person-token, and auth-token challenges (401,
or a 202 deferred delivery): it obtains a person token, redeems the resource
token at the PS, verifies what it receives, caches tokens per resource with a
five-minute refresh margin, and retries. Set Transport.OnRequirement to
show the user an interaction URL and code while a request waits for them.
An agent whose tokens come from a hosted provider uses
aauth.WithTokenSource, for example with agentprovider.Client.TokenSource.
Governance is optional and uses the same client:
m, err := ps.ProposeMission(ctx, aauth.MissionProposal{
Description: "Reconcile the March invoices",
Resources: []string{"https://ledger.example"},
})
if err != nil {
return err
}
tr := aauth.NewTransport(agent, ps)
tr.MissionS256 = m.S256 // person and auth tokens are requested under the mission
perm, err := ps.RequestPermission(ctx, aauth.PermissionRequest{
Action: "SendEmail",
Description: "email the reconciliation summary",
MissionS256: m.S256,
})
if err != nil {
return err // a *aauth.MissionStatusError once the mission is terminated
}
if !perm.Granted() {
return errors.New(perm.Reason)
}For most resources, aauth.ResourceGuard is enough: each operation declares
the least it requires (the agent's identity, the person, or an auth token for
a scope), and the guard verifies the presented token and challenges for the
next one. See the resource guide.
Without the guard, a resource verifies whichever token a request presents and
challenges for the next one. With PS authorization (three-party), an agent token is answered
with requirement=person-token, a person token with a resource token
challenge (requirement=auth-token), and an auth token is served:
const self = "https://files.example" // the resource's server identifier
verify := aauth.TokenVerifyOptions{Resolver: aauth.NewJWKSResolver(nil)} // nil: aauth.DiscoveryClient
// A valid signature proves the issuer signed the claims, not that this
// resource should trust that issuer or its subjects. Token issuers are URLs
// the request chooses, so accept only the person and access servers you have
// chosen to trust, and key your own records by (iss, sub): sub is unique only
// within its issuer (§9.4.3.2).
trusted := map[string]bool{"https://ps.example": true, "https://as.example": true}
checkSubject := func(ctx context.Context, iss, sub string) error {
if !trusted[iss] {
return fmt.Errorf("%w: %s is not an issuer this resource accepts", aauth.ErrInvalidToken, iss)
}
return nil // or look up (iss, sub) in your own records
}
handler := func(w http.ResponseWriter, r *http.Request) {
tok, err := aauth.ParseSignatureKey(r)
if err != nil {
aauth.WriteSignatureFailure(w, err) // 401 with Signature-Error
return
}
typ, err := aauth.TokenType(tok)
if err != nil {
aauth.WriteSignatureFailure(w, err)
return
}
switch typ {
case aauth.TypAuth:
claims, err := aauth.VerifyAndExtractAuth(r.Context(), r, self,
aauth.AuthTokenVerifyOptions{TokenVerifyOptions: verify, CheckSubject: checkSubject})
if err != nil {
aauth.WriteSignatureFailure(w, err)
return
}
// The identity is (iss, sub), never sub alone. The granted scope is
// the issuer's claim: apply your own policy to it before acting.
serve(w, claims.Issuer, claims.Subject, claims.Scope)
case aauth.TypPerson:
person, err := aauth.VerifyAndExtractPerson(r.Context(), r, self, verify)
if err != nil {
aauth.WriteSignatureFailure(w, err)
return
}
rt, err := aauth.IssueResourceToken(
aauth.ResourceTokenParams{Resource: self, Scope: "files:read"},
person, resourceKey, resourceKID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
aauth.ChallengeAuthToken(w, rt) // 401 requirement=auth-token
default:
aauth.ChallengePersonToken(w) // 401 requirement=person-token
}
}In four-party mode, also restrict which access server may grant which scopes
(draft -11 §13.7). The resource also serves /.well-known/aauth-resource.json
(aauth.ResourceMetadata) and the JWKS holding resourceKey. A resource that
needs only the agent's identity stops at aauth.VerifyAndExtractAgent. For
four-party access, set ResourceTokenParams.Audience to the resource's
access server. The compile-checked version is
Example_resource.
The server roles are http.Handlers over interfaces you implement: storage
(personserver.Store, accessserver.Store) and policy
(personserver.Decider, accessserver.Authorizer, and the optional
MissionApprover, PermissionDecider, InteractionRelay, and
agentprovider.Registrar). The library never decides policy. A decision
allows, denies, or defers, and the hosting application resolves deferred
requests from its own approval UI (Approve, Deny, Ask).
One origin can host all three roles (PS-AS collapse, §9.3.3); each keeps its own key, metadata document, and JWKS:
ap, err := agentprovider.New(agentprovider.Config{
Issuer: issuer, Key: apKey,
Limiter: ratelimit.Per(10, time.Minute),
})
as, err := accessserver.New(accessserver.Config{
Issuer: issuer, Key: asKey,
Store: accessserver.NewMemoryStore(), // your database in production
Authorizer: accessserver.AuthorizerFunc(func(ctx context.Context, r *accessserver.AuthorizationRequest) (accessserver.Decision, error) {
return accessserver.Allow(""), nil // the resource token's scope
}),
Limiter: ratelimit.Per(5, time.Second),
})
ps, err := personserver.New(personserver.Config{
Issuer: issuer, Key: psKey,
SubjectKey: subjectKey, // 32+ random bytes, kept stable
Store: personserver.NewMemoryStore(),
InteractionURL: issuer + "/consent",
Decider: personserver.DeciderFunc(func(ctx context.Context, r *personserver.TokenRequest) (personserver.Decision, error) {
switch {
case r.Person == "":
return personserver.DeferInteraction(), nil // a new agent: the person signs in at /consent
case r.Chained && r.Mission == nil:
return personserver.DeferApproval(), nil // call chaining: ask the person
}
return personserver.Allow(personserver.Grant{}), nil
}),
Federator: as.Local(issuer), // in-process PS-AS federation
CollocatedAS: func(resource string) bool { return resource == "https://ledger.example" },
Limiter: ratelimit.Per(5, time.Second),
})
mux := http.NewServeMux()
ap.Register(mux)
ps.Register(mux)
as.Register(mux)The consent page passes the ?code= it receives to ps.LookupCode to show
the request, which does not consume the code. At the decision it
authenticates the person, consumes the code as that person with
ps.ConsumeCode, and calls ps.Approve with a Grant naming the person,
which binds the agent, and the Revision of the request it showed.
ap.IssueAgentToken issues tokens for agent
keys the application has authorized. The PS routes live under /ps/ by
default, so agents find them with PSClient.Discover. The full,
compile-checked version is
Example_collapsedDeployment,
and the e2e test runs the whole flow: enrollment, three-party
access, missions, PS-AS collapse, audit, and a revocation cascade.
Status against draft -11, by section. Legend: ✅ implemented and tested, 🟡 partial, ⬜ not yet.
| Section | Status | Notes |
|---|---|---|
| §5.1 Agent provider | ✅ | Self-hosted (Agent, bootstrap §4.3) and hosted (agentprovider: separate provider key, aauth-agent.json, attestation hook) |
| §5.2 Agent identifiers | ✅ | aauth:name@domain, sub-agents name+worker@domain, charset and length rules, case-sensitive |
| §5.3 Agent token | ✅ | aa-agent+jwt minting and verification (Agent.MintToken, VerifyAgentToken, VerifyAndExtractAgent); pluggable trust through KeyResolver |
| Section | Status | Notes |
|---|---|---|
| §6.1 Agent token required | ✅ | ChallengeAgentToken; Transport retries with the agent token |
| §6.2 Resource-managed authorization | ✅ | 202 interaction and approval waits followed by Transport |
§6.3 AAuth-Access session token |
✅ | Signature-bound, rolling refresh |
| §6.4 Person token required | ✅ | ChallengePersonToken, VerifyAndExtractPerson; Transport obtains person tokens |
| §6.5 Auth token required | ✅ | 401 challenges and 202 deferred delivery (§6.5.1) |
| §6.6 Authorization endpoint | ✅ | Transport.Authorize (person token, deferred interaction, AAuth-Access, or a resource token redeemed at the PS) and AuthorizationHandler, WriteAuthorized; invalid_scope and invalid_account |
| §6.7 Resource token | ✅ | Issued only against a verified person or auth token; PS and AS verification with the presented-token cross-check (§6.7.2); agent-side challenge verification (§6.7.3) |
| Section | Status | Notes |
|---|---|---|
| §7.1 Person token endpoint | ✅ | PSClient.PersonToken (cached per key, resource, mission, and upstream token) and the personserver endpoint; directed sub per resource |
| §7.2 Auth token endpoint | ✅ | PSClient.RequestAuthToken with the REQUIRED presented_token; the personserver endpoint; resource-initiated interaction (§7.2.3) |
| §7.3 User interaction | ✅ | requirement=interaction with single-use interaction codes |
| §7.4 Consent presentation | ✅ | The verified request and the agent-asserted hints are handed to the Decider |
| §7.5 Clarification chat | ✅ | Question, answer, updated request, and cancel, with round limits |
| §7.6 Interaction endpoint | ✅ | RequestInteraction, RelayInteraction, interaction_unavailable fallback |
| §7.7 Permission endpoint | ✅ | With or without a mission |
| §7.8 Audit endpoint | ✅ | Records go to the mission log |
| §7.9 Re-authorization | ✅ | Top-down refresh with a five-minute margin; revoked or expired cached tokens dropped |
| Section | Status | Notes |
|---|---|---|
| §8.1, §8.2 Creation and approval | ✅ | Exact blob bytes and mission_s256; approval person tokens verified and cached |
| §8.3 Mission log | ✅ | Updates logged with verifiable digests |
| §8.4, §8.5 Update and completion | ✅ | Completion reviewed by the person |
| §8.6 Mission management | ✅ | Active and terminated states with termination reasons, personserver.Server.TerminateMission; MissionStatusError.Repropose and KnownTerminationReason; mission_control_endpoint carried in metadata. The control plane itself awaits its companion specification |
| §8.7, §8.8 Errors | ✅ | mission_not_found with no existence oracle; *MissionStatusError from every PS endpoint, including the token endpoints |
| Section | Status | Notes |
|---|---|---|
| §9.1 AS token endpoint | ✅ | accessserver over an Authorizer; PS side through a Federator with delivery checks (§9.1.3) and as_unreachable; an AS 402 is settled through accessserver.Client.Payment and its Location polled (§9.1.2) |
| §9.2 Claims required | ✅ | Answered by the PS through a ClaimsProvider, never sending sub |
| §9.3 PS-AS federation | ✅ | HTTP Client signed under jwks_uri; in-process Server.Local for collapse (§9.3.3) |
| §9.4 Auth token | ✅ | Draft -11 claim set; resource verification with an (iss, sub) hook; agent-side response checks (§9.4.4); upstream token checks (§9.4.5) |
| Section | Status | Notes |
|---|---|---|
| §10.1.1 Call chaining | ✅ | RouteDownstream, ChainRouter.Transport; the intermediary must be its own agent provider; downstream exp bounded by the upstream token |
| §10.1.2 Interaction chaining | ✅ | ChainInteraction |
| §10.2 Sub-agents | ✅ | Own keys (Agent.NewSubAgent, Agent.IssueSubAgentToken), single level, parent-mediated tokens with subagent_token |
| Section | Status | Notes |
|---|---|---|
| §11.1 Identifiers | ✅ | ValidateServerIdentifier (https, host only, lowercase, A-labels) |
| §11.2 Metadata documents | ✅ | All four roles; issuer checked against the fetch URL; Validate for REQUIRED members; aauth-resource link relation |
| §11.3 HTTP message signatures | ✅ | @method @authority @path signature-key, plus content-digest and content-type on bodies; created window and clock_skew; Signature-Key schemes jwt, jwks_uri (server-signed requests), and jkt-jwt and hwk (agent provider only) |
| §11.4 JWKS discovery and caching | ✅ | Cache headers, one-minute refresh floor, unknown-kid refresh, 24-hour maximum age, backoff, bounded entries |
| §11.5 AAuth tokens | ✅ | typ and dwk checked first, iat required, exp with no skew, iss a server identifier, one-hour person and auth tokens; Ed25519 and ES256 only |
| §11.6 Requirement responses | ✅ | AAuth-Requirement as an RFC 9651 dictionary with all seven values |
§11.7 AAuth-Capabilities header |
✅ | Transport.Capabilities (unioned with MissionCapabilities) sent on requests to resources and as the PS capabilities parameter; RequestCapabilities and CanRaise for resources |
| §11.8 Deferred responses | ✅ | 202, Location, Retry-After, Prefer: wait, 429 backoff; 402 settled through a PaymentHandler, then Location polled (§11.8.4) |
| §11.9 Error responses | ✅ | signature-key-09 Signature-Error codes; RFC 9457 problem bodies; token endpoint and polling error codes |
| §11.10, §11.11 Scopes and account binding | ✅ | Scope never broader than requested; account carried through |
| §11.12 Token revocation | ✅ | RevocationClient; recipients in every server role, with cascade and downstream outcomes |
Not implemented: the Signature-Key x509 scheme and Rich Resource Requests.
Payment (402) is carried as protocol shape only: a PaymentHandler settles
it through whatever payment protocol the server names, and the SDK polls
Location or retries; no payment protocol is built in.
- Library-first. Every role is a package you embed; there is no binary
and no hosted service. Handlers are plain
net/http, mountable on any mux or router, with no web framework dependency. - Storage-agnostic. Servers persist through
Storeinterfaces you implement over your own database. The in-memory stores define the reference semantics and suit tests and single-process demos. - Policy through interfaces. Consent, access, mission, permission,
issuance, and rate-limit decisions are callbacks (
Decider,Authorizer,MissionApprover,PermissionDecider,Registrar,Limiter). The library enforces the protocol; you decide who may do what. - Keys are
crypto.Signers. Any Ed25519 or P-256 signer works, so keys in a platform keystore, HSM, or secure enclave work like in-memory ones.KeyResolverreturns acrypto.PublicKey, and the same verification code serves JWKS discovery, pinned keys, and local self-signed agents. - Signed bytes are never re-serialized. Request bodies are signed and
verified as sent, through
content-digest; mission blobs are stored and returned as the exact bytes theirmission_s256covers. - Fully-specified algorithms. JWS and JWK
algisEd25519orES256;EdDSA,none, symmetric, and missingalgvalues are rejected.
- Discovery makes outbound requests to URLs taken from tokens. Verifiers
fetch metadata and JWKS from the issuers that tokens name, and servers
deliver revocations to endpoints that metadata names. With no client
configured, these requests use
aauth.DiscoveryClient. It allows https only, including redirects, and connects only to public unicast addresses, checked after name resolution. It follows at most five redirects, ignores proxy settings from the environment, and times out after 10 seconds (signature-key §7.3). A client you pass toNewJWKSResolver,RevocationClient, or a server'sConfig.HTTPClientreplaces it and should apply the same egress admission. In local development, pass a client that can reach your local servers. - In-memory stores and the
ratelimittoken bucket are single-instance. Their state lives in one process. Run several instances only with sharedStoreandLimiterimplementations. InsecureSkipIdentifierCheckis for development. It acceptshttpand port-bearing identifiers and relaxes the agent-token profile. Never set it in production.- Keep the PS
SubjectKeystable and secret. It derives every directedsub; changing it changes every person's identifier at every resource.
aauth-sandbox runs every role from this module on your machine, on https://*.aauth.test, with a development certificate authority, consent pages, a message trace and a signing helper for HTTP clients that cannot sign:
go install github.com/aauth-dev/aauth-go/cmd/aauth-sandbox@latest
aauth-sandbox serve
curl http://127.0.0.1:8099/https://resource.aauth.test/api/documentsIt listens on loopback only. It also runs in Docker (deploy/docker), one container for every role or one per role, and fronts a resource of your own at https://<name>.aauth.test with --upstream. See the sandbox guide.
examples/walkthrough has tested Go code for each step of the AAuth walkthroughs, from enrollment to federated authorization, for every role involved: agent, resource, person server, access server and agent provider. Each snippet is a named region that documentation tools can quote; the PIDL walkthrough viewer shows them next to the HTTP messages.
go test -race ./...
golangci-lint runUnit tests cover token round trips and rejections, signature tampering,
replay guards, delegation rules, JWKS caching, and every server endpoint
against live httptest servers, and the e2e package runs every
role together (below). Runnable Example functions double as API documentation.
The e2e package runs the roles together on httptest servers with
in-memory stores and seeded policy, so it needs no network, database, or
credentials and runs the same locally and in CI. Each deployment has an agent
provider and person server on one origin, two resources, and an enrolled
agent whose key a work session registered. Resource A uses the person server
directly (three-party); resource B uses an access server. The two tests differ
in where that access server runs:
| Test | Topology | Federation |
|---|---|---|
TestCollapsedDeployment |
Agent provider, person server, and access server on one origin (§9.3.3) | In-process (Server.Local) |
TestFourPartyDeployment |
Access server on its own origin (§9.1) | Over HTTP (accessserver.Client), signed as the PS; the test checks that requests reached the AS's token and revocation endpoints |
Both run the same scenario as subtests, in order, each skipped if an earlier one failed:
- three-party access at A, where Alice's approval binds the agent to her;
- propose a mission;
- B refuses a request outside a mission;
- access at B under the mission, with the access server's deferred approval;
- audit, update, and completion of the mission, checking the mission log;
- revocation of the agent token, cascading to both resources;
- the revoked agent obtains nothing new.
Run one topology, with the steps listed:
go test -race -v -run TestFourPartyDeployment ./e2eEach step logs a plain-English summary of its flow. To also see the HTTP
calls between the agent, resources, and servers, set AAUTH_E2E_TRACE=1:
AAUTH_E2E_TRACE=1 go test -v -run TestFourPartyDeployment ./e2etrace: #41 agent GET B/ledger
trace: #41 -> 401 AAuth-Requirement: requirement=person-token
trace: #42 agent POST AP+PS/ps/person
trace: #42 -> 200
trace: #43 agent GET B/ledger
trace: #43 -> 401 AAuth-Requirement: requirement=auth-token; resource-token="eyJhbGci…"
trace: #44 agent POST AP+PS/ps/token
trace: #45 PS POST AS/as/token
trace: #45 -> 200
trace: #44 -> 200
trace: #46 agent GET B/ledger
trace: #46 -> 200
Each call has a number and logs when it starts and when it returns. A call
made while handling another, such as the PS calling the AS to answer the
agent's /ps/token request (#45 inside #44), starts and ends between the outer
call's two lines. Parties are named by role (agent, AP+PS, AS, and the
resources A and B).
AAUTH_E2E_TRACE=1 shows the protocol calls: tokens, challenges, federation,
and revocation. AAUTH_E2E_TRACE=2 adds the metadata and key-set fetches
each party makes to learn how to verify or reach another, marked
(discovery), which are about half of all requests.
The e2e workflow runs the two topologies as
separate jobs with the trace at level 1, and each job's summary lists its
steps, so the Actions logs show which flow each job exercises.
Wire-format golden vectors live in testdata/vectors:
language-neutral JSON (JWK thumbprints, identifiers, header codecs, metadata
documents, deterministic Ed25519 JWTs, RFC 9421 request signatures) that
another implementation can run as a conformance check. After an intentional
wire change, regenerate the derived values and review the diff:
go test -run TestVectors -update