diff --git a/CLAUDE.md b/CLAUDE.md
index cdb2570..5a7e937 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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**
@@ -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) |
@@ -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`).
diff --git a/README.md b/README.md
index cdc9c9b..be195e5 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/client/registry.js b/client/registry.js
index 6a7713d..1a78846 100644
--- a/client/registry.js
+++ b/client/registry.js
@@ -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'
@@ -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,
@@ -91,7 +99,6 @@ async function signedFetch(url, { method = 'GET', body, jwt, headers = {} } = {}
signingKey: pub,
signingCryptoKey: kp.privateKey,
signatureKey: { type: 'jwt', jwt },
- components,
})
}
@@ -99,6 +106,9 @@ async function signedFetch(url, { method = 'GET', body, jwt, headers = {} } = {}
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' },
@@ -106,7 +116,6 @@ async function bootstrap() {
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')
@@ -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}`)
}
@@ -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
@@ -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)
}
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 || []
@@ -303,7 +380,7 @@ function renderResources(index) {
${esc(r.name)}
-
${esc(r.access_mode)}
+
${esc(r.access_mode || 'agent-token')}
${esc(r.description)}
${esc(r.issuer)}
diff --git a/package-lock.json b/package-lock.json
index 6559f71..ce4c850 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,7 +8,7 @@
"name": "registry.aauth.dev",
"version": "0.1.0",
"dependencies": {
- "@hellocoop/httpsig": "^2.0.0",
+ "@hellocoop/httpsig": "^2.2.0",
"hono": "^4.7.0"
},
"devDependencies": {
@@ -603,9 +603,9 @@
}
},
"node_modules/@hellocoop/httpsig": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/@hellocoop/httpsig/-/httpsig-2.0.1.tgz",
- "integrity": "sha512-nmAI+A3YQKOfeZ9cnKjtNXVVJ6sUwazwhM0zQV/t8XpbMKJdYoj3IfaTph9LUMlAvBlDN70wJ2jnUYOr0GQW6g==",
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@hellocoop/httpsig/-/httpsig-2.2.0.tgz",
+ "integrity": "sha512-UdWonQL79Nb/NmY5YKSmcdaC/Drjx5lINixdDk8yr2WN93q2zmAV28U/aV5YW35fZWu3n3G0cIavygNKoUPekA==",
"license": "MIT",
"engines": {
"node": ">=18"
diff --git a/package.json b/package.json
index 91abac1..a5b1199 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/public/registry.js b/public/registry.js
index acec111..cbf84ec 100644
--- a/public/registry.js
+++ b/public/registry.js
@@ -248,15 +248,17 @@
function validateJwk(jwk) {
determineAlgorithm(jwk);
}
+ function withoutAlg(jwk) {
+ const { alg: _alg, ...rest } = jwk;
+ return rest;
+ }
async function importPrivateKey(jwk) {
const algorithm = determineAlgorithm(jwk);
- return await crypto.subtle.importKey("jwk", jwk, algorithm, false, ["sign"]);
+ return await crypto.subtle.importKey("jwk", withoutAlg(jwk), algorithm, false, ["sign"]);
}
async function importPublicKey(jwk) {
const algorithm = determineAlgorithm(jwk);
- return await crypto.subtle.importKey("jwk", jwk, algorithm, false, [
- "verify"
- ]);
+ return await crypto.subtle.importKey("jwk", withoutAlg(jwk), algorithm, false, ["verify"]);
}
function getPublicJwk(privateJwk) {
const { d, p, q, dp, dq, qi, ...publicJwk2 } = privateJwk;
@@ -348,6 +350,612 @@
}
});
+ // node_modules/@hellocoop/httpsig/dist/vendor/structured-headers/types.js
+ var require_types2 = __commonJS({
+ "node_modules/@hellocoop/httpsig/dist/vendor/structured-headers/types.js"(exports) {
+ "use strict";
+ Object.defineProperty(exports, "__esModule", { value: true });
+ exports.ByteSequence = void 0;
+ var ByteSequence = class {
+ base64Value;
+ constructor(base64Value) {
+ this.base64Value = base64Value;
+ }
+ toBase64() {
+ return this.base64Value;
+ }
+ };
+ exports.ByteSequence = ByteSequence;
+ }
+ });
+
+ // node_modules/@hellocoop/httpsig/dist/vendor/structured-headers/util.js
+ var require_util = __commonJS({
+ "node_modules/@hellocoop/httpsig/dist/vendor/structured-headers/util.js"(exports) {
+ "use strict";
+ Object.defineProperty(exports, "__esModule", { value: true });
+ exports.isAscii = isAscii;
+ exports.isValidTokenStr = isValidTokenStr;
+ exports.isValidKeyStr = isValidKeyStr;
+ exports.isInnerList = isInnerList;
+ exports.isByteSequence = isByteSequence;
+ var asciiRe = /^[\x20-\x7E]*$/;
+ var tokenRe = /^[a-zA-Z*][:/!#$%&'*+\-.^_`|~A-Za-z0-9]*$/;
+ var keyRe = /^[a-z*][*\-_.a-z0-9]*$/;
+ function isAscii(str) {
+ return asciiRe.test(str);
+ }
+ function isValidTokenStr(str) {
+ return tokenRe.test(str);
+ }
+ function isValidKeyStr(str) {
+ return keyRe.test(str);
+ }
+ function isInnerList(input) {
+ return Array.isArray(input[0]);
+ }
+ function isByteSequence(input) {
+ return typeof input === "object" && "base64Value" in input;
+ }
+ }
+ });
+
+ // node_modules/@hellocoop/httpsig/dist/vendor/structured-headers/token.js
+ var require_token = __commonJS({
+ "node_modules/@hellocoop/httpsig/dist/vendor/structured-headers/token.js"(exports) {
+ "use strict";
+ Object.defineProperty(exports, "__esModule", { value: true });
+ exports.Token = void 0;
+ var util_js_1 = require_util();
+ var Token = class {
+ value;
+ constructor(value) {
+ if (!(0, util_js_1.isValidTokenStr)(value)) {
+ throw new TypeError("Invalid character in Token string. Tokens must start with *, A-Z and the rest of the string may only contain a-z, A-Z, 0-9, :/!#$%&'*+-.^_`|~");
+ }
+ this.value = value;
+ }
+ toString() {
+ return this.value;
+ }
+ };
+ exports.Token = Token;
+ }
+ });
+
+ // node_modules/@hellocoop/httpsig/dist/vendor/structured-headers/parser.js
+ var require_parser = __commonJS({
+ "node_modules/@hellocoop/httpsig/dist/vendor/structured-headers/parser.js"(exports) {
+ "use strict";
+ Object.defineProperty(exports, "__esModule", { value: true });
+ exports.ParseError = void 0;
+ exports.parseDictionary = parseDictionary;
+ exports.parseList = parseList;
+ exports.parseItem = parseItem;
+ var types_js_1 = require_types2();
+ var token_js_1 = require_token();
+ var util_js_1 = require_util();
+ function parseDictionary(input) {
+ const parser = new Parser(input);
+ return parser.parseDictionary();
+ }
+ function parseList(input) {
+ const parser = new Parser(input);
+ return parser.parseList();
+ }
+ function parseItem(input) {
+ const parser = new Parser(input);
+ return parser.parseItem();
+ }
+ var ParseError = class extends Error {
+ constructor(position, message) {
+ super(`Parse error: ${message} at offset ${position}`);
+ }
+ };
+ exports.ParseError = ParseError;
+ var Parser = class {
+ input;
+ pos;
+ constructor(input) {
+ this.input = input;
+ this.pos = 0;
+ }
+ parseDictionary() {
+ this.skipWS();
+ const dictionary = /* @__PURE__ */ new Map();
+ while (!this.eof()) {
+ const thisKey = this.parseKey();
+ let member;
+ if (this.lookChar() === "=") {
+ this.pos++;
+ member = this.parseItemOrInnerList();
+ } else {
+ member = [true, this.parseParameters()];
+ }
+ dictionary.set(thisKey, member);
+ this.skipOWS();
+ if (this.eof()) {
+ return dictionary;
+ }
+ this.expectChar(",");
+ this.pos++;
+ this.skipOWS();
+ if (this.eof()) {
+ throw new ParseError(this.pos, "Dictionary contained a trailing comma");
+ }
+ }
+ return dictionary;
+ }
+ parseList() {
+ this.skipWS();
+ const members = [];
+ while (!this.eof()) {
+ members.push(this.parseItemOrInnerList());
+ this.skipOWS();
+ if (this.eof()) {
+ return members;
+ }
+ this.expectChar(",");
+ this.pos++;
+ this.skipOWS();
+ if (this.eof()) {
+ throw new ParseError(this.pos, "A list may not end with a trailing comma");
+ }
+ }
+ return members;
+ }
+ parseItem(standaloneItem = true) {
+ if (standaloneItem)
+ this.skipWS();
+ const result = [this.parseBareItem(), this.parseParameters()];
+ if (standaloneItem)
+ this.checkTrail();
+ return result;
+ }
+ parseItemOrInnerList() {
+ if (this.lookChar() === "(") {
+ return this.parseInnerList();
+ } else {
+ return this.parseItem(false);
+ }
+ }
+ parseInnerList() {
+ this.expectChar("(");
+ this.pos++;
+ const innerList = [];
+ while (!this.eof()) {
+ this.skipWS();
+ if (this.lookChar() === ")") {
+ this.pos++;
+ return [innerList, this.parseParameters()];
+ }
+ innerList.push(this.parseItem(false));
+ const nextChar = this.lookChar();
+ if (nextChar !== " " && nextChar !== ")") {
+ throw new ParseError(this.pos, "Expected a whitespace or ) after every item in an inner list");
+ }
+ }
+ throw new ParseError(this.pos, "Could not find end of inner list");
+ }
+ parseBareItem() {
+ const char = this.lookChar();
+ if (char === void 0) {
+ throw new ParseError(this.pos, "Unexpected end of string");
+ }
+ if (char.match(/^[-0-9]/)) {
+ return this.parseIntegerOrDecimal();
+ }
+ if (char === '"') {
+ return this.parseString();
+ }
+ if (char.match(/^[A-Za-z*]/)) {
+ return this.parseToken();
+ }
+ if (char === ":") {
+ return this.parseByteSequence();
+ }
+ if (char === "?") {
+ return this.parseBoolean();
+ }
+ throw new ParseError(this.pos, "Unexpected input");
+ }
+ parseParameters() {
+ const parameters = /* @__PURE__ */ new Map();
+ while (!this.eof()) {
+ const char = this.lookChar();
+ if (char !== ";") {
+ break;
+ }
+ this.pos++;
+ this.skipWS();
+ const key = this.parseKey();
+ let value = true;
+ if (this.lookChar() === "=") {
+ this.pos++;
+ value = this.parseBareItem();
+ }
+ parameters.set(key, value);
+ }
+ return parameters;
+ }
+ parseIntegerOrDecimal() {
+ let type = "integer";
+ let sign = 1;
+ let inputNumber = "";
+ if (this.lookChar() === "-") {
+ sign = -1;
+ this.pos++;
+ }
+ if (!isDigit(this.lookChar())) {
+ throw new ParseError(this.pos, "Expected a digit (0-9)");
+ }
+ while (!this.eof()) {
+ const char = this.getChar();
+ if (isDigit(char)) {
+ inputNumber += char;
+ } else if (type === "integer" && char === ".") {
+ if (inputNumber.length > 12) {
+ throw new ParseError(this.pos, "Exceeded maximum decimal length");
+ }
+ inputNumber += ".";
+ type = "decimal";
+ } else {
+ this.pos--;
+ break;
+ }
+ if (type === "integer" && inputNumber.length > 15) {
+ throw new ParseError(this.pos, "Exceeded maximum integer length");
+ }
+ if (type === "decimal" && inputNumber.length > 16) {
+ throw new ParseError(this.pos, "Exceeded maximum decimal length");
+ }
+ }
+ if (type === "integer") {
+ return parseInt(inputNumber, 10) * sign;
+ } else {
+ if (inputNumber.endsWith(".")) {
+ throw new ParseError(this.pos, "Decimal cannot end on a period");
+ }
+ if (inputNumber.split(".")[1].length > 3) {
+ throw new ParseError(this.pos, "Number of digits after the decimal point cannot exceed 3");
+ }
+ return parseFloat(inputNumber) * sign;
+ }
+ }
+ parseString() {
+ let outputString = "";
+ this.expectChar('"');
+ this.pos++;
+ while (!this.eof()) {
+ const char = this.getChar();
+ if (char === "\\") {
+ if (this.eof()) {
+ throw new ParseError(this.pos, "Unexpected end of input");
+ }
+ const nextChar = this.getChar();
+ if (nextChar !== "\\" && nextChar !== '"') {
+ throw new ParseError(this.pos, "A backslash must be followed by another backslash or double quote");
+ }
+ outputString += nextChar;
+ } else if (char === '"') {
+ return outputString;
+ } else if (!(0, util_js_1.isAscii)(char)) {
+ throw new ParseError(this.pos, "Strings must be in the ASCII range");
+ } else {
+ outputString += char;
+ }
+ }
+ throw new ParseError(this.pos, "Unexpected end of input");
+ }
+ parseToken() {
+ let outputString = "";
+ while (!this.eof()) {
+ const char = this.lookChar();
+ if (char === void 0 || !/^[:/!#$%&'*+\-.^_`|~A-Za-z0-9]$/.test(char)) {
+ return new token_js_1.Token(outputString);
+ }
+ outputString += this.getChar();
+ }
+ return new token_js_1.Token(outputString);
+ }
+ parseByteSequence() {
+ this.expectChar(":");
+ this.pos++;
+ const endPos = this.input.indexOf(":", this.pos);
+ if (endPos === -1) {
+ throw new ParseError(this.pos, 'Could not find a closing ":" character to mark end of Byte Sequence');
+ }
+ const b64Content = this.input.substring(this.pos, endPos);
+ this.pos += b64Content.length + 1;
+ if (!/^[A-Za-z0-9+/=]*$/.test(b64Content)) {
+ throw new ParseError(this.pos, "ByteSequence does not contain a valid base64 string");
+ }
+ return new types_js_1.ByteSequence(b64Content);
+ }
+ parseBoolean() {
+ this.expectChar("?");
+ this.pos++;
+ const char = this.getChar();
+ if (char === "1") {
+ return true;
+ }
+ if (char === "0") {
+ return false;
+ }
+ throw new ParseError(this.pos, 'Unexpected character. Expected a "1" or a "0"');
+ }
+ parseKey() {
+ if (!this.lookChar()?.match(/^[a-z*]/)) {
+ throw new ParseError(this.pos, "A key must begin with an asterisk or letter (a-z)");
+ }
+ let outputString = "";
+ while (!this.eof()) {
+ const char = this.lookChar();
+ if (char === void 0 || !/^[a-z0-9_\-.*]$/.test(char)) {
+ return outputString;
+ }
+ outputString += this.getChar();
+ }
+ return outputString;
+ }
+ /**
+ * Looks at the next character without advancing the cursor.
+ *
+ * Returns undefined if we were at the end of the string.
+ */
+ lookChar() {
+ return this.input[this.pos];
+ }
+ /**
+ * Checks if the next character is 'char', and fail otherwise.
+ */
+ expectChar(char) {
+ if (this.lookChar() !== char) {
+ throw new ParseError(this.pos, `Expected ${char}`);
+ }
+ }
+ getChar() {
+ return this.input[this.pos++];
+ }
+ eof() {
+ return this.pos >= this.input.length;
+ }
+ // Advances the pointer to skip all whitespace.
+ skipOWS() {
+ while (true) {
+ const c = this.input.substr(this.pos, 1);
+ if (c === " " || c === " ") {
+ this.pos++;
+ } else {
+ break;
+ }
+ }
+ }
+ // Advances the pointer to skip all spaces
+ skipWS() {
+ while (this.lookChar() === " ") {
+ this.pos++;
+ }
+ }
+ // At the end of parsing, we need to make sure there are no bytes after the
+ // header except whitespace.
+ checkTrail() {
+ this.skipWS();
+ if (!this.eof()) {
+ throw new ParseError(this.pos, "Unexpected characters at end of input");
+ }
+ }
+ };
+ exports.default = Parser;
+ var isDigitRegex = /^[0-9]$/;
+ function isDigit(char) {
+ if (char === void 0)
+ return false;
+ return isDigitRegex.test(char);
+ }
+ }
+ });
+
+ // node_modules/@hellocoop/httpsig/dist/vendor/structured-headers/serializer.js
+ var require_serializer = __commonJS({
+ "node_modules/@hellocoop/httpsig/dist/vendor/structured-headers/serializer.js"(exports) {
+ "use strict";
+ Object.defineProperty(exports, "__esModule", { value: true });
+ exports.SerializeError = void 0;
+ exports.serializeList = serializeList;
+ exports.serializeDictionary = serializeDictionary;
+ exports.serializeItem = serializeItem;
+ exports.serializeInnerList = serializeInnerList;
+ exports.serializeBareItem = serializeBareItem;
+ exports.serializeInteger = serializeInteger;
+ exports.serializeDecimal = serializeDecimal;
+ exports.serializeString = serializeString;
+ exports.serializeBoolean = serializeBoolean;
+ exports.serializeByteSequence = serializeByteSequence;
+ exports.serializeToken = serializeToken;
+ exports.serializeParameters = serializeParameters;
+ exports.serializeKey = serializeKey;
+ var types_js_1 = require_types2();
+ var token_js_1 = require_token();
+ var util_js_1 = require_util();
+ var SerializeError = class extends Error {
+ };
+ exports.SerializeError = SerializeError;
+ function serializeList(input) {
+ return input.map((value) => {
+ if ((0, util_js_1.isInnerList)(value)) {
+ return serializeInnerList(value);
+ } else {
+ return serializeItem(value);
+ }
+ }).join(", ");
+ }
+ function serializeDictionary(input) {
+ return Array.from(input.entries()).map(([key, value]) => {
+ let out = serializeKey(key);
+ if (value[0] === true) {
+ out += serializeParameters(value[1]);
+ } else {
+ out += "=";
+ if ((0, util_js_1.isInnerList)(value)) {
+ out += serializeInnerList(value);
+ } else {
+ out += serializeItem(value);
+ }
+ }
+ return out;
+ }).join(", ");
+ }
+ function serializeItem(input) {
+ return serializeBareItem(input[0]) + serializeParameters(input[1]);
+ }
+ function serializeInnerList(input) {
+ return `(${input[0].map((value) => serializeItem(value)).join(" ")})${serializeParameters(input[1])}`;
+ }
+ function serializeBareItem(input) {
+ if (typeof input === "number") {
+ if (Number.isInteger(input)) {
+ return serializeInteger(input);
+ }
+ return serializeDecimal(input);
+ }
+ if (typeof input === "string") {
+ return serializeString(input);
+ }
+ if (input instanceof token_js_1.Token) {
+ return serializeToken(input);
+ }
+ if (input instanceof types_js_1.ByteSequence) {
+ return serializeByteSequence(input);
+ }
+ if (typeof input === "boolean") {
+ return serializeBoolean(input);
+ }
+ throw new SerializeError(`Cannot serialize values of type ${typeof input}`);
+ }
+ function serializeInteger(input) {
+ if (input < -999999999999999 || input > 999999999999999) {
+ throw new SerializeError("Structured headers can only encode integers in the range range of -999,999,999,999,999 to 999,999,999,999,999 inclusive");
+ }
+ return input.toString();
+ }
+ function serializeDecimal(input) {
+ const out = input.toFixed(3).replace(/0+$/, "");
+ const signifantDigits = out.split(".")[0].replace("-", "").length;
+ if (signifantDigits > 12) {
+ throw new SerializeError("Fractional numbers are not allowed to have more than 12 significant digits before the decimal point");
+ }
+ return out;
+ }
+ function serializeString(input) {
+ if (!(0, util_js_1.isAscii)(input)) {
+ throw new SerializeError("Only ASCII strings may be serialized");
+ }
+ return `"${input.replace(/("|\\)/g, (v) => "\\" + v)}"`;
+ }
+ function serializeBoolean(input) {
+ return input ? "?1" : "?0";
+ }
+ function serializeByteSequence(input) {
+ return `:${input.toBase64()}:`;
+ }
+ function serializeToken(input) {
+ return input.toString();
+ }
+ function serializeParameters(input) {
+ return Array.from(input).map(([key, value]) => {
+ let out = ";" + serializeKey(key);
+ if (value !== true) {
+ out += "=" + serializeBareItem(value);
+ }
+ return out;
+ }).join("");
+ }
+ function serializeKey(input) {
+ if (!(0, util_js_1.isValidKeyStr)(input)) {
+ throw new SerializeError("Keys in dictionaries must only contain lowercase letter, numbers, _-*. and must start with a letter or *");
+ }
+ return input;
+ }
+ }
+ });
+
+ // node_modules/@hellocoop/httpsig/dist/structured-fields.js
+ var require_structured_fields = __commonJS({
+ "node_modules/@hellocoop/httpsig/dist/structured-fields.js"(exports) {
+ "use strict";
+ Object.defineProperty(exports, "__esModule", { value: true });
+ exports.isValidKeyStr = exports.isValidTokenStr = exports.isByteSequence = exports.isInnerList = exports.ByteSequence = exports.Token = exports.SerializeError = exports.serializeParameters = exports.serializeBareItem = exports.serializeInnerList = exports.serializeItem = exports.serializeList = exports.serializeDictionary = exports.ParseError = exports.parseItem = exports.parseList = exports.parseDictionary = void 0;
+ exports.bareItemToString = bareItemToString;
+ var parser_js_1 = require_parser();
+ Object.defineProperty(exports, "parseDictionary", { enumerable: true, get: function() {
+ return parser_js_1.parseDictionary;
+ } });
+ Object.defineProperty(exports, "parseList", { enumerable: true, get: function() {
+ return parser_js_1.parseList;
+ } });
+ Object.defineProperty(exports, "parseItem", { enumerable: true, get: function() {
+ return parser_js_1.parseItem;
+ } });
+ Object.defineProperty(exports, "ParseError", { enumerable: true, get: function() {
+ return parser_js_1.ParseError;
+ } });
+ var serializer_js_1 = require_serializer();
+ Object.defineProperty(exports, "serializeDictionary", { enumerable: true, get: function() {
+ return serializer_js_1.serializeDictionary;
+ } });
+ Object.defineProperty(exports, "serializeList", { enumerable: true, get: function() {
+ return serializer_js_1.serializeList;
+ } });
+ Object.defineProperty(exports, "serializeItem", { enumerable: true, get: function() {
+ return serializer_js_1.serializeItem;
+ } });
+ Object.defineProperty(exports, "serializeInnerList", { enumerable: true, get: function() {
+ return serializer_js_1.serializeInnerList;
+ } });
+ Object.defineProperty(exports, "serializeBareItem", { enumerable: true, get: function() {
+ return serializer_js_1.serializeBareItem;
+ } });
+ Object.defineProperty(exports, "serializeParameters", { enumerable: true, get: function() {
+ return serializer_js_1.serializeParameters;
+ } });
+ Object.defineProperty(exports, "SerializeError", { enumerable: true, get: function() {
+ return serializer_js_1.SerializeError;
+ } });
+ var token_js_1 = require_token();
+ Object.defineProperty(exports, "Token", { enumerable: true, get: function() {
+ return token_js_1.Token;
+ } });
+ var types_js_1 = require_types2();
+ Object.defineProperty(exports, "ByteSequence", { enumerable: true, get: function() {
+ return types_js_1.ByteSequence;
+ } });
+ var util_js_1 = require_util();
+ Object.defineProperty(exports, "isInnerList", { enumerable: true, get: function() {
+ return util_js_1.isInnerList;
+ } });
+ Object.defineProperty(exports, "isByteSequence", { enumerable: true, get: function() {
+ return util_js_1.isByteSequence;
+ } });
+ Object.defineProperty(exports, "isValidTokenStr", { enumerable: true, get: function() {
+ return util_js_1.isValidTokenStr;
+ } });
+ Object.defineProperty(exports, "isValidKeyStr", { enumerable: true, get: function() {
+ return util_js_1.isValidKeyStr;
+ } });
+ var token_js_2 = require_token();
+ function bareItemToString(value) {
+ if (typeof value === "string") {
+ return value;
+ }
+ if (value instanceof token_js_2.Token) {
+ return value.toString();
+ }
+ throw new TypeError(`Expected a Structured Field String or Token, got ${typeof value}`);
+ }
+ }
+ });
+
// node_modules/@hellocoop/httpsig/dist/utils/signature.js
var require_signature = __commonJS({
"node_modules/@hellocoop/httpsig/dist/utils/signature.js"(exports) {
@@ -355,6 +963,7 @@
Object.defineProperty(exports, "__esModule", { value: true });
exports.generateSignatureBase = generateSignatureBase;
exports.generateSignatureInputHeader = generateSignatureInputHeader;
+ exports.generateSignatureParams = generateSignatureParams;
exports.generateSignatureKeyHeader = generateSignatureKeyHeader;
exports.generateSignatureHeader = generateSignatureHeader;
exports.generateContentDigest = generateContentDigest;
@@ -371,6 +980,18 @@
exports.parseSignature = parseSignature;
var base64_js_1 = require_base64();
var errors_js_1 = require_errors();
+ var structured_fields_js_1 = require_structured_fields();
+ function buildSignatureParams(components, created) {
+ const items = components.map((component) => [
+ component,
+ /* @__PURE__ */ new Map()
+ ]);
+ return [items, /* @__PURE__ */ new Map([["created", created]])];
+ }
+ function parseFailure(field, error) {
+ const detail = error instanceof Error ? error.message : String(error);
+ return new Error(`Invalid ${field} format: ${detail}`);
+ }
function generateSignatureBase(components, componentValues) {
const lines = [];
for (const component of components) {
@@ -383,10 +1004,15 @@
return lines.join("\n");
}
function generateSignatureInputHeader(label, components, created) {
- const componentList = components.map((c) => `"${c}"`).join(" ");
- return `${label}=(${componentList});created=${created}`;
+ return (0, structured_fields_js_1.serializeDictionary)(/* @__PURE__ */ new Map([[label, buildSignatureParams(components, created)]]));
+ }
+ function generateSignatureParams(components, created) {
+ return (0, structured_fields_js_1.serializeInnerList)(buildSignatureParams(components, created));
}
function generateSignatureKeyHeader(label, signatureKey, publicJwk2) {
+ const oneMember = (scheme, params) => (0, structured_fields_js_1.serializeDictionary)(/* @__PURE__ */ new Map([
+ [label, [new structured_fields_js_1.Token(scheme), new Map(params)]]
+ ]));
if (signatureKey.type === "hwk") {
if (!publicJwk2) {
throw new Error("Public JWK required for hwk signature key type");
@@ -395,40 +1021,46 @@
throw new Error("Public JWK missing required alg member for hwk signature key type");
}
const params = [
- `alg="${publicJwk2.alg}"`,
- `kty="${publicJwk2.kty}"`
+ ["alg", publicJwk2.alg],
+ ["kty", publicJwk2.kty]
];
if (publicJwk2.crv)
- params.push(`crv="${publicJwk2.crv}"`);
+ params.push(["crv", publicJwk2.crv]);
if (publicJwk2.x)
- params.push(`x="${publicJwk2.x}"`);
+ params.push(["x", publicJwk2.x]);
if (publicJwk2.y)
- params.push(`y="${publicJwk2.y}"`);
+ params.push(["y", publicJwk2.y]);
if (publicJwk2.n)
- params.push(`n="${publicJwk2.n}"`);
+ params.push(["n", publicJwk2.n]);
if (publicJwk2.e)
- params.push(`e="${publicJwk2.e}"`);
- return `${label}=hwk;${params.join(";")}`;
+ params.push(["e", publicJwk2.e]);
+ return oneMember("hwk", params);
}
if (signatureKey.type === "jwt") {
- return `${label}=jwt;jwt="${signatureKey.jwt}"`;
+ return oneMember("jwt", [["jwt", signatureKey.jwt]]);
}
if (signatureKey.type === "jkt_jwt") {
- return `${label}=jkt-jwt;jwt="${signatureKey.jwt}"`;
+ return oneMember("jkt-jwt", [["jwt", signatureKey.jwt]]);
}
if (signatureKey.type === "jwks_uri") {
- const params = [
- `id="${signatureKey.id}"`,
- `dwk="${signatureKey.dwk}"`,
- `kid="${signatureKey.kid}"`
- ];
- return `${label}=jwks_uri;${params.join(";")}`;
+ return oneMember("jwks_uri", [
+ ["id", signatureKey.id],
+ ["dwk", signatureKey.dwk],
+ ["kid", signatureKey.kid]
+ ]);
}
throw new Error(`Unsupported signature key type: ${signatureKey.type}`);
}
function generateSignatureHeader(label, signature) {
- const encoded = (0, base64_js_1.base64Encode)(signature);
- return `${label}=:${encoded}:`;
+ return (0, structured_fields_js_1.serializeDictionary)(/* @__PURE__ */ new Map([
+ [
+ label,
+ [
+ new structured_fields_js_1.ByteSequence((0, base64_js_1.base64Encode)(signature)),
+ /* @__PURE__ */ new Map()
+ ]
+ ]
+ ]));
}
async function generateContentDigest(body) {
let bytes;
@@ -441,65 +1073,73 @@
} else if (Buffer.isBuffer(body)) {
bytes = new Uint8Array(body);
} else {
- bytes = new TextEncoder().encode(String(body));
+ throw new Error(`Cannot generate content-digest for body type: ${body?.constructor?.name ?? typeof body}`);
}
const hash = await (0, base64_js_1.sha256)(bytes);
const encoded = (0, base64_js_1.base64Encode)(hash);
return `sha-256=:${encoded}:`;
}
function parseSignatureInput(header) {
+ let dictionary;
+ try {
+ dictionary = (0, structured_fields_js_1.parseDictionary)(header);
+ } catch (error) {
+ throw parseFailure("Signature-Input", error);
+ }
const results = [];
- const parts = header.split(",").map((p) => p.trim());
- for (const part of parts) {
- const match = part.match(/^([^=]+)=\(([^)]*)\);(.+)$/);
- if (!match) {
- throw new Error(`Invalid Signature-Input format: ${part}`);
- }
- const label = match[1].trim();
- const componentsStr = match[2];
- const paramsStr = match[3];
- const components = componentsStr.split(/\s+/).map((c) => c.replace(/"/g, "")).filter((c) => c);
- const params = {};
- const paramPairs = paramsStr.split(";").map((p) => p.trim());
- for (const pair of paramPairs) {
- const [key, value] = pair.split("=").map((s) => s.trim());
- if (key === "created") {
- params.created = parseInt(value, 10);
- } else {
- params[key] = value;
+ for (const [label, member] of dictionary) {
+ if (!(0, structured_fields_js_1.isInnerList)(member)) {
+ throw new Error(`Invalid Signature-Input format: member "${label}" is not an Inner List of covered components`);
+ }
+ const [items, parameters] = member;
+ const components = [];
+ for (const [bareItem, itemParameters] of items) {
+ if (typeof bareItem !== "string") {
+ throw new Error("Invalid Signature-Input format: a covered component identifier must be a String");
}
+ if (itemParameters.size > 0) {
+ throw new Error(`Unsupported component parameters on "${bareItem}" in Signature-Input`);
+ }
+ components.push(bareItem);
+ }
+ const params = {};
+ for (const [key, value] of parameters) {
+ params[key] = value;
}
- if (!params.created) {
+ if (typeof params.created !== "number") {
throw new Error("Signature-Input missing required parameter: created");
}
- results.push({ label, components, params });
+ results.push({
+ label,
+ components,
+ params,
+ signatureParams: member
+ });
}
return results;
}
function parseSignatureKey(header) {
- const trimmed = header.trim();
- let inQuote = false;
- for (let i = 0; i < trimmed.length; i++) {
- if (trimmed[i] === '"' && (i === 0 || trimmed[i - 1] !== "\\")) {
- inQuote = !inQuote;
- } else if (trimmed[i] === "," && !inQuote) {
- throw new Error("Invalid Signature-Key: must have exactly one dictionary member");
- }
- }
- const match = trimmed.match(/^([\w-]+)=([\w-]+)(.*)$/);
- if (!match) {
- throw new Error("Invalid Signature-Key: must be RFC 8941 Dictionary with format label=scheme;params");
- }
- const label = match[1];
- const scheme = match[2];
- const paramsStr = match[3];
+ const malformed = new Error("Invalid Signature-Key: must be RFC 8941 Dictionary with format label=scheme;params");
+ let dictionary;
+ try {
+ dictionary = (0, structured_fields_js_1.parseDictionary)(header);
+ } catch {
+ throw malformed;
+ }
+ if (dictionary.size !== 1) {
+ throw new Error("Invalid Signature-Key: must have exactly one dictionary member");
+ }
+ const [label, member] = [...dictionary][0];
+ if ((0, structured_fields_js_1.isInnerList)(member) || !(member[0] instanceof structured_fields_js_1.Token)) {
+ throw malformed;
+ }
+ const scheme = member[0].toString();
const params = {};
- if (paramsStr) {
- const paramMatches = paramsStr.matchAll(/;([\w-]+)=(?:"([^"]*)"|(\w+))/g);
- for (const paramMatch of paramMatches) {
- const key = paramMatch[1];
- const value = paramMatch[2] !== void 0 ? paramMatch[2] : paramMatch[3];
- params[key] = value;
+ for (const [key, value] of member[1]) {
+ try {
+ params[key] = (0, structured_fields_js_1.bareItemToString)(value);
+ } catch {
+ throw (0, errors_js_1.invalidKey)(`Signature-Key ${key} parameter must be a String or Token`);
}
}
if (!["hwk", "jwt", "jkt-jwt", "jwks_uri"].includes(scheme)) {
@@ -560,20 +1200,29 @@
throw (0, errors_js_1.unsupportedScheme)(`Unsupported Signature-Key scheme: ${scheme}`);
}
function generateSignatureErrorHeader(signatureError) {
- const parts = [`error=${signatureError.error}`];
+ const dictionary = /* @__PURE__ */ new Map([
+ ["error", [new structured_fields_js_1.Token(signatureError.error), /* @__PURE__ */ new Map()]]
+ ]);
if (signatureError.required_input) {
- const inputList = signatureError.required_input.map((c) => `"${c}"`).join(" ");
- parts.push(`required_input=(${inputList})`);
+ dictionary.set("required_input", [
+ signatureError.required_input.map((c) => [c, /* @__PURE__ */ new Map()]),
+ /* @__PURE__ */ new Map()
+ ]);
}
- return parts.join(", ");
+ return (0, structured_fields_js_1.serializeDictionary)(dictionary);
}
function parseSignatureError(header) {
- const trimmed = header.trim();
- const errorMatch = trimmed.match(/error=([\w]+)/);
- if (!errorMatch) {
+ let dictionary;
+ try {
+ dictionary = (0, structured_fields_js_1.parseDictionary)(header);
+ } catch (error2) {
+ throw parseFailure("Signature-Error", error2);
+ }
+ const errorMember = dictionary.get("error");
+ if (errorMember === void 0 || (0, structured_fields_js_1.isInnerList)(errorMember)) {
throw new Error("Invalid Signature-Error: missing error member");
}
- const error = errorMatch[1];
+ const error = (0, structured_fields_js_1.bareItemToString)(errorMember[0]);
const validCodes = [
"unsupported_algorithm",
"unsupported_scheme",
@@ -591,22 +1240,25 @@
throw new Error(`Invalid Signature-Error code: ${error}`);
}
const result = { error };
- const inputMatch = trimmed.match(/required_input=\(([^)]*)\)/);
- if (inputMatch) {
- result.required_input = inputMatch[1].split(/\s+/).map((c) => c.replace(/"/g, "")).filter((c) => c);
+ const inputMember = dictionary.get("required_input");
+ if (inputMember !== void 0) {
+ if (!(0, structured_fields_js_1.isInnerList)(inputMember)) {
+ throw new Error("Invalid Signature-Error: required_input must be an Inner List");
+ }
+ result.required_input = inputMember[0].map(([component]) => (0, structured_fields_js_1.bareItemToString)(component));
}
return result;
}
function generateTokenList(values) {
for (const value of values) {
- if (!/^[A-Za-z*][A-Za-z0-9!#$%&'*+\-.^_`|~:/]*$/.test(value)) {
+ if (!(0, structured_fields_js_1.isValidTokenStr)(value)) {
throw new Error(`Value is not a valid Structured Field Token: ${value}`);
}
}
- return values.join(", ");
+ return (0, structured_fields_js_1.serializeList)(values.map((value) => [new structured_fields_js_1.Token(value), /* @__PURE__ */ new Map()]));
}
function parseTokenList(header) {
- return header.split(",").map((v) => v.trim()).filter((v) => /^[A-Za-z*][A-Za-z0-9!#$%&'*+\-.^_`|~:/]*$/.test(v));
+ return (0, structured_fields_js_1.parseList)(header).filter((member) => !(0, structured_fields_js_1.isInnerList)(member)).map(([bareItem]) => bareItem).filter((bareItem) => bareItem instanceof structured_fields_js_1.Token).map((token) => token.toString());
}
function generateAcceptSignatureSchemeHeader(schemes) {
return generateTokenList(schemes);
@@ -622,52 +1274,61 @@
}
function generateAcceptSignatureHeader(params) {
const { label = "sig", components, alg, tag } = params;
- const componentList = components.map((c) => `"${c}"`).join(" ");
- let header = `${label}=(${componentList})`;
+ const parameters = /* @__PURE__ */ new Map();
if (alg) {
- header += `;alg="${alg}"`;
+ parameters.set("alg", alg);
}
if (tag) {
- header += `;tag="${tag}"`;
+ parameters.set("tag", tag);
}
- return header;
+ const innerList = [
+ components.map((c) => [c, /* @__PURE__ */ new Map()]),
+ parameters
+ ];
+ return (0, structured_fields_js_1.serializeDictionary)(/* @__PURE__ */ new Map([[label, innerList]]));
}
function parseAcceptSignature(header) {
- const trimmed = header.trim();
- const match = trimmed.match(/^([\w-]+)=\(([^)]*)\)(.*)$/);
- if (!match) {
- throw new Error("Invalid Accept-Signature format");
- }
- const label = match[1];
- const componentsStr = match[2];
- const paramsStr = match[3];
- const components = componentsStr.split(/\s+/).map((c) => c.replace(/"/g, "")).filter((c) => c);
- const result = { label, components };
- if (paramsStr) {
- const algMatch = paramsStr.match(/;alg="([^"]*)"/);
- if (algMatch) {
- result.alg = algMatch[1];
- }
- const tagMatch = paramsStr.match(/;tag="([^"]*)"/);
- if (tagMatch) {
- result.tag = tagMatch[1];
- }
+ const malformed = new Error("Invalid Accept-Signature format");
+ let dictionary;
+ try {
+ dictionary = (0, structured_fields_js_1.parseDictionary)(header);
+ } catch {
+ throw malformed;
+ }
+ if (dictionary.size !== 1) {
+ throw malformed;
+ }
+ const [label, member] = [...dictionary][0];
+ if (!(0, structured_fields_js_1.isInnerList)(member)) {
+ throw malformed;
+ }
+ const result = {
+ label,
+ components: member[0].map(([component]) => (0, structured_fields_js_1.bareItemToString)(component))
+ };
+ const alg = member[1].get("alg");
+ if (alg !== void 0) {
+ result.alg = (0, structured_fields_js_1.bareItemToString)(alg);
+ }
+ const tag = member[1].get("tag");
+ if (tag !== void 0) {
+ result.tag = (0, structured_fields_js_1.bareItemToString)(tag);
}
return result;
}
function parseSignature(header) {
+ let dictionary;
+ try {
+ dictionary = (0, structured_fields_js_1.parseDictionary)(header);
+ } catch (error) {
+ throw parseFailure("Signature", error);
+ }
const results = /* @__PURE__ */ new Map();
- const entries = header.split(/,(?=\s*\w+=)/);
- for (const entry of entries) {
- const trimmed = entry.trim();
- const match = trimmed.match(/^([^=]+)=:([^:]+):$/);
- if (!match) {
- throw new Error(`Invalid Signature format: ${trimmed}`);
- }
- const label = match[1].trim();
- const base64 = match[2];
- const signature = Buffer.from(base64, "base64");
- results.set(label, new Uint8Array(signature));
+ for (const [label, member] of dictionary) {
+ if ((0, structured_fields_js_1.isInnerList)(member) || !(0, structured_fields_js_1.isByteSequence)(member[0])) {
+ throw new Error(`Invalid Signature format: member "${label}" is not a Byte Sequence`);
+ }
+ results.set(label, (0, base64_js_1.base64Decode)(member[0].toBase64()));
}
return results;
}
@@ -701,6 +1362,9 @@
}
return "application/octet-stream";
}
+ function isDigestibleBody(body) {
+ return typeof body === "string" || body instanceof Uint8Array || body instanceof ArrayBuffer || Buffer.isBuffer(body);
+ }
function validateComponents(components, headers) {
for (const component of components) {
if (component === "@signature-params" || component === "signature-key" || component === "signature-input" || component === "signature") {
@@ -718,7 +1382,7 @@
}
}
async function fetch2(url, options) {
- const { signingKey, signingCryptoKey, signatureKey, label = "sig", components: customComponents, dryRun = false, returnSent = false, method = "GET", headers: inputHeaders = {}, body, ...fetchOptions } = options;
+ const { signingKey, signingCryptoKey, signatureKey, label = "sig", components: customComponents, contentDigest = "auto", dryRun = false, returnSent = false, method = "GET", headers: inputHeaders = {}, body, ...fetchOptions } = options;
(0, crypto_js_1.validateJwk)(signingKey);
let privateKey;
let algorithm;
@@ -743,6 +1407,15 @@
const hasBody = body !== void 0 && body !== null;
components = hasBody ? [...types_js_1.DEFAULT_COMPONENTS_BODY] : [...types_js_1.DEFAULT_COMPONENTS_GET];
}
+ if (body !== void 0 && body !== null && contentDigest !== "omit") {
+ const digestible = isDigestibleBody(body);
+ if (!digestible && contentDigest === "require") {
+ throw new Error('contentDigest is "require" but the body cannot be digested: only string, Uint8Array, ArrayBuffer, and Buffer bodies have their exact bytes available to hash');
+ }
+ if (digestible && !components.includes("content-digest")) {
+ components.push("content-digest");
+ }
+ }
const componentValues = /* @__PURE__ */ new Map();
if (body !== void 0 && body !== null) {
if (!headers.has("content-type")) {
@@ -752,8 +1425,8 @@
}
}
if (components.includes("content-digest")) {
- const contentDigest = await (0, signature_js_1.generateContentDigest)(body);
- headers.set("content-digest", contentDigest);
+ const contentDigest2 = await (0, signature_js_1.generateContentDigest)(body);
+ headers.set("content-digest", contentDigest2);
}
}
if (components.includes("signature-key")) {
@@ -798,9 +1471,7 @@
const created = Math.floor(Date.now() / 1e3);
const signatureInputHeader = (0, signature_js_1.generateSignatureInputHeader)(label, components, created);
headers.set("signature-input", signatureInputHeader);
- const componentList = components.map((c) => `"${c}"`).join(" ");
- const signatureParams = `(${componentList});created=${created}`;
- componentValues.set("@signature-params", signatureParams);
+ componentValues.set("@signature-params", (0, signature_js_1.generateSignatureParams)(components, created));
components.push("@signature-params");
const signatureBase = (0, signature_js_1.generateSignatureBase)(components, componentValues);
const signatureBaseBytes = new TextEncoder().encode(signatureBase);
@@ -996,6 +1667,7 @@
exports.verify = verify;
var crypto_js_1 = require_crypto();
var signature_js_1 = require_signature();
+ var structured_fields_js_1 = require_structured_fields();
var base64_js_1 = require_base64();
var thumbprint_js_1 = require_thumbprint();
var cache_js_1 = require_cache();
@@ -1202,7 +1874,8 @@
maxClockSkew = 60,
jwksCacheTtl = 36e5,
// 1 hour
- supportedAlgorithms
+ supportedAlgorithms,
+ requireContentDigest = false
} = options;
const accepted = supportedAlgorithms ?? crypto_js_1.SUPPORTED_ALGORITHMS;
try {
@@ -1294,6 +1967,16 @@
componentValues.set("@request-target", `${request.path}${queryString}`);
componentValues.set("@path", request.path);
componentValues.set("@query", request.query || "");
+ if (requireContentDigest && request.body !== void 0 && !components.includes("content-digest")) {
+ throw (0, errors_js_1.invalidInput)("content-digest must be a covered component on a request with a body", [
+ "@method",
+ "@authority",
+ "@path",
+ "content-digest",
+ "content-type",
+ "signature-key"
+ ]);
+ }
if (request.body !== void 0 && components.includes("content-digest")) {
const expectedDigest = headers.get("content-digest");
if (!expectedDigest) {
@@ -1315,18 +1998,7 @@
}
componentValues.set(component, value);
}
- const componentList = components.map((c) => `"${c}"`).join(" ");
- const paramPairs = Object.entries(params).map(([key, value]) => {
- if (typeof value === "number") {
- return `${key}=${value}`;
- }
- const stringValue = String(value);
- if (stringValue.startsWith('"') && stringValue.endsWith('"')) {
- return `${key}=${stringValue}`;
- }
- return `${key}="${stringValue}"`;
- }).join(";");
- const signatureParams = `(${componentList});${paramPairs}`;
+ const signatureParams = (0, structured_fields_js_1.serializeInnerList)(signatureInput.signatureParams);
componentValues.set("@signature-params", signatureParams);
const componentsWithParams = [...components, "@signature-params"];
const signatureBase = (0, signature_js_1.generateSignatureBase)(componentsWithParams, componentValues);
@@ -1434,7 +2106,7 @@
"node_modules/@hellocoop/httpsig/dist/index.js"(exports) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
- exports.DEFAULT_COMPONENTS_BODY = exports.DEFAULT_COMPONENTS_GET = exports.VALID_DERIVED_COMPONENTS = exports.calculateThumbprint = exports.SignatureVerificationError = exports.SUPPORTED_ALGORITHMS = exports.determineAlgorithm = exports.generateKeyPair = exports.parseAcceptSignatureAlg = exports.generateAcceptSignatureAlgHeader = exports.parseAcceptSignatureScheme = exports.generateAcceptSignatureSchemeHeader = exports.parseAcceptSignature = exports.generateAcceptSignatureHeader = exports.parseSignatureError = exports.generateSignatureErrorHeader = exports.nextJsPagesVerify = exports.nextJsVerify = exports.fastifyVerify = exports.expressVerify = exports.verify = void 0;
+ exports.DEFAULT_COMPONENTS_BODY = exports.DEFAULT_COMPONENTS_GET = exports.VALID_DERIVED_COMPONENTS = exports.calculateThumbprint = exports.SignatureVerificationError = exports.SUPPORTED_ALGORITHMS = exports.determineAlgorithm = exports.generateKeyPair = exports.SerializeError = exports.ParseError = exports.ByteSequence = exports.Token = exports.isValidKeyStr = exports.isValidTokenStr = exports.isByteSequence = exports.isInnerList = exports.bareItemToString = exports.serializeParameters = exports.serializeBareItem = exports.serializeInnerList = exports.serializeItem = exports.serializeList = exports.serializeDictionary = exports.parseItem = exports.parseList = exports.parseDictionary = exports.parseAcceptSignatureAlg = exports.generateAcceptSignatureAlgHeader = exports.parseAcceptSignatureScheme = exports.generateAcceptSignatureSchemeHeader = exports.parseAcceptSignature = exports.generateAcceptSignatureHeader = exports.parseSignatureError = exports.generateSignatureErrorHeader = exports.nextJsPagesVerify = exports.nextJsVerify = exports.fastifyVerify = exports.expressVerify = exports.verify = void 0;
var fetch_js_1 = require_fetch();
Object.defineProperty(exports, "fetch", { enumerable: true, get: function() {
return fetch_js_1.fetch;
@@ -1481,6 +2153,61 @@
Object.defineProperty(exports, "parseAcceptSignatureAlg", { enumerable: true, get: function() {
return signature_js_1.parseAcceptSignatureAlg;
} });
+ var structured_fields_js_1 = require_structured_fields();
+ Object.defineProperty(exports, "parseDictionary", { enumerable: true, get: function() {
+ return structured_fields_js_1.parseDictionary;
+ } });
+ Object.defineProperty(exports, "parseList", { enumerable: true, get: function() {
+ return structured_fields_js_1.parseList;
+ } });
+ Object.defineProperty(exports, "parseItem", { enumerable: true, get: function() {
+ return structured_fields_js_1.parseItem;
+ } });
+ Object.defineProperty(exports, "serializeDictionary", { enumerable: true, get: function() {
+ return structured_fields_js_1.serializeDictionary;
+ } });
+ Object.defineProperty(exports, "serializeList", { enumerable: true, get: function() {
+ return structured_fields_js_1.serializeList;
+ } });
+ Object.defineProperty(exports, "serializeItem", { enumerable: true, get: function() {
+ return structured_fields_js_1.serializeItem;
+ } });
+ Object.defineProperty(exports, "serializeInnerList", { enumerable: true, get: function() {
+ return structured_fields_js_1.serializeInnerList;
+ } });
+ Object.defineProperty(exports, "serializeBareItem", { enumerable: true, get: function() {
+ return structured_fields_js_1.serializeBareItem;
+ } });
+ Object.defineProperty(exports, "serializeParameters", { enumerable: true, get: function() {
+ return structured_fields_js_1.serializeParameters;
+ } });
+ Object.defineProperty(exports, "bareItemToString", { enumerable: true, get: function() {
+ return structured_fields_js_1.bareItemToString;
+ } });
+ Object.defineProperty(exports, "isInnerList", { enumerable: true, get: function() {
+ return structured_fields_js_1.isInnerList;
+ } });
+ Object.defineProperty(exports, "isByteSequence", { enumerable: true, get: function() {
+ return structured_fields_js_1.isByteSequence;
+ } });
+ Object.defineProperty(exports, "isValidTokenStr", { enumerable: true, get: function() {
+ return structured_fields_js_1.isValidTokenStr;
+ } });
+ Object.defineProperty(exports, "isValidKeyStr", { enumerable: true, get: function() {
+ return structured_fields_js_1.isValidKeyStr;
+ } });
+ Object.defineProperty(exports, "Token", { enumerable: true, get: function() {
+ return structured_fields_js_1.Token;
+ } });
+ Object.defineProperty(exports, "ByteSequence", { enumerable: true, get: function() {
+ return structured_fields_js_1.ByteSequence;
+ } });
+ Object.defineProperty(exports, "ParseError", { enumerable: true, get: function() {
+ return structured_fields_js_1.ParseError;
+ } });
+ Object.defineProperty(exports, "SerializeError", { enumerable: true, get: function() {
+ return structured_fields_js_1.SerializeError;
+ } });
var crypto_js_1 = require_crypto();
Object.defineProperty(exports, "generateKeyPair", { enumerable: true, get: function() {
return crypto_js_1.generateKeyPair;
@@ -1570,15 +2297,13 @@
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"];
return (0, import_httpsig.fetch)(url, {
method,
headers: hasBody ? { "Content-Type": "application/json", ...headers } : headers,
body: hasBody ? body : void 0,
signingKey: pub,
signingCryptoKey: kp.privateKey,
- signatureKey: { type: "jwt", jwt },
- components
+ signatureKey: { type: "jwt", jwt }
});
}
async function bootstrap() {
@@ -1590,8 +2315,7 @@
body: JSON.stringify({ ps: PS_DEFAULT }),
signingKey: pub,
signingCryptoKey: kp.privateKey,
- signatureKey: { type: "hwk" },
- components: ["@method", "@authority", "@path", "content-type", "signature-key"]
+ signatureKey: { type: "hwk" }
});
const data = await response.json();
if (!response.ok || !data.agent_token) throw new Error(data.error || "bootstrap failed");
@@ -1630,46 +2354,69 @@
});
return out;
}
- async function startAuthFlow(pending) {
- let agentToken = await ensureAgentToken();
- 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();
- }
- if (!resourceToken) throw new Error("no resource_token in challenge");
- const psMeta = await (await fetch(`${PS_DEFAULT}/.well-known/aauth-person.json`)).json();
- const psRes = await signedFetch(psMeta.token_endpoint, {
- method: "POST",
- jwt: agentToken,
- body: JSON.stringify({ resource_token: resourceToken, capabilities: ["interaction"], prompt: "consent" })
- });
- if (psRes.status === 200) {
- const body2 = await psRes.json();
- if (!body2.auth_token) throw new Error("PS returned no auth_token");
- return { authToken: body2.auth_token };
- }
- if (psRes.status !== 202) throw new Error(`PS token endpoint ${psRes.status}`);
+ 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();
- savePending({ ...pending, pollUrl });
+ savePending({ ...pending, stage, pollUrl });
window.location.href = `${interactionUrl}?code=${encodeURIComponent(code)}&callback=${encodeURIComponent(ORIGIN + "/")}`;
return { redirecting: true };
}
- async function pollForAuthToken(pollUrl, agentToken, maxCycles = 40) {
+ 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 (res.status === 202) return deferToPersonServer(res, psMeta, pending, "person");
+ throw new Error(`PS person token endpoint ${res.status}`);
+ }
+ 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;
+ }
+ 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 (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 (res.status === 202) return deferToPersonServer(res, psMeta, pending, "auth");
+ throw new Error(`PS auth token endpoint ${res.status}`);
+ }
+ async function startAuthFlow(pending) {
+ const agentToken = await ensureAgentToken();
+ const psMeta = await (await fetch(`${PS_DEFAULT}/.well-known/aauth-person.json`)).json();
+ 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 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}`);
}
@@ -1699,8 +2446,16 @@
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;
+ 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);
}
@@ -1731,6 +2486,14 @@
});
var $ = (id) => document.getElementById(id);
var esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
+ var ACCESS_MODE_TITLES = {
+ "agent-token": "Authorizes on the agent\u2019s identity alone",
+ "person-token": "Authorizes on the person\u2019s 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\u2019s parameters"
+ };
+ var accessModeTitle = (mode) => ACCESS_MODE_TITLES[mode] ?? (mode ? `Access mode \u201C${mode}\u201D \u2014 not one this page knows; agents call the resource and read its AAuth-Requirement` : "No access mode declared \u2014 defaults to agent-token");
function renderResources(index) {
const list = $("resources");
const items = index.resources || [];
@@ -1743,7 +2506,7 @@
${esc(r.name)}
-
${esc(r.access_mode)}
+
${esc(r.access_mode || "agent-token")}
${esc(r.description)}
${esc(r.issuer)}
diff --git a/scripts/test.sh b/scripts/test.sh
index 940fd3e..b6d6e10 100644
--- a/scripts/test.sh
+++ b/scripts/test.sh
@@ -51,6 +51,10 @@ check "has at least one key" \
"$(echo "$JWKS" | jq -e '.keys | length > 0' 2>/dev/null || echo false)"
check "key is OKP/Ed25519" \
"$(echo "$JWKS" | jq -e '.keys[0].kty == "OKP" and .keys[0].crv == "Ed25519"' 2>/dev/null || echo false)"
+# AAuth §Signature Algorithms: every published JWK carries a fully-specified
+# alg, and the polymorphic "EdDSA" MUST NOT be used.
+check "key alg is fully-specified Ed25519 (not EdDSA)" \
+ "$(echo "$JWKS" | jq -e '.keys[0].alg == "Ed25519"' 2>/dev/null || echo false)"
check "no private key material" \
"$(echo "$JWKS" | jq -e '.keys[0].d == null' 2>/dev/null || echo false)"
echo
diff --git a/src/agent-token.ts b/src/agent-token.ts
index 2dd78a8..68cef58 100644
--- a/src/agent-token.ts
+++ b/src/agent-token.ts
@@ -59,9 +59,14 @@ export async function verifyAgentToken(
...(rawBody !== undefined ? { body: rawBody } : {}),
})
+ // The registry is a resource, so it does not REQUIRE content-digest
+ // coverage (AAuth §10.3 binds PS and AS endpoints; resources declare
+ // needs instead). For a body-carrying request the challenge still asks
+ // for content-digest + content-type — what an httpsig 2.2 client signs
+ // by default — while verification accepts signatures without them.
const components =
rawBody !== undefined
- ? ['@method', '@authority', '@path', 'content-type', 'signature-key']
+ ? ['@method', '@authority', '@path', 'content-digest', 'content-type', 'signature-key']
: ['@method', '@authority', '@path', 'signature-key']
if (!sigResult.verified) {
diff --git a/src/ap.ts b/src/ap.ts
index f2648f7..ddf7476 100644
--- a/src/ap.ts
+++ b/src/ap.ts
@@ -6,7 +6,14 @@
import type { Context } from 'hono'
import { verify as httpSigVerify } from '@hellocoop/httpsig'
-import { getPublicJWK, importSigningKey, generateJTI, sanitizeCnfJwk, signJWT } from './crypto'
+import {
+ getPublicJWK,
+ importSigningKey,
+ generateJTI,
+ sanitizeCnfJwk,
+ signJWT,
+ SIGNING_ALG,
+} from './crypto'
import type { Env } from './types'
type HonoEnv = { Bindings: Env }
@@ -33,14 +40,20 @@ export interface SigHwkResult {
export async function verifySigHwk(c: Context
): Promise {
const rawBody = await c.req.text()
const url = new URL(c.req.url)
- const sigResult = await httpSigVerify({
- method: c.req.method,
- authority: url.host,
- path: url.pathname,
- query: url.search.replace(/^\?/, ''),
- headers: c.req.raw.headers,
- body: rawBody,
- })
+ // /bootstrap mints agent tokens, so it enforces the AAuth HTTPSig profile
+ // (§10.3): a body-carrying request MUST cover content-digest. The
+ // /resources API is the resource role and stays exempt (agent-token.ts).
+ const sigResult = await httpSigVerify(
+ {
+ method: c.req.method,
+ authority: url.host,
+ path: url.pathname,
+ query: url.search.replace(/^\?/, ''),
+ headers: c.req.raw.headers,
+ body: rawBody,
+ },
+ { requireContentDigest: true },
+ )
if (!sigResult.verified) {
return c.json({ error: `signature verification failed: ${sigResult.error || 'unknown'}` }, 401) as unknown as Response
}
@@ -91,7 +104,9 @@ export async function mintAgentToken(
const publicJwk = await getPublicJWK(env.SIGNING_KEY)
const now = Math.floor(Date.now() / 1000)
- const header = { alg: 'Ed25519', typ: 'aa-agent+jwt', kid: publicJwk.kid }
+ // alg is fully specified (AAuth -11 §Agent Token Structure); cnf.jwk gets
+ // its own fully-specified alg from sanitizeCnfJwk.
+ const header = { alg: SIGNING_ALG, typ: 'aa-agent+jwt', kid: publicJwk.kid }
const payload: Record = {
iss: origin,
dwk: 'aauth-agent.json',
diff --git a/src/crypto.ts b/src/crypto.ts
index 3644de3..f5a8ba6 100644
--- a/src/crypto.ts
+++ b/src/crypto.ts
@@ -1,4 +1,15 @@
-// Ed25519 JWT signing & verification — shared with other AAuth servers
+// Ed25519 JWT signing & verification — shared with other AAuth servers.
+//
+// AAuth -11 §Signature Algorithms: every `alg` — in a JWT header and in the
+// `alg` member of every JWK — MUST be a fully-specified identifier. The
+// polymorphic `EdDSA` MUST NOT be used; RFC 9864 replaced it with `Ed25519`
+// (and `Ed448`). This module is where that is enforced, on both sides:
+// SIGNING_ALG is what we put in JWT headers, fullySpecifiedAlg() stamps every
+// JWK we publish or put in a cnf, and JWT_ALG_PARAMS is the set of `alg`
+// values we will verify — `EdDSA` is deliberately absent from it.
+
+// The `alg` for every JWT this service signs. Its signing key is Ed25519.
+export const SIGNING_ALG = 'Ed25519'
const textEncoder = new TextEncoder()
@@ -21,9 +32,11 @@ function base64urlDecode(str: string): Uint8Array {
}
export async function importSigningKey(jwkJson: string): Promise {
- // Strip alg/key_ops/ext: a JWK exported by newer runtimes carries
- // alg:"Ed25519", which WebCrypto rejects on import as an Ed25519 key
- // (it expects alg absent or "EdDSA"). Usages come from the import call.
+ // Strip alg/key_ops/ext before WebCrypto import. This is a WebCrypto quirk,
+ // not a JOSE one: importKey with {name:'Ed25519'} rejects a JWK carrying
+ // alg:"Ed25519" (it only tolerates the alg being absent, or the legacy
+ // "EdDSA"). The curve comes from the import algorithm and the usages from
+ // the call, so nothing is lost. Everything we *emit* carries "Ed25519".
const { alg: _alg, key_ops: _ops, ext: _ext, ...jwk } = JSON.parse(jwkJson)
return crypto.subtle.importKey('jwk', jwk, { name: 'Ed25519' }, false, ['sign'])
}
@@ -32,10 +45,11 @@ import { calculateThumbprint } from '@hellocoop/httpsig'
export { calculateThumbprint as computeJwkThumbprint }
// Fully-specified JOSE algorithm identifier (RFC 9864) for a JWK.
-// httpsig 2.x (signature-key -08) takes the algorithm from the key's `alg`
-// member — never derived from kty/crv — and rejects keys without one, as
-// well as the polymorphic "EdDSA". Every JWK we publish or put in a cnf
-// claim must carry it.
+// httpsig 2.x (signature-key -08) and AAuth -11 §Signature Algorithms both
+// take the algorithm from the key's `alg` member — never derived from
+// kty/crv — and reject keys without one, as well as the polymorphic
+// "EdDSA". Every JWK we publish or put in a cnf claim must carry it, so an
+// incoming "EdDSA" is discarded and re-derived from the curve.
function fullySpecifiedAlg(jwk: JsonWebKey): string | undefined {
if (jwk.alg && jwk.alg !== 'EdDSA') return jwk.alg
if (jwk.kty === 'OKP') return jwk.crv // 'Ed25519' | 'Ed448'
@@ -93,11 +107,18 @@ export function decodeJWTPayload(jwt: string): Record {
return JSON.parse(new TextDecoder().decode(base64urlDecode(jwt.split('.')[1])))
}
-// JWT alg → WebCrypto parameters
+// Fully-specified JWT alg → WebCrypto parameters. AAuth §Signature
+// Algorithms: implementations MUST NOT accept `none`, the polymorphic
+// `EdDSA`, or any symmetric algorithm. `EdDSA` is therefore not a key here —
+// a JWT signed with it fails with `unsupported alg: EdDSA` rather than being
+// verified as Ed25519. `Ed25519` and `ES256` are already fully specified;
+// `RS256` names its hash and padding, so it qualifies too.
+//
+// Flag-day blocker: the live person server still signs every AAuth token with
+// `EdDSA` — `HelloCoop/Wallet/svr/issuer/sign.js:32`, `const alg = useEdDSA ?
+// 'EdDSA' : 'RS256'` with `useEdDSA = isAAuthType(typ)`. That line must ship
+// `Ed25519` in the same window as this, or auth tokens stop verifying here.
const JWT_ALG_PARAMS: Record = {
- EdDSA: { importAlgo: { name: 'Ed25519' }, verifyAlgo: 'Ed25519' },
- // RFC 9864 fully-specified identifier; what we mint, and what peers on
- // signature-key -08 conventions mint instead of the polymorphic EdDSA.
Ed25519: { importAlgo: { name: 'Ed25519' }, verifyAlgo: 'Ed25519' },
RS256: {
importAlgo: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
@@ -121,9 +142,16 @@ export async function verifyJWT(
const algParams = JWT_ALG_PARAMS[header.alg]
if (!algParams) throw new Error(`unsupported alg: ${header.alg}`)
- const candidates = jwks.keys.filter((k) =>
- header.kid ? (k as { kid?: string }).kid === header.kid : true
- )
+ const candidates = jwks.keys.filter((k) => {
+ if (header.kid && (k as { kid?: string }).kid !== header.kid) return false
+ // A key whose algorithm disagrees with the header's is not a candidate
+ // (AAuth -11: reject a key whose alg, kty, or crv disagree). The key's
+ // algorithm is its `alg` where it has a fully-specified one, and the
+ // curve otherwise; only RSA-without-alg is indeterminate, and those keys
+ // are still tried — the WebCrypto import below rejects a real mismatch.
+ const keyAlg = fullySpecifiedAlg(k)
+ return !keyAlg || keyAlg === header.alg
+ })
if (candidates.length === 0) throw new Error('no matching key in JWKS')
const signingInput = textEncoder.encode(`${headerB64}.${payloadB64}`)
diff --git a/src/discoverability.ts b/src/discoverability.ts
index a461837..0e518f9 100644
--- a/src/discoverability.ts
+++ b/src/discoverability.ts
@@ -41,5 +41,31 @@ export function llmsTxt(origin: string): string {
Each entry caches the resource's name, description, and access_mode; full,
current metadata is always at the resource's own /.well-known/aauth-resource.json.
+
+### access_mode
+
+The credential flow a resource expects on a first call:
+
+- \`agent-token\` — the resource authorizes on the agent's identity alone
+- \`person-token\` — the resource authorizes on the person's identity alone
+- \`session-token\` — the resource manages authorization itself and issues a
+ session token, returned in the AAuth-Access header
+- \`auth-token\` — the agent obtains an auth token from its person server using
+ a resource token; the initial call presents a person token
+- \`per-call\` — each invocation is authorized individually against that call's
+ parameters (defined by AAuth R3)
+
+This is an IANA registry (AAuth Access Mode Value Registry, Specification
+Required), so the list can grow. An agent that meets a value it does not
+recognize proceeds exactly as it would with no declaration: call the resource
+and read the AAuth-Requirement that comes back. The declaration is advisory —
+the runtime requirement is authoritative.
+
+A resource may also state the mode per operation rather than for the whole
+resource, as an R3 operation access annotation on the operation in its own
+vocabulary (its OpenAPI document, MCP tool list, AsyncAPI document, or OData
+metadata document). Those annotations replace the resource-wide access_mode for the
+operations they cover. This registry lists only the resource-wide value; read
+the resource's vocabulary for per-operation detail.
`
}
diff --git a/src/login.ts b/src/login.ts
index dcf6c3a..164569d 100644
--- a/src/login.ts
+++ b/src/login.ts
@@ -1,10 +1,25 @@
// "Login with Hellō" and submitter identity — both built on the AAuth
-// auth-token flow (no OIDC). A browser web-agent or an agent calls signed
-// with its agent token; the registry returns a resource_token (401 +
-// AAuth-Requirement). The caller takes it to the Person Server (Hellō),
-// the human approves there — the interaction — and the PS returns an
-// auth_token. Retried with the auth_token, the registry verifies it
-// against the PS JWKS and reads the verified person (sub/email/name).
+// auth-token flow (no OIDC). Three signed calls, each a step up the ladder:
+//
+// 1. agent token → 401 `requirement=person-token`. The registry will not
+// issue a resource token to an agent that cannot name a person.
+// 2. person token → verified against the PS's JWKS, then 401
+// `requirement=auth-token` carrying a resource token that copies the
+// person token's `ps`, `sub`, and `jti`.
+// 3. auth token → verified against the PS's JWKS; the registry reads the
+// verified person (sub/email/name) and, at /auth/identity, sets a session.
+//
+// Step 1 is what -11 added here (§Resource Access and Resource Tokens): "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 person server can redeem a resource token — it is the `aud` in
+// three-party and the only party that may call an AS token endpoint — so a
+// resource token naming no person is one nobody can act on. Before this step
+// existed the registry minted resource tokens straight off an agent token and
+// had nothing to put in `ps`/`sub`/`person_token_jti`.
+//
+// This does not change the registry's own `access_mode`: listing resources
+// still needs only an agent token. It is the login flow that climbs.
//
// handleIdentity — GET /auth/identity: on auth_token, set a session.
// resolveSubmitter — POST /resources: require a verified person to add.
@@ -23,9 +38,9 @@ import {
importSigningKey,
signJWT,
verifyJWT,
+ SIGNING_ALG,
} from './crypto'
import { mintSessionCookie, readSession } from './session'
-import { issuerJwks } from './agent-token'
import { emit, emitVerifyFailed } from './events'
import type { Env, SubmitterIdentity } from './types'
@@ -34,6 +49,28 @@ type HonoEnv = { Bindings: Env }
// Identity scopes the registry asks the PS to release (verified email + name).
const LOGIN_SCOPE = 'openid email name'
+// The verified person behind a person token, plus the token's own jti so the
+// resource token can bind to it.
+interface VerifiedPersonToken {
+ ps: string // the person token's iss — the PS that issued it
+ sub: string // directed identifier in that PS's namespace
+ jti: string // person_token_jti — binds the resource token to this token
+}
+
+// Resolve a person/auth-token issuer's JWKS. Per signature-key -08 the
+// metadata document must name the identity it is served under, byte-equal,
+// before its jwks_uri is trusted.
+async function psJwks(iss: string, dwk: string): Promise<{ keys: JsonWebKey[] }> {
+ const metaRes = await fetch(`${iss}/.well-known/${dwk}`)
+ if (!metaRes.ok) throw new Error(`PS metadata: ${metaRes.status}`)
+ const meta = (await metaRes.json()) as Record
+ if (meta.issuer !== iss) throw new Error('PS metadata issuer mismatch')
+ if (!meta.jwks_uri) throw new Error('PS metadata missing jwks_uri')
+ const jwksRes = await fetch(meta.jwks_uri as string)
+ if (!jwksRes.ok) throw new Error(`PS JWKS: ${jwksRes.status}`)
+ return (await jwksRes.json()) as { keys: JsonWebKey[] }
+}
+
// 401 returned when no signature is present, telling the caller what to sign.
function signatureRequired(c: Context): Response {
emitVerifyFailed(c, 'no_signature')
@@ -65,16 +102,7 @@ async function verifyAuthTokenIdentity(
let jwks: { keys: JsonWebKey[] }
try {
- const metaRes = await fetch(`${iss}/.well-known/${dwk}`)
- if (!metaRes.ok) return c.json({ error: `PS metadata: ${metaRes.status}` }, 502) as unknown as Response
- const meta = (await metaRes.json()) as Record
- // signature-key -08: metadata must name the identity it is served under
- // (byte-equal) before its jwks_uri is trusted.
- if (meta.issuer !== iss) return c.json({ error: 'PS metadata issuer mismatch' }, 502) as unknown as Response
- if (!meta.jwks_uri) return c.json({ error: 'PS metadata missing jwks_uri' }, 502) as unknown as Response
- const jwksRes = await fetch(meta.jwks_uri as string)
- if (!jwksRes.ok) return c.json({ error: `PS JWKS: ${jwksRes.status}` }, 502) as unknown as Response
- jwks = (await jwksRes.json()) as { keys: JsonWebKey[] }
+ jwks = await psJwks(iss, dwk)
} catch (err) {
return c.json({ error: `cannot reach PS: ${(err as Error).message}` }, 502) as unknown as Response
}
@@ -106,63 +134,125 @@ async function verifyAuthTokenIdentity(
}
}
-// Verify an agent_token against its provider's JWKS, then mint a
-// resource_token and return a 401 auth-token challenge (the consent step).
-async function challengeForAuthToken(
+// 401 telling an agent to come back with a person token. The header carries
+// no parameters (-11 §Person Token Required) — the agent gets one for this
+// resource from its PS's person token endpoint and retries. An agent with no
+// person server cannot satisfy this and surfaces it as an error.
+function personTokenRequired(c: Context, reason: string): Response {
+ emit(c, {
+ event: 'aauth.registry.person_token_challenge',
+ msg: `person token required: ${reason}`,
+ reason,
+ })
+ return c.json(
+ { error: 'person_token_required' },
+ { status: 401, headers: { 'AAuth-Requirement': 'requirement=person-token' } },
+ ) as unknown as Response
+}
+
+// Verify a person token per -11 §Person Token Verification. `jkt` is the
+// thumbprint of the key that signed the HTTP request. Returns the facts the
+// resource token must copy, or a 400 `invalid_person_token` Response.
+async function verifyPersonToken(
c: Context,
raw: string,
payload: Record,
-): Promise {
- const agentIss = payload.iss as string | undefined
- const agentDwk = (payload.dwk as string) || 'aauth-agent.json'
- if (!agentIss) return c.json({ error: 'agent_token missing iss' }, 401)
+ jkt: string,
+): Promise {
+ const bad = (detail: string, extra: Record = {}) => {
+ emitVerifyFailed(c, 'invalid_person_token', { detail, ...extra })
+ return c.json({ error: 'invalid_person_token', detail }, 400) as unknown as Response
+ }
+
+ // Every check that reads only the payload runs first, so a structurally
+ // wrong token is refused without the registry making an outbound request
+ // on its say-so. Signature verification, the one step that costs two
+ // fetches to a host named inside the token, goes last.
+ // 2. dwk is fixed for this token type; key discovery hangs off it.
+ const dwk = payload.dwk as string | undefined
+ if (dwk !== 'aauth-person.json') return bad(`dwk must be aauth-person.json, got ${dwk ?? 'none'}`)
+
+ // 4. iss must be an https URL with no query or fragment (§Server Identifiers).
+ const iss = payload.iss as string | undefined
+ if (!iss) return bad('missing iss')
try {
- const jwks = await issuerJwks(c.env, agentIss, agentDwk)
- await verifyJWT(raw, jwks)
- } catch (err) {
- emitVerifyFailed(c, 'agent_token_jwt_verify_failed', { iss: agentIss, detail: (err as Error).message })
- return c.json({ error: `agent_token verification failed: ${(err as Error).message}` }, 401)
+ const u = new URL(iss)
+ if (u.protocol !== 'https:' || u.search || u.hash) return bad(`iss is not a server identifier: ${iss}`)
+ } catch {
+ return bad(`iss is not a URL: ${iss}`)
}
+ // 3. exp in the future, iat not in the future.
const now = Math.floor(Date.now() / 1000)
- if (!payload.exp || (payload.exp as number) < now) {
- return c.json({ error: 'agent_token expired' }, 401)
+ if (!payload.exp || (payload.exp as number) < now) return bad('expired', { iss })
+ if (payload.iat && (payload.iat as number) > now + 60) return bad('iat in the future', { iss })
+
+ // 5. aud is this resource.
+ if (payload.aud !== c.env.ORIGIN) {
+ return bad(`aud mismatch: expected ${c.env.ORIGIN}, got ${String(payload.aud)}`, { iss })
}
- const psUrl = payload.ps as string | undefined
- if (!psUrl) return c.json({ error: 'agent_token missing ps claim — cannot establish identity' }, 400)
+ // 6. cnf.jwk is REQUIRED and must be the key that signed the request.
+ const cnf = payload.cnf as { jwk?: JsonWebKey } | undefined
+ if (!cnf?.jwk) return bad('missing cnf.jwk', { iss })
+ if ((await computeJwkThumbprint(cnf.jwk)) !== jkt) {
+ return bad('cnf.jwk is not the key that signed the request', { iss })
+ }
+
+ const sub = payload.sub as string | undefined
+ if (!sub) return bad('missing sub', { iss })
+ const jti = payload.jti as string | undefined
+ if (!jti) return bad('missing jti', { iss })
+
+ // A person token MUST NOT carry scope or account.
+ if (payload.scope !== undefined || payload.account !== undefined) {
+ return bad('person token carries scope or account', { iss })
+ }
- let psIssuer: string
+ // 2 (cont.). Discover the PS JWKS and verify the signature.
try {
- const psRes = await fetch(`${psUrl}/.well-known/aauth-person.json`)
- if (!psRes.ok) return c.json({ error: `PS metadata: ${psRes.status}` }, 502)
- const psMeta = (await psRes.json()) as Record
- if (!psMeta.issuer) return c.json({ error: 'PS metadata missing issuer' }, 502)
- // Byte-equal issuer check (signature-key -08 / RFC 8414 §3.3): don't
- // mint a resource_token aud'd to an issuer the ps host didn't prove.
- if (psMeta.issuer !== psUrl) return c.json({ error: 'PS metadata issuer mismatch' }, 502)
- psIssuer = psMeta.issuer as string
+ await verifyJWT(raw, await psJwks(iss, dwk))
} catch (err) {
- return c.json({ error: `cannot reach PS: ${(err as Error).message}` }, 502)
+ return bad(`signature: ${(err as Error).message}`, { iss })
}
- const cnf = payload.cnf as { jwk: JsonWebKey } | undefined
- if (!cnf?.jwk) return c.json({ error: 'agent_token missing cnf.jwk' }, 400)
- const agentJkt = await computeJwkThumbprint(cnf.jwk)
+ return { ps: iss, sub, jti }
+}
+// Mint a resource token for a verified person and return the 401 auth-token
+// challenge (the consent step). `aud` is the PS that issued the person token:
+// it is the party that can redeem this, and its identity was proved by the
+// byte-equal issuer check in psJwks() during verification.
+async function challengeForAuthToken(
+ c: Context,
+ person: VerifiedPersonToken,
+ agentJkt: string,
+): Promise {
+ const now = Math.floor(Date.now() / 1000)
const origin = c.env.ORIGIN
const privateKey = await importSigningKey(c.env.SIGNING_KEY)
const publicJwk = await getPublicJWK(c.env.SIGNING_KEY)
+ // AAuth -11 §Resource Token Structure.
+ //
+ // - No `agent` claim. A resource token carries no agent identifier;
+ // `agent_jkt` binds it to the agent's key, and the PS learns which agent
+ // is asking from the agent token that signs the token request.
+ // - `ps`, `sub`, `person_token_jti` are copied unchanged from the person
+ // token this registry verified. The PS looks the person token up by
+ // `person_token_jti` among those it issued and rejects the resource
+ // token on any mismatch, so these are not free-form assertions.
const resourceToken = await signJWT(
- { alg: 'Ed25519', typ: 'aa-resource+jwt', kid: publicJwk.kid },
+ { alg: SIGNING_ALG, typ: 'aa-resource+jwt', kid: publicJwk.kid },
{
iss: origin,
dwk: 'aauth-resource.json',
- aud: psIssuer,
+ aud: person.ps,
jti: generateJTI(),
- agent: payload.sub as string,
+ ps: person.ps,
+ sub: person.sub,
+ person_token_jti: person.jti,
agent_jkt: agentJkt,
scope: LOGIN_SCOPE,
iat: now,
@@ -174,8 +264,8 @@ async function challengeForAuthToken(
emit(c, {
event: 'aauth.registry.auth_token_challenge',
msg: 'resource_token minted; auth-token (consent) required',
- agent: payload.sub,
- ps: psUrl,
+ ps: person.ps,
+ user: person.sub,
})
return c.json(
@@ -189,12 +279,18 @@ async function challengeForAuthToken(
) as unknown as Response
}
-// Run httpSigVerify and return the inner JWT (header/payload/raw), or a
-// Response on any signature failure.
+// Run httpSigVerify and return the inner JWT (header/payload/raw) plus the
+// thumbprint of the key that signed the request, or a Response on any
+// signature failure. Under sig=jwt that key comes from the token's own
+// `cnf.jwk`, so the thumbprint is what a `cnf` must match and what goes in a
+// resource token's `agent_jkt`.
async function verifiedJwt(
c: Context,
rawBody?: string,
-): Promise<{ header: Record; payload: Record; raw: string } | Response> {
+): Promise<
+ | { header: Record; payload: Record; raw: string; jkt: string }
+ | Response
+> {
const url = new URL(c.req.url)
const sigResult = await httpSigVerify({
method: c.req.method,
@@ -221,11 +317,13 @@ async function verifiedJwt(
header: sigResult.jwt.header as Record,
payload: sigResult.jwt.payload as Record,
raw: sigResult.jwt.raw,
+ jkt: sigResult.thumbprint,
}
}
-// GET /auth/identity — the human login endpoint. agent_token → challenge;
-// auth_token → verify, set session cookie, return claims.
+// GET /auth/identity — the human login endpoint, one rung per call:
+// agent_token → person-token challenge; person_token → resource-token
+// challenge; auth_token → verify, set session cookie, return claims.
export async function handleIdentity(c: Context): Promise {
const v = await verifiedJwt(c)
if (v instanceof Response) return v
@@ -237,8 +335,13 @@ export async function handleIdentity(c: Context): Promise {
emit(c, { event: 'aauth.registry.login', msg: `human logged in: ${id.sub}`, user: id.sub, ps: id.ps, email: id.email })
return c.json({ status: 'logged_in', ...id }, 200, { 'Set-Cookie': cookie })
}
+ if (v.header.typ === 'aa-person+jwt') {
+ const person = await verifyPersonToken(c, v.raw, v.payload, v.jkt)
+ if (person instanceof Response) return person
+ return challengeForAuthToken(c, person, v.jkt)
+ }
if (v.header.typ === 'aa-agent+jwt') {
- return challengeForAuthToken(c, v.raw, v.payload)
+ return personTokenRequired(c, 'agent token presented at /auth/identity')
}
emitVerifyFailed(c, 'unsupported_jwt_type', { jwt_typ: v.header.typ })
return c.json({ error: `unsupported JWT type: ${v.header.typ}` }, 400)
@@ -252,8 +355,15 @@ export interface ResolvedSubmitter {
// Resolve the verified person behind a POST /resources call:
// - auth_token signature → identity from the token
// - agent_token + valid session → identity from the web session
-// - agent_token, no session → 401 auth-token challenge (consent)
+// - agent_token, no session → 401 person-token challenge
+// - person_token → 401 auth-token challenge (consent)
// Returns the submitter, or a Response (challenge / error) to return as-is.
+//
+// A person token alone is not enough to attribute an entry: it names the
+// person as a directed `(iss, sub)` pair and deliberately carries no scope,
+// so it cannot release the verified email and name an entry is credited
+// with. It is the rung that earns the resource token; the auth token that
+// comes back carries the claims.
export async function resolveSubmitter(
c: Context,
rawBody: string,
@@ -268,13 +378,19 @@ export async function resolveSubmitter(
return { user: id, agent: act?.sub }
}
+ if (v.header.typ === 'aa-person+jwt') {
+ const person = await verifyPersonToken(c, v.raw, v.payload, v.jkt)
+ if (person instanceof Response) return person
+ return challengeForAuthToken(c, person, v.jkt)
+ }
+
if (v.header.typ === 'aa-agent+jwt') {
// A logged-in human (web UI): the web-agent signs and the session
// cookie carries the verified person established at login.
const session = await readSession(c)
if (session) return { user: session, agent: v.payload.sub as string }
- // Otherwise the agent must obtain an auth_token → trigger consent.
- return challengeForAuthToken(c, v.raw, v.payload)
+ // Otherwise start the climb: person token first.
+ return personTokenRequired(c, 'agent token presented at POST /resources')
}
emitVerifyFailed(c, 'unsupported_jwt_type', { jwt_typ: v.header.typ })
diff --git a/src/session.ts b/src/session.ts
index a0b8e6e..8b8035a 100644
--- a/src/session.ts
+++ b/src/session.ts
@@ -5,7 +5,7 @@
// JWK), so no extra secret is needed.
import type { Context } from 'hono'
-import { getPublicJWK, importSigningKey, signJWT, verifyJWT } from './crypto'
+import { getPublicJWK, importSigningKey, signJWT, verifyJWT, SIGNING_ALG } from './crypto'
import type { Env, SubmitterIdentity } from './types'
type HonoEnv = { Bindings: Env }
@@ -20,7 +20,11 @@ export async function mintSessionCookie(env: Env, id: HumanIdentity): Promise {
const url = `${origin}/.well-known/aauth-resource.json`
@@ -122,7 +141,7 @@ export function buildEntry(
issuer: meta.issuer,
name: meta.name?.trim() || meta.issuer,
description: meta.description!.trim(),
- access_mode: (meta.access_mode as AccessMode) ?? 'agent-token',
+ access_mode: (meta.access_mode as AccessMode) ?? DEFAULT_ACCESS_MODE,
...(meta.documentation_uri ? { documentation_uri: meta.documentation_uri } : {}),
added: new Date().toISOString(),
submitted_by,