Skip to content

AAuth -11: require a person token at /authorize - #2

Merged
dickhardt merged 2 commits into
mainfrom
aauth-11/wp15-playground-popup
Aug 13, 2026
Merged

AAuth -11: require a person token at /authorize#2
dickhardt merged 2 commits into
mainfrom
aauth-11/wp15-playground-popup

Conversation

@dickhardt

@dickhardt dickhardt commented Aug 12, 2026

Copy link
Copy Markdown

The popup playground requires a person token at /authorize. Same protocol change as aauth-dev/playground, in the popup-flow variant, plus one genuine spec/implementation divergence found while doing it.

What changed — server

The agent now presents a person token at the authorization endpoint, not its agent token. A resource MUST have verified one before it issues a resource token — the resource token names ps, sub and person_token_jti, and only the PS that issued that person token can redeem it, so a resource token minted for an agent that cannot name a person is one nobody can redeem.

  • /authorize verifies typ, dwk, the issuer JWKS at {iss}/.well-known/{dwk}, aud, and cnf.jwk against the request-signing key. Missing or non-person token → 401 AAuth-Requirement: requirement=person-token; malformed, expired or wrongly addressed → 400 invalid_person_token.
  • The ps body parameter is gone. The person token's iss is the person server, and it is PS-asserted rather than agent-asserted.
  • Resource token drops agent, gains ps, sub, person_token_jti, keeps agent_jkt, and copies mission_s256 and tenant when the person token carried them. The PS checks all four against the person token it issued and rejects on any mismatch or omission, so for a person with a tenant, dropping it fails the exchange outright. exp is clamped to the person token's exp, which transitively keeps a mission-scoped token inside its mission's window.
  • psJwksVerifier takes a required accept set of token kinds, stated at the call site: /api/demo accepts ['auth'] only. A person token and a PS-issued auth token share iss, dwk, aud, sub and cnf and differ only in typ, so without this a verifier that checks everything else takes a credential carrying no authorization as though it carried some. Measured failure mode: it falls through to the scope gate as 403 insufficient_scopea wrong-credential problem reported as a permissions problem, which is the kind of thing nobody debugs correctly.
  • alg: EdDSAEd25519 on the agent and resource token headers, and the polymorphic identifier is no longer accepted on input either. The strip before crypto.subtle.importKey stays — that is about the alg member, not its value, and workerd rejects an OKP JWK carrying alg: "Ed25519".

What changed — browser narration

Both flows POST to the PS's person_token_endpoint before touching the resource, and handle the 202 deferred-consent response through the same long-poll machinery the auth-token leg already used. PS metadata field token_endpointauth_token_endpoint. Body-carrying PS requests now sign content-digest and content-type.

A genuine divergence, recorded

The error table maps an expired person token to invalid_person_token (400), but signature-key -08 bounds how long a Signature-Key token stays usable and @hellocoop/httpsig rejects it first — a signature failure, which -10 pins to 401. verifyPersonToken still checks exp itself, for the case where httpsig's clock-skew tolerance is wider than ours. This is a real divergence between the spec's error table and what a conformant httpsig implementation does first; the test pins which layer answers.

This is one coordinated wave

Twelve PRs across twelve repositories implement AAuth -11 and R3 -02, built in parallel worktrees that could not see each other and reconciled in one integration pass.

Merging any one alone breaks the others. For this repo, concretely:

  • Merging this before HelloCoop/Wallet ships Ed25519 breaks /api/demo outright. Every aa-auth+jwt and aa-person+jwt person.hello.coop issues today carries alg: EdDSA, and this branch no longer accepts it. The demo would reject every live PS auth token.
  • The popup drives whoami and notes live, and now presents a person token to both. Merging this without aauth-dev/whoami and aauth-dev/notes means it presents a credential those resources do not yet expect.
  • This is a Cloudflare connected repo: a push to main auto-deploys. There is no staging.

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).

⚠️ Flag day

Everyone who has used the popup demo re-consents: R3 documents drop version, so the document bytes change, so every hash changes, so every grant keyed by hash is dead.

The same cutover in hellocoop/aauth-proxy costs users a second and larger thing: the connection store is rekeyed from a bare sub to personId(ps, sub), so every stored upstream OAuth credential is orphaned — its key can no longer be computed. Users reconnect every service, and six times for GitHub, which splits into six resources in the same wave. Do not describe the cutover as "people re-consent."

Ordering constraints

  1. @aauth/protocol 1.0.0 needs a manual first publish — npm will not register a trusted publisher for a name absent from the registry.
  2. @aauth/proxy 1.0.0 must publish before the 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 in the same window. const alg = useEdDSA ? 'EdDSA' : 'RS256', useEdDSA = isAAuthType(typ). RS256 stays for OIDC.

Not covered by tests

  • Missions, and every expires_at clamp. mission_endpoint is unimplemented by agreement. The reference PS accepts any value as a mission_s256, so §Resource Token Verification step 7 (mission active, now before expires_at) is never enforced and the exp clamp here is untested.
  • Sub-agents, session tokens, revocation — no coverage in the wave.
  • Anything Workers-specific. Tests run under environment: 'node', where jose accepts the OKP alg: "Ed25519" that workerd's crypto.subtle.importKey rejects — breaking the strip passes CI and fails on deploy.
  • The signed happy path under wrangler dev. A [[routes]] custom_domain entry makes the dev server hand the Worker the production hostname, so @authority never matches what the client signed; httpsig reports verified: false with no error string.
  • Nothing has run against a live PS, because none issues -11 person tokens yet.

