Skip to content

AAuth -11: @aauth/protocol 1.0.0, agent/resource renames, whole-repo integration - #16

Merged
dickhardt merged 27 commits into
mainfrom
aauth-11/wp20-integration
Aug 13, 2026
Merged

AAuth -11: @aauth/protocol 1.0.0, agent/resource renames, whole-repo integration#16
dickhardt merged 27 commits into
mainfrom
aauth-11/wp20-integration

Conversation

@dickhardt

@dickhardt dickhardt commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

The whole npm surface for AAuth -11 and R3 -02, in one branch. Eight parallel work packages built in isolated worktrees against a pinned interface contract; this is their integration. All eight merged with zero conflicts — they touched disjoint directories by design — plus the shared-file changes, the name reconciliations and a rewritten e2e suite.

21 commits. npm ci clean from a wiped node_modules. 727 passed / 1 skipped / 0 failed across 40 files, stable over four runs. The e2e suite alone is 58 tests running mockin as a real child process over real signed HTTP.

What changed, and why

Two packages are renamed and moved. mcp-agent/agent/, mcp-server/resource/. Neither ever contained any MCP; the names were misleading and @aauth/resource was stale on -10 as well as -11. Deprecation shims stay at @aauth/mcp-agent and @aauth/mcp-server.

One package is new. @aauth/protocol 1.0.0 — the -11 wire format with zero runtime dependencies: AAuth-Requirement build/parse, AAuth-Capabilities, planAccessMode, the TOKEN_TYP / DWK / SIGNING_ALG constants, and unverified JWT header/payload decode. @aauth/agent and @aauth/resource consume it and define none of it themselves.

@aauth/resource 2.0.0 is a real resource-side implementation, not a rename. verifyPersonToken did not exist; the old verifyToken handled only aa-agent+jwt and aa-auth+jwt and hard-required the removed agent claim, so it rejected every conformant -11 auth token. It now ships verifyToken with a required accept: ('agent'|'person'|'auth')[] and no default, createResourceToken, JWKS discovery, R3 documents and per-call proposals, and a PersonIdentity record-key helper so "never compare a sub across issuers" is enforced by the package rather than re-implemented per resource.

@aauth/agent 3.0.0 adds the person-token hop (requestPersonToken, createPersonTokenCache) and the 202 requirement=interaction deferred path. @aauth/fetch 3.0.0, @aauth/mcp-stdio 3.0.0 and @aauth/mcp-openclaw 3.0.0 move onto it. @aauth/local-keys 2.0.0 changes the cached-metadata shape and drops 'EdDSA' from KeyAlgorithm. @aauth/bootstrap 2.0.0 now fails against a PS with no person_token_endpoint.

Versions

Package Version Depends on
@aauth/protocol 1.0.0 (new)
@aauth/agent (was mcp-agent 2.0.0) 3.0.0 @aauth/protocol ^1.0.0
@aauth/resource (was mcp-server 1.0.0) 2.0.0 @aauth/protocol ^1.0.0
@aauth/local-keys 2.0.0
@aauth/bootstrap 2.0.0 local-keys ^2.0.0
@aauth/fetch 3.0.0 agent ^3.0.0, protocol ^1.0.0, local-keys ^2.0.0
@aauth/mcp-stdio 3.0.0 agent ^3.0.0, local-keys ^2.0.0
@aauth/mcp-openclaw 3.0.0 agent ^3.0.0, protocol ^1.0.0, local-keys ^2.0.0
@aauth/interaction-code, @aauth/hardware-keys + 4 natives unchanged bumping the natives breaks npm ci

Three things integration found that nobody predicted

  • release.yml was broken by the directory moves. It iterates a hard-coded package list in two places, still naming mcp-agent/mcp-server and never protocol. The release would have built nothing and silently skipped publishing @aauth/protocol, @aauth/agent and @aauth/resource — the three central packages of the wave. Fixed here.
  • A root vitest alias for @aauth/interaction-code became necessary: deleting resource/vitest.config.ts removed the only thing resolving it.
  • bootstrap was built against a superseded local-keys surface. 2.0.0 dropped 'EdDSA' from KeyAlgorithm and bootstrap still passed it to generateKey — a runtime break, not only a type error. This was the one instance of a branch carrying an interim position.

package-lock.json was edited by hand and verified with npm ci. npm install is forbidden on macOS in this repo — it prunes the @aauth/hardware-keys-* optional nodes.

This is one coordinated wave

Twelve PRs across twelve repositories. They were built in parallel worktrees that could not see each other, against a pinned interface contract, and reconciled here.

Merging any one alone breaks the others. This repo is the dependency root, so it cuts both ways:

  • Nothing else can merge until this does. aauth-dev/notes, aauth-dev/whoami, aauth-dev/registry and hellocoop/aauth-proxy all take @aauth/protocol and/or @aauth/resource as dependencies that do not exist on npm today.
  • But releasing this before Wallet ships Ed25519 breaks the live fleet. @aauth/resource's verifier lists EdDSA in PROHIBITED_ALGS, so a resource on 2.0.0 rejects every token person.hello.coop issues today.

The twelve PRs

