Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ registry-aauth-dev → Deployments).
- `npm run typecheck` — `tsc --noEmit`.
- `bash scripts/test.sh [base_url]` — curl smoke tests.

**Signed requests fail under plain `wrangler dev`, and it is not your
signature.** The `[[routes]] custom_domain` entry makes the dev server hand
the Worker a URL whose host is `registry.aauth.dev`, so `new URL(c.req.url)
.host` — what every handler passes to httpsig as `authority` — does not match
the `@authority` the client signed (`localhost:PORT`). httpsig reports
`verified: false` with **no** `error` string, which reads like a bad key. To
test signed flows locally, run with a config copy that drops the `[[routes]]`
block, and set `ORIGIN` to `http://localhost:PORT` so `aud` checks line up
(`.dev.vars` overrides `[vars]`, so set it there).

## Architecture quick ref

- Cloudflare Worker (`src/index.ts`, Hono). Plays the AAuth **resource**
Expand Down Expand Up @@ -61,7 +71,7 @@ registry-aauth-dev → Deployments).
| `GET /.well-known/jwks.json` | none | Public key (shared by both roles) |
| `GET /robots.txt` · `/sitemap.xml` · `/llms.txt` | none | Discoverability — keep in sync with routes |
| `POST /bootstrap` | sig=hwk | Mint a browser web-agent token (AP role); `ps` defaults to Hellō |
| `GET /auth/identity` | agent/auth token | Login: agent token → resource_token challenge; auth token → set session |
| `GET /auth/identity` | agent/person/auth token | Login ladder: agent token → 401 `requirement=person-token`; person token → 401 `requirement=auth-token` + resource_token; auth token → set session |
| `GET /auth/session` | session cookie | Current human session or `{logged_in:false}` |
| `POST /auth/logout` | — | Clear session cookie |
| `GET /resources` | agent token | List (R2 index, ETag) |
Expand All @@ -74,6 +84,17 @@ registry-aauth-dev → Deployments).
(signed session cookie). Server side done; the browser UI (`public/`, ported
from playground's client) is still to come.

**Person token first.** AAuth -11: 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 that names no person is unredeemable. `src/login.ts` verifies
the person token (`typ: aa-person+jwt`, `dwk: aauth-person.json`, JWKS at
`{iss}/.well-known/{dwk}`, `aud` = our ORIGIN, `cnf.jwk` = the request signing
key) and copies its `iss`/`sub`/`jti` into the resource token's
`ps`/`sub`/`person_token_jti`. A bad one is `400 invalid_person_token`. This
does not change the registry's own `access_mode`: listing still needs only an
agent token.

## Cloudflare setup (one-time, outside this repo)

- KV namespace → put its id in `wrangler.toml` (`REGISTRY_KV`).
Expand Down
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,28 @@ discovery convenience, never a gatekeeper.
Body: `{ "issuer": "https://notes.aauth.dev" }`. The registry fetches **only**
`https://{host}/.well-known/aauth-resource.json` (no redirects, timeout, size
cap), and requires `issuer === https://{host}` (anti-spoof — proves control of
the host), a present `description`, and a valid `access_mode`. It caches the
resource's `name`, `description`, `access_mode`, `logo_uri`, and the submitting
agent (`submitted_by`).
the host), a present `description`, and an `access_mode` the registry lists
(or none — the default is `agent-token`). It caches the resource's `name`,
`description`, `access_mode`, `documentation_uri`, and the submitting agent
(`submitted_by`).

Responses: `201 added`, `200 already_present`, `422 metadata_invalid`.

### `access_mode`

The registry lists the five registered values: `agent-token`, `person-token`,
`session-token`, `auth-token`, and `per-call` (defined by AAuth R3). The field
is an IANA registry with a Specification Required policy, so more values can
appear; the registry declining to list an unregistered one is an editorial
choice about this directory, not protocol behaviour. **Agents must not copy
it:** an agent meeting an `access_mode` it does not recognize proceeds as it
would with no declaration — it calls the resource and reads the
`AAuth-Requirement` it gets back.

The value here describes the resource as a whole. A resource may also state a
mode per operation, as an R3 operation access annotation on the operation in
its own vocabulary; read the resource's vocabulary for that detail.

## Develop

