Skip to content

AAuth -11: five access modes, person token before resource token, Ed25519 - #6

Merged
dickhardt merged 3 commits into
mainfrom
aauth-11/wp16-registry
Aug 13, 2026
Merged

AAuth -11: five access modes, person token before resource token, Ed25519#6
dickhardt merged 3 commits into
mainfrom
aauth-11/wp16-registry

Conversation

@dickhardt

@dickhardt dickhardt commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

The registry learns the five -11 access modes, and its login flow gets the protocol fix it was missing: a person token before a resource token.

What changed

access_mode — five values, not four

  • aauth-access-token renamed session-token — the credential a resource issues for its own consumption, returned in AAuth-Access.
  • Added person-token and R3's per-call. The set is now the five values of the AAuth Access Mode Value Registry, which -11 established with a Specification Required policy.
  • DEFAULT_ACCESS_MODE extracted (agent-token, the spec default) so the two places that fall back to it cannot drift.

Validation scope, deliberately. validate.ts keeps rejecting an access_mode outside the list. The field is an open registry, so this is the registry service choosing what its directory will list — an editorial call on the registry-curation side. It does not reach agent behaviour: nothing on the agent-provider (ap.ts), agent-token (agent-token.ts) or web-agent (client) paths reads access_mode, and the UI renders an unknown value verbatim rather than treating it as an error. Documented at each site.

Person token before resource token — the substantive protocol fix

-11 §Resource Access: "A resource MUST have verified a person token before it issues a resource token, and MUST challenge with requirement=person-token when it has not." Only a PS can redeem a resource token, so one issued to an agent that cannot name a person is unredeemable. The login flow was minting resource tokens straight off an agent token — which is both wrong and the reason it had nothing to put in ps/sub/person_token_jti.

The flow is now three rungs, at /auth/identity and POST /resources alike:

agent token   → 401 AAuth-Requirement: requirement=person-token
person token  → verified, then 401 requirement=auth-token + resource token
auth token    → verified; identity read, session set

verifyPersonToken() implements §Person Token Verification: typ: aa-person+jwt, dwk: aauth-person.json, JWKS at {iss}/.well-known/{dwk} with the byte-equal issuer check, aud = our ORIGIN, cnf.jwk = the key that signed the request, plus exp/iat, sub, jti, and the MUST NOT on scope/account. Failures are 400 invalid_person_token. Payload checks run before the signature step, so a structurally invalid token never causes an outbound fetch to a host it names. The resource token now carries ps, sub and person_token_jti copied from that token, and still no agent claim.

A person token alone does not attribute a submission: it is a directed (iss, sub) with no scope, so it cannot release the verified email and name an entry is credited with. It earns the resource token; the auth token carries the claims.

This does not change the registry's own access_mode — listing still requires only an agent token.

Signature algorithms

-11 requires a fully-specified alg everywhere and forbids the polymorphic EdDSA.

  • New SIGNING_ALG = 'Ed25519', used by the resource token (login.ts), the agent token (ap.ts) and the session cookie (session.ts). The last is this service's own format, not an AAuth token type, but it shares the key and the verifier, so it moves too.
  • verifyJWT no longer accepts EdDSA: the entry is gone from JWT_ALG_PARAMS, so such a JWT fails with unsupported alg: EdDSA. A JWKS key whose own algorithm contradicts the header is no longer a candidate.
  • The two alg strips before crypto.subtle.importKey stay exactly as they were. workerd rejects an OKP JWK whose alg is "Ed25519", and the algorithm is passed explicitly via the import parameters. Emit Ed25519, strip before import — opposite ends of the same pipe.

Client and display

client/registry.js (and the rebuilt public/registry.js): the browser web-agent climbs the same ladder — person token from the PS's person_token_endpoint, then the resource token, then the auth token from auth_token_endpoint. Either PS step can defer with a 202, so the saved pending state records which rung deferred and resumePending() picks up from there.

discoverability.ts, client and README describe all five values, with the note that an agent meeting an unrecognized value proceeds as with no declaration, and that a resource may state a mode per operation via an R3 operation access annotation in its own vocabulary.