Repo PR What it is
hellocoop/mockin #6 The reference PS. The only thing issuing -11 person tokens — the gate on verifying everything else.
dickhardt/AAuth #93 The specs: editorial corrections, plus six issues filed.
aauth-dev/packages-js #16 The npm surface. @aauth/protocol 1.0.0 (new), @aauth/agent 3.0.0, @aauth/resource 2.0.0, five more.
aauth-dev/proxy #2 @aauth/proxy 1.0.0 — the agent-proxy core.
hellocoop/aauth-proxy #33 The 38-proxy fleet. Carries the flag day. Merging deploys.
hellocoop/proxy-mcp #1 Tracks @aauth/proxy ^1.0.0; AP metadata cleanup.
aauth-dev/notes #2 Reference R3 resource; implements the #90 retention pattern.
aauth-dev/whoami #1 Agent identity + the new person-identity path.
aauth-dev/web-agent-demo (playground) #5 Browser walkthrough; handles the 202 deferred path.
aauth-dev/playground-popup #2 Popup-flow variant.
aauth-dev/registry #6 Five access modes; three-rung login.
aauth-dev/www #7 Six access-mode diagrams.

Related and already open: aauth-dev/explorer #5 reworks the explorer for -11 / R3 -02.

Background: AAUTH-11-INTEGRATION.md (the integration ledger) and AAUTH-11-PACKAGE-CONTRACT.md (the pinned interface contract the parallel work packages were built against).

Ordering constraints

  1. @aauth/protocol 1.0.0 needs a manual first publish before release.yml can ever publish it. npm will not accept a trusted publisher for a name absent from the registry. It needs Dick's npm credentials:
    npm login
    cd protocol && npm publish --access public
    npm trust github @aauth/protocol --repository aauth-dev/packages-js --file release.yml --allow-publish
    
  2. @aauth/proxy 1.0.0 (aauth-dev/proxy) must publish before the resource fleet serves access_mode: person-token — 0.4.0 hard-fails on an unrecognized value.
  3. HelloCoop/Wallet svr/issuer/sign.js:32 must ship Ed25519 for AAuth token types in the same window. It reads const alg = useEdDSA ? 'EdDSA' : 'RS256' with useEdDSA = isAAuthType(typ). Every verifier in this wave rejects EdDSA; -10 allows no transition. RS256 stays for OIDC. Third-party agents still signing EdDSA are locked out at the same moment — the intended consequence of -10 conformance, taken deliberately.

Not covered by tests

Honest list, because 727 green tests overstate what has been proven:

  • Missions, and every expires_at clamp. mission_endpoint is unimplemented by agreement. mockin accepts any value as a mission_s256 — there is no mission to look up — so §Resource Token Verification step 7 (mission active, now before expires_at) is never enforced. Every clamp built in this wave is untested.
  • Sub-agents. subagent_token is accepted at both endpoints; no end-to-end coverage.
  • Session tokens. Renamed and implemented; not exercised end to end.
  • Revocation. revocation_endpoint is not published by the reference PS.
  • upstream_token / call chaining. Rejected at both endpoints, deferred fleet-wide.
  • Anything Workers-specific. Everything here runs under Node, where jose accepts the OKP alg: "Ed25519" that workerd's crypto.subtle.importKey rejects. @aauth/resource must strip alg before importKey or Workers fail on deploy while these tests stay green.

What a reviewer should check

  • No verifier accepts EdDSA. resource/src/verify-token.tsPROHIBITED_ALGS must contain 'none', 'EdDSA' and the HMAC algorithms.
  • The alg-strip before crypto.subtle.importKey is intact everywhere a JWK is imported. Emit Ed25519, strip before import — opposite ends of the same pipe. Never remove a strip while fixing an emit-side EdDSA.
  • verifyToken's accept set is named at every call site, narrowest-first. A person token and a PS-issued auth token share iss, dwk, aud, sub and cnf and differ only in typ; without the check a person token passes signature, iss, aud and cnf verification and falls through to the scope gate as 403 insufficient_scope — a wrong-credential problem reported as a permissions problem.
  • tenant copy-through. §Resource Token Structure: copied from the person token when it carried one. Step 6 has the PS verify ps, sub, mission_s256 and tenant match exactly, rejecting on any mismatch or omission — so dropping it fails the exchange for a tenant-bearing person rather than losing a hint.
  • release.yml's package list names protocol, agent and resource in both places it iterates.

Known open items, recorded rather than fixed

  • createResourceToken's signature forecloses the retention pattern. WP-4 shipped {resource, audience, personToken, agentJkt, …} — it takes the whole verified person token. hellocoop/aauth-proxy coded against {resource, aud, ps, sub, personTokenJti, agentJkt, missionS256, tenant, r3Uri, r3S256}, and that is the shape a caller holding only a record of the person token can satisfy — which is exactly the situation at challenge time on the API path, and the whole point of AAuth#90. Worth deciding whether it should accept either form.
  • @aauth/agent sends AAuth-Capabilities to the person token endpoint, contradicting §AAuth-Capabilities. Deliberate, pending AAuth#89.

⚠️ CI on this PR is red, and it is the e2e harness, not the code

ci.yml reports 1 failed suite / 38 passed / 1 skipped, 659 tests passed / 70 skipped. The single failure is a collection error, not an assertion:

Error: mockin not found at /home/runner/work/packages-js/wp19/src/server.js.
This suite drives the real -11 person server; check out WP-19
(branch aauth-11/wp19-mockin) beside this worktree and run `npm ci` in it.

e2e/helpers.ts:261 resolves mockin at a sibling worktree path. That holds locally — where this branch runs 727 passed / 1 skipped / 0 failed across 40 files, stable over four runs — and it cannot hold on a GitHub runner, which checks out one repo. The 58 e2e tests are not run in CI at all; they are the 70 counted as skipped plus this uncollected suite.