```bash
Expand Down
173 changes: 125 additions & 48 deletions client/registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,17 @@
//
// The page is an AAuth web-agent. It bootstraps an agent token (signed
// sig=hwk with a key held in IndexedDB), lists resources (sig=jwt with the
// agent token), and — to add a resource — runs the auth-token flow against
// the person's Person Server (Hellō): the registry challenges, the human
// approves at the PS (the interaction), and the resulting auth_token proves
// a verified identity. Bundled to public/registry.js by `npm run build:client`.
// agent token), and — to add a resource or log in — climbs the AAuth ladder
// against the person's Person Server (Hellō):
//
// agent token → registry answers 401 requirement=person-token
// person token → from the PS's person_token_endpoint; registry verifies it
// and answers 401 requirement=auth-token + resource token
// auth token → from the PS's auth_token_endpoint, given that resource
// token; proves a verified identity to the registry
//
// The person approves at the PS (the interaction), which either step may
// defer to with a 202. Bundled to public/registry.js by `npm run build:client`.

import { fetch as sigFetch } from '@hellocoop/httpsig'

Expand Down Expand Up @@ -76,13 +83,14 @@ async function publicJwk(kp) {
}

// sig=jwt call (agent_token or auth_token), with optional body + headers.
// No explicit component list: httpsig 2.2's defaults are exactly the lists
// this used to hand-roll, and its contentDigest: 'auto' default appends
// content-digest for our string bodies — which §10.3 requires on the
// body-carrying calls this makes to PS token endpoints.
async function signedFetch(url, { method = 'GET', body, jwt, headers = {} } = {}) {
const kp = await getKeyPair()
const pub = await publicJwk(kp)
const hasBody = body != null
const components = hasBody
? ['@method', '@authority', '@path', 'content-type', 'signature-key']
: ['@method', '@authority', '@path', 'signature-key']
// sigFetch returns a plain Response unless returnSent is set.
return sigFetch(url, {
method,
Expand All @@ -91,22 +99,23 @@ async function signedFetch(url, { method = 'GET', body, jwt, headers = {} } = {}
signingKey: pub,
signingCryptoKey: kp.privateKey,
signatureKey: { type: 'jwt', jwt },
components,
})
}

// POST /bootstrap signed sig=hwk → mint + store an agent token.
async function bootstrap() {
const kp = await getKeyPair()
const pub = await publicJwk(kp)
// Default components + contentDigest: 'auto' cover content-digest and
// content-type over the JSON body, which /bootstrap requires (it mints
// tokens, so it enforces §10.3 coverage).
const response = await sigFetch(`${ORIGIN}/bootstrap`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ps: PS_DEFAULT }),
signingKey: pub,
signingCryptoKey: kp.privateKey,
signatureKey: { type: 'hwk' },
components: ['@method', '@authority', '@path', 'content-type', 'signature-key'],
})
const data = await response.json()
if (!response.ok || !data.agent_token) throw new Error(data.error || 'bootstrap failed')
Expand Down Expand Up @@ -160,59 +169,92 @@ function parseRequirement(h) {
return out
}

// Begin the auth-token flow. Returns { authToken } if consent was cached
// (the PS answered 200). If consent is needed (202), saves `pending` plus
// the poll URL and redirects this page to Hellō — returns { redirecting:true }
// and the caller should stop (the page is navigating away).
async function startAuthFlow(pending) {
let agentToken = await ensureAgentToken()
// A 202 from the PS means the person has to approve at Hellō first. Save the
// pending action plus the poll URL and redirect this page there; on return
// resumePending() polls for the token and picks the flow back up. `stage`
// records which token we were waiting for, since either step can defer.
async function deferToPersonServer(psRes, psMeta, pending, stage) {
const body = await psRes.json().catch(() => ({}))
const req = parseRequirement(psRes.headers.get('aauth-requirement'))
const interactionUrl = req.url || body.url || psMeta.interaction_endpoint
const code = req.code || body.code
const pollUrl = new URL(psRes.headers.get('location') || body.location, PS_DEFAULT).toString()

// Get the resource_token challenge. If the agent token is stale/rejected
// (no challenge header on the 401), re-bootstrap once and retry.
const challenge = async () => {
const res = await signedFetch(`${ORIGIN}/auth/identity`, { jwt: agentToken })
return res.status === 401 ? parseRequirement(res.headers.get('aauth-requirement'))['resource-token'] : null
}
let resourceToken = await challenge()
if (!resourceToken) {
await bootstrap()
agentToken = getAgentToken()
resourceToken = await challenge()
savePending({ ...pending, stage, pollUrl })
window.location.href = `${interactionUrl}?code=${encodeURIComponent(code)}&callback=${encodeURIComponent(ORIGIN + '/')}`
return { redirecting: true }
}

// Step 1 of the climb: a person token for this resource, from the PS's
// person_token_endpoint. It names the person to the registry and is what the
// registry must verify before it will issue a resource token at all.
async function obtainPersonToken(agentToken, psMeta, pending) {
if (!psMeta.person_token_endpoint) throw new Error('PS publishes no person_token_endpoint')
const res = await signedFetch(psMeta.person_token_endpoint, {
method: 'POST',
jwt: agentToken,
body: JSON.stringify({ resource: ORIGIN }),
})
if (res.status === 200) {
const body = await res.json()
if (!body.person_token) throw new Error('PS returned no person_token')
return { personToken: body.person_token }
}
if (!resourceToken) throw new Error('no resource_token in challenge')
if (res.status === 202) return deferToPersonServer(res, psMeta, pending, 'person')
throw new Error(`PS person token endpoint ${res.status}`)
}

const psMeta = await (await fetch(`${PS_DEFAULT}/.well-known/aauth-person.json`)).json()
const psRes = await signedFetch(psMeta.token_endpoint, {
// Step 2: present the person token to the registry and read the resource
// token out of its 401 auth-token challenge.
async function resourceTokenFor(personToken) {
const res = await signedFetch(`${ORIGIN}/auth/identity`, { jwt: personToken })
if (res.status !== 401) throw new Error(`expected an auth-token challenge, got ${res.status}`)
const rt = parseRequirement(res.headers.get('aauth-requirement'))['resource-token']
if (!rt) throw new Error('no resource_token in challenge')
return rt
}

// Step 3: take the resource token to the PS for an auth token.
async function obtainAuthToken(agentToken, psMeta, resourceToken, pending) {
const res = await signedFetch(psMeta.auth_token_endpoint, {
method: 'POST',
jwt: agentToken,
body: JSON.stringify({ resource_token: resourceToken, capabilities: ['interaction'], prompt: 'consent' }),
})

if (psRes.status === 200) {
const body = await psRes.json()
if (res.status === 200) {
const body = await res.json()
if (!body.auth_token) throw new Error('PS returned no auth_token')
return { authToken: body.auth_token }
}
if (psRes.status !== 202) throw new Error(`PS token endpoint ${psRes.status}`)
if (res.status === 202) return deferToPersonServer(res, psMeta, pending, 'auth')
throw new Error(`PS auth token endpoint ${res.status}`)
}

const body = await psRes.json().catch(() => ({}))
const req = parseRequirement(psRes.headers.get('aauth-requirement'))
const interactionUrl = req.url || body.url || psMeta.interaction_endpoint
const code = req.code || body.code
const pollUrl = new URL(psRes.headers.get('location') || body.location, PS_DEFAULT).toString()
// Walk the whole ladder: agent token → person token → resource token → auth
// token. Returns { authToken }, or { redirecting:true } if the PS deferred to
// the person at either step (the page is navigating away; stop).
async function startAuthFlow(pending) {
const agentToken = await ensureAgentToken()
const psMeta = await (await fetch(`${PS_DEFAULT}/.well-known/aauth-person.json`)).json()

savePending({ ...pending, pollUrl })
// Redirect this page to Hellō; the PS sends us back to ORIGIN/ after approval.
window.location.href = `${interactionUrl}?code=${encodeURIComponent(code)}&callback=${encodeURIComponent(ORIGIN + '/')}`
return { redirecting: true }
const person = await obtainPersonToken(agentToken, psMeta, pending)
if (person.redirecting) return person

return finishAfterPersonToken(person.personToken, agentToken, psMeta, pending)
}

async function finishAfterPersonToken(personToken, agentToken, psMeta, pending) {
const resourceToken = await resourceTokenFor(personToken)
return obtainAuthToken(agentToken, psMeta, resourceToken, pending)
}

async function pollForAuthToken(pollUrl, agentToken, maxCycles = 40) {
// Poll a PS deferred-response URL for whichever token we are waiting on.
async function pollForToken(pollUrl, agentToken, field, maxCycles = 40) {
for (let i = 0; i < maxCycles; i++) {
const res = await signedFetch(pollUrl, { jwt: agentToken, headers: { Prefer: 'wait=30' } })
if (res.status === 200) {
const body = await res.json()
if (body.auth_token) return body.auth_token
if (body[field]) return body[field]
} else if (res.status === 403 || res.status === 404 || res.status === 408) {
throw new Error(`consent ${res.status}`)
}
Expand All @@ -237,7 +279,9 @@ async function completeWithAuthToken(authToken, pending) {
return res.json()
}

// On load, if we're returning from a Hellō redirect, finish the flow.
// On load, if we're returning from a Hellō redirect, finish the flow. Either
// rung can defer, so pick up from the one that did: a person token still has
// the resource-token and auth-token steps ahead of it.
async function resumePending() {
const pending = loadPending()
if (!pending) return false
Expand All @@ -247,8 +291,17 @@ async function resumePending() {
try {
const agentToken = getAgentToken()
if (!agentToken) throw new Error('agent token missing after redirect')
const authToken = await pollForAuthToken(pending.pollUrl, agentToken)
await completeWithAuthToken(authToken, pending)

if (pending.stage === 'person') {
const personToken = await pollForToken(pending.pollUrl, agentToken, 'person_token')
const psMeta = await (await fetch(`${PS_DEFAULT}/.well-known/aauth-person.json`)).json()
const r = await finishAfterPersonToken(personToken, agentToken, psMeta, pending)
if (r.redirecting) return true // deferred again at the auth-token step
await completeWithAuthToken(r.authToken, pending)
} else {
const authToken = await pollForToken(pending.pollUrl, agentToken, 'auth_token')
await completeWithAuthToken(authToken, pending)
}
} catch (err) {
console.error('resume failed', err)
}
Expand Down Expand Up @@ -290,6 +343,30 @@ const logout = () => fetch(`${ORIGIN}/auth/logout`, { method: 'POST' }).then(()
const $ = (id) => document.getElementById(id)
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]))

// What each access_mode means, for the badge tooltip. `access_mode` is an
// IANA registry (Specification Required), so this map is open-ended by
// design: an unknown value is shown verbatim with a neutral tooltip, never
// hidden or flagged as an error. This page is a web agent, and an agent that
// meets a value it does not recognize proceeds as it would with no
// declaration — calls the resource and reads the AAuth-Requirement it gets
// back. Nothing here branches on the value.
const ACCESS_MODE_TITLES = {
'agent-token': 'Authorizes on the agent’s identity alone',
'person-token': 'Authorizes on the person’s identity alone',
'session-token': 'Runs its own authorization and issues a session token',
'auth-token': 'Needs an auth token from your person server',
'per-call': 'Authorizes each call individually, against that call’s parameters',
}

const accessModeTitle = (mode) =>
ACCESS_MODE_TITLES[mode] ??
(mode
? `Access mode “${mode}” — not one this page knows; agents call the resource and read its AAuth-Requirement`
: 'No access mode declared — defaults to agent-token')

// The mode a resource declares covers the resource as a whole. A resource may
// also state a mode per operation, as an R3 operation access annotation in its
// own vocabulary; those are read from the resource, not from this registry.
function renderResources(index) {
const list = $('resources')
const items = index.resources || []
Expand All @@ -303,7 +380,7 @@ function renderResources(index) {
<div class="card">
<div class="card-head">
<a class="name" href="${esc(r.issuer)}" target="_blank" rel="noopener">${esc(r.name)}</a>
<span class="badge">${esc(r.access_mode)}</span>
<span class="badge" title="${esc(accessModeTitle(r.access_mode))}">${esc(r.access_mode || 'agent-token')}</span>
</div>
<p class="desc">${esc(r.description)}</p>
<div class="host">${esc(r.issuer)}</div>
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"test:deploy": "bash scripts/test.sh"
},
"dependencies": {
"@hellocoop/httpsig": "^2.0.0",
"@hellocoop/httpsig": "^2.2.0",
"hono": "^4.7.0"
},
"devDependencies": {
Expand Down
Loading