diff --git a/README.md b/README.md
index 8671c0e..df8dcd2 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,9 @@ Live at [web-agent.aauth.dev](https://web-agent.aauth.dev).
- Registers and authenticates users via WebAuthn passkeys
- Issues ephemeral `aa-agent+jwt` agent tokens bound to a browser-generated Ed25519 key pair
- Publishes `/.well-known/aauth-agent.json` and `/.well-known/jwks.json`
+- Wearing its resource hat, requires an `aa-person+jwt` person token at
+ `/authorize` and issues `aa-resource+jwt` resource tokens naming the
+ person that token identified
## Getting started
diff --git a/client/protocol.js b/client/protocol.js
index d4f4c65..7e0666f 100644
--- a/client/protocol.js
+++ b/client/protocol.js
@@ -624,6 +624,152 @@ function getHints() {
return {}
}
+// ── Person Server metadata ──
+//
+// Every PS publishes /.well-known/aauth-person.json. `person_token_endpoint`
+// is where the agent asks for a person token; `auth_token_endpoint` is where
+// it later trades a resource token for an auth token. (Through AAuth -10 the
+// latter was called `token_endpoint`; -11 renamed it, because there are now
+// two token endpoints.)
+async function fetchPsMetadata(bindingPs, requiredField) {
+ const psMetadataUrl = `${bindingPs.replace(/\/$/, '')}/.well-known/aauth-person.json`
+ try {
+ const metaRes = await fetch(psMetadataUrl)
+ const psMetadata = await metaRes.json().catch(() => null)
+ if (!metaRes.ok || !psMetadata?.[requiredField]) {
+ addLogStep('Person Server metadata fetch failed', 'error',
+ `
The Person Server's metadata is missing ${escapeHtml(requiredField)}.
` +
+ formatResponse(metaRes.status, null, psMetadata) + anotherRequestButton())
+ return null
+ }
+ return psMetadata
+ } catch (err) {
+ addLogStep('Person Server metadata fetch failed', 'error',
+ `${escapeHtml(err.message)}
` + anotherRequestButton())
+ return null
+ }
+}
+
+// ── Person token ──
+//
+// The first hop of every resource call under AAuth -11. A resource MUST
+// have verified a person token before it issues a resource token, so the
+// agent asks its Person Server for one naming the resource it is about to
+// call. The request is signed with the agent's key and presents the
+// agent_token; the PS returns an `aa-person+jwt` whose `aud` is that
+// resource, whose `sub` is the person's directed identifier there, and
+// whose `cnf.jwk` is this agent's signing key. The agent then presents it
+// in the Signature-Key header in place of its agent_token — which is how
+// the resource learns who the agent acts for without the agent asserting
+// it. Nothing about the agent's own identity travels in that token.
+//
+// The PS MAY require the person to approve first, in which case it answers
+// 202 and the agent long-polls (#person-token-endpoint). That is the
+// ordinary first run, not an edge case: the question the PS puts to the
+// person is whether this agent may act at the resource AS THEM, and Wallet
+// asks it on first contact with any resource the person has not used. So
+// the very first Whoami/Notes click of a fresh demo goes through consent
+// here, before any resource_token exists.
+//
+// Returns { personToken, psMetadata } or null (the failure is logged).
+// Does not resolve at all when the person approves after a same-tab
+// redirect — resumePendingAuthorize continues the flow on return.
+async function fetchPersonToken({
+ resource,
+ bindingPs,
+ keyPair,
+ agentToken,
+ signingJwk,
+ missionS256,
+ consentKey,
+ pendingRecord,
+}) {
+ const psMetadata = await fetchPsMetadata(bindingPs, 'person_token_endpoint')
+ if (!psMetadata) return null
+
+ const endpoint = psMetadata.person_token_endpoint
+ const path = new URL(endpoint).pathname
+ // `mission_s256` scopes the token to a mission when the agent is
+ // working under one; the resource copies it into the resource token so
+ // the PS can confirm the mission was never stripped.
+ const requestBody = missionS256 ? { resource, mission_s256: missionS256 } : { resource }
+
+ const step = addLogStep(
+ fmt(copy('person_token.request.label_template'), { path }),
+ 'pending',
+ desc('person_token.request'),
+ )
+ try {
+ const { response: res, sent } = await sigFetch(endpoint, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(requestBody),
+ signingKey: signingJwk,
+ signingCryptoKey: keyPair.privateKey,
+ signatureKey: { type: 'jwt', jwt: agentToken },
+ // A request with a body to a PS endpoint signs content-digest and
+ // content-type as well, so the body is covered by the signature.
+ components: SIGNED_COMPONENTS_WITH_BODY,
+ returnSent: true,
+ })
+ appendStepBody(step, formatRequest(sent.method, sent.url, headersToObject(sent.headers), tryParseBody(sent.body)))
+ const body = await res.json().catch(() => null)
+ const respHeaders = {}
+ for (const key of ['location', 'retry-after', 'aauth-requirement']) {
+ const v = res.headers.get(key)
+ if (v) respHeaders[key] = v
+ }
+ // 202 is a success at this step — the request was accepted and the
+ // Person Server is asking you first. A 200 without a person_token is
+ // not, however it is dressed up.
+ const accepted = (res.status === 200 && !!body?.person_token) || res.status === 202
+ resolveStep(step, accepted ? 'success' : 'error',
+ fmt(copy('person_token.request.label_resolved_template'), { path, status: res.status }))
+ appendStepBody(step, formatResponse(res.status, respHeaders, body))
+
+ if (res.status === 200 && body?.person_token) {
+ appendStepBody(step, formatDecoded(decodeJWTBrowser(body.person_token), 'person_token decoded'))
+ return { personToken: body.person_token, psMetadata }
+ }
+
+ if (res.status === 202) {
+ const personToken = await runDeferredResponse({
+ res,
+ body,
+ endpoint,
+ psMetadata,
+ consentKey: `${consentKey}-person`,
+ copyPrefix: 'person_token',
+ tokenField: 'person_token',
+ consentLabel: copy('person_token.ps_consent_prompt.label'),
+ consentDescription: desc('person_token.ps_consent_prompt'),
+ pendingRecord: { ...pendingRecord, stage: 'person-token', psUrl: bindingPs },
+ })
+ if (!personToken) return null
+ showPersonTokenReceived(personToken)
+ return { personToken, psMetadata }
+ }
+
+ appendStepBody(step, anotherRequestButton())
+ return null
+ } catch (err) {
+ resolveStep(step, 'error', fmt(copy('person_token.request.label_error_network_template'), { path }))
+ appendStepBody(step, `${escapeHtml(err.message)}
` + anotherRequestButton())
+ return null
+ }
+}
+
+// The 200 path renders the decoded person_token inline on the request
+// step. The 202 path arrives via long-poll long after that step resolved,
+// so without this the token the person just approved would never surface.
+function showPersonTokenReceived(personToken) {
+ addLogStep(copy('person_token.received.label'), 'success',
+ desc('person_token.received') +
+ formatDecoded(decodeJWTBrowser(personToken), 'person_token decoded'),
+ { kind: 'response' },
+ )
+}
+
// ── Bootstrap ──
//
// Per draft-hardt-aauth-bootstrap, the agent provider issues an agent
@@ -772,7 +918,6 @@ async function rebindPs(psUrl) {
clearAllPersistedLogs()
localStorage.removeItem(NOTES_AUTH_TOKEN_KEY)
localStorage.removeItem(PENDING_AUTHZ_KEY)
- localStorage.removeItem('aauth-pending-whoami')
document.getElementById('bootstrap-artifacts')?.classList.remove('hidden')
setActiveLog('bootstrap-log')
@@ -846,14 +991,18 @@ function getBoundPs() {
// ── Whoami resource call ──
//
-// Three-step ceremony that demonstrates the full resource-call flow:
+// Four-step ceremony that demonstrates the full resource-call flow:
//
-// 1. Agent GETs whoami with its agent_token. Whoami responds 401 with
-// a minted resource_token in AAuth-Requirement — it knows who the
-// agent is, but the agent hasn't presented a user-released token yet.
-// 2. Agent exchanges the resource_token at the PS's /token endpoint.
-// Returns auth_token on 200 (user already consented to this scope
-// pair) or 202 + interaction on first-time consent.
+// 0. Agent asks its Person Server for a person token naming whoami.
+// The PS returns an aa-person+jwt bound to the agent's key.
+// 1. Agent GETs whoami presenting that person token. Whoami verifies
+// it, learns who the agent acts for, and responds 401 with a minted
+// resource_token in AAuth-Requirement — it now knows the person, but
+// no authorization has been released yet.
+// 2. Agent exchanges the resource_token at the PS's auth_token_endpoint,
+// signing that request with its agent_token. Returns auth_token on
+// 200 (user already consented to this scope pair) or 202 +
+// interaction on first-time consent.
// 3. Agent retries the GET with auth_token. Whoami verifies the token
// against the PS's JWKS, checks 'whoami' scope, and returns the
// identity claims encoded in the payload.
@@ -912,14 +1061,47 @@ async function runWhoamiCall(whoamiUrl, bindingPs, hints) {
addLogSection(copy('sections.whoami'))
+ // Step 0: get a person token for whoami. Without one the resource can't
+ // issue a resource_token — it would have no PS-asserted person to name
+ // in it, and only the PS can act on a resource token. This may block on
+ // the person's approval at the PS; if that approval arrives after a
+ // same-tab redirect, this call never returns and resumePendingAuthorize
+ // re-enters at continueWhoami below.
+ const personResult = await fetchPersonToken({
+ resource: new URL(whoamiUrl).origin,
+ bindingPs,
+ keyPair,
+ agentToken,
+ signingJwk,
+ consentKey: 'whoami',
+ pendingRecord: { whoamiUrl },
+ })
+ if (!personResult) return
+
+ await continueWhoami({
+ whoamiUrl,
+ bindingPs,
+ hints,
+ keyPair,
+ agentToken,
+ signingJwk,
+ personToken: personResult.personToken,
+ psMetadata: personResult.psMetadata,
+ })
+}
+
+// Everything after the person token is in hand. Called directly by
+// runWhoamiCall, and by resumePendingAuthorize when the person approved
+// the person token after a same-tab redirect to the PS.
+async function continueWhoami({ whoamiUrl, bindingPs, hints, keyPair, agentToken, signingJwk, personToken, psMetadata }) {
const urlObj = new URL(whoamiUrl)
const whoamiPathDisplay = urlObj.pathname + urlObj.search
- // Step 1: unauthenticated-for-user GET. Agent token proves the agent's
- // identity but carries no user claims, so whoami bounces with a
- // resource_token the agent can trade at the PS.
+ // Step 1: GET whoami presenting the person token. It identifies the
+ // person to this one resource but releases no authorization, so whoami
+ // bounces with a resource_token the agent can trade at the PS.
const step1 = addLogStep(`Agent → Whoami`, 'pending',
- `Agent calls whoami with its agent_token. The resource knows the agent but has no user claims yet, so it returns 401 with a resource_token the agent can exchange at the Person Server.
`)
+ `Agent calls whoami presenting the person token in place of its agent_token. The resource now knows who the agent acts for, but identity is not authorization — so it returns 401 with a resource_token the agent can exchange at the Person Server.
`)
let resourceToken
try {
@@ -927,7 +1109,7 @@ async function runWhoamiCall(whoamiUrl, bindingPs, hints) {
method: 'GET',
signingKey: signingJwk,
signingCryptoKey: keyPair.privateKey,
- signatureKey: { type: 'jwt', jwt: agentToken },
+ signatureKey: { type: 'jwt', jwt: personToken },
components: SIGNED_COMPONENTS,
returnSent: true,
})
@@ -940,15 +1122,15 @@ async function runWhoamiCall(whoamiUrl, bindingPs, hints) {
resourceToken = parseInteractionHeader(requirement)['resource-token']
}
- // 200 with no scope requested: whoami returns the agent identity
- // (sub + ps) directly off the agent_token without needing user
- // claims. No PS exchange step — render the body as the final
- // response and end the flow.
+ // 200 with no scope requested: whoami answers on identity alone,
+ // reading `ps` + `sub` straight off the person token without
+ // releasing any claims. No PS exchange step — render the body as the
+ // final response and end the flow.
if (res.status === 200) {
resolveStep(step1, 'success', `Agent → Whoami`)
appendStepBody(step1, formatResponse(200, respHeaders, body))
- addLogStep('Agent identity received', 'success',
- `No scopes were requested, so whoami returned the agent's own identity straight from the agent_token — no Person Server exchange needed.
` +
+ addLogStep('Person identity received', 'success',
+ `No scopes were requested, so whoami answered on identity alone — the ps and sub off the person token. No auth token, no Person Server exchange.
` +
tokenWrap(renderJSON(body)) +
anotherRequestButton(),
{ kind: 'response' }
@@ -982,6 +1164,7 @@ async function runWhoamiCall(whoamiUrl, bindingPs, hints) {
await runPSTokenExchange({
resourceToken,
bindingPs,
+ psMetadata,
hints,
keyPair,
agentToken,
@@ -993,12 +1176,11 @@ async function runWhoamiCall(whoamiUrl, bindingPs, hints) {
? `Agent → Person Server`
: `Agent → Person Server → ${status}`,
postLabelNetworkError: (path) => `Agent → Person Server (network error)`,
- postDescription: `Agent presents the resource_token and its agent_token to the Person Server's token endpoint. The PS either releases an auth_token immediately (cached consent) or returns a 202 with a consent prompt.
`,
- pollLabel: (path) => `Agent → Person Server (long-poll)`,
- pollDescription: `Agent keeps a request open while you decide, instead of polling. The Person Server answers the moment you approve or deny.
`,
+ postDescription: `Agent presents the resource_token and its agent_token to the Person Server's auth token endpoint. The PS looks up the person token named by person_token_jti, checks the resource_token's ps and sub against it, then either releases an auth_token immediately (cached consent) or returns a 202 with a consent prompt.
`,
consentLabel: copy('authorize.ps_consent_prompt.label'),
consentDescription: desc('authorize.ps_consent_prompt'),
},
+ copyPrefix: 'authorize',
consentKey: 'whoami',
pendingExtra: { whoamiUrl },
onAuthToken: async (token, { viaPolling }) => {
@@ -1166,9 +1348,19 @@ function clearPendingAuthorize() {
// skew at the 60s tolerance boundary.
let _resumeAuthorizePolling = false
-// Called on page load: if we have a persisted pending-authorize, resume
-// polling the PS for auth_token. Mounted after app.js init so the
-// signing key + agent_token are already restored.
+// Called on page load: if we have a persisted pending record, resume
+// polling the PS for whichever token was deferred. Mounted after app.js
+// init so the signing key + agent_token are already restored.
+//
+// Two stages can defer, and the record's `stage` says which:
+// 'person-token' — the PS asked whether this agent may act at the
+// resource as the person. Resuming polls for the
+// person token and then re-enters the ceremony at
+// continueWhoami / continueNotesAuthorize, which run
+// the resource call and the auth-token exchange.
+// 'auth-token' — the PS asked which scopes to release. Resuming
+// polls for the auth token and finishes the flow.
+// Records written before `stage` existed are treated as 'auth-token'.
async function resumePendingAuthorize() {
let saved
try { saved = JSON.parse(localStorage.getItem(PENDING_AUTHZ_KEY) || 'null') } catch { saved = null }
@@ -1217,7 +1409,12 @@ async function resumePendingAuthorize() {
// notesAuthorize) tell us which branch to rehydrate. Default to
// whoami for records saved before the notes flow existed.
const isNotes = !!saved.notesAuthorize
- const promptKey = isNotes ? 'notes_resumed.ps_consent_prompt' : 'whoami_resumed.ps_consent_prompt'
+ // The person-token leg asks a different question than the auth-token
+ // leg — whether the agent may act as you at the resource, not which
+ // scopes to release — so it gets its own resumed copy.
+ const promptKey = saved.stage === 'person-token'
+ ? 'person_token_resumed.ps_consent_prompt'
+ : (isNotes ? 'notes_resumed.ps_consent_prompt' : 'whoami_resumed.ps_consent_prompt')
// Persisted log (restored at init) should already carry the in-progress
// Notes/Whoami section; append into it rather than branching a new
// "(resumed)" section. Fallback opens a fresh section if nothing's
@@ -1233,43 +1430,78 @@ async function resumePendingAuthorize() {
// stops the flare, turning it into a completed record without
// blowing out the section. Fallback creates a fresh step if the
// persisted log was cleared mid-flow.
- const consentKey = isNotes ? 'notes' : 'whoami'
+ const isPersonStage = saved.stage === 'person-token'
+ // The person-token leg tags its steps with a '-person' suffix so both
+ // consent cards can coexist in one ceremony's log without colliding.
+ const consentKey = `${isNotes ? 'notes' : 'whoami'}${isPersonStage ? '-person' : ''}`
let interactionStep = log.querySelector(`[data-consent-key="${consentKey}"]`)
if (!interactionStep) {
interactionStep = addLogStep(copy(`${promptKey}.label`), 'pending', desc(promptKey))
}
- // On auth_token arrival, route to the flow-specific handler:
+ // Reuse pre-redirect pollStep if the persisted log carries it —
+ // otherwise the poll loop would create a fresh one, leaving the
+ // original stuck pending.
+ const existingPollStep = log.querySelector(`[data-poll-key="${consentKey}"]`)
+ const signingJwk = await exportSigningJwk(keyPair)
+
+ const token = await startDeferredPolling(
+ saved.pollUrl, saved.tokenEndpoint, interactionStep, existingPollStep || null,
+ {
+ tokenField: isPersonStage ? 'person_token' : 'auth_token',
+ copyPrefix: isPersonStage ? 'person_token' : (isNotes ? 'notes' : 'authorize'),
+ // No continuation below for a record with neither marker — fall
+ // back to the generic "Authorization Granted" step.
+ renderGranted: !isPersonStage && !isNotes && !saved.whoamiUrl,
+ },
+ )
+ if (!token) return true
+
+ if (isPersonStage) {
+ // The person approved the agent acting as them at this resource.
+ // Pick the ceremony back up where fetchPersonToken left off. Pass no
+ // psMetadata — the continuation re-fetches the PS document rather
+ // than carrying it across a page load.
+ showPersonTokenReceived(token)
+ if (isNotes) {
+ await continueNotesAuthorize({
+ authzEndpoint: saved.authzEndpoint || `${window.NOTES_ORIGIN}/authorize`,
+ operations: saved.operations || [],
+ bindingPs: saved.psUrl,
+ hints: getHints(),
+ keyPair,
+ agentToken,
+ signingJwk,
+ personToken: token,
+ psMetadata: null,
+ })
+ } else if (saved.whoamiUrl) {
+ await continueWhoami({
+ whoamiUrl: saved.whoamiUrl,
+ bindingPs: saved.psUrl,
+ hints: getHints(),
+ keyPair,
+ agentToken,
+ signingJwk,
+ personToken: token,
+ psMetadata: null,
+ })
+ }
+ return true
+ }
+
+ // Auth-token stage: route to the flow-specific finish.
// notes → finalizeNotesAuthToken persists the token and mounts the
// Notes app.
// whoami → retryWhoami replays the GET whoami/?scope=… signed with
// the fresh auth_token and renders identity claims.
- // (neither marker) → startAuthTokenPolling falls through to the
- // generic "Authorization Granted" step.
- let options = {}
if (isNotes) {
- options = {
- onAuthToken: async (tokenFromPoll) => {
- await finalizeNotesAuthToken(tokenFromPoll)
- },
- }
+ await finalizeNotesAuthToken(token)
} else if (saved.whoamiUrl) {
const urlObj = new URL(saved.whoamiUrl)
- const whoamiPathDisplay = urlObj.pathname + urlObj.search
- const signingJwk = await exportSigningJwk(keyPair)
- options = {
- onAuthToken: async (tokenFromPoll) => {
- showWhoamiAuthTokenReceived(tokenFromPoll)
- await retryWhoami(saved.whoamiUrl, whoamiPathDisplay, tokenFromPoll, keyPair, signingJwk)
- },
- }
+ showWhoamiAuthTokenReceived(token)
+ await retryWhoami(saved.whoamiUrl, urlObj.pathname + urlObj.search, token, keyPair, signingJwk)
}
-
- // Reuse pre-redirect pollStep if persisted log carries it — otherwise
- // startAuthTokenPolling would create a fresh one, leaving the
- // original stuck pending.
- const existingPollStep = log.querySelector(`[data-poll-key="${consentKey}"]`)
- startAuthTokenPolling(saved.pollUrl, saved.tokenEndpoint, interactionStep, existingPollStep || null, options)
return true
}
window.resumePendingAuthorize = resumePendingAuthorize
@@ -1301,14 +1533,17 @@ if (document.readyState === 'complete') {
// The pre-PS phase differs (whoami: 401 bounce on the resource itself;
// notes: discovery + openapi + POST /authorize) and the post-token
// phase differs (whoami: retry the GET, render identity claims; notes:
-// mount the Notes app, refresh the list), but PS metadata fetch +
-// POST /aauth/token + the 200/202 split + savePendingAuthorize +
+// mount the Notes app, refresh the list), but the POST to the PS's
+// auth_token_endpoint + the 200/202 split + savePendingAuthorize +
// kicking off the long-poll are identical. Per-flow log strings are
// passed in via `labels` so the rendered output stays exactly what
// each flow had before.
async function runPSTokenExchange({
resourceToken,
bindingPs,
+ // PS metadata already fetched for the person-token hop. Both flows
+ // pass it through rather than re-fetching the same document.
+ psMetadata,
hints,
keyPair,
agentToken,
@@ -1316,6 +1551,10 @@ async function runPSTokenExchange({
// Per-flow labels/descriptions. Functions where the value depends
// on runtime state (path, status); plain strings/HTML otherwise.
labels,
+ // log-text.json block the deferred (202) leg reads its long-poll and
+ // denied/timed-out narration from: 'authorize' for whoami, 'notes' for
+ // notes.
+ copyPrefix,
// 'whoami' | 'notes' — written to data-poll-key / data-consent-key
// so resumePendingAuthorize can re-locate the steps after a same-tab
// PS redirect.
@@ -1332,23 +1571,16 @@ async function runPSTokenExchange({
// ignores it — its finalizeNotesAuthToken always emits its own step.
onAuthToken,
}) {
- const psMetadataUrl = `${bindingPs.replace(/\/$/, '')}/.well-known/aauth-person.json`
- let psMetadata
- try {
- const metaRes = await fetch(psMetadataUrl)
- psMetadata = await metaRes.json()
- if (!metaRes.ok || !psMetadata?.token_endpoint) {
- addLogStep('Person Server metadata fetch failed', 'error',
- formatResponse(metaRes.status, null, psMetadata) + anotherRequestButton())
- return
- }
- } catch (err) {
+ if (!psMetadata) psMetadata = await fetchPsMetadata(bindingPs, 'auth_token_endpoint')
+ if (!psMetadata) return
+ if (!psMetadata.auth_token_endpoint) {
addLogStep('Person Server metadata fetch failed', 'error',
- `${escapeHtml(err.message)}
` + anotherRequestButton())
+ `The Person Server's metadata is missing auth_token_endpoint.
` +
+ tokenWrap(renderJSON(psMetadata)) + anotherRequestButton())
return
}
- const tokenEndpoint = psMetadata.token_endpoint
+ const tokenEndpoint = psMetadata.auth_token_endpoint
const psPath = new URL(tokenEndpoint).pathname
const psBody = {
resource_token: resourceToken,
@@ -1371,6 +1603,8 @@ async function runPSTokenExchange({
signingKey: signingJwk,
signingCryptoKey: keyPair.privateKey,
signatureKey: { type: 'jwt', jwt: agentToken },
+ // Body-carrying request to a PS endpoint: cover content-digest and
+ // content-type so the resource_token being exchanged is signed over.
components: SIGNED_COMPONENTS_WITH_BODY,
returnSent: true,
})
@@ -1397,52 +1631,20 @@ async function runPSTokenExchange({
resolveStep(step2, 'success', labels.postLabelResolved(psPath, 202))
appendStepBody(step2, formatResponse(202, respHeaders, psResBody))
- const reqHeader = psRes.headers.get('aauth-requirement') || ''
- const fromHeader = parseInteractionHeader(reqHeader)
- const interaction = {
- requirement: fromHeader.requirement || psResBody?.requirement,
- code: fromHeader.code || psResBody?.code,
- url: fromHeader.url || psMetadata.interaction_endpoint,
- }
- const pollUrl = psRes.headers.get('location') || psResBody?.location
-
- let pollStep = null
- if (pollUrl) {
- const absolutePollUrl = new URL(pollUrl, tokenEndpoint).href
- pollStep = addLogStep(labels.pollLabel(new URL(absolutePollUrl).pathname), 'pending',
- labels.pollDescription)
- // Real signed-request headers + body are filled in by pollPendingAuthorize
- // on its first cycle so we don't repeat fabricated placeholders here.
- if (pollStep) {
- pollStep.dataset.pollKey = consentKey
- persistActiveLog()
- }
- }
- const interactionStep = addLogStep(labels.consentLabel, 'pending',
- labels.consentDescription + renderInteraction(interaction, pollUrl, 'authorize'))
- // Tag so resumePendingAuthorize can reuse this step on return
- // from the PS instead of leaving the stale "Continue with Hellō /
- // QR" card alongside the fresh poll step.
- if (interactionStep) {
- interactionStep.dataset.consentKey = consentKey
- persistActiveLog()
- }
-
- if (pollUrl) {
- const absolutePollUrl = new URL(pollUrl, tokenEndpoint).href
- savePendingAuthorize({
- pollUrl: absolutePollUrl,
- tokenEndpoint,
- psUrl: bindingPs,
- ...pendingExtra,
- })
- startAuthTokenPolling(pollUrl, tokenEndpoint, interactionStep, pollStep, {
- onAuthToken: async (tokenFromPoll) => {
- await onAuthToken(tokenFromPoll, { viaPolling: true })
- },
- })
- }
- return // polling handles the rest
+ const tokenFromPoll = await runDeferredResponse({
+ res: psRes,
+ body: psResBody,
+ endpoint: tokenEndpoint,
+ psMetadata,
+ consentKey,
+ copyPrefix,
+ tokenField: 'auth_token',
+ consentLabel: labels.consentLabel,
+ consentDescription: labels.consentDescription,
+ pendingRecord: { ...pendingExtra, stage: 'auth-token', psUrl: bindingPs },
+ })
+ if (tokenFromPoll) await onAuthToken(tokenFromPoll, { viaPolling: true })
+ return // the deferred leg handled the rest
} else {
resolveStep(step2, 'error', labels.postLabelResolved(psPath, psRes.status))
appendStepBody(step2, formatResponse(psRes.status, respHeaders, psResBody) + anotherRequestButton())
@@ -1459,32 +1661,112 @@ async function runPSTokenExchange({
await onAuthToken(authToken, { viaPolling: false })
}
-// ── Auth-token polling (for PS /token interaction flow) ──
+// ── Deferred-response polling ──
+//
+// Both PS token endpoints answer 202 when the person has to decide first:
+// `person_token_endpoint` asks whether this agent may act at the resource
+// as them, `auth_token_endpoint` asks which scopes to release. The shape
+// is identical either way — Location, Retry-After, an
+// `AAuth-Requirement: requirement=interaction` with url + code, and a
+// long-poll until terminal — so one loop drives both. `tokenField` names
+// the member the 200 carries (`person_token` or `auth_token`) and
+// `copyPrefix` selects the narration block in log-text.json.
+//
+// Long-poll pattern: send `Prefer: wait=POLL_WAIT_SECONDS` and loop
+// immediately on 202. Agent token + ephemeral key are snapshotted once at
+// start; the polling is signed with sig=jwt using them.
//
-// Long-poll pattern: send `Prefer: wait=POLL_WAIT_SECONDS`
-// and loop immediately on 202. Agent token + ephemeral key are snapshotted
-// once at start; the polling is signed with sig=jwt using them.
-
-// Module-level guard: at most one authz poll loop ever running. Callers
-// (runWhoamiCall, resumePendingAuthorize) may each invoke us
-// independently; without this flag their loops interleave and one loop's
-// signature stamps trail the other's by 30s+, which the PS sees as stale
-// signatures and rejects with skew-at-tolerance-boundary 401s. Clear on
-// terminal status (200 / 403 / 408) so a follow-up authorization can
-// start fresh.
-let _authzPollRunning = false
-
-async function startAuthTokenPolling(pollUrl, baseUrl, interactionStep, pollStep, options = {}) {
- if (_authzPollRunning) return
- _authzPollRunning = true
+// Resolves with the token string, or null if the interaction was denied,
+// expired, or timed out. Never resolves when the person approves after a
+// same-tab redirect — the page is gone by then, and resumePendingAuthorize
+// picks the flow back up on return.
+
+// Module-level guard: at most one poll loop ever running. Callers
+// (fetchPersonToken, runPSTokenExchange, resumePendingAuthorize) may each
+// invoke us independently; without this flag their loops interleave and
+// one loop's signature stamps trail the other's by 30s+, which the PS sees
+// as stale signatures and rejects with skew-at-tolerance-boundary 401s.
+// Cleared on terminal status (200 / 403 / 404 / 408) so the next leg of
+// the same ceremony — and any follow-up authorization — can start fresh.
+let _deferredPollRunning = false
+
+async function startDeferredPolling(pollUrl, baseUrl, interactionStep, pollStep, options = {}) {
+ if (_deferredPollRunning) return null
+ _deferredPollRunning = true
try {
- await _startAuthTokenPollingImpl(pollUrl, baseUrl, interactionStep, pollStep, options)
+ return await _deferredPollingImpl(pollUrl, baseUrl, interactionStep, pollStep, options)
} finally {
- _authzPollRunning = false
+ _deferredPollRunning = false
+ }
+}
+
+// Render a 202 from either PS token endpoint and drive it to a token:
+// long-poll step, the consent card the person acts on, and a persisted
+// pending record so a same-tab redirect to the PS can be resumed on
+// return. Resolves with the token, or null if the person declined or the
+// interaction lapsed.
+async function runDeferredResponse({
+ res,
+ body,
+ endpoint,
+ psMetadata,
+ // 'whoami' | 'notes', suffixed per leg — written to data-poll-key /
+ // data-consent-key so resumePendingAuthorize can re-locate both steps
+ // after the redirect instead of orphaning them as stale pending rows.
+ consentKey,
+ copyPrefix,
+ tokenField,
+ consentLabel,
+ consentDescription,
+ // Merged into the persisted record; carries `stage` plus whatever the
+ // resumed flow needs to pick up where it left off.
+ pendingRecord,
+}) {
+ const fromHeader = parseInteractionHeader(res.headers.get('aauth-requirement') || '')
+ const interaction = {
+ requirement: fromHeader.requirement || body?.requirement,
+ code: fromHeader.code || body?.code,
+ url: fromHeader.url || psMetadata?.interaction_endpoint,
+ }
+ const pollUrl = res.headers.get('location') || body?.location
+ if (!pollUrl) {
+ addLogStep('Deferred response missing Location', 'error',
+ `The Person Server answered 202 without a Location to poll, so the agent has nowhere to wait.
` +
+ anotherRequestButton())
+ return null
+ }
+ const absolutePollUrl = new URL(pollUrl, endpoint).href
+
+ const pollStep = addLogStep(
+ fmt(copy(`${copyPrefix}.ps_pending_longpoll.label_template`), { path: new URL(absolutePollUrl).pathname }),
+ 'pending',
+ desc(`${copyPrefix}.ps_pending_longpoll`),
+ )
+ // Real signed-request headers + body are filled in by the poll loop on
+ // its first cycle so we don't repeat fabricated placeholders here.
+ if (pollStep) {
+ pollStep.dataset.pollKey = consentKey
+ persistActiveLog()
+ }
+
+ const interactionStep = addLogStep(consentLabel, 'pending',
+ consentDescription + renderInteraction(interaction, pollUrl, 'authorize'))
+ if (interactionStep) {
+ interactionStep.dataset.consentKey = consentKey
+ persistActiveLog()
}
+
+ savePendingAuthorize({ ...pendingRecord, pollUrl: absolutePollUrl, tokenEndpoint: endpoint })
+
+ return startDeferredPolling(absolutePollUrl, endpoint, interactionStep, pollStep, {
+ tokenField,
+ copyPrefix,
+ })
}
-async function _startAuthTokenPollingImpl(pollUrl, baseUrl, interactionStep, pollStep, options = {}) {
+async function _deferredPollingImpl(pollUrl, baseUrl, interactionStep, pollStep, options = {}) {
+ const tokenField = options.tokenField || 'auth_token'
+ const copyPrefix = options.copyPrefix || 'authorize'
// Pin the log container this poll loop writes into BEFORE any await.
// While the long-poll awaits user interaction, other code may run
// that flips __activeLogContainer (e.g. restoreNotesApp →
@@ -1493,25 +1775,25 @@ async function _startAuthTokenPollingImpl(pollUrl, baseUrl, interactionStep, pol
// may no longer point at the log this flow started in — terminal
// steps would then land in the wrong tab and read as a "stuck"
// flow. Capture synchronously here, restore right before every
- // terminal addLogStep / onAuthToken handoff. Capturing after any
+ // terminal addLogStep and before resolving. Capturing after any
// await is too late: the clobber can have already happened.
const targetLog = currentLog()
const pinLog = () => { if (targetLog) __activeLogContainer = targetLog }
const absolutePollUrl = new URL(pollUrl, baseUrl).href
const keyPair = window.aauthEphemeral.get()
const agentToken = localStorage.getItem('aauth-agent-token')
- if (!keyPair || !agentToken) return
+ if (!keyPair || !agentToken) return null
const signingJwk = await exportSigningJwk(keyPair)
const pollPath = new URL(absolutePollUrl).pathname
// Caller can pre-create the pollStep so the log orders as
- // POST /aauth/token → 202
- // GET /aauth/pending (long-poll)
+ // POST {endpoint} → 202
+ // GET {pending} (long-poll)
// User at PS: consent prompt
// When not provided (resume paths), fall back to creating it inline.
if (!pollStep) {
- pollStep = addLogStep(fmt(copy('authorize.ps_pending_longpoll.label_template'), { path: pollPath }), 'pending',
- desc('authorize.ps_pending_longpoll'))
+ pollStep = addLogStep(fmt(copy(`${copyPrefix}.ps_pending_longpoll.label_template`), { path: pollPath }), 'pending',
+ desc(`${copyPrefix}.ps_pending_longpoll`))
}
let cycle = 0
@@ -1551,46 +1833,43 @@ async function _startAuthTokenPollingImpl(pollUrl, baseUrl, interactionStep, pol
}
if (res.status === 200) {
clearPendingAuthorize()
- resolveStep(pollStep, 'success', fmt(copy('authorize.ps_pending_longpoll.label_resolved_template'), { path: pollPath, status: 200 }))
+ resolveStep(pollStep, 'success', fmt(copy(`${copyPrefix}.ps_pending_longpoll.label_resolved_template`), { path: pollPath, status: 200 }))
// Resolve only — the interaction-box body stays in place as
// a record of the consent interface the user was handed. CSS
// (.log-step.success .interaction-box) stops the flare and
// overlays an approved check mark across the box.
resolveStep(interactionStep, 'success', 'Interaction Completed')
pinLog()
- // If a caller supplied onAuthToken (e.g. whoami needs to retry the
- // resource call with the freshly-minted token), hand off to them.
- // Otherwise render the generic "Authorization Granted" step.
- if (options.onAuthToken && body?.auth_token) {
- await options.onAuthToken(body.auth_token)
- } else {
- addLogStep(copy('authorize.authorization_granted.label'), 'success',
- (body?.auth_token ? formatAuthToken(body.auth_token) : '') +
- anotherRequestButton(),
- { kind: 'response' })
- }
- return
+ const token = body?.[tokenField]
+ // The caller resumes its ceremony with the token. The generic
+ // "granted" step is only for the auth-token leg with no
+ // continuation — the person-token leg always has one.
+ if (!options.renderGranted) return token || null
+ addLogStep(copy(`${copyPrefix}.authorization_granted.label`), 'success',
+ (token ? formatAuthToken(token) : '') + anotherRequestButton(),
+ { kind: 'response' })
+ return token || null
}
if (res.status === 404) {
clearPendingAuthorize()
- resolveStep(pollStep, 'error', fmt(copy('authorize.ps_pending_longpoll.label_resolved_template'), { path: pollPath, status: 404 }))
+ resolveStep(pollStep, 'error', fmt(copy(`${copyPrefix}.ps_pending_longpoll.label_resolved_template`), { path: pollPath, status: 404 }))
resolveStep(interactionStep, 'error', 'Interaction Expired')
pinLog()
addLogStep('Interaction expired', 'error',
formatResponse(404, null, body) + anotherRequestButton(),
{ kind: 'response' })
- return
+ return null
}
if (res.status === 403 || res.status === 408) {
clearPendingAuthorize()
const label = res.status === 403 ? 'Interaction Denied' : 'Interaction Timed Out'
- resolveStep(pollStep, 'error', fmt(copy('authorize.ps_pending_longpoll.label_resolved_template'), { path: pollPath, status: res.status }))
+ resolveStep(pollStep, 'error', fmt(copy(`${copyPrefix}.ps_pending_longpoll.label_resolved_template`), { path: pollPath, status: res.status }))
resolveStep(interactionStep, 'error', label)
pinLog()
- addLogStep(copy(res.status === 403 ? 'authorize.authorization_denied.label' : 'authorize.authorization_timed_out.label'), 'error',
+ addLogStep(copy(res.status === 403 ? `${copyPrefix}.authorization_denied.label` : `${copyPrefix}.authorization_timed_out.label`), 'error',
formatResponse(res.status, null, body) + anotherRequestButton(),
{ kind: 'response' })
- return
+ return null
}
// 202 → loop immediately (server already held up to 30s)
} catch (err) {
@@ -1632,13 +1911,14 @@ function decodeJWTBrowser(jwt) {
// • /.well-known/aauth-resource.json — advertises authorization_endpoint
// and r3_vocabularies[urn:aauth:vocabulary:openapi] pointing at an
// OpenAPI spec enumerating the API's operations.
-// • /authorize — POST signed with agent_token + r3_operations body
+// • /authorize — POST signed with a person token + r3_operations body
// naming the operationIds we want; returns a resource_token.
// • /notes* — CRUD API gated by auth_token.r3_granted.
//
// Flow: tab activation fetches the metadata + OpenAPI (once per page)
-// and renders a checkbox per operationId. "Notes with Hellō" signs the
-// /authorize POST, exchanges the resource_token at the user's PS, and
+// and renders a checkbox per operationId. "Notes with Hellō" gets a
+// person token for notes.aauth.dev, signs the /authorize POST with it,
+// exchanges the resource_token at the user's PS, and
// either gets a 200 auth_token (cached consent) or 202 + interaction
// that drives the existing auth-token polling loop. Once an auth_token
// lands we persist it, reveal the Notes fieldset, and render a
@@ -1901,6 +2181,42 @@ async function runNotesAuthorize(operations, bindingPs, hints) {
}
_notesMetadata = discovery.metadata
const authzEndpoint = discovery.metadata.authorization_endpoint || `${window.NOTES_ORIGIN}/authorize`
+
+ // Step 1: get a person token for notes.aauth.dev. The authorization
+ // endpoint requires one — a resource that can't name a person can only
+ // issue a resource token nobody can redeem. May block on the person's
+ // approval; if that lands after a same-tab redirect this never returns
+ // and resumePendingAuthorize re-enters at continueNotesAuthorize.
+ const personResult = await fetchPersonToken({
+ resource: new URL(authzEndpoint).origin,
+ bindingPs,
+ keyPair,
+ agentToken,
+ signingJwk,
+ consentKey: 'notes',
+ pendingRecord: { notesAuthorize: true, operations, authzEndpoint },
+ })
+ if (!personResult) return
+
+ await continueNotesAuthorize({
+ authzEndpoint,
+ operations,
+ bindingPs,
+ hints,
+ keyPair,
+ agentToken,
+ signingJwk,
+ personToken: personResult.personToken,
+ psMetadata: personResult.psMetadata,
+ })
+}
+
+// Everything after the person token is in hand. Called directly by
+// runNotesAuthorize, and by resumePendingAuthorize when the person
+// approved the person token after a same-tab redirect to the PS.
+async function continueNotesAuthorize({
+ authzEndpoint, operations, bindingPs, hints, keyPair, agentToken, signingJwk, personToken, psMetadata,
+}) {
const authzPath = new URL(authzEndpoint).pathname
const requestBody = {
r3_operations: {
@@ -1909,7 +2225,7 @@ async function runNotesAuthorize(operations, bindingPs, hints) {
},
}
- // Step 1: POST /authorize to notes.aauth.dev, signed with agent_token.
+ // Step 2: POST /authorize to notes.aauth.dev, presenting the person token.
const step1 = addLogStep(
fmt(copy('notes.authorize_request.label_template'), { path: authzPath }),
'pending',
@@ -1923,7 +2239,7 @@ async function runNotesAuthorize(operations, bindingPs, hints) {
body: JSON.stringify(requestBody),
signingKey: signingJwk,
signingCryptoKey: keyPair.privateKey,
- signatureKey: { type: 'jwt', jwt: agentToken },
+ signatureKey: { type: 'jwt', jwt: personToken },
components: SIGNED_COMPONENTS_WITH_BODY,
returnSent: true,
})
@@ -1946,14 +2262,15 @@ async function runNotesAuthorize(operations, bindingPs, hints) {
return
}
- // Step 2: hand off to the shared resource flow. Notes' R3-specific
+ // Step 3: hand off to the shared resource flow. Notes' R3-specific
// behavior (the resource_token names an R3 document; the PS fetches
// it and emits an auth_token with r3_granted) is captured in the
- // copy keys passed below — the protocol shape from POST /aauth/token
- // onward is identical to whoami's.
+ // copy keys passed below — the protocol shape from the POST to the
+ // auth_token_endpoint onward is identical to whoami's.
await runPSTokenExchange({
resourceToken,
bindingPs,
+ psMetadata,
hints,
keyPair,
agentToken,
@@ -1965,11 +2282,10 @@ async function runNotesAuthorize(operations, bindingPs, hints) {
postLabelNetworkError: (path) =>
fmt(copy('notes.ps_token_request.label_error_network_template'), { path }),
postDescription: desc('notes.ps_token_request'),
- pollLabel: (path) => fmt(copy('notes.ps_pending_longpoll.label_template'), { path }),
- pollDescription: desc('notes.ps_pending_longpoll'),
consentLabel: copy('notes.ps_consent_prompt.label'),
consentDescription: desc('notes.ps_consent_prompt'),
},
+ copyPrefix: 'notes',
consentKey: 'notes',
pendingExtra: { notesAuthorize: true },
onAuthToken: async (token) => {
diff --git a/public/log-text.json b/public/log-text.json
index 5b0d97e..82500d4 100644
--- a/public/log-text.json
+++ b/public/log-text.json
@@ -37,6 +37,47 @@
}
},
+ "person_token": {
+ "request": {
+ "label_template": "Agent → Person Server",
+ "label_resolved_template": "Agent → Person Server",
+ "label_error_network_template": "Agent → Person Server (network error)",
+ "description": "Before calling a resource, the agent asks your Person Server for a person token naming that resource. The Person Server returns an aa-person+jwt whose aud is the resource, whose sub is your directed identifier there, and whose cnf holds the agent's signing key. The agent presents it in place of its agent_token — the resource learns who the agent acts for from your Person Server, not from the agent. A person token is identity, not authorization: it carries no scope. A 200 means your Person Server already knows you use this resource; a 202 means it wants to ask you first."
+ },
+ "ps_pending_longpoll": {
+ "label_template": "Agent → Person Server (long-poll)",
+ "label_resolved_template": "Agent → Person Server",
+ "description": "The agent keeps one request open while you decide, instead of polling. The Person Server answers the moment you approve or deny."
+ },
+ "ps_consent_prompt": {
+ "label": "User at Person Server: recognition prompt",
+ "description": "Your Person Server asks whether this agent may act at this resource as you. This is not a scope question — no permissions are being released yet. Because a resource may serve requests on identity alone, naming you to it is itself the decision. Approve here, or scan the QR to approve on another device."
+ },
+ "received": {
+ "label": "Person Token received",
+ "description": "You approved this agent acting as you at this resource, and the Person Server released a person token. The agent now presents it to the resource in place of its agent_token."
+ },
+ "authorization_granted": {
+ "label": "Person Token Granted",
+ "description": ""
+ },
+ "authorization_denied": {
+ "label": "Person Token Denied",
+ "description": ""
+ },
+ "authorization_timed_out": {
+ "label": "Person Token Request Timed Out",
+ "description": ""
+ }
+ },
+
+ "person_token_resumed": {
+ "ps_consent_prompt": {
+ "label": "User at Person Server: recognition prompt (resumed)",
+ "description": "You returned mid-approval. The agent picks up the same pending person token request instead of starting over, then carries on with the resource call."
+ }
+ },
+
"authorize": {
"missing_context": {
"label": "Missing agent_token or signing key",
@@ -52,7 +93,7 @@
"label_template": "Agent → Person Server",
"label_resolved_template": "Agent → Person Server",
"label_error_network_template": "Agent → Person Server (network error)",
- "description": "The agent trades that resource token with your Person Server for an auth token. A 200 means you've already consented to this scope; 202 means the Person Server needs your approval for a new one."
+ "description": "The agent trades that resource token at your Person Server's auth token endpoint for an auth token, signing the request with its agent_token. The Person Server resolves the person token the resource token names and confirms its ps, sub, and mission match. A 200 means you've already consented to this scope; 202 means the Person Server needs your approval for a new one."
},
"ps_pending_longpoll": {
"label_template": "Agent → Person Server (long-poll)",
@@ -104,7 +145,7 @@
"label_template": "Agent → Notes Resource",
"label_resolved_template": "Agent → Notes Resource",
"label_error_network_template": "Agent → Notes Resource (network error)",
- "description": "The agent POSTs the operations it wants to the resource's authorize endpoint, signed with its agent_token. The resource responds with a resource_token naming an R3 document the Person Server will fetch during token exchange."
+ "description": "The agent POSTs the operations it wants to the resource's authorize endpoint, presenting the person token it just obtained. The resource verifies that token, then responds with a resource_token carrying the person's ps and sub plus an R3 document the Person Server will fetch during token exchange."
},
"r3_document_request": {
"label_template": "Demo (R3 document)",
@@ -116,7 +157,7 @@
"label_template": "Agent → Person Server",
"label_resolved_template": "Agent → Person Server",
"label_error_network_template": "Agent → Person Server (network error)",
- "description": "The agent trades the resource_token at the Person Server's token endpoint. A 200 means consent was already on file; a 202 triggers a consent prompt. The Person Server fetches the R3 document, then emits an auth_token carrying r3_granted — the operations it's releasing."
+ "description": "The agent trades the resource_token at the Person Server's auth token endpoint, signing the request with its agent_token. The Person Server matches the resource_token against the person token it names, then fetches the R3 document and emits an auth_token carrying r3_granted — the operations it's releasing. A 200 means consent was already on file; a 202 triggers a consent prompt."
},
"ps_pending_longpoll": {
"label_template": "Agent → Person Server (long-poll)",
diff --git a/public/protocol.js b/public/protocol.js
index 9318748..55a61a8 100644
--- a/public/protocol.js
+++ b/public/protocol.js
@@ -3187,6 +3187,45 @@
description: "The agent signs a refresh request with the same hwk key the Agent Provider already has on file. The Agent Provider looks up the agent name by thumbprint and mints a fresh agent_token bound to the same key."
}
},
+ person_token: {
+ request: {
+ label_template: "Agent \u2192 Person Server",
+ label_resolved_template: "Agent \u2192 Person Server",
+ label_error_network_template: "Agent \u2192 Person Server (network error)",
+ description: "Before calling a resource, the agent asks your Person Server for a person token naming that resource. The Person Server returns an aa-person+jwt whose aud is the resource, whose sub is your directed identifier there, and whose cnf holds the agent's signing key. The agent presents it in place of its agent_token \u2014 the resource learns who the agent acts for from your Person Server, not from the agent. A person token is identity, not authorization: it carries no scope. A 200 means your Person Server already knows you use this resource; a 202 means it wants to ask you first."
+ },
+ ps_pending_longpoll: {
+ label_template: "Agent \u2192 Person Server (long-poll)",
+ label_resolved_template: "Agent \u2192 Person Server",
+ description: "The agent keeps one request open while you decide, instead of polling. The Person Server answers the moment you approve or deny."
+ },
+ ps_consent_prompt: {
+ label: "User at Person Server: recognition prompt",
+ description: "Your Person Server asks whether this agent may act at this resource as you. This is not a scope question \u2014 no permissions are being released yet. Because a resource may serve requests on identity alone, naming you to it is itself the decision. Approve here, or scan the QR to approve on another device."
+ },
+ received: {
+ label: "Person Token received",
+ description: "You approved this agent acting as you at this resource, and the Person Server released a person token. The agent now presents it to the resource in place of its agent_token."
+ },
+ authorization_granted: {
+ label: "Person Token Granted",
+ description: ""
+ },
+ authorization_denied: {
+ label: "Person Token Denied",
+ description: ""
+ },
+ authorization_timed_out: {
+ label: "Person Token Request Timed Out",
+ description: ""
+ }
+ },
+ person_token_resumed: {
+ ps_consent_prompt: {
+ label: "User at Person Server: recognition prompt (resumed)",
+ description: "You returned mid-approval. The agent picks up the same pending person token request instead of starting over, then carries on with the resource call."
+ }
+ },
authorize: {
missing_context: {
label: "Missing agent_token or signing key",
@@ -3202,7 +3241,7 @@
label_template: "Agent \u2192 Person Server",
label_resolved_template: "Agent \u2192 Person Server",
label_error_network_template: "Agent \u2192 Person Server (network error)",
- description: "The agent trades that resource token with your Person Server for an auth token. A 200 means you've already consented to this scope; 202 means the Person Server needs your approval for a new one."
+ description: "The agent trades that resource token at your Person Server's auth token endpoint for an auth token, signing the request with its agent_token. The Person Server resolves the person token the resource token names and confirms its ps, sub, and mission match. A 200 means you've already consented to this scope; 202 means the Person Server needs your approval for a new one."
},
ps_pending_longpoll: {
label_template: "Agent \u2192 Person Server (long-poll)",
@@ -3252,7 +3291,7 @@
label_template: "Agent \u2192 Notes Resource",
label_resolved_template: "Agent \u2192 Notes Resource",
label_error_network_template: "Agent \u2192 Notes Resource (network error)",
- description: "The agent POSTs the operations it wants to the resource's authorize endpoint, signed with its agent_token. The resource responds with a resource_token naming an R3 document the Person Server will fetch during token exchange."
+ description: "The agent POSTs the operations it wants to the resource's authorize endpoint, presenting the person token it just obtained. The resource verifies that token, then responds with a resource_token carrying the person's ps and sub plus an R3 document the Person Server will fetch during token exchange."
},
r3_document_request: {
label_template: "Demo (R3 document)",
@@ -3264,7 +3303,7 @@
label_template: "Agent \u2192 Person Server",
label_resolved_template: "Agent \u2192 Person Server",
label_error_network_template: "Agent \u2192 Person Server (network error)",
- description: "The agent trades the resource_token at the Person Server's token endpoint. A 200 means consent was already on file; a 202 triggers a consent prompt. The Person Server fetches the R3 document, then emits an auth_token carrying r3_granted \u2014 the operations it's releasing."
+ description: "The agent trades the resource_token at the Person Server's auth token endpoint, signing the request with its agent_token. The Person Server matches the resource_token against the person token it names, then fetches the R3 document and emits an auth_token carrying r3_granted \u2014 the operations it's releasing. A 200 means consent was already on file; a 202 triggers a consent prompt."
},
ps_pending_longpoll: {
label_template: "Agent \u2192 Person Server (long-poll)",
@@ -3724,6 +3763,113 @@ ${renderJSON(body)}`;
function getHints() {
return {};
}
+ async function fetchPsMetadata(bindingPs, requiredField) {
+ const psMetadataUrl = `${bindingPs.replace(/\/$/, "")}/.well-known/aauth-person.json`;
+ try {
+ const metaRes = await fetch(psMetadataUrl);
+ const psMetadata = await metaRes.json().catch(() => null);
+ if (!metaRes.ok || !psMetadata?.[requiredField]) {
+ addLogStep(
+ "Person Server metadata fetch failed",
+ "error",
+ `The Person Server's metadata is missing ${escapeHtml(requiredField)}.
` + formatResponse(metaRes.status, null, psMetadata) + anotherRequestButton()
+ );
+ return null;
+ }
+ return psMetadata;
+ } catch (err) {
+ addLogStep(
+ "Person Server metadata fetch failed",
+ "error",
+ `${escapeHtml(err.message)}
` + anotherRequestButton()
+ );
+ return null;
+ }
+ }
+ async function fetchPersonToken({
+ resource,
+ bindingPs,
+ keyPair,
+ agentToken,
+ signingJwk,
+ missionS256,
+ consentKey,
+ pendingRecord
+ }) {
+ const psMetadata = await fetchPsMetadata(bindingPs, "person_token_endpoint");
+ if (!psMetadata) return null;
+ const endpoint = psMetadata.person_token_endpoint;
+ const path = new URL(endpoint).pathname;
+ const requestBody = missionS256 ? { resource, mission_s256: missionS256 } : { resource };
+ const step = addLogStep(
+ fmt(copy("person_token.request.label_template"), { path }),
+ "pending",
+ desc("person_token.request")
+ );
+ try {
+ const { response: res, sent } = await (0, import_httpsig.fetch)(endpoint, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(requestBody),
+ signingKey: signingJwk,
+ signingCryptoKey: keyPair.privateKey,
+ signatureKey: { type: "jwt", jwt: agentToken },
+ // A request with a body to a PS endpoint signs content-digest and
+ // content-type as well, so the body is covered by the signature.
+ components: SIGNED_COMPONENTS_WITH_BODY,
+ returnSent: true
+ });
+ appendStepBody(step, formatRequest(sent.method, sent.url, headersToObject(sent.headers), tryParseBody(sent.body)));
+ const body = await res.json().catch(() => null);
+ const respHeaders = {};
+ for (const key of ["location", "retry-after", "aauth-requirement"]) {
+ const v = res.headers.get(key);
+ if (v) respHeaders[key] = v;
+ }
+ const accepted = res.status === 200 && !!body?.person_token || res.status === 202;
+ resolveStep(
+ step,
+ accepted ? "success" : "error",
+ fmt(copy("person_token.request.label_resolved_template"), { path, status: res.status })
+ );
+ appendStepBody(step, formatResponse(res.status, respHeaders, body));
+ if (res.status === 200 && body?.person_token) {
+ appendStepBody(step, formatDecoded(decodeJWTBrowser(body.person_token), "person_token decoded"));
+ return { personToken: body.person_token, psMetadata };
+ }
+ if (res.status === 202) {
+ const personToken = await runDeferredResponse({
+ res,
+ body,
+ endpoint,
+ psMetadata,
+ consentKey: `${consentKey}-person`,
+ copyPrefix: "person_token",
+ tokenField: "person_token",
+ consentLabel: copy("person_token.ps_consent_prompt.label"),
+ consentDescription: desc("person_token.ps_consent_prompt"),
+ pendingRecord: { ...pendingRecord, stage: "person-token", psUrl: bindingPs }
+ });
+ if (!personToken) return null;
+ showPersonTokenReceived(personToken);
+ return { personToken, psMetadata };
+ }
+ appendStepBody(step, anotherRequestButton());
+ return null;
+ } catch (err) {
+ resolveStep(step, "error", fmt(copy("person_token.request.label_error_network_template"), { path }));
+ appendStepBody(step, `${escapeHtml(err.message)}
` + anotherRequestButton());
+ return null;
+ }
+ }
+ function showPersonTokenReceived(personToken) {
+ addLogStep(
+ copy("person_token.received.label"),
+ "success",
+ desc("person_token.received") + formatDecoded(decodeJWTBrowser(personToken), "person_token decoded"),
+ { kind: "response" }
+ );
+ }
async function runBootstrap(psUrl) {
addLogSection(copy("sections.bootstrap"));
const { keyPair, publicJwk } = await window.aauthEphemeral.rotate();
@@ -3836,7 +3982,6 @@ ${renderJSON(body)}`;
clearAllPersistedLogs();
localStorage.removeItem(NOTES_AUTH_TOKEN_KEY);
localStorage.removeItem(PENDING_AUTHZ_KEY);
- localStorage.removeItem("aauth-pending-whoami");
document.getElementById("bootstrap-artifacts")?.classList.remove("hidden");
setActiveLog("bootstrap-log");
clearLog();
@@ -3916,12 +4061,34 @@ ${renderJSON(body)}`;
}
const signingJwk = await exportSigningJwk(keyPair);
addLogSection(copy("sections.whoami"));
+ const personResult = await fetchPersonToken({
+ resource: new URL(whoamiUrl).origin,
+ bindingPs,
+ keyPair,
+ agentToken,
+ signingJwk,
+ consentKey: "whoami",
+ pendingRecord: { whoamiUrl }
+ });
+ if (!personResult) return;
+ await continueWhoami({
+ whoamiUrl,
+ bindingPs,
+ hints,
+ keyPair,
+ agentToken,
+ signingJwk,
+ personToken: personResult.personToken,
+ psMetadata: personResult.psMetadata
+ });
+ }
+ async function continueWhoami({ whoamiUrl, bindingPs, hints, keyPair, agentToken, signingJwk, personToken, psMetadata }) {
const urlObj = new URL(whoamiUrl);
const whoamiPathDisplay = urlObj.pathname + urlObj.search;
const step1 = addLogStep(
`Agent \u2192 Whoami`,
"pending",
- `Agent calls whoami with its agent_token. The resource knows the agent but has no user claims yet, so it returns 401 with a resource_token the agent can exchange at the Person Server.
`
+ `Agent calls whoami presenting the person token in place of its agent_token. The resource now knows who the agent acts for, but identity is not authorization \u2014 so it returns 401 with a resource_token the agent can exchange at the Person Server.
`
);
let resourceToken;
try {
@@ -3929,7 +4096,7 @@ ${renderJSON(body)}`;
method: "GET",
signingKey: signingJwk,
signingCryptoKey: keyPair.privateKey,
- signatureKey: { type: "jwt", jwt: agentToken },
+ signatureKey: { type: "jwt", jwt: personToken },
components: SIGNED_COMPONENTS,
returnSent: true
});
@@ -3945,9 +4112,9 @@ ${renderJSON(body)}`;
resolveStep(step1, "success", `Agent \u2192 Whoami`);
appendStepBody(step1, formatResponse(200, respHeaders, body));
addLogStep(
- "Agent identity received",
+ "Person identity received",
"success",
- `No scopes were requested, so whoami returned the agent's own identity straight from the agent_token \u2014 no Person Server exchange needed.
` + tokenWrap(renderJSON(body)) + anotherRequestButton(),
+ `No scopes were requested, so whoami answered on identity alone \u2014 the ps and sub off the person token. No auth token, no Person Server exchange.
` + tokenWrap(renderJSON(body)) + anotherRequestButton(),
{ kind: "response" }
);
return;
@@ -3969,6 +4136,7 @@ ${renderJSON(body)}`;
await runPSTokenExchange({
resourceToken,
bindingPs,
+ psMetadata,
hints,
keyPair,
agentToken,
@@ -3977,12 +4145,11 @@ ${renderJSON(body)}`;
postLabel: (path) => `Agent \u2192 Person Server`,
postLabelResolved: (path, status) => status === 200 || status === 202 ? `Agent \u2192 Person Server` : `Agent \u2192 Person Server \u2192 ${status}`,
postLabelNetworkError: (path) => `Agent \u2192 Person Server (network error)`,
- postDescription: `Agent presents the resource_token and its agent_token to the Person Server's token endpoint. The PS either releases an auth_token immediately (cached consent) or returns a 202 with a consent prompt.
`,
- pollLabel: (path) => `Agent \u2192 Person Server (long-poll)`,
- pollDescription: `Agent keeps a request open while you decide, instead of polling. The Person Server answers the moment you approve or deny.
`,
+ postDescription: `Agent presents the resource_token and its agent_token to the Person Server's auth token endpoint. The PS looks up the person token named by person_token_jti, checks the resource_token's ps and sub against it, then either releases an auth_token immediately (cached consent) or returns a 202 with a consent prompt.
`,
consentLabel: copy("authorize.ps_consent_prompt.label"),
consentDescription: desc("authorize.ps_consent_prompt")
},
+ copyPrefix: "authorize",
consentKey: "whoami",
pendingExtra: { whoamiUrl },
onAuthToken: async (token, { viaPolling }) => {
@@ -4133,36 +4300,68 @@ ${renderJSON(body)}`;
showLog();
currentLog()?.querySelectorAll(":scope > details.log-section").forEach((s) => s.setAttribute("open", ""));
const isNotes = !!saved.notesAuthorize;
- const promptKey = isNotes ? "notes_resumed.ps_consent_prompt" : "whoami_resumed.ps_consent_prompt";
+ const promptKey = saved.stage === "person-token" ? "person_token_resumed.ps_consent_prompt" : isNotes ? "notes_resumed.ps_consent_prompt" : "whoami_resumed.ps_consent_prompt";
const log = currentLog();
if (!log.querySelector(":scope > details.log-section")) {
addLogSection(copy(isNotes ? "sections.notes" : "sections.whoami"));
}
- const consentKey = isNotes ? "notes" : "whoami";
+ const isPersonStage = saved.stage === "person-token";
+ const consentKey = `${isNotes ? "notes" : "whoami"}${isPersonStage ? "-person" : ""}`;
let interactionStep = log.querySelector(`[data-consent-key="${consentKey}"]`);
if (!interactionStep) {
interactionStep = addLogStep(copy(`${promptKey}.label`), "pending", desc(promptKey));
}
- let options = {};
+ const existingPollStep = log.querySelector(`[data-poll-key="${consentKey}"]`);
+ const signingJwk = await exportSigningJwk(keyPair);
+ const token = await startDeferredPolling(
+ saved.pollUrl,
+ saved.tokenEndpoint,
+ interactionStep,
+ existingPollStep || null,
+ {
+ tokenField: isPersonStage ? "person_token" : "auth_token",
+ copyPrefix: isPersonStage ? "person_token" : isNotes ? "notes" : "authorize",
+ // No continuation below for a record with neither marker — fall
+ // back to the generic "Authorization Granted" step.
+ renderGranted: !isPersonStage && !isNotes && !saved.whoamiUrl
+ }
+ );
+ if (!token) return true;
+ if (isPersonStage) {
+ showPersonTokenReceived(token);
+ if (isNotes) {
+ await continueNotesAuthorize({
+ authzEndpoint: saved.authzEndpoint || `${window.NOTES_ORIGIN}/authorize`,
+ operations: saved.operations || [],
+ bindingPs: saved.psUrl,
+ hints: getHints(),
+ keyPair,
+ agentToken,
+ signingJwk,
+ personToken: token,
+ psMetadata: null
+ });
+ } else if (saved.whoamiUrl) {
+ await continueWhoami({
+ whoamiUrl: saved.whoamiUrl,
+ bindingPs: saved.psUrl,
+ hints: getHints(),
+ keyPair,
+ agentToken,
+ signingJwk,
+ personToken: token,
+ psMetadata: null
+ });
+ }
+ return true;
+ }
if (isNotes) {
- options = {
- onAuthToken: async (tokenFromPoll) => {
- await finalizeNotesAuthToken(tokenFromPoll);
- }
- };
+ await finalizeNotesAuthToken(token);
} else if (saved.whoamiUrl) {
const urlObj = new URL(saved.whoamiUrl);
- const whoamiPathDisplay = urlObj.pathname + urlObj.search;
- const signingJwk = await exportSigningJwk(keyPair);
- options = {
- onAuthToken: async (tokenFromPoll) => {
- showWhoamiAuthTokenReceived(tokenFromPoll);
- await retryWhoami(saved.whoamiUrl, whoamiPathDisplay, tokenFromPoll, keyPair, signingJwk);
- }
- };
+ showWhoamiAuthTokenReceived(token);
+ await retryWhoami(saved.whoamiUrl, urlObj.pathname + urlObj.search, token, keyPair, signingJwk);
}
- const existingPollStep = log.querySelector(`[data-poll-key="${consentKey}"]`);
- startAuthTokenPolling(saved.pollUrl, saved.tokenEndpoint, interactionStep, existingPollStep || null, options);
return true;
}
window.resumePendingAuthorize = resumePendingAuthorize;
@@ -4183,6 +4382,9 @@ ${renderJSON(body)}`;
async function runPSTokenExchange({
resourceToken,
bindingPs,
+ // PS metadata already fetched for the person-token hop. Both flows
+ // pass it through rather than re-fetching the same document.
+ psMetadata,
hints,
keyPair,
agentToken,
@@ -4190,6 +4392,10 @@ ${renderJSON(body)}`;
// Per-flow labels/descriptions. Functions where the value depends
// on runtime state (path, status); plain strings/HTML otherwise.
labels,
+ // log-text.json block the deferred (202) leg reads its long-poll and
+ // denied/timed-out narration from: 'authorize' for whoami, 'notes' for
+ // notes.
+ copyPrefix,
// 'whoami' | 'notes' — written to data-poll-key / data-consent-key
// so resumePendingAuthorize can re-locate the steps after a same-tab
// PS redirect.
@@ -4206,28 +4412,17 @@ ${renderJSON(body)}`;
// ignores it — its finalizeNotesAuthToken always emits its own step.
onAuthToken
}) {
- const psMetadataUrl = `${bindingPs.replace(/\/$/, "")}/.well-known/aauth-person.json`;
- let psMetadata;
- try {
- const metaRes = await fetch(psMetadataUrl);
- psMetadata = await metaRes.json();
- if (!metaRes.ok || !psMetadata?.token_endpoint) {
- addLogStep(
- "Person Server metadata fetch failed",
- "error",
- formatResponse(metaRes.status, null, psMetadata) + anotherRequestButton()
- );
- return;
- }
- } catch (err) {
+ if (!psMetadata) psMetadata = await fetchPsMetadata(bindingPs, "auth_token_endpoint");
+ if (!psMetadata) return;
+ if (!psMetadata.auth_token_endpoint) {
addLogStep(
"Person Server metadata fetch failed",
"error",
- `${escapeHtml(err.message)}
` + anotherRequestButton()
+ `The Person Server's metadata is missing auth_token_endpoint.
` + tokenWrap(renderJSON(psMetadata)) + anotherRequestButton()
);
return;
}
- const tokenEndpoint = psMetadata.token_endpoint;
+ const tokenEndpoint = psMetadata.auth_token_endpoint;
const psPath = new URL(tokenEndpoint).pathname;
const psBody = {
resource_token: resourceToken,
@@ -4248,6 +4443,8 @@ ${renderJSON(body)}`;
signingKey: signingJwk,
signingCryptoKey: keyPair.privateKey,
signatureKey: { type: "jwt", jwt: agentToken },
+ // Body-carrying request to a PS endpoint: cover content-digest and
+ // content-type so the resource_token being exchanged is signed over.
components: SIGNED_COMPONENTS_WITH_BODY,
returnSent: true
});
@@ -4267,50 +4464,19 @@ ${renderJSON(body)}`;
} else if (psRes.status === 202) {
resolveStep(step2, "success", labels.postLabelResolved(psPath, 202));
appendStepBody(step2, formatResponse(202, respHeaders, psResBody));
- const reqHeader = psRes.headers.get("aauth-requirement") || "";
- const fromHeader = parseInteractionHeader(reqHeader);
- const interaction = {
- requirement: fromHeader.requirement || psResBody?.requirement,
- code: fromHeader.code || psResBody?.code,
- url: fromHeader.url || psMetadata.interaction_endpoint
- };
- const pollUrl = psRes.headers.get("location") || psResBody?.location;
- let pollStep = null;
- if (pollUrl) {
- const absolutePollUrl = new URL(pollUrl, tokenEndpoint).href;
- pollStep = addLogStep(
- labels.pollLabel(new URL(absolutePollUrl).pathname),
- "pending",
- labels.pollDescription
- );
- if (pollStep) {
- pollStep.dataset.pollKey = consentKey;
- persistActiveLog();
- }
- }
- const interactionStep = addLogStep(
- labels.consentLabel,
- "pending",
- labels.consentDescription + renderInteraction(interaction, pollUrl, "authorize")
- );
- if (interactionStep) {
- interactionStep.dataset.consentKey = consentKey;
- persistActiveLog();
- }
- if (pollUrl) {
- const absolutePollUrl = new URL(pollUrl, tokenEndpoint).href;
- savePendingAuthorize({
- pollUrl: absolutePollUrl,
- tokenEndpoint,
- psUrl: bindingPs,
- ...pendingExtra
- });
- startAuthTokenPolling(pollUrl, tokenEndpoint, interactionStep, pollStep, {
- onAuthToken: async (tokenFromPoll) => {
- await onAuthToken(tokenFromPoll, { viaPolling: true });
- }
- });
- }
+ const tokenFromPoll = await runDeferredResponse({
+ res: psRes,
+ body: psResBody,
+ endpoint: tokenEndpoint,
+ psMetadata,
+ consentKey,
+ copyPrefix,
+ tokenField: "auth_token",
+ consentLabel: labels.consentLabel,
+ consentDescription: labels.consentDescription,
+ pendingRecord: { ...pendingExtra, stage: "auth-token", psUrl: bindingPs }
+ });
+ if (tokenFromPoll) await onAuthToken(tokenFromPoll, { viaPolling: true });
return;
} else {
resolveStep(step2, "error", labels.postLabelResolved(psPath, psRes.status));
@@ -4324,17 +4490,76 @@ ${renderJSON(body)}`;
}
await onAuthToken(authToken, { viaPolling: false });
}
- var _authzPollRunning = false;
- async function startAuthTokenPolling(pollUrl, baseUrl, interactionStep, pollStep, options = {}) {
- if (_authzPollRunning) return;
- _authzPollRunning = true;
+ var _deferredPollRunning = false;
+ async function startDeferredPolling(pollUrl, baseUrl, interactionStep, pollStep, options = {}) {
+ if (_deferredPollRunning) return null;
+ _deferredPollRunning = true;
try {
- await _startAuthTokenPollingImpl(pollUrl, baseUrl, interactionStep, pollStep, options);
+ return await _deferredPollingImpl(pollUrl, baseUrl, interactionStep, pollStep, options);
} finally {
- _authzPollRunning = false;
+ _deferredPollRunning = false;
+ }
+ }
+ async function runDeferredResponse({
+ res,
+ body,
+ endpoint,
+ psMetadata,
+ // 'whoami' | 'notes', suffixed per leg — written to data-poll-key /
+ // data-consent-key so resumePendingAuthorize can re-locate both steps
+ // after the redirect instead of orphaning them as stale pending rows.
+ consentKey,
+ copyPrefix,
+ tokenField,
+ consentLabel,
+ consentDescription,
+ // Merged into the persisted record; carries `stage` plus whatever the
+ // resumed flow needs to pick up where it left off.
+ pendingRecord
+ }) {
+ const fromHeader = parseInteractionHeader(res.headers.get("aauth-requirement") || "");
+ const interaction = {
+ requirement: fromHeader.requirement || body?.requirement,
+ code: fromHeader.code || body?.code,
+ url: fromHeader.url || psMetadata?.interaction_endpoint
+ };
+ const pollUrl = res.headers.get("location") || body?.location;
+ if (!pollUrl) {
+ addLogStep(
+ "Deferred response missing Location",
+ "error",
+ `The Person Server answered 202 without a Location to poll, so the agent has nowhere to wait.
` + anotherRequestButton()
+ );
+ return null;
+ }
+ const absolutePollUrl = new URL(pollUrl, endpoint).href;
+ const pollStep = addLogStep(
+ fmt(copy(`${copyPrefix}.ps_pending_longpoll.label_template`), { path: new URL(absolutePollUrl).pathname }),
+ "pending",
+ desc(`${copyPrefix}.ps_pending_longpoll`)
+ );
+ if (pollStep) {
+ pollStep.dataset.pollKey = consentKey;
+ persistActiveLog();
+ }
+ const interactionStep = addLogStep(
+ consentLabel,
+ "pending",
+ consentDescription + renderInteraction(interaction, pollUrl, "authorize")
+ );
+ if (interactionStep) {
+ interactionStep.dataset.consentKey = consentKey;
+ persistActiveLog();
}
+ savePendingAuthorize({ ...pendingRecord, pollUrl: absolutePollUrl, tokenEndpoint: endpoint });
+ return startDeferredPolling(absolutePollUrl, endpoint, interactionStep, pollStep, {
+ tokenField,
+ copyPrefix
+ });
}
- async function _startAuthTokenPollingImpl(pollUrl, baseUrl, interactionStep, pollStep, options = {}) {
+ async function _deferredPollingImpl(pollUrl, baseUrl, interactionStep, pollStep, options = {}) {
+ const tokenField = options.tokenField || "auth_token";
+ const copyPrefix = options.copyPrefix || "authorize";
const targetLog = currentLog();
const pinLog = () => {
if (targetLog) __activeLogContainer = targetLog;
@@ -4342,14 +4567,14 @@ ${renderJSON(body)}`;
const absolutePollUrl = new URL(pollUrl, baseUrl).href;
const keyPair = window.aauthEphemeral.get();
const agentToken = localStorage.getItem("aauth-agent-token");
- if (!keyPair || !agentToken) return;
+ if (!keyPair || !agentToken) return null;
const signingJwk = await exportSigningJwk(keyPair);
const pollPath = new URL(absolutePollUrl).pathname;
if (!pollStep) {
pollStep = addLogStep(
- fmt(copy("authorize.ps_pending_longpoll.label_template"), { path: pollPath }),
+ fmt(copy(`${copyPrefix}.ps_pending_longpoll.label_template`), { path: pollPath }),
"pending",
- desc("authorize.ps_pending_longpoll")
+ desc(`${copyPrefix}.ps_pending_longpoll`)
);
}
let cycle = 0;
@@ -4384,24 +4609,22 @@ ${renderJSON(body)}`;
}
if (res.status === 200) {
clearPendingAuthorize();
- resolveStep(pollStep, "success", fmt(copy("authorize.ps_pending_longpoll.label_resolved_template"), { path: pollPath, status: 200 }));
+ resolveStep(pollStep, "success", fmt(copy(`${copyPrefix}.ps_pending_longpoll.label_resolved_template`), { path: pollPath, status: 200 }));
resolveStep(interactionStep, "success", "Interaction Completed");
pinLog();
- if (options.onAuthToken && body?.auth_token) {
- await options.onAuthToken(body.auth_token);
- } else {
- addLogStep(
- copy("authorize.authorization_granted.label"),
- "success",
- (body?.auth_token ? formatAuthToken(body.auth_token) : "") + anotherRequestButton(),
- { kind: "response" }
- );
- }
- return;
+ const token = body?.[tokenField];
+ if (!options.renderGranted) return token || null;
+ addLogStep(
+ copy(`${copyPrefix}.authorization_granted.label`),
+ "success",
+ (token ? formatAuthToken(token) : "") + anotherRequestButton(),
+ { kind: "response" }
+ );
+ return token || null;
}
if (res.status === 404) {
clearPendingAuthorize();
- resolveStep(pollStep, "error", fmt(copy("authorize.ps_pending_longpoll.label_resolved_template"), { path: pollPath, status: 404 }));
+ resolveStep(pollStep, "error", fmt(copy(`${copyPrefix}.ps_pending_longpoll.label_resolved_template`), { path: pollPath, status: 404 }));
resolveStep(interactionStep, "error", "Interaction Expired");
pinLog();
addLogStep(
@@ -4410,21 +4633,21 @@ ${renderJSON(body)}`;
formatResponse(404, null, body) + anotherRequestButton(),
{ kind: "response" }
);
- return;
+ return null;
}
if (res.status === 403 || res.status === 408) {
clearPendingAuthorize();
const label = res.status === 403 ? "Interaction Denied" : "Interaction Timed Out";
- resolveStep(pollStep, "error", fmt(copy("authorize.ps_pending_longpoll.label_resolved_template"), { path: pollPath, status: res.status }));
+ resolveStep(pollStep, "error", fmt(copy(`${copyPrefix}.ps_pending_longpoll.label_resolved_template`), { path: pollPath, status: res.status }));
resolveStep(interactionStep, "error", label);
pinLog();
addLogStep(
- copy(res.status === 403 ? "authorize.authorization_denied.label" : "authorize.authorization_timed_out.label"),
+ copy(res.status === 403 ? `${copyPrefix}.authorization_denied.label` : `${copyPrefix}.authorization_timed_out.label`),
"error",
formatResponse(res.status, null, body) + anotherRequestButton(),
{ kind: "response" }
);
- return;
+ return null;
}
} catch (err) {
console.log("Poll error:", err.message);
@@ -4662,6 +4885,39 @@ ${renderJSON(body)}`;
}
_notesMetadata = discovery.metadata;
const authzEndpoint = discovery.metadata.authorization_endpoint || `${window.NOTES_ORIGIN}/authorize`;
+ const personResult = await fetchPersonToken({
+ resource: new URL(authzEndpoint).origin,
+ bindingPs,
+ keyPair,
+ agentToken,
+ signingJwk,
+ consentKey: "notes",
+ pendingRecord: { notesAuthorize: true, operations, authzEndpoint }
+ });
+ if (!personResult) return;
+ await continueNotesAuthorize({
+ authzEndpoint,
+ operations,
+ bindingPs,
+ hints,
+ keyPair,
+ agentToken,
+ signingJwk,
+ personToken: personResult.personToken,
+ psMetadata: personResult.psMetadata
+ });
+ }
+ async function continueNotesAuthorize({
+ authzEndpoint,
+ operations,
+ bindingPs,
+ hints,
+ keyPair,
+ agentToken,
+ signingJwk,
+ personToken,
+ psMetadata
+ }) {
const authzPath = new URL(authzEndpoint).pathname;
const requestBody = {
r3_operations: {
@@ -4682,7 +4938,7 @@ ${renderJSON(body)}`;
body: JSON.stringify(requestBody),
signingKey: signingJwk,
signingCryptoKey: keyPair.privateKey,
- signatureKey: { type: "jwt", jwt: agentToken },
+ signatureKey: { type: "jwt", jwt: personToken },
components: SIGNED_COMPONENTS_WITH_BODY,
returnSent: true
});
@@ -4707,6 +4963,7 @@ ${renderJSON(body)}`;
await runPSTokenExchange({
resourceToken,
bindingPs,
+ psMetadata,
hints,
keyPair,
agentToken,
@@ -4716,11 +4973,10 @@ ${renderJSON(body)}`;
postLabelResolved: (path, status) => fmt(copy("notes.ps_token_request.label_resolved_template"), { path, status }),
postLabelNetworkError: (path) => fmt(copy("notes.ps_token_request.label_error_network_template"), { path }),
postDescription: desc("notes.ps_token_request"),
- pollLabel: (path) => fmt(copy("notes.ps_pending_longpoll.label_template"), { path }),
- pollDescription: desc("notes.ps_pending_longpoll"),
consentLabel: copy("notes.ps_consent_prompt.label"),
consentDescription: desc("notes.ps_consent_prompt")
},
+ copyPrefix: "notes",
consentKey: "notes",
pendingExtra: { notesAuthorize: true },
onAuthToken: async (token) => {
diff --git a/src/crypto.ts b/src/crypto.ts
index 7dfb752..c3e699b 100644
--- a/src/crypto.ts
+++ b/src/crypto.ts
@@ -108,16 +108,24 @@ export function decodeJWTHeader(jwt: string): Record {
}
// Verify a signed JWT against a JWKS. Finds the verification key by `kid`
-// (falling back to first key if no kid), rejects unknown algs, and checks the
-// signature. Callers are responsible for payload claim checks
+// (falling back to first key if no kid), rejects unsupported algs, and
+// checks the signature. Callers are responsible for payload claim checks
// (iss/aud/exp/nbf/jti).
// Maps JWT alg → WebCrypto import/verify parameters. Extend here for new
-// algorithms. Hellō's issuer JWKS uses RS256; we sign with the fully-specified
-// alg "Ed25519" (RFC 9864). "EdDSA" stays accepted for tokens minted by peers
-// that have not moved to the fully-specified name.
+// algorithms. Hellō's issuer JWKS uses RS256; our own JWKS uses Ed25519.
+//
+// `EdDSA` is deliberately absent, on both sides. We emit the
+// fully-specified `Ed25519`, and we refuse the polymorphic identifier on
+// input: "Implementations MUST NOT accept none, the polymorphic EdDSA
+// identifier, or any symmetric algorithm"
+// (draft-hardt-oauth-aauth-protocol §Signature Algorithms). There is no
+// transition allowance in the spec, so there is none here.
+//
+// Flag day: Wallet's svr/issuer/sign.js:32 still heads every aa-auth+jwt
+// and aa-person+jwt with `EdDSA`, so /api/demo rejects live PS auth tokens
+// until that ships `Ed25519` for AAuth token types.
const JWT_ALG_PARAMS: Record = {
Ed25519: { importAlgo: { name: 'Ed25519' }, verifyAlgo: 'Ed25519' },
- EdDSA: { importAlgo: { name: 'Ed25519' }, verifyAlgo: 'Ed25519' },
RS256: {
importAlgo: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
verifyAlgo: 'RSASSA-PKCS1-v1_5',
@@ -157,6 +165,12 @@ export async function verifyJWT(
// fully-specified alg "Ed25519" (RFC 9864), which workerd's importKey
// rejects for OKP keys (it only accepts "EdDSA" or no alg). The
// algorithm is passed explicitly via importAlgo, so alg is redundant.
+ //
+ // DO NOT REMOVE while fixing an emit-side alg. Emitting "Ed25519"
+ // (headers, JWKS, cnf.jwk) and stripping it before importKey are
+ // opposite ends of the same pipe. Tests run under environment:
+ // 'node', where this import succeeds either way — breaking it passes
+ // CI and fails on deploy to workerd.
const { alg: _alg, ...importJwk } = jwk as JsonWebKey & { alg?: string }
const key = await crypto.subtle.importKey(
'jwk',
diff --git a/src/httpsig-verify.ts b/src/httpsig-verify.ts
index e8a3aba..f583c38 100644
--- a/src/httpsig-verify.ts
+++ b/src/httpsig-verify.ts
@@ -1,18 +1,44 @@
import { verify as httpSigVerify } from '@hellocoop/httpsig'
import type { Context } from 'hono'
-import { verifyJWT } from './crypto'
+import { computeJwkThumbprint, decodeJWTHeader, decodeJWTPayload, verifyJWT } from './crypto'
// Shared RFC 9421 verification for endpoints that accept sig=jwt. Reads
-// the body once, runs httpSigVerify, enforces the jwt scheme, and
-// optionally runs a caller-supplied JWT verification on the inner token
-// (e.g. against our own JWKS or a PS JWKS).
+// the body once, runs httpSigVerify, enforces the jwt scheme, enforces the
+// set of AAuth token types the endpoint accepts, and optionally runs a
+// caller-supplied JWT verification on the inner token (e.g. against our
+// own JWKS or a PS JWKS).
//
// Returns the parsed body as text (callers parse JSON themselves, since
// c.req.json() would re-consume the stream) and the inner JWT payload.
//
// On failure, returns a Hono Response — callers can return it directly.
+// The three AAuth token types that can arrive in a Signature-Key header.
+export const TOKEN_TYP = {
+ agent: 'aa-agent+jwt',
+ person: 'aa-person+jwt',
+ auth: 'aa-auth+jwt',
+} as const
+
+export type TokenKind = keyof typeof TOKEN_TYP
+
export interface SigJwtVerifyOptions {
+ // Which token types this endpoint accepts. REQUIRED, with no default,
+ // so every call site has to state it.
+ //
+ // "A recipient MUST reject an aa-person+jwt wherever an auth token is
+ // required. Only typ distinguishes the two" (§Person Token
+ // Verification). A person token and a PS-issued auth token share iss,
+ // dwk, aud, sub and cnf — every check except this one passes for both,
+ // so a verifier that omits it accepts a credential carrying no
+ // authorization as though it carried authorization. Making the accepted
+ // set an explicit argument is what keeps that from being forgotten.
+ accept: TokenKind[]
+ // Response to return when the presented token's typ is not accepted.
+ // Defaults to a flat 401; endpoints that can tell the agent how to
+ // recover (e.g. /authorize challenging with requirement=person-token)
+ // supply their own.
+ onTypMismatch?: (c: Context, typ: string | undefined) => Response
// Optional inner-token verifier: takes the raw JWT string and returns
// { payload } if it's valid. Use this to verify against our own JWKS
// (agent_token from us) or a PS JWKS (auth_token from the PS).
@@ -33,7 +59,7 @@ export interface SigJwtVerifyResult {
export async function verifySigJwt(
c: Context,
- options: SigJwtVerifyOptions = {}
+ options: SigJwtVerifyOptions
): Promise {
// Read body as text — httpSigVerify needs it to reconstruct the
// signature base, and c.req.json() would consume the stream first.
@@ -56,6 +82,27 @@ export async function verifySigJwt(
}
const innerJwt = sigResult.jwt.raw
+
+ // Enforce typ before anything else looks at the token. This runs on the
+ // unverified header, which is fine — it only ever narrows what we go on
+ // to verify, and a token whose typ we do not accept is rejected whether
+ // or not its signature would have checked out.
+ let presentedTyp: string | undefined
+ try {
+ presentedTyp = decodeJWTHeader(innerJwt).typ as string | undefined
+ } catch {
+ return c.json({ error: 'Signature-Key jwt has an undecodable header' }, 401)
+ }
+ const acceptedTyps = options.accept.map((kind) => TOKEN_TYP[kind])
+ if (!presentedTyp || !acceptedTyps.includes(presentedTyp as typeof acceptedTyps[number])) {
+ if (options.onTypMismatch) return options.onTypMismatch(c, presentedTyp)
+ return c.json({
+ error: 'invalid_token_type',
+ typ: presentedTyp ?? null,
+ accepted: acceptedTyps,
+ }, 401)
+ }
+
let innerPayload: Record | null = null
if (options.verifyInner) {
@@ -79,12 +126,6 @@ export async function verifySigJwt(
return { rawBody, innerJwt, innerPayload, callerJkt: sigResult.thumbprint }
}
-// Convenience: build a verifyInner that uses our own JWKS. For tokens we
-// issued (agent_token minted at bootstrap/refresh).
-export function ourJwksVerifier(ourJwk: JsonWebKey) {
- return (jwt: string) => verifyJWT(jwt, { keys: [ourJwk] })
-}
-
// Result of verifying a sig=hwk request — the public key that signed it,
// its JWK thumbprint (for KV lookup), and the raw body so the caller can
// JSON.parse without re-consuming the stream.
@@ -118,23 +159,106 @@ export async function verifySigHwk(c: Context): Promise
+ metadataUrl: string
+ jwks: { keys: JsonWebKey[] }
+}
+
+export async function resolveIssuerKeys(iss: string, dwk: string): Promise {
+ const metadataUrl = `${iss}/.well-known/${dwk}`
+ const metaRes = await fetch(metadataUrl)
+ if (!metaRes.ok) throw new Error(`fetch ${dwk} failed: ${metaRes.status}`)
+ const metadata = (await metaRes.json()) as Record
+ const jwksUri = metadata.jwks_uri as string | undefined
+ if (!jwksUri) throw new Error(`${dwk} missing jwks_uri`)
+ const jwksRes = await fetch(jwksUri)
+ if (!jwksRes.ok) throw new Error(`fetch JWKS failed: ${jwksRes.status}`)
+ const jwks = (await jwksRes.json()) as { keys: JsonWebKey[] }
+ return { metadata, metadataUrl, jwks }
+}
+
// Build a verifyInner that fetches the PS JWKS (via the JWT's iss+dwk)
// and verifies against it. Used for auth_tokens at /api/demo.
export function psJwksVerifier() {
return async (jwt: string) => {
- const { decodeJWTPayload } = await import('./crypto')
const unverified = decodeJWTPayload(jwt)
const iss = unverified.iss as string | undefined
- const dwk = (unverified.dwk as string | undefined) ?? 'aauth-person.json'
+ const dwk = (unverified.dwk as string | undefined) ?? PERSON_DWK
if (!iss) throw new Error('token missing iss')
- const metaRes = await fetch(`${iss}/.well-known/${dwk}`)
- if (!metaRes.ok) throw new Error(`fetch PS metadata failed: ${metaRes.status}`)
- const meta = (await metaRes.json()) as Record
- const jwksUri = meta.jwks_uri as string | undefined
- if (!jwksUri) throw new Error('PS metadata missing jwks_uri')
- const jwksRes = await fetch(jwksUri)
- if (!jwksRes.ok) throw new Error(`fetch PS JWKS failed: ${jwksRes.status}`)
- const jwks = (await jwksRes.json()) as { keys: JsonWebKey[] }
+ const { jwks } = await resolveIssuerKeys(iss, dwk)
return verifyJWT(jwt, jwks)
}
}
+
+// ── Person token verification ──
+//
+// draft-hardt-oauth-aauth-protocol §Person Token Verification. The agent
+// presents the person token in place of its agent token via
+// `Signature-Key: sig=jwt`, so the HTTP signature has already proven
+// possession of `cnf.jwk` — step 6 is the check that the key which signed
+// the request is the key the PS bound the token to.
+export const PERSON_TYP = TOKEN_TYP.person
+export const PERSON_DWK = 'aauth-person.json'
+
+export interface PersonTokenResult {
+ payload: Record
+ // The PS metadata document fetched for key discovery — reused by the
+ // caller so a resource token's `aud` (the PS `issuer`) costs no second
+ // round trip.
+ psMetadata: Record
+ psMetadataUrl: string
+}
+
+// Throws Error on any verification failure; the message is the reason.
+export async function verifyPersonToken(
+ jwt: string,
+ opts: { aud: string; callerJkt: string }
+): Promise {
+ // 1. typ
+ const header = decodeJWTHeader(jwt)
+ if (header.typ !== PERSON_TYP) throw new Error(`typ must be ${PERSON_TYP}`)
+
+ // 2. dwk + issuer key discovery + signature
+ const unverified = decodeJWTPayload(jwt)
+ if (unverified.dwk !== PERSON_DWK) throw new Error(`dwk must be ${PERSON_DWK}`)
+ const iss = unverified.iss
+ if (typeof iss !== 'string') throw new Error('missing iss')
+ // 4. iss must be an HTTPS server identifier
+ let issUrl: URL
+ try {
+ issUrl = new URL(iss)
+ } catch {
+ throw new Error('iss is not a valid URL')
+ }
+ if (issUrl.protocol !== 'https:') throw new Error('iss must be HTTPS')
+
+ const { metadata, metadataUrl, jwks } = await resolveIssuerKeys(iss, PERSON_DWK)
+ const { payload } = await verifyJWT(jwt, jwks)
+
+ // 3. exp / iat
+ const now = Math.floor(Date.now() / 1000)
+ if (typeof payload.exp !== 'number' || payload.exp < now) throw new Error('expired')
+ if (typeof payload.iat === 'number' && payload.iat > now + 60) throw new Error('iat in the future')
+
+ // 5. aud is this resource
+ if (payload.aud !== opts.aud) throw new Error(`aud is not ${opts.aud}`)
+
+ if (typeof payload.sub !== 'string' || !payload.sub) throw new Error('missing sub')
+ if (typeof payload.jti !== 'string' || !payload.jti) throw new Error('missing jti')
+
+ // 6. cnf.jwk is the key that signed the HTTP request
+ const cnfJwk = (payload.cnf as { jwk?: JsonWebKey } | undefined)?.jwk
+ if (!cnfJwk) throw new Error('missing cnf.jwk')
+ const cnfJkt = await computeJwkThumbprint(cnfJwk)
+ if (cnfJkt !== opts.callerJkt) throw new Error('cnf.jwk is not the request signing key')
+
+ return { payload, psMetadata: metadata, psMetadataUrl: metadataUrl }
+}
diff --git a/src/index.ts b/src/index.ts
index 4608509..7e34af9 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,7 +1,13 @@
import { Hono } from 'hono'
+import type { Context } from 'hono'
import { cors } from 'hono/cors'
import type { Env } from './types'
-import { verifySigJwt, verifySigHwk, ourJwksVerifier, psJwksVerifier } from './httpsig-verify'
+import {
+ verifySigJwt,
+ verifySigHwk,
+ psJwksVerifier,
+ verifyPersonToken,
+} from './httpsig-verify'
import {
importSigningKey,
getPublicJWK,
@@ -314,6 +320,8 @@ async function mintAgentToken(
const publicJwk = await getPublicJWK(env.SIGNING_KEY)
const now = Math.floor(Date.now() / 1000)
+ // alg is the fully-specified `Ed25519`, not the polymorphic `EdDSA`
+ // (draft-hardt-oauth-aauth-protocol §Signature Algorithms).
const agentHeader = { alg: 'Ed25519', typ: 'aa-agent+jwt', kid: publicJwk.kid }
const agentPayload: Record = {
iss: origin,
@@ -336,40 +344,94 @@ async function mintAgentToken(
}
// ── Authorization (resource token issuance) ──
+//
+// Under AAuth -11 the agent presents a PERSON token here, not its agent
+// token: a resource MUST have verified a person token before it issues a
+// resource token (draft-hardt-oauth-aauth-protocol §Resource Access and
+// Resource Tokens). The person token's `iss` IS the person server, so the
+// request body carries only `scope` — there is no `ps` parameter to
+// believe or disbelieve, and the identity written into the resource token
+// is PS-asserted rather than agent-asserted.
+
+// 401 challenge for a request that presented no person token. The agent
+// gets one from its PS's person_token_endpoint and retries
+// (§Person Token Required).
+function personTokenRequired(c: Context) {
+ return c.json({ error: 'person token required' }, 401, {
+ 'AAuth-Requirement': 'requirement=person-token',
+ })
+}
app.post('/authorize', async (c) => {
- // sig=jwt;jwt=. Verify the HTTP signature against
- // agent_token.cnf.jwk, then verify the agent_token itself against our
- // own JWKS — proves both that the token is ours and that the caller
- // holds the cnf-bound ephemeral.
const ourJwk = await getPublicJWK(c.env.SIGNING_KEY)
const origin = c.env.ORIGIN
+
+ // No Signature-Key at all: nothing to verify, so challenge rather than
+ // reject — the agent can satisfy this by fetching a person token.
+ if (!c.req.header('Signature-Key')) {
+ emitVerifyFailed(c, 'person_token_missing', { detail: 'no Signature-Key header' })
+ return personTokenRequired(c)
+ }
+
+ // sig=jwt;jwt=. verifySigJwt proves the caller holds the
+ // key named in the presented JWT's cnf; the person token's own
+ // signature and claims are checked below.
+ //
+ // An agent token where a person token belongs is the "absent" case
+ // rather than an error: the agent holds the wrong credential and needs
+ // to be told which one this endpoint wants, so the typ mismatch becomes
+ // the requirement=person-token challenge.
+ let challenged = false
const verifyRes = await verifySigJwt(c, {
- verifyInner: ourJwksVerifier(ourJwk),
- expectedIss: origin,
+ accept: ['person'],
+ onTypMismatch: (ctx, typ) => {
+ challenged = true
+ emitVerifyFailed(ctx, 'person_token_missing', {
+ detail: `presented typ ${typ ?? 'none'}`,
+ })
+ return personTokenRequired(ctx as Context)
+ },
})
if (verifyRes instanceof Response) {
- emitVerifyFailed(c, 'sig_jwt_failed', { detail: await readVerifyError(verifyRes) })
+ // The challenge already logged its own reason; anything else that
+ // came back is a signature failure.
+ if (!challenged) {
+ emitVerifyFailed(c, 'sig_jwt_failed', { detail: await readVerifyError(verifyRes) })
+ }
return verifyRes
}
- const agentPayload = verifyRes.innerPayload as Record
+ let person: Awaited>
+ try {
+ person = await verifyPersonToken(verifyRes.innerJwt, {
+ aud: origin,
+ callerJkt: verifyRes.callerJkt,
+ })
+ } catch (err) {
+ const detail = (err as Error).message
+ emitVerifyFailed(c, 'person_token_invalid', { detail, caller_jkt: verifyRes.callerJkt })
+ return c.json({ error: 'invalid_person_token', detail }, 400)
+ }
+
+ const personPayload = person.payload
+ const psMetadata = person.psMetadata
+ const psMetadataUrl = person.psMetadataUrl
- let body: { ps: string; scope: string }
+ let body: { scope: string }
try {
- body = JSON.parse(verifyRes.rawBody) as { ps: string; scope: string }
+ body = JSON.parse(verifyRes.rawBody) as { scope: string }
} catch {
return c.json({ error: 'invalid JSON body' }, 400)
}
- if (!body.ps || !body.scope) {
- return c.json({ error: 'missing required fields: ps, scope' }, 400)
+ if (!body.scope) {
+ return c.json({ error: 'missing required field: scope' }, 400)
}
// resource_token.scope is the combined identity + resource string the
- // PS will classify at /aauth/token. The resource validates its own
- // scopes (SCOPE_DESCRIPTIONS) and lets PS-known identity scopes pass
- // through; anything else is a typo / spoofing attempt.
+ // PS will classify at its auth_token_endpoint. The resource validates
+ // its own scopes (SCOPE_DESCRIPTIONS) and lets PS-known identity scopes
+ // pass through; anything else is a typo / spoofing attempt.
const requestedScopes = body.scope.trim().split(/\s+/).filter(Boolean)
const unknown = requestedScopes.filter(
(s) => !(s in SCOPE_DESCRIPTIONS) && !PS_IDENTITY_SCOPES.has(s),
@@ -378,47 +440,23 @@ app.post('/authorize', async (c) => {
return c.json({ error: 'invalid_scope', unknown }, 400)
}
- // Validate PS URL is HTTPS
- let psUrl: URL
- try {
- psUrl = new URL(body.ps)
- if (psUrl.protocol !== 'https:') {
- return c.json({ error: 'PS URL must be HTTPS' }, 400)
- }
- } catch {
- return c.json({ error: 'invalid PS URL' }, 400)
- }
-
- // Step 1: Fetch and validate PS metadata
- let psMetadata: Record
- const psMetadataUrl = `${psUrl.origin}/.well-known/aauth-person.json`
- try {
- const psRes = await fetch(psMetadataUrl)
- if (!psRes.ok) {
- return c.json({
- error: `Failed to fetch PS metadata: ${psRes.status}`,
- ps_metadata_url: psMetadataUrl,
- }, 502)
- }
- psMetadata = await psRes.json() as Record
- } catch (err) {
+ // The PS metadata was fetched during person-token key discovery. We
+ // still need `issuer` (the resource token's audience in three-party) and
+ // `auth_token_endpoint` — renamed from `token_endpoint` in -11 — which
+ // the agent reads out of the response to place its token request.
+ if (!psMetadata.issuer || !psMetadata.auth_token_endpoint || !psMetadata.jwks_uri) {
return c.json({
- error: `Cannot reach PS: ${(err as Error).message}`,
- ps_metadata_url: psMetadataUrl,
- }, 502)
- }
-
- // Validate required PS metadata fields
- if (!psMetadata.issuer || !psMetadata.token_endpoint || !psMetadata.jwks_uri) {
- return c.json({
- error: 'PS metadata missing required fields (issuer, token_endpoint, jwks_uri)',
+ error: 'PS metadata missing required fields (issuer, auth_token_endpoint, jwks_uri)',
ps_metadata: psMetadata,
}, 502)
}
- // Step 2: Create resource token.
+ // Mint the resource token. It carries no agent identifier: `agent_jkt`
+ // binds it to the agent's key, and `ps`/`sub`/`person_token_jti` name
+ // the person and the exact person token this authorization rests on
+ // (§Resource Token Structure).
const agentJkt = await computeJwkThumbprint(
- (agentPayload.cnf as { jwk: JsonWebKey }).jwk
+ (personPayload.cnf as { jwk: JsonWebKey }).jwk
)
const privateKey = await importSigningKey(c.env.SIGNING_KEY)
@@ -429,16 +467,36 @@ app.post('/authorize', async (c) => {
typ: 'aa-resource+jwt',
kid: ourJwk.kid,
}
- const rtPayload = {
+ const rtPayload: Record = {
iss: origin,
dwk: 'aauth-resource.json',
aud: psMetadata.issuer as string,
jti: generateJTI(),
- agent: agentPayload.sub as string,
+ ps: personPayload.iss as string,
+ sub: personPayload.sub as string,
+ person_token_jti: personPayload.jti as string,
agent_jkt: agentJkt,
scope: body.scope,
iat: now,
- exp: now + 300, // 5 minutes
+ // 5 minutes, but never past the person token this rests on. The
+ // person token is itself clamped to the mission's expires_at, so
+ // this transitively keeps a mission-scoped resource token from
+ // outliving its mission.
+ exp: Math.min(now + 300, personPayload.exp as number),
+ }
+ // REQUIRED when the person token carried one, copied unchanged — the
+ // PS re-reads it off the person token it issued, so dropping it here
+ // would be detected as mission stripping.
+ if (typeof personPayload.mission_s256 === 'string') {
+ rtPayload.mission_s256 = personPayload.mission_s256
+ }
+ // Likewise copied when present. §Resource Token Verification step 6 has
+ // the PS check ps, sub, mission_s256 AND tenant against the person token
+ // it issued, "rejecting the resource token on any mismatch or omission"
+ // — so dropping a tenant the person token carried makes every resource
+ // token we mint for an org-affiliated person unredeemable.
+ if (typeof personPayload.tenant === 'string') {
+ rtPayload.tenant = personPayload.tenant
}
const resourceToken = await signJWT(rtHeader, rtPayload, privateKey)
@@ -447,13 +505,14 @@ app.post('/authorize', async (c) => {
event: 'aauth.resource_token.minted',
msg: 'resource_token minted for agent',
route: '/authorize',
- agent_sub: agentPayload.sub,
agent_jkt: agentJkt,
caller_jkt: verifyRes.callerJkt,
requested_scope: body.scope,
granted_scope: body.scope,
- ps: body.ps,
+ ps: personPayload.iss,
ps_issuer: psMetadata.issuer,
+ person_token_jti: personPayload.jti,
+ mission_s256: personPayload.mission_s256,
})
return c.json({
@@ -476,8 +535,15 @@ app.get('/api/demo', async (c) => {
// from Signature-Key and verifies the RFC 9421 signature — proving
// possession of the ephemeral. psJwksVerifier fetches the auth_token's
// issuer JWKS (the PS) and verifies the token's own JWT signature.
+ //
+ // accept is auth only. A person token from the same PS carries the same
+ // iss, dwk, aud, sub and cnf and would pass every other check here —
+ // but it asserts identity, not authorization, and this endpoint is
+ // gated on scope. §Person Token Verification: "A recipient MUST reject
+ // an aa-person+jwt wherever an auth token is required."
const origin = c.env.ORIGIN
const verifyRes = await verifySigJwt(c, {
+ accept: ['auth'],
verifyInner: psJwksVerifier(),
})
if (verifyRes instanceof Response) {
diff --git a/test/crypto.test.ts b/test/crypto.test.ts
index c84ad2c..de17781 100644
--- a/test/crypto.test.ts
+++ b/test/crypto.test.ts
@@ -9,6 +9,7 @@ import {
signJWT,
generateJTI,
decodeJWTPayload,
+ verifyJWT,
} from '../src/crypto'
// Make Web Crypto available as a global for the module under test.
@@ -224,6 +225,44 @@ describe('signJWT + decodeJWTPayload', () => {
})
})
+describe('verifyJWT alg handling', () => {
+ // §Signature Algorithms: a fully-specified identifier is REQUIRED, and
+ // implementations MUST NOT accept the polymorphic `EdDSA`. Both halves
+ // are pinned here — emit Ed25519, and refuse EdDSA on input.
+ it('accepts a token headed Ed25519', async () => {
+ const privateKey = await importSigningKey(signingKeyJson)
+ const publicJwk = await getPublicJWK(signingKeyJson)
+ const jwt = await signJWT(
+ { alg: 'Ed25519', typ: 'aa-person+jwt', kid: publicJwk.kid },
+ { iss: 'https://ps.test', sub: 'abc' },
+ privateKey,
+ )
+ const { payload } = await verifyJWT(jwt, { keys: [publicJwk] })
+ expect(payload.sub).toBe('abc')
+ })
+
+ it('rejects a token headed with the polymorphic EdDSA', async () => {
+ // Flag day with Wallet: svr/issuer/sign.js heads every aa-auth+jwt and
+ // aa-person+jwt with EdDSA today, so /api/demo rejects live PS tokens
+ // until that ships Ed25519. Spec-correct, deliberately not lenient.
+ const privateKey = await importSigningKey(signingKeyJson)
+ const publicJwk = await getPublicJWK(signingKeyJson)
+ const jwt = await signJWT(
+ { alg: 'EdDSA', typ: 'aa-auth+jwt', kid: publicJwk.kid },
+ { iss: 'https://ps.test', sub: 'abc' },
+ privateKey,
+ )
+ await expect(verifyJWT(jwt, { keys: [publicJwk] })).rejects.toThrow(/unsupported alg/)
+ })
+
+ it('rejects an unsupported alg', async () => {
+ const publicJwk = await getPublicJWK(signingKeyJson)
+ // header={"alg":"none"}; payload={"a":1}; sig=""
+ await expect(verifyJWT('eyJhbGciOiJub25lIn0.eyJhIjoxfQ.', { keys: [publicJwk] }))
+ .rejects.toThrow(/unsupported alg/)
+ })
+})
+
describe('agent_jkt computation (resource-token claim)', () => {
it('thumbprint of cnf.jwk equals agent_jkt expected by the spec', async () => {
// Simulate: agent token contains cnf.jwk for the ephemeral key.
diff --git a/test/protocol.test.ts b/test/protocol.test.ts
index 4253a4c..f1cc7c7 100644
--- a/test/protocol.test.ts
+++ b/test/protocol.test.ts
@@ -18,6 +18,51 @@ function extractFn(name: string): Function {
return new Function(`${match[0]}\nreturn ${name};`)()
}
+// The deferred-response poll loop resolves its narration by template —
+// copy(`${copyPrefix}.ps_pending_longpoll.label_template`) and friends —
+// so a missing key renders as `undefined` in the log rather than throwing.
+// Pin every prefix the loop can be driven with.
+describe('deferred-poll narration keys', () => {
+ const LOG_TEXT = JSON.parse(
+ readFileSync(resolve(__dirname, '../public/log-text.json'), 'utf-8')
+ )
+ const at = (path: string) =>
+ path.split('.').reduce((o, k) => (o == null ? undefined : o[k]), LOG_TEXT)
+
+ // 'person_token' drives the person-token leg, 'authorize' the whoami
+ // auth-token leg, 'notes' the notes auth-token leg.
+ for (const prefix of ['person_token', 'authorize', 'notes']) {
+ it(`resolves every key the poll loop reads for prefix "${prefix}"`, () => {
+ for (const key of [
+ 'ps_pending_longpoll.label_template',
+ 'ps_pending_longpoll.label_resolved_template',
+ 'ps_pending_longpoll.description',
+ 'ps_consent_prompt.label',
+ 'authorization_denied.label',
+ 'authorization_timed_out.label',
+ ]) {
+ expect(at(`${prefix}.${key}`), `${prefix}.${key}`).toBeTypeOf('string')
+ }
+ })
+ }
+
+ it('has the person-token request, received, and resumed-consent copy', () => {
+ for (const key of [
+ 'person_token.request.label_template',
+ 'person_token.request.label_resolved_template',
+ 'person_token.request.label_error_network_template',
+ 'person_token.request.description',
+ 'person_token.received.label',
+ 'person_token.received.description',
+ 'person_token.authorization_granted.label',
+ 'person_token_resumed.ps_consent_prompt.label',
+ 'person_token_resumed.ps_consent_prompt.description',
+ ]) {
+ expect(at(key), key).toBeTypeOf('string')
+ }
+ })
+})
+
describe('parseInteractionHeader', () => {
const parse = extractFn('parseInteractionHeader') as (h: string) => Record
diff --git a/test/server.test.ts b/test/server.test.ts
index 4c58914..e0ea0b1 100644
--- a/test/server.test.ts
+++ b/test/server.test.ts
@@ -1,7 +1,7 @@
import { describe, it, expect, beforeAll, vi } from 'vitest'
import { webcrypto } from 'node:crypto'
import { fetch as sigFetch } from '@hellocoop/httpsig'
-import { decodeJWTHeader, decodeJWTPayload } from '../src/crypto'
+import { decodeJWTPayload } from '../src/crypto'
beforeAll(() => {
if (!(globalThis as any).crypto) {
@@ -115,76 +115,176 @@ describe('GET /.well-known/jwks.json', () => {
})
})
-// Mint an agent_token signed by the env's SIGNING_KEY without going
-// through any deleted legacy path. Used by /authorize tests to craft a
-// valid token whose cnf.jwk matches an ephemeral we control.
-async function mintAgentTokenForTest(env: any, opts?: { sub?: string; exp?: number }): Promise<{
- agentToken: string
- publicJwk: JsonWebKey
- privateJwk: JsonWebKey
-}> {
- const { computeJwkThumbprint } = await import('../src/crypto')
+// ── Token fixtures ──
+
+const enc = new TextEncoder()
+const b64 = (bytes: Uint8Array) => {
+ let s = ''
+ for (const b of bytes) s += String.fromCharCode(b)
+ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
+}
+
+async function signJwtWith(
+ header: Record,
+ payload: Record,
+ privateKey: CryptoKey,
+): Promise {
+ const headerB64 = b64(enc.encode(JSON.stringify(header)))
+ const payloadB64 = b64(enc.encode(JSON.stringify(payload)))
+ const sig = await webcrypto.subtle.sign('Ed25519', privateKey, enc.encode(`${headerB64}.${payloadB64}`))
+ return `${headerB64}.${payloadB64}.${b64(new Uint8Array(sig))}`
+}
+
+// The agent's own signing key. Its public half goes in the cnf of every
+// token issued to this agent; its private half signs the HTTP requests.
+async function makeAgentKey(): Promise<{ publicJwk: JsonWebKey; privateJwk: JsonWebKey }> {
const kp = await webcrypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']) as CryptoKeyPair
const publicJwk = await webcrypto.subtle.exportKey('jwk', kp.publicKey)
publicJwk.alg = 'Ed25519'
const privateJwk = await webcrypto.subtle.exportKey('jwk', kp.privateKey)
privateJwk.alg = 'Ed25519'
+ return { publicJwk, privateJwk }
+}
+
+// A stand-in Person Server: its own Ed25519 key, a metadata document at
+// /.well-known/aauth-person.json, a JWKS, and the ability to mint person
+// tokens. `install()` stubs global fetch so the worker's key discovery
+// (iss + dwk → metadata → jwks_uri → JWKS) resolves against it.
+const PS_ORIGIN = 'https://ps.test'
+
+async function makePersonServer(overrides?: Record) {
+ const { computeJwkThumbprint } = await import('../src/crypto')
+ const kp = await webcrypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']) as CryptoKeyPair
+ const publicJwk = await webcrypto.subtle.exportKey('jwk', kp.publicKey)
+ const { key_ops: _ops, ext: _ext, ...pub } = publicJwk as any
+ const kid = await computeJwkThumbprint(pub)
+ const jwksKey = { ...pub, alg: 'Ed25519', key_ops: ['verify'], kid }
+
+ const metadata: Record = {
+ issuer: PS_ORIGIN,
+ // -11 renamed `token_endpoint` to `auth_token_endpoint` and added
+ // `person_token_endpoint`.
+ auth_token_endpoint: `${PS_ORIGIN}/token`,
+ person_token_endpoint: `${PS_ORIGIN}/person`,
+ jwks_uri: `${PS_ORIGIN}/.well-known/jwks.json`,
+ ...overrides,
+ }
+ return {
+ metadata,
+ // Mint an aa-person+jwt for `aud`, bound to the agent's key. `typ`
+ // is overridable because a person token and a PS-issued auth token
+ // differ ONLY in typ — the tests that matter here mint the same
+ // payload under both and check the recipient tells them apart.
+ async mintPersonToken(opts: {
+ aud: string
+ agentPublicJwk: JsonWebKey
+ sub?: string
+ jti?: string
+ missionS256?: string
+ tenant?: string
+ typ?: string
+ dwk?: string
+ exp?: number
+ extra?: Record
+ }): Promise {
+ const now = Math.floor(Date.now() / 1000)
+ const payload: Record = {
+ iss: PS_ORIGIN,
+ dwk: opts.dwk ?? 'aauth-person.json',
+ aud: opts.aud,
+ sub: opts.sub ?? '8f14e45fceea167a5a36dedd4bea2543',
+ jti: opts.jti ?? 'pt-3ab910',
+ cnf: {
+ jwk: {
+ kty: opts.agentPublicJwk.kty,
+ crv: opts.agentPublicJwk.crv,
+ x: opts.agentPublicJwk.x,
+ alg: 'Ed25519',
+ },
+ },
+ iat: now,
+ exp: opts.exp ?? now + 3600,
+ ...opts.extra,
+ }
+ if (opts.missionS256) payload.mission_s256 = opts.missionS256
+ if (opts.tenant) payload.tenant = opts.tenant
+ return signJwtWith(
+ { alg: 'Ed25519', typ: opts.typ ?? 'aa-person+jwt', kid },
+ payload,
+ kp.privateKey,
+ )
+ },
+ // Serve the PS's metadata + JWKS to the worker's fetch calls.
+ install() {
+ vi.stubGlobal('fetch', vi.fn(async (url: string) => {
+ if (url === `${PS_ORIGIN}/.well-known/aauth-person.json`) {
+ return new Response(JSON.stringify(metadata), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ }
+ if (url === metadata.jwks_uri) {
+ return new Response(JSON.stringify({ keys: [jwksKey] }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ }
+ return new Response('not found', { status: 404 })
+ }))
+ },
+ }
+}
+
+// Mint an agent_token signed by the env's SIGNING_KEY. Under -11 this is
+// NOT accepted at /authorize — the test that presents one asserts the
+// requirement=person-token challenge.
+async function mintAgentTokenForTest(env: any, agentPublicJwk: JsonWebKey): Promise {
+ const { computeJwkThumbprint } = await import('../src/crypto')
const serverJwk = JSON.parse(env.SIGNING_KEY)
const serverKey = await webcrypto.subtle.importKey('jwk', serverJwk, { name: 'Ed25519' }, false, ['sign'])
const { d: _d, key_ops: _ops, ext: _ext, ...serverPub } = serverJwk
const serverKid = await computeJwkThumbprint(serverPub)
const now = Math.floor(Date.now() / 1000)
- const header = { alg: 'Ed25519', typ: 'aa-agent+jwt', kid: serverKid }
- const payload = {
- iss: env.ORIGIN,
- dwk: 'aauth-agent.json',
- sub: opts?.sub ?? 'aauth:test@playground.test',
- jti: `jti-${Math.random().toString(36).slice(2)}`,
- // cnf.jwk must carry alg — httpsig 2.0 takes the verification
- // algorithm from the JWK and rejects a key without one.
- cnf: { jwk: { kty: publicJwk.kty, crv: publicJwk.crv, x: publicJwk.x, alg: 'Ed25519' } },
- iat: now,
- exp: opts?.exp ?? now + 3600,
- }
- const enc = new TextEncoder()
- const b64 = (bytes: Uint8Array) => {
- let s = ''
- for (const b of bytes) s += String.fromCharCode(b)
- return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
- }
- const headerB64 = b64(enc.encode(JSON.stringify(header)))
- const payloadB64 = b64(enc.encode(JSON.stringify(payload)))
- const sig = await webcrypto.subtle.sign('Ed25519', serverKey, enc.encode(`${headerB64}.${payloadB64}`))
- const agentToken = `${headerB64}.${payloadB64}.${b64(new Uint8Array(sig))}`
-
- return { agentToken, publicJwk, privateJwk }
+ return signJwtWith(
+ { alg: 'Ed25519', typ: 'aa-agent+jwt', kid: serverKid },
+ {
+ iss: env.ORIGIN,
+ dwk: 'aauth-agent.json',
+ sub: 'aauth:playground@playground.test',
+ jti: `jti-${Math.random().toString(36).slice(2)}`,
+ // cnf.jwk must carry alg — httpsig 2.0 takes the verification
+ // algorithm from the JWK and rejects a key without one.
+ cnf: { jwk: { kty: agentPublicJwk.kty, crv: agentPublicJwk.crv, x: agentPublicJwk.x, alg: 'Ed25519' } },
+ iat: now,
+ exp: now + 3600,
+ },
+ serverKey,
+ )
}
// ── Authorize ──
+//
+// Under AAuth -11 the agent presents a PERSON token in Signature-Key at
+// the authorization endpoint. The request body carries only `scope` — the
+// person server is the person token's `iss`, not a body parameter.
describe('POST /authorize', () => {
// The authority that app.request() constructs for the Request URL. Signing
// must match so the server's httpsig verify sees the same value on the
// @authority component.
const TEST_URL = 'http://localhost/authorize'
-
- // Mint an agent_token + keep the full ephemeral keypair (private + public)
- // so we can sign RFC 9421 requests against /authorize. Bootstrap path is
- // now the only way, but for unit tests we cut out WebAuthn + PS and sign
- // the agent_token directly with the server's own SIGNING_KEY.
- const mintAgentTokenWithKey = async (_app: any, env: any, _kv: InMemoryKV) =>
- mintAgentTokenForTest(env, { sub: 'aauth:playground@playground.test' })
+ const MISSION = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'
// Produce the header map a signed POST /authorize request carries.
- async function signedHeaders(bodyJSON: string, agentToken: string, privateJwk: JsonWebKey): Promise> {
+ async function signedHeaders(bodyJSON: string, jwt: string, privateJwk: JsonWebKey): Promise> {
const dry = await sigFetch(TEST_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: bodyJSON,
signingKey: privateJwk,
- signatureKey: { type: 'jwt', jwt: agentToken },
+ signatureKey: { type: 'jwt', jwt },
components: ['@method', '@authority', '@path', 'content-type', 'signature-key'],
dryRun: true,
}) as { headers: Headers }
@@ -193,122 +293,169 @@ describe('POST /authorize', () => {
return out
}
- it('rejects missing required fields', async () => {
+ // The common setup: an agent key, a PS serving its metadata + JWKS, and
+ // a person token for this resource bound to the agent's key. The mission
+ // rides along on every request so the copy-through is exercised
+ // throughout, not in one isolated case.
+ async function setup(opts?: {
+ aud?: string
+ missionS256?: string | null
+ tenant?: string
+ metadata?: Record
+ }) {
const app = await loadApp()
- const { env, kv } = await makeEnv()
- const { agentToken, privateJwk } = await mintAgentTokenWithKey(app, env, kv)
- const body = JSON.stringify({ ps: 'https://ps.test' })
- const headers = await signedHeaders(body, agentToken, privateJwk)
- const res = await app.request('/authorize', { method: 'POST', headers, body }, env)
+ const { env } = await makeEnv()
+ const agent = await makeAgentKey()
+ const ps = await makePersonServer(opts?.metadata)
+ ps.install()
+ const personToken = await ps.mintPersonToken({
+ aud: opts?.aud ?? env.ORIGIN,
+ agentPublicJwk: agent.publicJwk,
+ missionS256: opts?.missionS256 === null ? undefined : (opts?.missionS256 ?? MISSION),
+ tenant: opts?.tenant,
+ })
+ return { app, env, agent, ps, personToken }
+ }
+
+ async function post(app: any, env: any, bodyObj: unknown, jwt: string, privateJwk: JsonWebKey) {
+ const body = JSON.stringify(bodyObj)
+ const headers = await signedHeaders(body, jwt, privateJwk)
+ return app.request('/authorize', { method: 'POST', headers, body }, env)
+ }
+
+ it('rejects a missing scope', async () => {
+ const { app, env, agent, personToken } = await setup()
+ const res = await post(app, env, {}, personToken, agent.privateJwk)
expect(res.status).toBe(400)
+ vi.unstubAllGlobals()
})
- it('rejects when Signature-Key header is missing', async () => {
+ it('challenges with requirement=person-token when Signature-Key is missing', async () => {
const app = await loadApp()
const { env } = await makeEnv()
const res = await app.request('/authorize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ ps: 'https://ps.test', scope: 'playground.demo' }),
+ body: JSON.stringify({ scope: 'playground.demo' }),
}, env)
expect(res.status).toBe(401)
- expect((await res.json() as any).error).toMatch(/signature verification failed/i)
+ expect(res.headers.get('AAuth-Requirement')).toBe('requirement=person-token')
+ })
+
+ it('challenges with requirement=person-token when an agent token is presented', async () => {
+ const app = await loadApp()
+ const { env } = await makeEnv()
+ const agent = await makeAgentKey()
+ const agentToken = await mintAgentTokenForTest(env, agent.publicJwk)
+ const res = await post(app, env, { scope: 'playground.demo' }, agentToken, agent.privateJwk)
+ expect(res.status).toBe(401)
+ expect(res.headers.get('AAuth-Requirement')).toBe('requirement=person-token')
})
- it('rejects invalid agent_token', async () => {
+ it('rejects a malformed token in Signature-Key', async () => {
const app = await loadApp()
const { env } = await makeEnv()
const res = await app.request('/authorize', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Signature-Key': 'sig=jwt;jwt="not.a.jwt"' },
- body: JSON.stringify({ ps: 'https://ps.test', scope: 'playground.demo' }),
+ body: JSON.stringify({ scope: 'playground.demo' }),
}, env)
expect(res.status).toBe(401)
})
it('rejects when the httpsig is signed by a key other than the one in cnf.jwk', async () => {
- const app = await loadApp()
- const { env, kv } = await makeEnv()
- const { agentToken } = await mintAgentTokenWithKey(app, env, kv)
+ const { app, env, personToken } = await setup()
// Sign with a DIFFERENT private key so the cnf.jwk → public key doesn't
// verify the signature. This is the key guarantee full httpsig gives us
// over the old JWT-only check.
- const attackerKp = await webcrypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']) as CryptoKeyPair
- const attackerPriv = await webcrypto.subtle.exportKey('jwk', attackerKp.privateKey)
- const body = JSON.stringify({ ps: 'https://ps.test', scope: 'playground.demo' })
- const headers = await signedHeaders(body, agentToken, attackerPriv)
- const res = await app.request('/authorize', { method: 'POST', headers, body }, env)
+ const attacker = await makeAgentKey()
+ const res = await post(app, env, { scope: 'playground.demo' }, personToken, attacker.privateJwk)
expect(res.status).toBe(401)
expect((await res.json() as any).error).toMatch(/signature verification failed/i)
+ vi.unstubAllGlobals()
+ })
+
+ it('rejects a person token whose aud is a different resource', async () => {
+ const { app, env, agent, personToken } = await setup({ aud: 'https://other.test' })
+ const res = await post(app, env, { scope: 'playground.demo' }, personToken, agent.privateJwk)
+ expect(res.status).toBe(400)
+ const resBody = await res.json() as any
+ expect(resBody.error).toBe('invalid_person_token')
+ expect(resBody.detail).toMatch(/aud/)
+ vi.unstubAllGlobals()
})
- it('rejects non-HTTPS PS URL', async () => {
+ it('rejects a person token carrying the wrong dwk', async () => {
const app = await loadApp()
- const { env, kv } = await makeEnv()
- const { agentToken, privateJwk } = await mintAgentTokenWithKey(app, env, kv)
- const body = JSON.stringify({ ps: 'http://ps.test', scope: 'playground.demo' })
- const headers = await signedHeaders(body, agentToken, privateJwk)
- const res = await app.request('/authorize', { method: 'POST', headers, body }, env)
+ const { env } = await makeEnv()
+ const agent = await makeAgentKey()
+ const ps = await makePersonServer()
+ ps.install()
+ const token = await ps.mintPersonToken({
+ aud: env.ORIGIN,
+ agentPublicJwk: agent.publicJwk,
+ dwk: 'aauth-agent.json',
+ })
+ const res = await post(app, env, { scope: 'playground.demo' }, token, agent.privateJwk)
expect(res.status).toBe(400)
- expect((await res.json() as any).error).toMatch(/HTTPS/)
+ expect((await res.json() as any).error).toBe('invalid_person_token')
+ vi.unstubAllGlobals()
})
- it('returns 502 when PS metadata fetch fails', async () => {
+ it('rejects an expired person token', async () => {
const app = await loadApp()
- const { env, kv } = await makeEnv()
- const { agentToken, privateJwk } = await mintAgentTokenWithKey(app, env, kv)
- const body = JSON.stringify({ ps: 'https://ps.test', scope: 'playground.demo' })
- const headers = await signedHeaders(body, agentToken, privateJwk)
- vi.stubGlobal('fetch', vi.fn(async () => new Response('nope', { status: 404 })))
- const res = await app.request('/authorize', { method: 'POST', headers, body }, env)
- expect(res.status).toBe(502)
+ const { env } = await makeEnv()
+ const agent = await makeAgentKey()
+ const ps = await makePersonServer()
+ ps.install()
+ const token = await ps.mintPersonToken({
+ aud: env.ORIGIN,
+ agentPublicJwk: agent.publicJwk,
+ exp: Math.floor(Date.now() / 1000) - 10,
+ })
+ const res = await post(app, env, { scope: 'playground.demo' }, token, agent.privateJwk)
+ expect(res.status).toBe(400)
+ expect((await res.json() as any).detail).toMatch(/expired/)
vi.unstubAllGlobals()
})
- it('returns 502 when PS metadata is missing required fields', async () => {
+ it('rejects a person token when the PS metadata is unreachable', async () => {
const app = await loadApp()
- const { env, kv } = await makeEnv()
- const { agentToken, privateJwk } = await mintAgentTokenWithKey(app, env, kv)
- const body = JSON.stringify({ ps: 'https://ps.test', scope: 'playground.demo' })
- const headers = await signedHeaders(body, agentToken, privateJwk)
- vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ issuer: 'https://ps.test' }), {
- status: 200,
- headers: { 'Content-Type': 'application/json' },
- })))
- const res = await app.request('/authorize', { method: 'POST', headers, body }, env)
+ const { env } = await makeEnv()
+ const agent = await makeAgentKey()
+ const ps = await makePersonServer()
+ const token = await ps.mintPersonToken({ aud: env.ORIGIN, agentPublicJwk: agent.publicJwk })
+ vi.stubGlobal('fetch', vi.fn(async () => new Response('nope', { status: 404 })))
+ const res = await post(app, env, { scope: 'playground.demo' }, token, agent.privateJwk)
+ expect(res.status).toBe(400)
+ expect((await res.json() as any).error).toBe('invalid_person_token')
+ vi.unstubAllGlobals()
+ })
+
+ it('returns 502 when PS metadata lacks auth_token_endpoint', async () => {
+ // -10 called this field `token_endpoint`; a PS still publishing the old
+ // name cannot tell the agent where to place its token request.
+ const { app, env, agent, personToken } = await setup({
+ metadata: { auth_token_endpoint: undefined, token_endpoint: `${PS_ORIGIN}/token` },
+ })
+ const res = await post(app, env, { scope: 'playground.demo' }, personToken, agent.privateJwk)
expect(res.status).toBe(502)
- expect((await res.json() as any).error).toMatch(/missing required/)
+ expect((await res.json() as any).error).toMatch(/auth_token_endpoint/)
vi.unstubAllGlobals()
})
- it('issues an aa-resource+jwt with correct claims when PS metadata is valid', async () => {
- const app = await loadApp()
- const { env, kv } = await makeEnv()
- const { agentToken, publicJwk, privateJwk } = await mintAgentTokenWithKey(app, env, kv)
-
- const psMetadata = {
- issuer: 'https://ps.test',
- token_endpoint: 'https://ps.test/token',
- jwks_uri: 'https://ps.test/.well-known/jwks.json',
- }
- vi.stubGlobal('fetch', vi.fn(async (url: string) => {
- expect(url).toBe('https://ps.test/.well-known/aauth-person.json')
- return new Response(JSON.stringify(psMetadata), {
- status: 200,
- headers: { 'Content-Type': 'application/json' },
- })
- }))
-
- const body = JSON.stringify({ ps: 'https://ps.test', scope: 'playground.demo' })
- const headers = await signedHeaders(body, agentToken, privateJwk)
- const res = await app.request('/authorize', { method: 'POST', headers, body }, env)
+ it('issues an aa-resource+jwt carrying ps, sub, person_token_jti and mission_s256', async () => {
+ const { app, env, agent, ps, personToken } = await setup()
+ const res = await post(app, env, { scope: 'playground.demo' }, personToken, agent.privateJwk)
expect(res.status).toBe(200)
const resBody = await res.json() as any
- expect(resBody.ps_metadata).toEqual(psMetadata)
+ expect(resBody.ps_metadata).toEqual(ps.metadata)
expect(resBody.ps_metadata_url).toBe('https://ps.test/.well-known/aauth-person.json')
expect(resBody.resource_token).toBeDefined()
+ const { decodeJWTHeader, computeJwkThumbprint } = await import('../src/crypto')
+ // Fully-specified alg — never the polymorphic EdDSA.
const header = decodeJWTHeader(resBody.resource_token)
expect(header.alg).toBe('Ed25519')
expect(header.typ).toBe('aa-resource+jwt')
@@ -318,67 +465,181 @@ describe('POST /authorize', () => {
expect(payload.dwk).toBe('aauth-resource.json')
expect(payload.aud).toBe('https://ps.test')
expect(payload.scope).toBe('playground.demo')
- expect(payload.agent).toBe('aauth:playground@playground.test')
- expect(payload.agent_jkt).toBeDefined()
expect(payload.exp as number).toBe((payload.iat as number) + 300)
- // agent_jkt must be the RFC 7638 thumbprint of the ephemeral JWK
- const { computeJwkThumbprint } = await import('../src/crypto')
- expect(payload.agent_jkt).toBe(await computeJwkThumbprint(publicJwk))
+ // The -11 claims, copied from the verified person token.
+ expect(payload.ps).toBe('https://ps.test')
+ expect(payload.sub).toBe('8f14e45fceea167a5a36dedd4bea2543')
+ expect(payload.person_token_jti).toBe('pt-3ab910')
+ expect(payload.mission_s256).toBe(MISSION)
+ // -11 removed the agent identifier from the resource token.
+ expect(payload.agent).toBeUndefined()
+ // agent_jkt stays: the RFC 7638 thumbprint of the agent's signing key.
+ expect(payload.agent_jkt).toBe(await computeJwkThumbprint(agent.publicJwk))
vi.unstubAllGlobals()
})
- it('rejects unknown scopes with 400 invalid_scope', async () => {
+ it('clamps the resource token exp to the person token exp', async () => {
+ // The person token is itself clamped to the mission's expires_at, so
+ // never outliving it keeps a mission-scoped resource token inside the
+ // mission's window without the resource knowing the mission.
const app = await loadApp()
- const { env, kv } = await makeEnv()
- const { agentToken, privateJwk } = await mintAgentTokenWithKey(app, env, kv)
+ const { env } = await makeEnv()
+ const agent = await makeAgentKey()
+ const ps = await makePersonServer()
+ ps.install()
+ const shortExp = Math.floor(Date.now() / 1000) + 60
+ const token = await ps.mintPersonToken({
+ aud: env.ORIGIN,
+ agentPublicJwk: agent.publicJwk,
+ missionS256: MISSION,
+ exp: shortExp,
+ })
+ const res = await post(app, env, { scope: 'playground.demo' }, token, agent.privateJwk)
+ expect(res.status).toBe(200)
+ const payload = decodeJWTPayload((await res.json() as any).resource_token)
+ expect(payload.exp).toBe(shortExp)
+ vi.unstubAllGlobals()
+ })
+
+ it('copies tenant from the person token', async () => {
+ // §Resource Token Verification step 6: the PS checks ps, sub,
+ // mission_s256 AND tenant against the person token it issued, and
+ // rejects on any mismatch or omission. Dropping tenant here makes the
+ // resource token unredeemable for any org-affiliated person.
+ const { app, env, agent, personToken } = await setup({ tenant: 'acme-corp' })
+ const res = await post(app, env, { scope: 'playground.demo' }, personToken, agent.privateJwk)
+ expect(res.status).toBe(200)
+ const payload = decodeJWTPayload((await res.json() as any).resource_token)
+ expect(payload.tenant).toBe('acme-corp')
+ vi.unstubAllGlobals()
+ })
+
+ it('omits tenant when the person token carried none', async () => {
+ const { app, env, agent, personToken } = await setup()
+ const res = await post(app, env, { scope: 'playground.demo' }, personToken, agent.privateJwk)
+ expect(res.status).toBe(200)
+ const payload = decodeJWTPayload((await res.json() as any).resource_token)
+ expect(payload.tenant).toBeUndefined()
+ vi.unstubAllGlobals()
+ })
+
+ it('omits mission_s256 when the person token carried none', async () => {
+ const { app, env, agent, personToken } = await setup({ missionS256: null })
+ const res = await post(app, env, { scope: 'playground.demo' }, personToken, agent.privateJwk)
+ expect(res.status).toBe(200)
+ const resBody = await res.json() as any
+ expect(decodeJWTPayload(resBody.resource_token).mission_s256).toBeUndefined()
+ vi.unstubAllGlobals()
+ })
+
+ it('rejects unknown scopes with 400 invalid_scope', async () => {
+ const { app, env, agent, personToken } = await setup()
// Typo'd resource scope — neither in SCOPE_DESCRIPTIONS nor in
// PS_IDENTITY_SCOPES — is the only thing that should still 400.
- const body = JSON.stringify({
- ps: 'https://ps.test',
- scope: 'playground.demo playground.typo',
- })
- const headers = await signedHeaders(body, agentToken, privateJwk)
- const res = await app.request('/authorize', { method: 'POST', headers, body }, env)
+ const res = await post(
+ app, env, { scope: 'playground.demo playground.typo' }, personToken, agent.privateJwk,
+ )
expect(res.status).toBe(400)
const resBody = await res.json() as any
expect(resBody.error).toBe('invalid_scope')
expect(resBody.unknown).toEqual(['playground.typo'])
+ vi.unstubAllGlobals()
})
it('passes PS identity scopes through to resource_token.scope', async () => {
// Per aauth-claims-plan v3 §4.2 the resource server MUST pass
// identity scopes through unmodified — the PS classifies them at
- // /aauth/token time.
- const app = await loadApp()
- const { env, kv } = await makeEnv()
- const { agentToken, privateJwk } = await mintAgentTokenWithKey(app, env, kv)
-
- const psMetadata = {
- issuer: 'https://ps.test',
- token_endpoint: 'https://ps.test/token',
- jwks_uri: 'https://ps.test/.well-known/jwks.json',
- }
- vi.stubGlobal('fetch', vi.fn(async () => {
- return new Response(JSON.stringify(psMetadata), {
- status: 200,
- headers: { 'Content-Type': 'application/json' },
- })
- }))
-
- const body = JSON.stringify({
- ps: 'https://ps.test',
- scope: 'openid profile playground.demo',
- })
- const headers = await signedHeaders(body, agentToken, privateJwk)
- const res = await app.request('/authorize', { method: 'POST', headers, body }, env)
+ // auth-token time.
+ const { app, env, agent, personToken } = await setup()
+ const res = await post(
+ app, env, { scope: 'openid profile playground.demo' }, personToken, agent.privateJwk,
+ )
expect(res.status).toBe(200)
const resBody = await res.json() as any
// scope on the resource_token carries the agent's request verbatim.
- expect(resBody.resource_token_decoded.scope).toBe(
- 'openid profile playground.demo',
- )
+ expect(resBody.resource_token_decoded.scope).toBe('openid profile playground.demo')
vi.unstubAllGlobals()
})
})
+
+// ── Demo resource API ──
+//
+// The one endpoint gated on an auth token. A person token from the same
+// PS carries the same iss, dwk, aud, sub and cnf as the auth token — only
+// `typ` separates them — so this suite exists mainly to pin that the
+// difference is actually enforced.
+
+describe('GET /api/demo', () => {
+ const DEMO_URL = 'http://localhost/api/demo'
+
+ async function signedGetHeaders(jwt: string, privateJwk: JsonWebKey): Promise> {
+ const dry = await sigFetch(DEMO_URL, {
+ method: 'GET',
+ signingKey: privateJwk,
+ signatureKey: { type: 'jwt', jwt },
+ components: ['@method', '@authority', '@path', 'signature-key'],
+ dryRun: true,
+ }) as { headers: Headers }
+ const out: Record = {}
+ dry.headers.forEach((v, k) => { out[k] = v })
+ return out
+ }
+
+ // Same PS, same key, same claims — the only difference between the two
+ // tokens below is the `typ` in the header.
+ async function setupDemo(typ: string) {
+ const app = await loadApp()
+ const { env } = await makeEnv()
+ const agent = await makeAgentKey()
+ const ps = await makePersonServer()
+ ps.install()
+ const token = await ps.mintPersonToken({
+ aud: env.ORIGIN,
+ agentPublicJwk: agent.publicJwk,
+ typ,
+ extra: { scope: 'playground.demo', name: 'Ada' },
+ })
+ return { app, env, agent, token }
+ }
+
+ async function get(app: any, env: any, token: string, privateJwk: JsonWebKey) {
+ const headers = await signedGetHeaders(token, privateJwk)
+ return app.request('/api/demo', { method: 'GET', headers }, env)
+ }
+
+ it('serves the demo for a valid auth token', async () => {
+ const { app, env, agent, token } = await setupDemo('aa-auth+jwt')
+ const res = await get(app, env, token, agent.privateJwk)
+ expect(res.status).toBe(200)
+ const body = await res.json() as any
+ expect(body.hello).toBe('Ada')
+ expect(body.granted_scopes).toEqual(['playground.demo'])
+ vi.unstubAllGlobals()
+ })
+
+ it('rejects a person token where an auth token is required', async () => {
+ // §Person Token Verification: "A recipient MUST reject an
+ // aa-person+jwt wherever an auth token is required." Identical
+ // payload to the passing case above, aa-person+jwt in the header.
+ const { app, env, agent, token } = await setupDemo('aa-person+jwt')
+ const res = await get(app, env, token, agent.privateJwk)
+ expect(res.status).toBe(401)
+ const body = await res.json() as any
+ expect(body.error).toBe('invalid_token_type')
+ expect(body.typ).toBe('aa-person+jwt')
+ expect(body.accepted).toEqual(['aa-auth+jwt'])
+ vi.unstubAllGlobals()
+ })
+
+ it('rejects an agent token where an auth token is required', async () => {
+ const app = await loadApp()
+ const { env } = await makeEnv()
+ const agent = await makeAgentKey()
+ const agentToken = await mintAgentTokenForTest(env, agent.publicJwk)
+ const res = await get(app, env, agentToken, agent.privateJwk)
+ expect(res.status).toBe(401)
+ expect((await res.json() as any).error).toBe('invalid_token_type')
+ })
+})