ci.yml needs a step that checks out hellocoop/mockin at #6 and runs npm ci in it before npm test, or the e2e suite needs to be gated behind an opt-in env var so its absence skips rather than fails. Left for a decision rather than patched here, because the answer determines whether e2e is a required check for this repo going forward — and mockin is currently the only thing in the world issuing -11 person tokens, so this is the only end-to-end coverage the wave has.

dickhardt and others added 21 commits August 11, 2026 22:05
Rename the dependency and its import from @aauth/mcp-agent ^2.0.0 to
@aauth/agent ^3.0.0, and take @aauth/local-keys ^2.0.0.

Thread the person server through. cli.ts never passed a PS URL to
createAAuthFetch, so the auth-token exchange could not run at all and
the -11 person-token hop had nowhere to go. Add --person-server /
AAUTH_PERSON_SERVER, fall back to the agent's configured personServerUrl,
stamp it on the agent token as the `ps` claim, and hand it to
createAAuthFetch so it can reach person_token_endpoint and
auth_token_endpoint. Warn on stderr when there is no PS: such an agent
cannot satisfy requirement=person-token.

Fix the swapped onInteraction arguments in cli.ts — pollDeferred calls it
(url, code), and cli.ts built `${code}?code=${url}`.

Move the auth serialization out of cli.ts into proxy.ts as
serializeAuthFlows, and fix its thundering herd: queued POSTs used to all
resume together once the first flow finished, so each could start its own
flow and open its own browser tab. They now take the gate one at a time
and reuse the token the first one obtained. That window is wider under
-11, where a fresh flow is person-token then auth-token.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FmiCqDjRUSx6zb1N4gZPXE
…algs