scripts/test.sh asserts the published JWK carries alg: "Ed25519".

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 login entirely. Every aa-auth+jwt and aa-person+jwt person.hello.coop issues today carries alg: EdDSA, and JWT_ALG_PARAMS no longer has an entry for it. Nobody could sign in.
  • The registry lists the resources the rest of this wave changes. Its directory schema now admits person-token and per-call — the modes hellocoop/aauth-proxy starts publishing in this same wave. Merging the fleet first means 38 resources publish a mode this service rejects at validation.
  • 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 signed in to the registry signs in again, through a login flow that now has an extra rung and can defer twice.

The wave's larger cost lands in hellocoop/aauth-proxy: R3 documents drop version, so every hash changes and every grant keyed by hash is dead (re-consent); and 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. The blocker is recorded in the code.

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.
  • 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 either strip passes CI and fails on deploy.
  • The signed happy path under wrangler dev. This is the repo that found it: a [[routes]] custom_domain entry makes the dev server hand the Worker a URL whose host is the production hostname, so @authority never matches what the client signed. httpsig then reports verified: false with no error string, which reads like a bad key rather than a host mismatch. It cost several wasted rounds and it is written up in CLAUDE.md. It affects notes, whoami, playground, playground-popup, shipper, www and 20 of the aauth-proxy fleet's wrangler.toml files — effectively everything.
  • Nothing has run against a live PS, because none issues -11 person tokens yet.

What a reviewer should check

  • No verifier accepts EdDSA. (Checked: gone from JWT_ALG_PARAMS.)
  • No API-serving path accepts 'person'. The three-rung ladder means /auth/identity and POST /resources accept a person token only to issue a resource token, never to serve the request. Confirm the rungs cannot be short-circuited.
  • Both alg-strips before crypto.subtle.importKey are intactimportSigningKey and verifyJWT.
  • fullySpecifiedAlg in src/crypto.ts opens with if (jwk.alg && jwk.alg !== 'EdDSA') return jwk.alg. That is fine as an emit-side normalizer, which is all it is used for, but note it does not enforce -10's verifier MUST that a key's kty/crv agree with its alg — a JWK claiming alg: 'ES256' on an OKP/Ed25519 key would pass through. The verify path handles this separately by dropping candidates whose algorithm contradicts the header; worth confirming that is the only path that matters here.
  • Payload checks precede the signature step, so a hostile iss never drives an outbound fetch.

…5519

access_mode (types.ts, store.ts, validate.ts):
- `aauth-access-token` renamed `session-token` — the credential a resource
  issues for its own consumption, returned in `AAuth-Access`.
- Added `person-token` and R3's `per-call`. The set is now the five values of
  the AAuth Access Mode Value Registry, which -11 established with a
  Specification Required policy.
- Extracted DEFAULT_ACCESS_MODE ('agent-token', the spec default) so the two
  places that fall back to it cannot drift.

Validation scope: validate.ts keeps rejecting an access_mode outside the list,
deliberately. The field is an open registry, so this is the registry service
choosing what its directory will list — an editorial call, on the
registry-curation side. It does not reach agent behaviour: nothing on the
agent-provider (ap.ts), agent-token (agent-token.ts), or web-agent (client)
paths reads access_mode, and the UI renders an unknown value verbatim rather
than treating it as an error. Documented at each site.

Person token before resource token (login.ts) — the substantive protocol fix.
-11 §Resource Access: "A resource MUST have verified a person token before it
issues a resource token, and MUST challenge with `requirement=person-token`
when it has not." Only a PS can redeem a resource token, so one issued to an
agent that cannot name a person is unredeemable. The login flow was minting
resource tokens straight off an agent token, which is both wrong and the
reason it had nothing to put in `ps`/`sub`/`person_token_jti`.

The flow is now three rungs, at /auth/identity and POST /resources alike:
  agent token  → 401 `AAuth-Requirement: requirement=person-token`
  person token → verified, then 401 `requirement=auth-token` + resource token
  auth token   → verified; identity read, session set

verifyPersonToken() implements §Person Token Verification: `typ`
`aa-person+jwt`, `dwk` `aauth-person.json`, JWKS at `{iss}/.well-known/{dwk}`
with the byte-equal issuer check, `aud` = our ORIGIN, `cnf.jwk` = the key that
signed the request, plus exp/iat, sub, jti, and the MUST NOT on scope/account.
Failures are `400 invalid_person_token`. Payload checks run before the
signature step so a structurally invalid token never causes an outbound fetch
to a host it names. The resource token now carries `ps`, `sub` and
`person_token_jti` copied from that token, and still no `agent` claim.