What a reviewer should check

  • tenant copy-through. (Checked: src/index.ts copies it, with the "mismatch or omission" reasoning in a comment.)
  • No verifier accepts EdDSA. (Checked: it survives only in a test fixture asserting rejection.)
  • No API-serving path accepts 'person'. (Checked: /api/demo is ['auth'].)
  • The alg-strip before crypto.subtle.importKey is intact.

Two items still open

  • /.well-known/aauth-resource.json emits client_name and no name; /.well-known/aauth-agent.json emits both. client_name is an RFC 7591 borrowing that appears nowhere in the AAuth specs — -11 defines name as the common metadata field. Dropping it is safe (Wallet's entity-cache.js reads client_name || name), but the resource document currently publishes neither the field the spec defines. Worth fixing before this ships. The same field is still emitted by aauth-dev/playground, where a commit on main explicitly restored it and reverting blind seemed worse than flagging it.
  • Not done here: -10 also makes it a verifier MUST that a JWKS key's own alg be fully specified and agree with its kty/crv. Enforcing that would reject keys published with alg: EdDSA, which un-migrated issuers still serve — so it is a fleet-wide sweep with its own sequencing, not a change to slip into this PR.

The agent now presents a PERSON token at the authorization endpoint, not
its agent token. A resource MUST have verified one before it issues a
resource token — the resource token names `ps`, `sub`, and
`person_token_jti`, and only the PS that issued that person token can
redeem it, so a resource token minted for an agent that cannot name a
person is one nobody can redeem.

- /authorize verifies typ, dwk, the issuer JWKS at {iss}/.well-known/{dwk},
  aud, and cnf.jwk against the request-signing key. Missing or non-person
  token → 401 AAuth-Requirement: requirement=person-token; malformed,
  expired, or wrongly addressed → 400 invalid_person_token.
- The `ps` body parameter is gone. The person token's `iss` IS the person
  server, and it is PS-asserted rather than agent-asserted.
- Resource token drops `agent`, gains `ps`, `sub`, `person_token_jti`,
  keeps `agent_jkt`, and copies `mission_s256` and `tenant` when the
  person token carried them. The PS checks all four against the person
  token it issued and rejects on any mismatch OR omission, so for a person
  with a tenant, dropping it fails the exchange outright. exp is clamped
  to the person token's exp, which transitively keeps a mission-scoped
  token inside its mission's window.
- psJwksVerifier takes a required `accept` set of token kinds, stated at
  the call site: /api/demo accepts ['auth'] only. A person token and a
  PS-issued auth token share iss, dwk, aud, sub and cnf and differ only in
  typ, so without this a verifier that checks everything else takes a
  credential carrying no authorization as though it carried some.
- alg: EdDSA → Ed25519 on the agent and resource token headers, and the
  polymorphic identifier is no longer accepted on input either. The strip
  before crypto.subtle.importKey stays — that is about the alg member, not
  its value, and workerd rejects an OKP JWK carrying alg: "Ed25519".
- Browser narration gains the person-token hop: both flows POST to the
  PS's person_token_endpoint before touching the resource, and handle the
  202 deferred-consent response through the same long-poll machinery the
  auth-token leg uses. PS metadata field token_endpoint →
  auth_token_endpoint. Body-carrying PS requests now sign content-digest
  and content-type.

The error table maps an expired person token to invalid_person_token
(400), but signature-key -08 bounds how long a Signature-Key token stays
usable and @hellocoop/httpsig rejects it first — a signature failure,
which -10 pins to 401. verifyPersonToken still checks exp itself, for the
case where httpsig's clock skew tolerance is wider than ours. A genuine
divergence between the spec's error table and what a conformant httpsig
implementation does first; the test pins which layer answers.

Flag day: Wallet's svr/issuer/sign.js:32 still heads every aa-auth+jwt and
aa-person+jwt with EdDSA, so /api/demo rejects live PS auth tokens until
that ships Ed25519 for AAuth token types.

Follow-up, not done here: -10 also makes it a verifier MUST that a JWKS
key's own `alg` be fully specified and agree with its kty/crv. Enforcing
that would reject keys published with `alg: EdDSA`, which un-migrated
issuers still serve, so it is a fleet-wide sweep with its own sequencing.

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

cloudflare-workers-and-pages Bot commented Aug 12, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
playground-popup-aauth-dev 930db77 Aug 13 2026, 02:40 PM

Beta wallet enforces AAuth -11 §10.3: a signed request carrying a body
to a PS or AS endpoint must cover content-digest (RFC 9530) and
content-type in addition to the four mandatory components, and rejects
anything else with "signature must cover content-digest on requests
with a body". Several body-carrying POSTs here (bootstrap, refresh,
notes authorize, the sig-fetch helpers, the notes API helper) signed
only ("@method" "@authority" "@path" "content-type" "signature-key")
or the bare four.

Replace the twelve ad-hoc component arrays with SIGNED_COMPONENTS /
SIGNED_COMPONENTS_WITH_BODY plus a signedComponents(hasBody) helper —
the same pattern web-agent-demo adopted in 97abdc8 — so the
body/digest pairing can't drift again. @hellocoop/httpsig derives and
sends the Content-Digest header itself whenever 'content-digest' is in
the component list, hashing the exact body string handed to it. Six
body-bearing sites now cover content-type + content-digest; six
bodyless sites keep the four-component list. Log-display mock headers
updated to match what actually goes on the wire.

public/protocol.js regenerated via npm run build:client.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQ2FCHHAnuJWF5TJB3838S
@dickhardt
dickhardt merged commit fde59cf 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