Cached PS metadata (WP-2 task 1)
--------------------------------
-11 renames `token_endpoint` to `auth_token_endpoint` and adds
`person_token_endpoint` as REQUIRED (#ps-metadata). The cache now knows
the shape it stores: `PersonServerMetadata`, `isPersonServerMetadata` and
`missingPersonServerMembers` are exported, and `readCachedMetadata` is
typed to it.

Pre-11 entries are INVALIDATED, not migrated. `person_token_endpoint` is
REQUIRED and nothing in a pre-11 document can produce it, so a migrated
entry would still fail -11 validation while looking current — exactly the
"reads back as a PS with no auth token endpoint" failure the rename
creates. The cache is a latency optimization over a public document; the
cost of invalidating is one GET per PS.

Two gates enforce it: every entry carries `schema: 2` and a read at any
other version evicts the entry and misses; and reads/writes of
`aauth-person.json` validate the document, so a right-version entry with
a wrong body still misses. Writing a non-conforming PS document throws,
naming the -10 → -11 rename when the document still has `token_endpoint`.

Fully-specified algs (WP-2 task 2)
----------------------------------
-11 §Signature Algorithms: `alg` MUST be present and fully specified,
the polymorphic `EdDSA` MUST NOT be used, and a verifier MUST reject a
key whose `kty`/`crv` disagrees with its `alg`.

New `jwk-alg.ts` owns this — `@aauth/proxy` had a private
`withFullySpecifiedAlg` for the keys this package produces, which is the
wrong end of the pipe; it should import this one. The version here also
enforces the kty/crv-vs-alg rule, which the proxy copy did not.

Fixed emitters, all of which stamped `EdDSA` or nothing at all:
  - keygen.generateKey / toPublicJwk — these keys are published in
    jwks.json, where a -11 verifier rejects `EdDSA`
  - backends/software generateKey / listKeys / getPublicKey
  - backends/yubikey-piv and secure-enclave, whose native JWKs carry no
    `alg` at all
  - agent-token: the JWT protected header took its `alg` from the stored
    key, so a pre-11 keychain entry produced `alg: EdDSA` on the wire

`KeyAlgorithm` is now `Ed25519 | ES256 | RS256`. Keys and config written
before 2.0.0 are normalized on read rather than migrated on disk, so an
existing install keeps signing without a re-bootstrap.

JWKS matching stays thumbprint-only (alg is not an input), so an agent
whose published jwks.json is still on `EdDSA` resolves; the signing alg
comes from the local key, never the remote document.
…uirements

AAuth -11 (WP-8).

- Dependency and imports renamed from @aauth/mcp-agent ^2.0.0 to
  @aauth/agent ^3.0.0; @aauth/local-keys to ^2.0.0; adds @aauth/protocol
  ^1.0.0.
- ServerManager obtains a person token per MCP server from the PS's
  person_token_endpoint (getPersonToken from @aauth/agent), for
  resource = the server's origin, and presents it via Signature-Key in
  place of the agent token. A resource MUST have verified a person token
  before it issues a resource token (-11 §Resource Access and Resource
  Tokens). It is fetched up front for access_mode person-token/auth-token
  and on demand for a 401 requirement=person-token challenge
  (§Person Token Required).
- PS metadata carries auth_token_endpoint (renamed from token_endpoint)
  and the new REQUIRED person_token_endpoint; ServerManager caches the
  document across servers and reports it via onPersonServerMetadata.
- AAuth-Requirement parsing and access_mode reasoning go through
  @aauth/protocol: parseRequirementHeader recognizes person-token and
  raises UnsupportedRequirementError for values this agent cannot satisfy,
  which reaches the MCP caller as an error. planAccessMode skips servers
  whose declared mode this agent's setup cannot complete (getSkippedServers).
- Plugin config gains person_server and mission_s256.
Union of the two divergent aauth-header.ts copies in mcp-agent/src and
mcp-server/src, corrected for draft-hardt-oauth-aauth-protocol-11 and
with the mission helpers deleted.

- parseRequirementHeader throws UnsupportedRequirementError, carrying the
  raw value, on any requirement= value it does not recognize. -11: an
  agent that does not recognize the value MUST NOT treat the response as
  satisfiable and surfaces it to the caller as an error. The mcp-agent
  copy threw a generic Error and did not list agent-token or person-token
  at all, so it rejected two requirements -11 defines. A missing required
  parameter stays a plain Error — a different failure from an unknown
  value, and only the latter is UnsupportedRequirementError.
- parseCapabilitiesHeader filters unrecognized values and never throws;
  buildCapabilitiesHeader does not filter, since the agent unions its own
  capabilities with the ones its PS reports at mission approval, which
  can postdate this library.
- planAccessMode: absent or unrecognized -> undeclared, never an error;
  the value space is a registry and access_mode is advisory. With no
  person server, three of the five modes are unsatisfiable — person-token
  and auth-token, and per-call, whose flow also terminates in an auth
  token from the PS with the grant carried as the r3_per_call claim. Each
  gets its own human-readable reason. agent-token and session-token reach
  no PS and stay satisfiable.
- Header parsing is quote-aware, so a ; or , inside a quoted parameter no
  longer splits the header — both source copies would have mis-parsed the
  spec's own requirement=interaction example — and sf-string escapes
  round-trip.
- No AAuth-Mission helpers. The header and its IANA registration were
  removed in -11.

INTEGRATION.md records the four steps this commit deliberately does not
take, each touching a file shared with the other -11 work packages:
the workspaces entry, the lockfile node, the vitest alias, and the npm
trusted-publisher bootstrap for a package name that does not yet exist.

ESM, TypeScript, zero runtime dependencies. 79 tests.
The package contains no MCP and never did. Renamed to @aauth/agent; it is
the AAuth agent-side protocol library. Directory move and the deprecation
shim are integration steps.

Protocol primitives now come from @aauth/protocol: aauth-header.ts and
decode-jwt.ts are deleted, and buildMissionHeader/parseMissionHeader/
AAuthMission are gone entirely — AAuth-Mission was removed in -11.
createSignedFetch loses its `mission` option.

New src/person-token.ts. A person token is required before a resource will
issue a resource token and on every authorization endpoint request. Signed
POST to the PS's person_token_endpoint presenting the agent token via
Signature-Key, body {resource, mission_s256?, subagent_token?}; a 202 with
requirement=interaction is polled through deferred.ts. upstream_token is
not implemented — call chaining is deferred. createPersonTokenCache keys on
(resource, mission_s256); clear() flushes everything, because every cached
token binds the same signing key through cnf and one rotation invalidates
them all at once.

AuthServerMetadata.token_endpoint -> auth_token_endpoint, and
person_token_endpoint is now required — a PS without one is non-conformant
and the metadata document is rejected.

createSignedFetch gains `signBody`: a request carrying a body to a PS or AS
endpoint now signs content-digest and content-type, which @hellocoop/httpsig
generates only when the covered-component list names them. Resources are
untouched — they declare their needs via additional_signature_components.
createAAuthFetch builds one signed fetch per flavour and hands the PS one to
token exchange and the person-token client.

mission_s256 is plumbed end to end: AAuthFetchOptions.missionS256 reaches the
person token request, and the tests carry a mission through the path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FmiCqDjRUSx6zb1N4gZPXE
…ed alg

PS metadata (draft-hardt-oauth-aauth-protocol §Person Server Metadata):
`token_endpoint` is now `auth_token_endpoint`, and `person_token_endpoint`
is REQUIRED. A PS that does not publish `person_token_endpoint` cannot issue
the person token an agent needs at a resource it has not used, so it cannot
serve the agent anywhere. `create` now fails on it, naming every missing
REQUIRED field, instead of binding and failing at first use.

Binding moved ahead of key generation, so a rejected person server leaves
no orphaned key in the keystore and no half-created provider that `create`
would then refuse to re-create.

Every emitted JWK — created, published, cached in the keychain, or shown by
`list` — now carries a fully-specified `alg` (`Ed25519`, never the
polymorphic `EdDSA`) per RFC 9864, including keys generated by an earlier
version. `EdDSA` remains only as `@aauth/local-keys`'s curve selector for
key generation, where it is not a JWK member.

Narration in `create`/`token` help and the setup skill now describes the
-11 flow: the agent presents its agent token to the person server's
`person_token_endpoint` and presents the person token it gets back to the
resource.

Depends on @aauth/local-keys ^2.0.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FmiCqDjRUSx6zb1N4gZPXE
…n step

Protocol -11 / R3 -02 pass over @aauth/fetch.

- session token. -11 named the credential a resource issues in resource-managed
  access. `--aauth-access-token` becomes `--session-token`, `AAUTH_ACCESS_TOKEN`
  becomes `AAUTH_SESSION_TOKEN`, and the JSON-stdin field and the `--emit` /
  `authorize` output field become `session_token`. The wire header is unchanged
  (`AAuth-Access`), and the agent package still calls the option `opaqueToken`.

- access_mode is a registry, not a closed list. The set is agent-token |
  person-token | session-token | auth-token, plus R3's per-call. An unrecognized
  value is not an error and not a declaration: call the resource and read the
  AAuth-Requirement it returns. `planAccessMode` from @aauth/protocol decides
  this; fetch reimplements none of it.

- openapi-gateway is gone. R3 -02 deleted urn:aauth:vocabulary:openapi-gateway
  (continued as dickhardt/AAuth#72) — its service labels were grant-bearing
  identifiers that silently invalidated grants when renamed. `--operations` now
  sends every id verbatim under urn:aauth:vocabulary:openapi; a colon no longer
  selects a second vocabulary and there is no mixed-form rejection.

- person-token step. An agent gets a person token from its PS before a resource
  will issue a resource token, so the rendered flow is now
  ps_metadata -> person_token_endpoint -> agent_token_request ->
  auth_token_endpoint -> auth_token_request. `token_endpoint` was renamed
  `auth_token_endpoint` in -11 and the narration follows. Both PS hops are
  registered under the agent package's internal step name and the
  endpoint-shaped spelling, so neither falls through unlabelled.

- operation access annotations (new). When a fetched body is an OpenAPI document
  whose Operation Objects carry `x-aauth-access-mode` / `x-aauth-budget`, fetch
  groups the operations by the credential each needs and prints them on stderr
  (stdout stays the raw body), saying which ones this agent cannot complete.
  Advisory throughout: a resource MAY return any AAuth-Requirement at runtime, so
  nothing is enforced or gated. R3's budget rules are applied when planning —
  budget implies auth-token, session-token is invalid on an operation — and with
  --explain the same data rides the stream as an operation_annotations event.

vitest.config.ts + test/protocol-shim.ts are scaffolding: they let this package's
tests run before @aauth/agent (WP-3) and @aauth/protocol (WP-1) exist in the
workspace, and self-heal to the real sources once they do. Both go away at
integration, when the aliases move to the root vitest config.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FmiCqDjRUSx6zb1N4gZPXE
Renames @aauth/mcp-server. The package contains no MCP and never did; it is
what a resource uses, whatever protocol the resource speaks. Directory stays
mcp-server for this branch — integration moves it.

Headers move to @aauth/protocol
- Delete src/aauth-header.ts. buildAAuthHeader/buildAAuthAccessHeader are now a
  thin resource-side layer over buildRequirementHeader, with agent-token and
  person-token overloads added.
- buildMissionHeader/parseMissionHeader/AAuthMission are gone. AAuth-Mission and
  its IANA registration were removed in -11; a mission reaches a resource only
  inside a PS-issued token, as mission_s256.

Person token verification
- verifyToken now handles aa-person+jwt: typ, dwk aauth-person.json, JWKS at
  {iss}/.well-known/{dwk}, kid match, signature, exp future / iat not future,
  iss a valid HTTPS server identifier, aud equal to this resource, cnf.jwk equal
  to the HTTP signing key with the RFC 7800 structural checks.
- accept is a required parameter naming the token kinds a call site allows. A
  person token and a PS-issued auth token differ only in typ, and accepting one
  for the other fails open, so the check cannot be forgotten.
- resource is a required parameter; aud is now actually checked.
- The polymorphic EdDSA is rejected in the JWT header and in cnf.jwk (RFC 9864).
- VerifiedAuthToken drops agent, adds ps and a required sub, and surfaces
  account, jti, r3_uri, r3_s256, r3_granted and r3_per_call. VerifiedAgentToken
  surfaces ps and parent_agent. VerifiedPersonToken is new.

Resource tokens
- alg Ed25519, not EdDSA. ps / sub / person_token_jti copied from the verified
  person token, agent_jkt, scope. mission_s256 copied unchanged when the person
  token carried one. Optional account, tenant, interaction, r3_uri, r3_s256.
- No agent claim, no mission object, no approver.
- personToken is required: only a PS can act on a resource token, so one issued
  to an agent that cannot name a person is one nobody can redeem.
- clampToMission exported — no token carrying mission_s256 outlives its mission.

R3
- Documents are serialized once and the exact bytes stored and served; content
  addressing hashes the bytes as served with no canonicalization step. No
  version field (R3 -02 removed it).
- R3Store is a two-method interface the caller supplies (KV, Redis, Map).
  Records are written under both s256 and uri.
- Fetch is restricted to the AS named in the resource token's aud and the PS
  named by the agent token's ps claim, by exact string equality. Every other
  signer is rejected; agents can never read an R3 document.
- Per-call proposals: a full R3 document scoped to one invocation with a
  required parameters object, a large or sensitive value optionally a
  {s256, excerpt, media_type} digest. On retry the resource verifies the actual
  parameters against the approved proposal and hashes digest parameters. Any
  difference is a rejection.

Workers
- No node: imports remain in this package's own code — crypto, crypto.subtle,
  fetch, TextEncoder only. nodejs_compat is still needed transitively via
  @aauth/interaction-code.

@aauth/protocol ^1.0.0 added; it will not resolve until integration. Tests run
against a contract-faithful stub in test/protocol-stub via a package-local
vitest config. 109 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FmiCqDjRUSx6zb1N4gZPXE
…ions

Merged aauth-11/wp{1..8} into one tree. Every branch touched a disjoint
package directory, so all eight merges were clean — no conflicts.

Shared files no branch was allowed to touch:
- mcp-agent/ -> agent/, mcp-server/ -> resource/ (git mv)
- root package.json workspaces: + protocol, mcp-agent -> agent,
  mcp-server -> resource
- root vitest.config.ts: aliases for @aauth/protocol, @aauth/agent,
  @aauth/resource, and @aauth/interaction-code (the last is newly needed
  because deleting resource/vitest.config.ts removed the only alias that
  resolved it from source; its package entry points at an unbuilt dist/)
- package-lock.json by hand, verified with `npm ci` (never `npm install`)
- deleted the scaffolds: fetch/vitest.config.ts, fetch/test/protocol-shim.ts,
  resource/vitest.config.ts, resource/tsconfig.check.json,
  resource/test/protocol-stub/

Name reconciliations (call sites fixed, no compatibility shims):
- mcp-openclaw: getPersonToken -> requestPersonToken; its local
  PersonServerMetadata collapses to an alias of @aauth/agent's
  AuthServerMetadata
- mcp-stdio cli.ts: the // INTEGRATION: line — createAAuthFetch takes
  authServerUrl, so the PS URL is passed under that name
- fetch/src/handlers.ts: parseAAuthHeader -> parseRequirementHeader and
  Capability, both from @aauth/protocol
- bootstrap: 'EdDSA' is no longer a local-keys KeyAlgorithm — the software
  keystore selector is 'Ed25519' (cli.ts, resolve.ts, render.ts + tests)

@aauth/fetch's opaqueToken/onOpaqueToken needed no change: @aauth/agent kept
those names and @aauth/fetch already maps its own sessionToken onto them.
The old suite mocked @hellocoop/httpsig's fetch and every server behind it,
imported parseAAuthHeader from @aauth/mcp-agent, called verifyToken without
the now-required `resource` and `accept`, minted resource tokens with the
removed `agent` claim, and mentioned "person" four times in 841 lines, all
incidental. It could not compile, let alone pass.

The rewrite drives real packages against a real -11 person server: mockin
(WP-19) started as a child process, @aauth/agent making real signed HTTP
requests through @hellocoop/httpsig, and @aauth/resource verifying those
signatures inside a real node:http server. Nothing stubs the protocol.

Server identifiers are https with no port, so nothing on loopback can be one.
Every party therefore has a real identifier (https://ps.mockin.test,
https://rs.mockin.test, https://agent.mockin.test) and a LoopbackRouter
rewrites only the transport. No claim, signature input or comparison is
altered — requests are signed over the loopback authority they are sent to.

43 tests covering: the full chain agent token -> person token -> resource
token -> auth token -> 200; the 202 deferred path in both mockin modes,
including a counted-poll proof that pollDeferred really re-polls; mission
stripping and invention; tenant copy-through and its two failure modes;
`typ` discrimination proving the person token never reaches the scope gate;
planAccessMode across five modes x both hasPersonServer states against a
resource that declares each; Ed25519 emitted and EdDSA rejected on every
token type at both ends; and content-digest/content-type coverage on bodied
PS requests, proved real by flipping require_body_signing.

The file header lists what the suite cannot prove — mission_endpoint is
unimplemented, so §Resource Token Verification step 7 and every expires_at
clamp are unverifiable, and the mission tests say so at the assertion.

Wire-level assertions are used for every PS rejection because @aauth/agent
discards the PS's error/error_description on a direct (non-deferred) failure:
PersonTokenError and TokenExchangeError are constructed with the status alone
unless the response came back through pollDeferred.
Neither ledger listed these, and both are direct fallout of moving
mcp-agent/ -> agent/ and mcp-server/ -> resource/.

- .github/workflows/release.yml iterates a hard-coded package-directory list
  in two places (build order, publish loop). It still named mcp-agent and
  mcp-server and never named protocol, so the release would have failed to
  build and silently skipped publishing all three of @aauth/protocol,
  @aauth/agent and @aauth/resource. Now:
  protocol interaction-code local-keys agent resource bootstrap fetch
  mcp-openclaw mcp-stdio — protocol first, since agent and resource build
  against it. The `npm view "@aauth/$pkg"` lookup still derives the npm name
  from the directory, which stays correct under the new names.

- README.md's package table linked to ./mcp-agent and ./mcp-server, which no
  longer exist. Renamed both rows and added @aauth/protocol.

NOT fixed, and reported instead: README's Quick Start examples are stale -10
API independently of this wave (verifyToken without `resource`/`accept`,
createResourceToken with the removed `agent` claim). Rewriting them is a docs
decision, not an integration mechanic.
## @aauth/agent

1. `requestPersonToken` sends the consent-flow parameter set the endpoint
   accepts. It sent `resource`, `mission_s256`, `subagent_token` and nothing
   else, so no agent could exercise `tenant` — the agreed resolution to
   dickhardt/AAuth#88, and the only thing that selects which tenant a person
   token carries. Added `tenant`, `justification`, `login_hint`, `domain_hint`,
   `prompt`, `platform`, `device`, `capabilities`. `exchangeToken` was missing
   `platform` and `device` from the same set; added there too.

2. The PS's error survives the direct path. `PersonTokenError` and
   `TokenExchangeError` took the status alone unless the response came through
   `pollDeferred`, so a mission-stripping refusal read as "Token exchange
   failed with status 400". `parseErrorBody` is now exported from deferred.ts
   and used on the direct path, and both errors carry `.error` and `.detail`.
   §Error Response Format is RFC 9457 — `application/problem+json`, REQUIRED
   `error`, OPTIONAL `detail` — so the parser accepts `+json` and reads both
   `detail` and the pre-11 `error_description` (which is what mockin and
   Wallet still emit), preferring `detail`.

3. The two naming deviations reconcile to the contract, not away from it.
   `createAAuthFetch` takes `personServerUrl` / `personServerMetadata`, and the
   exported type is `PersonServerMetadata`, with `AuthServerMetadata` kept as a
   deprecated alias. Under -11 the PS has two token endpoints, so a name
   containing "auth" names the wrong one. `exchangeToken`'s own `authServerUrl`
   is left alone deliberately — that hop's server is the AS in four-party — and
   flagged in the report. `mcp-stdio`'s `// INTEGRATION:` line reverts to the
   `personServerUrl` it originally wanted.

Consumers updated: fetch/src/{handlers,cli}.ts, mcp-stdio/src/cli.ts,
mcp-openclaw/src/server-manager.ts (its local alias is now a plain re-export).

## e2e

The tenant tests now drive the real request parameter instead of mockin's
fallback switch, and every PS-refusal assertion reads `.error` / `.detail` off
the thrown error — the `psPost` wire-reading helper is gone, which was the
point: needing it was the symptom.

New R3 section, 15 tests. The resource gained `POST /authorize` (the
`r3_operations` request `@aauth/fetch --operations` sends), `GET /r3/<key>`
serving stored bytes verbatim through `serveR3Document`, and `POST /invoke`
running the granted / per-call / retry ladder. Covered: an authorization
request producing a resource token with `r3_uri`/`r3_s256`, the PS fetching
that document over a signed request and returning `r3_granted`; byte-stable
serving proved through two real PS fetches of the same URI, plus the tampered
negative; fetch authorization refusing an agent (401, `sig=jwt` carries no
server identifier) and refusing a server that authenticates but is not
entitled (403); a per-call proposal round trip with the resource enforcing
parameters on retry, and four negatives — changed parameter, extra parameter,
missing parameter, wrong operation; and digest parameters, where the value
reaches neither the fetched document nor the token and is verified against the
digest at call time.

`r3_uri` is `http://localhost:<port>/r3/<hash>` — a document location, not a
server identifier, and the PS dials it for real. `LoopbackRouter.install()`
now swaps the global fetch, because `@hellocoop/httpsig`'s verify() resolves a
`sig=jwks_uri` Signature-Key on the global with no injection point.

The R3 section header states what mockin cannot prove: it never routes an
operation to `r3_per_call` on its own (the `r3_grants` switch stands in for
the person's decision), never links a proposal to a prior class grant, and has
no proposal approval endpoint.
1. `resolveAuthServerMetadata` / `fetchAuthServerMetadata` become
   `resolvePersonServerMetadata` / `fetchPersonServerMetadata`, and
   `AuthServerMetadataOptions` becomes `PersonServerMetadataOptions`.
   Deprecated aliases at all three old names. These fetch `aauth-person.json`,
   which only a person server publishes. `exchangeToken`'s `authServerUrl`
   stays: in four-party access that hop's server really is the AS.

2. `@aauth/resource` exports `isProposal`, and `proposal.ts`'s module doc names
   the trap it exists for. A class-grant auth token carries the class
   document's `r3_s256` and a per-call retry carries the proposal's — both
   present, both strings, nothing in the token telling them apart. Branching on
   `if (auth.r3_s256)` sends every granted call into
   `verifyProposalParameters`, which fails with `invalid_proposal`: an error
   naming the document when the dispatch logic is what is wrong. `isProposal`
   takes a stored record, a parsed document, or a null store lookup, so a call
   site can pass a `getR3ByHash` result straight in. The e2e resource now uses
   it; six unit tests cover it, including the class-document case that is the
   whole point.

3. Two more entries in the suite header's cannot-prove list: no PS classifies
   operations into `r3_per_call` at all (mockin's `autoGrantR3` grants the
   whole document every time, so the claim exists only where the `r3_grants`
   switch puts it), and "you may only propose what you were granted in
   principle" is unverified (mockin does not remember the class document
   between exchanges, does not require a proposal's operations to be a subset,
   and connects the two `POST /aauth/token` calls in no way at all).

Also: mockin adopted RFC 9457 while this branch was in flight (wp19 18a7513),
so it now emits `{error, detail}` with `application/problem+json`. One raw
assertion moved from `error_description` to `detail` and now also checks the
media type. Everything else was already reading `.detail` off the thrown error
and did not move — which is the dual-spelling parser doing its job. The
`error_description` branch stays load-bearing for Wallet and keeps its unit
test.
mockin moved both token endpoints under a shared prefix (wp19 884b782):
`/aauth/token/auth` and `/aauth/token/person`, with the bare `/aauth/token`
answering 404 and naming the two real ones. The suite broke at eight places
that reached past the metadata document to a hard-coded path.

Fixed by resolving, not substituting. `Mockin` gains `metadata()` (fetched
once, cached — the document does not change while the server runs) and
`endpoint(field)`, and the test file gains a `psPost(field, body, fetch?)` that
every raw-HTTP site now goes through. No token endpoint path is written down in
a request anywhere in the suite: the metadata fields exist so a PS can move its
endpoints, and a test that reaches past them is a test that breaks when one
does.

Literals survive in exactly one place — the two assertions that pin what the PS
*publishes*, which are about the metadata document itself. Those now expect the
new paths.

Two more reach-pasts fixed while in there, same class of bug: the published-key
test guessed `/aauth/jwks.json` instead of reading `jwks_uri`, and the consent
tests hard-coded `/aauth/consent` instead of opening the `url` the PS handed
out in its `AAuth-Requirement`. `Mockin.consent` now takes that url.

Added a test pinning the bare-prefix 404 and that its `detail` names both real
endpoints. That guard is the reason this move cost one commit.

Fixture alignment. WP-19 found three path conventions in the fleet's test data;
there were four — `fetch/src/handlers.test.ts` invented `/aauth/auth-token` and
`/aauth/person-token`, `agent/src/person-token.test.ts` and `mcp-openclaw` used
bare `/token` and `/person`, and `agent/src/token-exchange.test.ts` and
`local-keys` used `/aauth/token` and `/aauth/person`. All now use the real
shape. One relative-`Location` fixture moved with them, which is the case that
proves `resolveUrl` still lands in the right place under a nested prefix. The
deliberate pre-11 `token_endpoint` fixture in `local-keys` keeps its -10 path,
because misrepresenting -10 is what it exists to catch.
`protocol/src/sf.ts` was a fourth hand-rolled Structured Fields
implementation in this family: quote-aware splitting on a delimiter plus
quote/unquote of sf-strings. WP-21 vendored a real RFC 8941 parser into
`@hellocoop/httpsig` and put it on a `/structured-fields` subpath
precisely so this one could go.

Zero *external* dependencies is the property worth keeping; an internal
one is not a cost. `@aauth/protocol` is not useful on its own, and
everything that imports it signs its requests with `@hellocoop/httpsig`
already -- so the parser is installed either way, and the choice was
between using it and maintaining a second implementation of the same
grammar. Take the dependency; delete `sf.ts`.

Six behaviour changes, all in the direction of the RFC:

  - `buildRequirementHeader` emits the canonical serialization, `;`
    with no following space. Both forms parse identically and the
    spaced form is still accepted on the way in.
  - `buildCapabilitiesHeader` validates its values as Tokens and throws
    on one that is not. This does not reintroduce filtering: an
    unrecognized but syntactically valid capability still serializes, so
    an agent can still union in a capability this library predates.
  - `parseCapabilitiesHeader` still never throws. `parseList` does, on a
    malformed header, so it is wrapped and returns `[]` -- "Recipients
    MUST ignore unrecognized capability values" reaches the same place
    for a header that cannot be read at all.
  - Unescaping follows RFC 8941 3.3.3: only `\\` and `\"`. `unquoteString`
    unescaped `\<anything>`.
  - Duplicate dictionary members are last-wins, per the RFC. `sf.ts`
    took the first.
  - A bare unquoted value still parses -- `.`, `-` and `_` are Token
    characters, so `resource-token=eyJ...` reads the same as the quoted
    form. Pinned by a test, because it is leniency kept on purpose.

Two bugs go with `sf.ts`. The `\s+ -> ' '` pre-pass collapsed runs of
whitespace *inside* quoted values as well as the line folding it was
aimed at; it is now an obs-fold unfold that touches only line breaks.
And the `head!` non-null assertion is gone with the code that needed it.

`sf.ts` was never re-exported from `index.ts`, so the public API of
`@aauth/protocol` is unchanged.

The lockfile records the new dependency but still resolves
`@hellocoop/httpsig` at 2.0.1: 2.1.0 is not on npm yet. httpsig
publishes first, then `npm install` here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FmiCqDjRUSx6zb1N4gZPXE
dickhardt and others added 5 commits August 12, 2026 17:16
Published from hellocoop/packages-js#68. Hand-edited per CLAUDE.md — npm
install on macOS prunes the @aauth/hardware-keys-* optional nodes.

npm ci clean from a wiped node_modules; 735 passed / 1 skipped against the
published package rather than a linked worktree build.
helpers.ts pointed at ../../wp19 — a worktree that existed on one machine.
CI could not start the person server at all, so all 58 e2e tests failed to
collect while the rest of the suite stayed green: the wave's only
cross-package verification proved nothing on a runner.

@hellocoop/mockin is now a devDependency at ^2.0.0. Resolved through
package.json and the bin map rather than as a bare specifier — mockin is a
bin-only package with no main and no exports, so resolve('@hellocoop/mockin')
has no entry point and throws. Reading bin also leaves the path mockin's to
change.

2.0.0 is the -11 surface: both token endpoints moved, errors became RFC 9457,
EdDSA is rejected, and a person token is required before any resource token.
^1.7.0 consumers are unaffected.

Verified against a locally packed 2.0.0: 59 e2e passed, 735 total.
The lockfile entry lands once mockin 2.0.0 publishes.
Published from hellocoop/mockin#6. Regenerated with --package-lock-only,
which does not touch node_modules and so does not hit the optional-node
pruning bug CLAUDE.md warns about — all four @aauth/hardware-keys-*
platform nodes survive with integrity intact. Diff is purely additive:
52 nodes added, none removed.

npm ci from a wiped node_modules resolves mockin 2.0.0 and httpsig 2.1.0
from the registry; 735 passed / 1 skipped, including the 59 e2e tests that
could not run on CI at all before this.
jose 6 is ESM-only and WebCrypto-backed; the whole surface this repo
uses is unchanged in v6. The one behavioural change that bit:
generateKeyPair now returns non-extractable keys by default, and every
call site here exports to JWK immediately, so pass extractable: true.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQ2FCHHAnuJWF5TJB3838S
2.2.0's contentDigest: 'auto' appends content-digest to the signed
components whenever the body is digestible, so the agent inherits the
§10.3 PS/AS body mandate from the library. signed-fetch forwards a
contentDigest option; signBody now implies 'require' so a non-digestible
body fails loudly instead of silently losing coverage. The two e2e tests
that simulated a forgetful signer now opt out explicitly with 'omit' —
under 'auto' that client cannot otherwise exist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQ2FCHHAnuJWF5TJB3838S
@dickhardt
dickhardt merged commit 5074be0 into main Aug 13, 2026
1 check passed
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