A person token alone does not attribute a submission: it is a directed
`(iss, sub)` with no scope, so it cannot release the verified email and name
an entry is credited with. It earns the resource token; the auth token
carries the claims.

This does not change the registry's own access_mode — listing still requires
only an agent token.

Signature algorithms (crypto.ts and its three callers): -11 requires a
fully-specified `alg` everywhere and forbids the polymorphic `EdDSA`.
- New SIGNING_ALG = 'Ed25519', used by the resource token (login.ts), the
  agent token (ap.ts), and the session cookie (session.ts). The last is this
  service's own format, not an AAuth token type, but it shares the key and
  the verifier, so it moves too.
- verifyJWT no longer accepts `EdDSA`: the entry is gone from JWT_ALG_PARAMS,
  so such a JWT fails with `unsupported alg: EdDSA`. A JWKS key whose own
  algorithm contradicts the header is no longer a candidate.
- The two `alg` strips before crypto.subtle.importKey stay exactly as they
  were. workerd rejects an OKP JWK whose alg is "Ed25519", and the algorithm
  is passed explicitly via the import parameters. Emit Ed25519, strip before
  import — opposite ends of the same pipe.
- Flag-day blocker recorded in the code: HelloCoop/Wallet/svr/issuer/sign.js:32
  still signs every AAuth token with `EdDSA`, and must ship `Ed25519` in the
  same window or auth tokens stop verifying here.

Client (client/registry.js, rebuilt public/registry.js): the browser web-agent
climbs the same ladder — person token from the PS's `person_token_endpoint`,
then the resource token, then the auth token from `auth_token_endpoint`.
Either PS step can defer with a 202, so the saved pending state records which
rung deferred and resumePending() picks up from there.

Display (discoverability.ts, client, README): all five values described, with
the note that an agent meeting an unrecognized value proceeds as with no
declaration, and that a resource may state a mode per operation via an R3
operation access annotation in its own vocabulary.

scripts/test.sh: assert the published JWK carries alg "Ed25519".

CLAUDE.md: the login ladder, and why signed requests fail under plain
`wrangler dev` (the custom_domain route rewrites the authority the Worker
sees, and httpsig reports the mismatch with no error string).

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
registry-aauth-dev f33585c Aug 13 2026, 03:07 PM

dickhardt and others added 2 commits August 13, 2026 15:37
Main's hotfix 6ffbc6b (fully-specified alg Ed25519 for signing and
verification) is subsumed by this branch: SIGNING_ALG = 'Ed25519' in
src/crypto.ts stamps every minted JWT header, JWT_ALG_PARAMS accepts
Ed25519 (and deliberately drops EdDSA per AAuth -11 §Signature
Algorithms), and alg/key_ops/ext are stripped before every WebCrypto
importKey. httpsig 2.0.1 (workerd importKey fix, af364ff) was already
an ancestor. All four conflicts resolved to the branch side; the merge
result is tree-identical to the branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQ2FCHHAnuJWF5TJB3838S
Bump @hellocoop/httpsig to ^2.2.0 and apply AAuth -11 §10.3: body-carrying
requests to token-issuing endpoints MUST cover content-digest and
content-type; resources are exempt.

- client/registry.js: drop the hand-rolled component lists in signedFetch
  and bootstrap — httpsig 2.2's defaults are the same lists, and its
  contentDigest: 'auto' default appends content-digest for string bodies.
  That makes the browser agent's POSTs to PS token endpoints (person_token,
  auth_token) §10.3-compliant. public/registry.js rebuilt.
- src/ap.ts: /bootstrap mints agent tokens, so verifySigHwk now passes
  requireContentDigest: true.
- src/agent-token.ts: the /resources API stays exempt (resource role) —
  verification unchanged — but the with-body Accept-Signature challenge
  now advertises content-digest + content-type, matching what a 2.2
  client signs by default.
- src/login.ts: unchanged — GET /auth/identity is bodyless and POST
  /resources is the resource role.

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