diff --git a/README.md b/README.md
index 75e066b..f0c7aa5 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,9 @@ Live at [aauth.dev](https://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 2310046..5cd1014 100644
--- a/client/protocol.js
+++ b/client/protocol.js
@@ -67,6 +67,26 @@ async function exportSigningJwk(publicKey) {
return jwk
}
+// RFC 9421 covered components for every signed request we make.
+//
+// A signature over a request that carries a body MUST cover
+// content-digest (RFC 9530) — a body the signature doesn't commit to can
+// be swapped in transit while the signature still verifies. The Person
+// Server enforces this and rejects anything else with
+// `signature must cover content-digest on requests with a body`.
+//
+// sigFetch derives the Content-Digest header itself when the component is
+// listed, hashing the exact bytes handed to it as `body`. That means every
+// call site must pass a pre-serialized string — hand it a parsed object
+// and the digest is computed over something other than what goes on the
+// wire, so verification fails at the far end.
+const SIGNED_COMPONENTS = ['@method', '@authority', '@path', 'signature-key']
+const SIGNED_COMPONENTS_WITH_BODY = [
+ '@method', '@authority', '@path', 'content-type', 'content-digest', 'signature-key',
+]
+const signedComponents = (hasBody) =>
+ hasBody ? SIGNED_COMPONENTS_WITH_BODY : SIGNED_COMPONENTS
+
// Signed fetch helpers exposed for app.js (which can't import sigFetch
// directly since it isn't bundled).
// aauthSigFetch — sig=jwt (agent_token or auth_token)
@@ -77,9 +97,7 @@ window.aauthSigFetch = async function aauthSigFetch(url, { method = 'GET', heade
if (!jwt) throw new Error('jwt required for sig=jwt scheme')
const signingKey = await exportSigningJwk(keyPair.publicKey)
const hasBody = body !== undefined && body !== null
- const components = hasBody
- ? ['@method', '@authority', '@path', 'content-type', 'signature-key']
- : ['@method', '@authority', '@path', 'signature-key']
+ const components = signedComponents(hasBody)
const mergedHeaders = hasBody
? { 'Content-Type': 'application/json', ...headers }
: { ...headers }
@@ -99,9 +117,7 @@ window.aauthSigFetchHwk = async function aauthSigFetchHwk(url, { method = 'POST'
if (!keyPair) throw new Error('no signing key available')
const signingKey = await exportSigningJwk(keyPair.publicKey)
const hasBody = body !== undefined && body !== null
- const components = hasBody
- ? ['@method', '@authority', '@path', 'content-type', 'signature-key']
- : ['@method', '@authority', '@path', 'signature-key']
+ const components = signedComponents(hasBody)
const mergedHeaders = hasBody
? { 'Content-Type': 'application/json', ...headers }
: { ...headers }
@@ -581,10 +597,167 @@ function getSelectedIdentityScopes() {
function getHints() {
// Hints UI was removed from the bootstrap section (PS routing hints
// belonged to the old PS bootstrap call); resource flows still call
- // this and an empty object Just Works at the PS /token endpoint.
+ // this and an empty object Just Works at the PS auth token endpoint.
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') +
+ formatRequest('POST', endpoint, {
+ 'Content-Type': 'application/json',
+ 'Content-Digest': 'sha-256=:...:',
+ 'Signature-Input': 'sig=("@method" "@authority" "@path" "content-type" "content-digest" "signature-key");created=...',
+ 'Signature': 'sig=:...:',
+ 'Signature-Key': `sig=jwt;jwt="${agentToken?.substring(0, 20)}..."`,
+ }, requestBody),
+ )
+ try {
+ const res = await sigFetch(endpoint, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(requestBody),
+ signingKey: signingJwk,
+ signingCryptoKey: keyPair.privateKey,
+ // The agent is still only an agent here — the person token is what
+ // it is asking for, so it presents its agent_token to get one.
+ 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,
+ })
+ 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
+ }
+ resolveStep(step, res.status === 200 || res.status === 202 ? '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(decodeJWTPayloadBrowser(body.person_token), 'person_token payload'))
+ 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(decodeJWTPayloadBrowser(personToken), 'person_token payload'),
+ { kind: 'response' },
+ )
+}
+
+// The mission the demo is operating under, when one has been set. There
+// is no mission UI in the playground yet; this is the seam the digest
+// travels through, so setting it is all it takes for mission_s256 to
+// appear on the person token and every token minted from it.
+function currentMissionS256() {
+ return window.AAUTH_MISSION_S256 || null
+}
+
// ── Bootstrap ──
//
// Per draft-hardt-aauth-bootstrap, the agent provider issues an agent
@@ -616,7 +789,8 @@ async function runBootstrap(psUrl) {
desc('bootstrap.agent_provider_request') +
formatRequest('POST', endpoint, {
'Content-Type': 'application/json',
- 'Signature-Input': 'sig=("@method" "@authority" "@path" "content-type" "signature-key");created=...',
+ 'Content-Digest': 'sha-256=:...:',
+ 'Signature-Input': 'sig=("@method" "@authority" "@path" "content-type" "content-digest" "signature-key");created=...',
'Signature': 'sig=:...:',
'Signature-Key': `sig=hwk;alg="${publicJwk.alg}";kty="${publicJwk.kty}";crv="${publicJwk.crv}";x="${publicJwk.x}"`,
}, body)
@@ -631,7 +805,7 @@ async function runBootstrap(psUrl) {
signingKey: publicJwk,
signingCryptoKey: keyPair.privateKey,
signatureKey: { type: 'hwk' },
- components: ['@method', '@authority', '@path', 'content-type', 'signature-key'],
+ components: SIGNED_COMPONENTS_WITH_BODY,
})
result = await res.json().catch(() => null)
if (!res.ok || !result?.agent_token) {
@@ -685,7 +859,8 @@ async function runRefresh() {
desc('refresh.agent_provider_request') +
formatRequest('POST', endpoint, {
'Content-Type': 'application/json',
- 'Signature-Input': 'sig=("@method" "@authority" "@path" "content-type" "signature-key");created=...',
+ 'Content-Digest': 'sha-256=:...:',
+ 'Signature-Input': 'sig=("@method" "@authority" "@path" "content-type" "content-digest" "signature-key");created=...',
'Signature': 'sig=:...:',
'Signature-Key': `sig=hwk;alg="${publicJwk.alg}";kty="${publicJwk.kty}";crv="${publicJwk.crv}";x="${publicJwk.x}"`,
}, body)
@@ -700,7 +875,7 @@ async function runRefresh() {
signingKey: publicJwk,
signingCryptoKey: keyPair.privateKey,
signatureKey: { type: 'hwk' },
- components: ['@method', '@authority', '@path', 'content-type', 'signature-key'],
+ components: SIGNED_COMPONENTS_WITH_BODY,
})
result = await res.json().catch(() => null)
if (!res.ok || !result?.agent_token) {
@@ -782,12 +957,17 @@ 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.
+// 0. Agent POSTs its agent_token to the PS's person_token_endpoint,
+// naming whoami as the resource, and gets a person_token back.
+// -11 added this hop: a resource must have verified a person token
+// before it may issue a resource token.
+// 1. Agent GETs whoami with that person_token. Whoami responds 401 with
+// a minted resource_token in AAuth-Requirement — it knows which
+// person the agent acts for, but a person token carries no
+// authorization, so there is nothing yet that says what it may read.
+// 2. Agent exchanges the resource_token at the PS's auth_token_endpoint.
// 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
@@ -848,18 +1028,53 @@ 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,
+ missionS256: currentMissionS256(),
+ 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 in place of the agent
+ // 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: GET ${whoamiPathDisplay}`, '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 with the person_token it just obtained. The resource knows which person the agent acts for, but a person token carries no authorization, so it returns 401 with a resource_token the agent can exchange at the Person Server.
` +
formatRequest('GET', whoamiUrl, {
'Signature-Input': 'sig=("@method" "@authority" "@path" "signature-key");created=...',
'Signature': 'sig=:...:',
- 'Signature-Key': `sig=jwt;jwt="${agentToken?.substring(0, 20)}..."`,
+ 'Signature-Key': `sig=jwt;jwt="${personToken?.substring(0, 20)}..."`,
}, null)
)
@@ -869,8 +1084,8 @@ async function runWhoamiCall(whoamiUrl, bindingPs, hints) {
method: 'GET',
signingKey: signingJwk,
signingCryptoKey: keyPair.privateKey,
- signatureKey: { type: 'jwt', jwt: agentToken },
- components: ['@method', '@authority', '@path', 'signature-key'],
+ signatureKey: { type: 'jwt', jwt: personToken },
+ components: SIGNED_COMPONENTS,
})
const body = await res.json().catch(() => null)
const requirement = res.headers.get('aauth-requirement') || ''
@@ -887,8 +1102,8 @@ async function runWhoamiCall(whoamiUrl, bindingPs, hints) {
if (res.status === 200) {
resolveStep(step1, 'success', `Agent → Whoami: GET ${whoamiPathDisplay}`)
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 — straight from the person_token, with no auth token and no Person Server exchange. A resource that serves requests this way treats holding a person token as access.
` +
tokenWrap(renderJSON(body)) +
anotherRequestButton(),
{ kind: 'response' }
@@ -926,6 +1141,7 @@ async function runWhoamiCall(whoamiUrl, bindingPs, hints) {
keyPair,
agentToken,
signingJwk,
+ psMetadata,
labels: {
postLabel: (path) => `Agent → Person Server: POST ${path}`,
postLabelResolved: (path, status) =>
@@ -933,12 +1149,11 @@ async function runWhoamiCall(whoamiUrl, bindingPs, hints) {
? `Agent → Person Server: POST ${path}`
: `Agent → Person Server: POST ${path} → ${status}`,
postLabelNetworkError: (path) => `Agent → Person Server: POST ${path} (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: GET ${path} (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 }) => {
@@ -980,7 +1195,7 @@ async function retryWhoami(whoamiUrl, whoamiPathDisplay, authToken, keyPair, sig
signingKey: signingJwk,
signingCryptoKey: keyPair.privateKey,
signatureKey: { type: 'jwt', jwt: authToken },
- components: ['@method', '@authority', '@path', 'signature-key'],
+ components: SIGNED_COMPONENTS,
})
const body = await res.json().catch(() => null)
resolveStep(step, res.ok ? 'success' : 'error', `Agent → Whoami: GET ${whoamiPathDisplay}`)
@@ -1112,8 +1327,18 @@ function clearPendingAuthorize() {
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.
+// 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 }
@@ -1162,7 +1387,13 @@ 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'
+ const isPersonStage = saved.stage === 'person-token'
+ // 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 = isPersonStage
+ ? '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
@@ -1178,43 +1409,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'
+ //
+ // 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.publicKey)
+
+ 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.publicKey)
- 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
@@ -1246,8 +1512,8 @@ 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.
@@ -1276,24 +1542,27 @@ async function runPSTokenExchange({
// a separate "Auth Token received" step would just duplicate). Notes
// ignores it — its finalizeNotesAuthToken always emits its own step.
onAuthToken,
+ // PS metadata already fetched for the person-token hop. Both flows pass
+ // it through rather than re-fetching the same document.
+ psMetadata: knownPsMetadata,
+ // Narration block in log-text.json the deferred (202) leg reads its
+ // long-poll and terminal labels from.
+ copyPrefix,
}) {
- 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) {
+ const psMetadata = knownPsMetadata || 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
+ // -11 renamed this field from `token_endpoint`. The PS now has two
+ // token endpoints — this one issues auth tokens, `person_token_endpoint`
+ // issues person tokens — so a name containing only "token" no longer
+ // says which.
+ const tokenEndpoint = psMetadata.auth_token_endpoint
const psPath = new URL(tokenEndpoint).pathname
const psBody = {
resource_token: resourceToken,
@@ -1309,7 +1578,8 @@ async function runPSTokenExchange({
labels.postDescription +
formatRequest('POST', tokenEndpoint, {
'Content-Type': 'application/json',
- 'Signature-Input': 'sig=("@method" "@authority" "@path" "signature-key");created=...',
+ 'Content-Digest': 'sha-256=:...:',
+ 'Signature-Input': 'sig=("@method" "@authority" "@path" "content-type" "content-digest" "signature-key");created=...',
'Signature': 'sig=:...:',
'Signature-Key': `sig=jwt;jwt="${agentToken?.substring(0, 20)}..."`,
}, psBody),
@@ -1324,7 +1594,9 @@ async function runPSTokenExchange({
signingKey: signingJwk,
signingCryptoKey: keyPair.privateKey,
signatureKey: { type: 'jwt', jwt: agentToken },
- components: ['@method', '@authority', '@path', 'signature-key'],
+ // -11: a request carrying a body to a PS or AS endpoint MUST
+ // additionally sign content-digest and content-type.
+ components: SIGNED_COMPONENTS_WITH_BODY,
})
const psResBody = await psRes.json().catch(() => null)
const respHeaders = {}
@@ -1343,57 +1615,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 +
- formatRequest('GET', absolutePollUrl, {
- 'Prefer': `wait=${POLL_WAIT_SECONDS}`,
- 'Signature-Input': 'sig=("@method" "@authority" "@path" "signature-key");created=...',
- 'Signature': 'sig=:...:',
- 'Signature-Key': `sig=jwt;jwt="${agentToken?.substring(0, 20)}..."`,
- }, null),
- )
- 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())
@@ -1410,32 +1645,117 @@ 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 agentToken = localStorage.getItem('aauth-agent-token')
+
+ const pollStep = addLogStep(
+ fmt(copy(`${copyPrefix}.ps_pending_longpoll.label_template`), { path: new URL(absolutePollUrl).pathname }),
+ 'pending',
+ desc(`${copyPrefix}.ps_pending_longpoll`) +
+ formatRequest('GET', absolutePollUrl, {
+ 'Prefer': `wait=${POLL_WAIT_SECONDS}`,
+ 'Signature-Input': 'sig=("@method" "@authority" "@path" "signature-key");created=...',
+ 'Signature': 'sig=:...:',
+ 'Signature-Key': `sig=jwt;jwt="${agentToken?.substring(0, 20)}..."`,
+ }, null),
+ )
+ 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 →
@@ -1444,25 +1764,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.publicKey)
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`) +
formatRequest('GET', absolutePollUrl, {
'Prefer': `wait=${POLL_WAIT_SECONDS}`,
'Signature-Input': 'sig=("@method" "@authority" "@path" "signature-key");created=...',
@@ -1481,8 +1801,11 @@ async function _startAuthTokenPollingImpl(pollUrl, baseUrl, interactionStep, pol
headers: { Prefer: `wait=${POLL_WAIT_SECONDS}` },
signingKey: signingJwk,
signingCryptoKey: keyPair.privateKey,
+ // The poll is signed with the agent_token in every case: it is
+ // the agent asking its own PS about a request it made, not a
+ // resource call.
signatureKey: { type: 'jwt', jwt: agentToken },
- components: ['@method', '@authority', '@path', 'signature-key'],
+ components: SIGNED_COMPONENTS,
})
const respHeaders = {}
for (const key of ['retry-after', 'aauth-requirement']) {
@@ -1504,46 +1827,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) {
@@ -1571,15 +1891,16 @@ function decodeJWTPayloadBrowser(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
-// either gets a 200 auth_token (cached consent) or 202 + interaction
-// that drives the existing auth-token polling loop. Once an auth_token
+// 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
+// deferred-response polling loop. Once an auth_token
// lands we persist it, reveal the Notes fieldset, and render a
// list/create/view/edit/delete UI gated on r3_granted.operations.
@@ -1840,6 +2161,43 @@ 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,
+ missionS256: currentMissionS256(),
+ 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: {
@@ -1848,16 +2206,17 @@ 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',
desc('notes.authorize_request') +
formatRequest('POST', authzEndpoint, {
'Content-Type': 'application/json',
- 'Signature-Input': 'sig=("@method" "@authority" "@path" "content-type" "signature-key");created=...',
+ 'Content-Digest': 'sha-256=:...:',
+ 'Signature-Input': 'sig=("@method" "@authority" "@path" "content-type" "content-digest" "signature-key");created=...',
'Signature': 'sig=:...:',
- 'Signature-Key': `sig=jwt;jwt="${agentToken?.substring(0, 20)}..."`,
+ 'Signature-Key': `sig=jwt;jwt="${personToken?.substring(0, 20)}..."`,
}, requestBody),
)
let resourceToken
@@ -1868,8 +2227,8 @@ async function runNotesAuthorize(operations, bindingPs, hints) {
body: JSON.stringify(requestBody),
signingKey: signingJwk,
signingCryptoKey: keyPair.privateKey,
- signatureKey: { type: 'jwt', jwt: agentToken },
- components: ['@method', '@authority', '@path', 'content-type', 'signature-key'],
+ signatureKey: { type: 'jwt', jwt: personToken },
+ components: SIGNED_COMPONENTS_WITH_BODY,
})
const body = await res.json().catch(() => null)
if (res.ok && body?.resource_token) {
@@ -1889,11 +2248,11 @@ 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,
@@ -1901,6 +2260,7 @@ async function runNotesAuthorize(operations, bindingPs, hints) {
keyPair,
agentToken,
signingJwk,
+ psMetadata,
labels: {
postLabel: (path) => fmt(copy('notes.ps_token_request.label_template'), { path }),
postLabelResolved: (path, status) =>
@@ -1908,11 +2268,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) => {
@@ -2138,9 +2497,7 @@ async function callNotesAPI(method, path, body) {
const origin = window.NOTES_ORIGIN || 'https://notes.aauth.dev'
const url = `${origin}${path}`
const hasBody = body !== undefined && body !== null
- const components = hasBody
- ? ['@method', '@authority', '@path', 'content-type', 'signature-key']
- : ['@method', '@authority', '@path', 'signature-key']
+ const components = signedComponents(hasBody)
const copyKey =
method === 'GET' && path === '/notes' ? 'notes_app.list_request'
@@ -2166,7 +2523,7 @@ async function callNotesAPI(method, path, body) {
'pending',
desc(copyKey) +
formatRequest(method, url, {
- ...(hasBody ? { 'Content-Type': 'application/json' } : {}),
+ ...(hasBody ? { 'Content-Type': 'application/json', 'Content-Digest': 'sha-256=:...:' } : {}),
'Signature-Input': 'sig=(...);created=...',
'Signature': 'sig=:...:',
'Signature-Key': `sig=jwt;jwt="${authToken.substring(0, 20)}..."`,
@@ -2325,7 +2682,7 @@ async function callDemoResourceApi(authToken) {
signingKey: signingJwk,
signingCryptoKey: keyPair.privateKey,
signatureKey: { type: 'jwt', jwt: authToken },
- components: ['@method', '@authority', '@path', 'signature-key'],
+ components: SIGNED_COMPONENTS,
})
const body = await res.json().catch(() => null)
resolveStep(reqStep, res.ok ? 'success' : 'error', fmt(copy('demo_api.request.label_resolved_template'), { path: '/api/demo', status: res.status }))
diff --git a/public/log-text.json b/public/log-text.json
index 125f6a9..808101b 100644
--- a/public/log-text.json
+++ b/public/log-text.json
@@ -37,6 +37,47 @@
}
},
+ "person_token": {
+ "request": {
+ "label_template": "Agent → Person Server: POST {path}",
+ "label_resolved_template": "Agent → Person Server: POST {path}",
+ "label_error_network_template": "Agent → Person Server: POST {path} (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: GET {path} (long-poll)",
+ "label_resolved_template": "Agent → Person Server: GET {path}",
+ "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: POST {path}",
"label_resolved_template": "Agent → Person Server: POST {path}",
"label_error_network_template": "Agent → Person Server: POST {path} (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: GET {path} (long-poll)",
@@ -104,7 +145,7 @@
"label_template": "Agent → Notes Resource: POST {path}",
"label_resolved_template": "Agent → Notes Resource: POST {path}",
"label_error_network_template": "Agent → Notes Resource: POST {path} (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: GET {path} (R3 document)",
@@ -116,7 +157,7 @@
"label_template": "Agent → Person Server: POST {path}",
"label_resolved_template": "Agent → Person Server: POST {path}",
"label_error_network_template": "Agent → Person Server: POST {path} (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: GET {path} (long-poll)",
diff --git a/public/protocol.js b/public/protocol.js
index 9f13236..832ca8e 100644
--- a/public/protocol.js
+++ b/public/protocol.js
@@ -248,15 +248,17 @@
function validateJwk(jwk) {
determineAlgorithm(jwk);
}
+ function withoutAlg(jwk) {
+ const { alg: _alg, ...rest } = jwk;
+ return rest;
+ }
async function importPrivateKey(jwk) {
const algorithm = determineAlgorithm(jwk);
- return await crypto.subtle.importKey("jwk", jwk, algorithm, false, ["sign"]);
+ return await crypto.subtle.importKey("jwk", withoutAlg(jwk), algorithm, false, ["sign"]);
}
async function importPublicKey(jwk) {
const algorithm = determineAlgorithm(jwk);
- return await crypto.subtle.importKey("jwk", jwk, algorithm, false, [
- "verify"
- ]);
+ return await crypto.subtle.importKey("jwk", withoutAlg(jwk), algorithm, false, ["verify"]);
}
function getPublicJwk(privateJwk) {
const { d, p, q, dp, dq, qi, ...publicJwk } = privateJwk;
@@ -3185,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: POST {path}",
+ label_resolved_template: "Agent \u2192 Person Server: POST {path}",
+ label_error_network_template: "Agent \u2192 Person Server: POST {path} (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: GET {path} (long-poll)",
+ label_resolved_template: "Agent \u2192 Person Server: GET {path}",
+ 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",
@@ -3200,7 +3241,7 @@
label_template: "Agent \u2192 Person Server: POST {path}",
label_resolved_template: "Agent \u2192 Person Server: POST {path}",
label_error_network_template: "Agent \u2192 Person Server: POST {path} (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: GET {path} (long-poll)",
@@ -3250,7 +3291,7 @@
label_template: "Agent \u2192 Notes Resource: POST {path}",
label_resolved_template: "Agent \u2192 Notes Resource: POST {path}",
label_error_network_template: "Agent \u2192 Notes Resource: POST {path} (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: GET {path} (R3 document)",
@@ -3262,7 +3303,7 @@
label_template: "Agent \u2192 Person Server: POST {path}",
label_resolved_template: "Agent \u2192 Person Server: POST {path}",
label_error_network_template: "Agent \u2192 Person Server: POST {path} (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: GET {path} (long-poll)",
@@ -3400,13 +3441,23 @@
jwk.alg = "Ed25519";
return jwk;
}
+ var SIGNED_COMPONENTS = ["@method", "@authority", "@path", "signature-key"];
+ var SIGNED_COMPONENTS_WITH_BODY = [
+ "@method",
+ "@authority",
+ "@path",
+ "content-type",
+ "content-digest",
+ "signature-key"
+ ];
+ var signedComponents = (hasBody) => hasBody ? SIGNED_COMPONENTS_WITH_BODY : SIGNED_COMPONENTS;
window.aauthSigFetch = async function aauthSigFetch(url, { method = "GET", headers = {}, body, jwt } = {}) {
const keyPair = window.aauthEphemeral.get();
if (!keyPair) throw new Error("no signing key available");
if (!jwt) throw new Error("jwt required for sig=jwt scheme");
const signingKey = await exportSigningJwk(keyPair.publicKey);
const hasBody = body !== void 0 && body !== null;
- const components = hasBody ? ["@method", "@authority", "@path", "content-type", "signature-key"] : ["@method", "@authority", "@path", "signature-key"];
+ const components = signedComponents(hasBody);
const mergedHeaders = hasBody ? { "Content-Type": "application/json", ...headers } : { ...headers };
return (0, import_httpsig.fetch)(url, {
method,
@@ -3423,7 +3474,7 @@
if (!keyPair) throw new Error("no signing key available");
const signingKey = await exportSigningJwk(keyPair.publicKey);
const hasBody = body !== void 0 && body !== null;
- const components = hasBody ? ["@method", "@authority", "@path", "content-type", "signature-key"] : ["@method", "@authority", "@path", "signature-key"];
+ const components = signedComponents(hasBody);
const mergedHeaders = hasBody ? { "Content-Type": "application/json", ...headers } : { ...headers };
return (0, import_httpsig.fetch)(url, {
method,
@@ -3693,6 +3744,121 @@ ${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") + formatRequest("POST", endpoint, {
+ "Content-Type": "application/json",
+ "Content-Digest": "sha-256=:...:",
+ "Signature-Input": 'sig=("@method" "@authority" "@path" "content-type" "content-digest" "signature-key");created=...',
+ "Signature": "sig=:...:",
+ "Signature-Key": `sig=jwt;jwt="${agentToken?.substring(0, 20)}..."`
+ }, requestBody)
+ );
+ try {
+ const res = await (0, import_httpsig.fetch)(endpoint, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(requestBody),
+ signingKey: signingJwk,
+ signingCryptoKey: keyPair.privateKey,
+ // The agent is still only an agent here — the person token is what
+ // it is asking for, so it presents its agent_token to get one.
+ 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
+ });
+ 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;
+ }
+ resolveStep(
+ step,
+ res.status === 200 || res.status === 202 ? "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(decodeJWTPayloadBrowser(body.person_token), "person_token payload"));
+ 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(decodeJWTPayloadBrowser(personToken), "person_token payload"),
+ { kind: "response" }
+ );
+ }
+ function currentMissionS256() {
+ return window.AAUTH_MISSION_S256 || null;
+ }
async function runBootstrap(psUrl) {
addLogSection(copy("sections.bootstrap"));
const { keyPair, publicJwk } = await window.aauthEphemeral.rotate();
@@ -3708,7 +3874,8 @@ ${renderJSON(body)}`;
"pending",
desc("bootstrap.agent_provider_request") + formatRequest("POST", endpoint, {
"Content-Type": "application/json",
- "Signature-Input": 'sig=("@method" "@authority" "@path" "content-type" "signature-key");created=...',
+ "Content-Digest": "sha-256=:...:",
+ "Signature-Input": 'sig=("@method" "@authority" "@path" "content-type" "content-digest" "signature-key");created=...',
"Signature": "sig=:...:",
"Signature-Key": `sig=hwk;alg="${publicJwk.alg}";kty="${publicJwk.kty}";crv="${publicJwk.crv}";x="${publicJwk.x}"`
}, body)
@@ -3722,7 +3889,7 @@ ${renderJSON(body)}`;
signingKey: publicJwk,
signingCryptoKey: keyPair.privateKey,
signatureKey: { type: "hwk" },
- components: ["@method", "@authority", "@path", "content-type", "signature-key"]
+ components: SIGNED_COMPONENTS_WITH_BODY
});
result = await res.json().catch(() => null);
if (!res.ok || !result?.agent_token) {
@@ -3769,7 +3936,8 @@ ${renderJSON(body)}`;
"pending",
desc("refresh.agent_provider_request") + formatRequest("POST", endpoint, {
"Content-Type": "application/json",
- "Signature-Input": 'sig=("@method" "@authority" "@path" "content-type" "signature-key");created=...',
+ "Content-Digest": "sha-256=:...:",
+ "Signature-Input": 'sig=("@method" "@authority" "@path" "content-type" "content-digest" "signature-key");created=...',
"Signature": "sig=:...:",
"Signature-Key": `sig=hwk;alg="${publicJwk.alg}";kty="${publicJwk.kty}";crv="${publicJwk.crv}";x="${publicJwk.x}"`
}, body)
@@ -3783,7 +3951,7 @@ ${renderJSON(body)}`;
signingKey: publicJwk,
signingCryptoKey: keyPair.privateKey,
signatureKey: { type: "hwk" },
- components: ["@method", "@authority", "@path", "content-type", "signature-key"]
+ components: SIGNED_COMPONENTS_WITH_BODY
});
result = await res.json().catch(() => null);
if (!res.ok || !result?.agent_token) {
@@ -3872,15 +4040,38 @@ ${renderJSON(body)}`;
}
const signingJwk = await exportSigningJwk(keyPair.publicKey);
addLogSection(copy("sections.whoami"));
+ const personResult = await fetchPersonToken({
+ resource: new URL(whoamiUrl).origin,
+ bindingPs,
+ keyPair,
+ agentToken,
+ signingJwk,
+ missionS256: currentMissionS256(),
+ 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: GET ${whoamiPathDisplay}`,
"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.
` + formatRequest("GET", whoamiUrl, {
+ `Agent calls whoami with the person_token it just obtained. The resource knows which person the agent acts for, but a person token carries no authorization, so it returns 401 with a resource_token the agent can exchange at the Person Server.
` + formatRequest("GET", whoamiUrl, {
"Signature-Input": 'sig=("@method" "@authority" "@path" "signature-key");created=...',
"Signature": "sig=:...:",
- "Signature-Key": `sig=jwt;jwt="${agentToken?.substring(0, 20)}..."`
+ "Signature-Key": `sig=jwt;jwt="${personToken?.substring(0, 20)}..."`
}, null)
);
let resourceToken;
@@ -3889,8 +4080,8 @@ ${renderJSON(body)}`;
method: "GET",
signingKey: signingJwk,
signingCryptoKey: keyPair.privateKey,
- signatureKey: { type: "jwt", jwt: agentToken },
- components: ["@method", "@authority", "@path", "signature-key"]
+ signatureKey: { type: "jwt", jwt: personToken },
+ components: SIGNED_COMPONENTS
});
const body = await res.json().catch(() => null);
const requirement = res.headers.get("aauth-requirement") || "";
@@ -3903,9 +4094,9 @@ ${renderJSON(body)}`;
resolveStep(step1, "success", `Agent \u2192 Whoami: GET ${whoamiPathDisplay}`);
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 straight from the person_token, with no auth token and no Person Server exchange. A resource that serves requests this way treats holding a person token as access.
` + tokenWrap(renderJSON(body)) + anotherRequestButton(),
{ kind: "response" }
);
return;
@@ -3931,16 +4122,16 @@ ${renderJSON(body)}`;
keyPair,
agentToken,
signingJwk,
+ psMetadata,
labels: {
postLabel: (path) => `Agent \u2192 Person Server: POST ${path}`,
postLabelResolved: (path, status) => status === 200 || status === 202 ? `Agent \u2192 Person Server: POST ${path}` : `Agent \u2192 Person Server: POST ${path} \u2192 ${status}`,
postLabelNetworkError: (path) => `Agent \u2192 Person Server: POST ${path} (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: GET ${path} (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 }) => {
@@ -3973,7 +4164,7 @@ ${renderJSON(body)}`;
signingKey: signingJwk,
signingCryptoKey: keyPair.privateKey,
signatureKey: { type: "jwt", jwt: authToken },
- components: ["@method", "@authority", "@path", "signature-key"]
+ components: SIGNED_COMPONENTS
});
const body = await res.json().catch(() => null);
resolveStep(step, res.ok ? "success" : "error", `Agent \u2192 Whoami: GET ${whoamiPathDisplay}`);
@@ -4093,36 +4284,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 isPersonStage = saved.stage === "person-token";
+ const promptKey = isPersonStage ? "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 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.publicKey);
+ 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.publicKey);
- 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;
@@ -4164,30 +4387,25 @@ ${renderJSON(body)}`;
// (the 200 path already renders decoded inline on the POST step, so
// a separate "Auth Token received" step would just duplicate). Notes
// ignores it — its finalizeNotesAuthToken always emits its own step.
- onAuthToken
+ onAuthToken,
+ // PS metadata already fetched for the person-token hop. Both flows pass
+ // it through rather than re-fetching the same document.
+ psMetadata: knownPsMetadata,
+ // Narration block in log-text.json the deferred (202) leg reads its
+ // long-poll and terminal labels from.
+ copyPrefix
}) {
- 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) {
+ const psMetadata = knownPsMetadata || 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,
@@ -4203,7 +4421,8 @@ ${renderJSON(body)}`;
"pending",
labels.postDescription + formatRequest("POST", tokenEndpoint, {
"Content-Type": "application/json",
- "Signature-Input": 'sig=("@method" "@authority" "@path" "signature-key");created=...',
+ "Content-Digest": "sha-256=:...:",
+ "Signature-Input": 'sig=("@method" "@authority" "@path" "content-type" "content-digest" "signature-key");created=...',
"Signature": "sig=:...:",
"Signature-Key": `sig=jwt;jwt="${agentToken?.substring(0, 20)}..."`
}, psBody)
@@ -4217,7 +4436,9 @@ ${renderJSON(body)}`;
signingKey: signingJwk,
signingCryptoKey: keyPair.privateKey,
signatureKey: { type: "jwt", jwt: agentToken },
- components: ["@method", "@authority", "@path", "signature-key"]
+ // -11: a request carrying a body to a PS or AS endpoint MUST
+ // additionally sign content-digest and content-type.
+ components: SIGNED_COMPONENTS_WITH_BODY
});
const psResBody = await psRes.json().catch(() => null);
const respHeaders = {};
@@ -4233,55 +4454,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 + formatRequest("GET", absolutePollUrl, {
- "Prefer": `wait=${POLL_WAIT_SECONDS}`,
- "Signature-Input": 'sig=("@method" "@authority" "@path" "signature-key");created=...',
- "Signature": "sig=:...:",
- "Signature-Key": `sig=jwt;jwt="${agentToken?.substring(0, 20)}..."`
- }, null)
- );
- 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));
@@ -4295,17 +4480,82 @@ ${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 agentToken = localStorage.getItem("aauth-agent-token");
+ const pollStep = addLogStep(
+ fmt(copy(`${copyPrefix}.ps_pending_longpoll.label_template`), { path: new URL(absolutePollUrl).pathname }),
+ "pending",
+ desc(`${copyPrefix}.ps_pending_longpoll`) + formatRequest("GET", absolutePollUrl, {
+ "Prefer": `wait=${POLL_WAIT_SECONDS}`,
+ "Signature-Input": 'sig=("@method" "@authority" "@path" "signature-key");created=...',
+ "Signature": "sig=:...:",
+ "Signature-Key": `sig=jwt;jwt="${agentToken?.substring(0, 20)}..."`
+ }, null)
+ );
+ 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;
@@ -4313,14 +4563,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.publicKey);
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") + formatRequest("GET", absolutePollUrl, {
+ desc(`${copyPrefix}.ps_pending_longpoll`) + formatRequest("GET", absolutePollUrl, {
"Prefer": `wait=${POLL_WAIT_SECONDS}`,
"Signature-Input": 'sig=("@method" "@authority" "@path" "signature-key");created=...',
"Signature": "sig=:...:",
@@ -4337,8 +4587,11 @@ ${renderJSON(body)}`;
headers: { Prefer: `wait=${POLL_WAIT_SECONDS}` },
signingKey: signingJwk,
signingCryptoKey: keyPair.privateKey,
+ // The poll is signed with the agent_token in every case: it is
+ // the agent asking its own PS about a request it made, not a
+ // resource call.
signatureKey: { type: "jwt", jwt: agentToken },
- components: ["@method", "@authority", "@path", "signature-key"]
+ components: SIGNED_COMPONENTS
});
const respHeaders = {};
for (const key of ["retry-after", "aauth-requirement"]) {
@@ -4356,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(
@@ -4382,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);
@@ -4625,6 +4876,40 @@ ${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,
+ missionS256: currentMissionS256(),
+ 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: {
@@ -4637,9 +4922,10 @@ ${renderJSON(body)}`;
"pending",
desc("notes.authorize_request") + formatRequest("POST", authzEndpoint, {
"Content-Type": "application/json",
- "Signature-Input": 'sig=("@method" "@authority" "@path" "content-type" "signature-key");created=...',
+ "Content-Digest": "sha-256=:...:",
+ "Signature-Input": 'sig=("@method" "@authority" "@path" "content-type" "content-digest" "signature-key");created=...',
"Signature": "sig=:...:",
- "Signature-Key": `sig=jwt;jwt="${agentToken?.substring(0, 20)}..."`
+ "Signature-Key": `sig=jwt;jwt="${personToken?.substring(0, 20)}..."`
}, requestBody)
);
let resourceToken;
@@ -4650,8 +4936,8 @@ ${renderJSON(body)}`;
body: JSON.stringify(requestBody),
signingKey: signingJwk,
signingCryptoKey: keyPair.privateKey,
- signatureKey: { type: "jwt", jwt: agentToken },
- components: ["@method", "@authority", "@path", "content-type", "signature-key"]
+ signatureKey: { type: "jwt", jwt: personToken },
+ components: SIGNED_COMPONENTS_WITH_BODY
});
const body = await res.json().catch(() => null);
if (res.ok && body?.resource_token) {
@@ -4677,16 +4963,16 @@ ${renderJSON(body)}`;
keyPair,
agentToken,
signingJwk,
+ psMetadata,
labels: {
postLabel: (path) => fmt(copy("notes.ps_token_request.label_template"), { path }),
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) => {
@@ -4886,7 +5172,7 @@ ${renderJSON(body)}`;
const origin = window.NOTES_ORIGIN || "https://notes.aauth.dev";
const url = `${origin}${path}`;
const hasBody = body !== void 0 && body !== null;
- const components = hasBody ? ["@method", "@authority", "@path", "content-type", "signature-key"] : ["@method", "@authority", "@path", "signature-key"];
+ const components = signedComponents(hasBody);
const copyKey = method === "GET" && path === "/notes" ? "notes_app.list_request" : method === "POST" ? "notes_app.create_request" : method === "PUT" ? "notes_app.update_request" : method === "DELETE" ? "notes_app.delete_request" : "notes_app.get_request";
setActiveLog("notes-api-log");
const apiLog = currentLog();
@@ -4898,7 +5184,7 @@ ${renderJSON(body)}`;
fmt(copy(`${copyKey}.label_template`), { path }),
"pending",
desc(copyKey) + formatRequest(method, url, {
- ...hasBody ? { "Content-Type": "application/json" } : {},
+ ...hasBody ? { "Content-Type": "application/json", "Content-Digest": "sha-256=:...:" } : {},
"Signature-Input": "sig=(...);created=...",
"Signature": "sig=:...:",
"Signature-Key": `sig=jwt;jwt="${authToken.substring(0, 20)}..."`
@@ -5006,7 +5292,7 @@ ${renderJSON(body)}`;
signingKey: signingJwk,
signingCryptoKey: keyPair.privateKey,
signatureKey: { type: "jwt", jwt: authToken },
- components: ["@method", "@authority", "@path", "signature-key"]
+ components: SIGNED_COMPONENTS
});
const body = await res.json().catch(() => null);
resolveStep(reqStep, res.ok ? "success" : "error", fmt(copy("demo_api.request.label_resolved_template"), { path: "/api/demo", status: res.status }));
diff --git a/src/crypto.ts b/src/crypto.ts
index 502488b..43afae4 100644
--- a/src/crypto.ts
+++ b/src/crypto.ts
@@ -37,10 +37,11 @@ export async function getPublicJWK(jwkJson: string): Promise {
return JSON.parse(json)
}
-// Verify an Ed25519-signed JWT against a JWKS. Finds the verification key by
-// `kid` (falling back to first key if no kid), rejects non-EdDSA algs, and
+// Verify a signed JWT against a JWKS. Finds the verification key by `kid`
+// (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; our own JWKS uses EdDSA.
+// algorithms. Hellō's issuer JWKS uses RS256; our own JWKS uses Ed25519.
+//
+// `Ed25519` everywhere, emit and accept — the polymorphic `EdDSA` appears
+// in neither direction. draft-hardt-oauth-aauth-protocol §Signature
+// Algorithms: "The polymorphic EdDSA identifier MUST NOT be used", with no
+// transition allowance, and RFC 9864 deprecated it because a verifier
+// reading only `alg: EdDSA` cannot tell Ed25519 from Ed448. Accepting it
+// would mean taking the algorithm from the key rather than the header,
+// which is the ambiguity the fully-specified identifiers exist to close.
+//
+// This is a flag day with the issuers we verify: Wallet's
+// svr/issuer/sign.js:32 heads every aa-auth+jwt and aa-person+jwt with
+// `EdDSA` today, and must ship `Ed25519` in the same window or auth tokens
+// from the live PS stop verifying at /api/demo.
+//
+// Note this is about the alg VALUE in the JWT header. Two related things
+// are deliberately NOT handled here:
+//
+// - Whether the `alg` MEMBER is present on a JWK handed to importKey.
+// See the strip in verifyJWT below, which stays.
+// - Whether a JWKS key's own `alg` is fully specified and agrees with
+// its `kty`/`crv`, which -10 also makes a verifier MUST. Enforcing it
+// would reject keys published with `alg: EdDSA`, which un-migrated
+// issuers still serve, so it is a fleet-wide sweep with its own
+// sequencing rather than a per-repo change. Follow-up.
const JWT_ALG_PARAMS: Record = {
- EdDSA: { importAlgo: { name: 'Ed25519' }, verifyAlgo: 'Ed25519' },
- // RFC 9864 fully-specified identifier — accepted alongside the legacy
- // polymorphic 'EdDSA' so issuers can move their JWT headers over.
Ed25519: { importAlgo: { name: 'Ed25519' }, verifyAlgo: 'Ed25519' },
RS256: {
importAlgo: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
@@ -159,6 +182,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 8025d5a..d71b346 100644
--- a/src/httpsig-verify.ts
+++ b/src/httpsig-verify.ts
@@ -1,6 +1,6 @@
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
@@ -28,6 +28,10 @@ export interface SigJwtVerifyResult {
rawBody: string
innerJwt: string
innerPayload: Record | null
+ // RFC 7638 thumbprint of the key that actually signed the HTTP request
+ // (httpsig extracts it from the sig=jwt token's cnf.jwk). Callers use it
+ // to bind the token they verify to this request.
+ callerJkt: string
}
export async function verifySigJwt(
@@ -75,13 +79,7 @@ export async function verifySigJwt(
}
}
- return { rawBody, innerJwt, innerPayload }
-}
-
-// 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] })
+ return { rawBody, innerJwt, innerPayload, callerJkt: sigResult.thumbprint }
}
// Result of verifying a sig=hwk request — the public key that signed it,
@@ -117,23 +115,131 @@ 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 }
+}
+
+// ── 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 = 'aa-person+jwt'
+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 }
+}
+
+// ── Token kinds ──
+//
+// A person token and a PS-issued auth token are near-identical: same
+// `iss`, same `dwk`, same `aud`, same `sub`, same `cnf`. Only `typ`
+// separates them, so a verifier that checks everything else will accept a
+// credential carrying no authorization as though it carried some. That is
+// what §Person Token Verification means by "A recipient MUST reject an
+// aa-person+jwt wherever an auth token is required".
+//
+// Making the accepted set a required argument is the point: the rejection
+// becomes a parameter you have to state rather than one you can forget.
+export type TokenKind = 'agent' | 'person' | 'auth'
+
+export const TOKEN_TYP: Record = {
+ agent: 'aa-agent+jwt',
+ person: PERSON_TYP,
+ auth: 'aa-auth+jwt',
+}
+
// 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() {
+// and verifies against it. `accept` names the token kinds this call site
+// will take — it has no default on purpose.
+export function psJwksVerifier(accept: TokenKind[]) {
+ const acceptedTyps = accept.map((kind) => TOKEN_TYP[kind])
return async (jwt: string) => {
- const { decodeJWTPayload } = await import('./crypto')
+ const typ = decodeJWTHeader(jwt).typ
+ if (typeof typ !== 'string' || !acceptedTyps.includes(typ)) {
+ throw new Error(`typ ${JSON.stringify(typ)} is not one of ${acceptedTyps.join(', ')}`)
+ }
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)
}
}
diff --git a/src/index.ts b/src/index.ts
index c3f60a0..8c3ae67 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,13 +1,21 @@
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,
+ PERSON_TYP,
+} from './httpsig-verify'
import {
importSigningKey,
getPublicJWK,
signJWT,
generateJTI,
computeJwkThumbprint,
+ decodeJWTHeader,
sanitizeCnfJwk,
} from './crypto'
import { generateAgentLocal } from './agent-local'
@@ -221,7 +229,9 @@ async function mintAgentToken(
const publicJwk = await getPublicJWK(env.SIGNING_KEY)
const now = Math.floor(Date.now() / 1000)
- const agentHeader = { alg: 'EdDSA', typ: 'aa-agent+jwt', kid: publicJwk.kid }
+ // 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,
dwk: 'aauth-agent.json',
@@ -243,37 +253,74 @@ 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
- const verifyRes = await verifySigJwt(c, {
- verifyInner: ourJwksVerifier(ourJwk),
- expectedIss: 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')) 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.
+ const verifyRes = await verifySigJwt(c)
if (verifyRes instanceof Response) return verifyRes
- const agentPayload = verifyRes.innerPayload as Record
+ // An agent token where a person token belongs is the "absent" case:
+ // only `typ` distinguishes the two, and the agent needs to be told
+ // which one this endpoint wants.
+ if (decodeJWTHeader(verifyRes.innerJwt).typ !== PERSON_TYP) {
+ return personTokenRequired(c)
+ }
+
+ let person: Awaited>
+ try {
+ person = await verifyPersonToken(verifyRes.innerJwt, {
+ aud: origin,
+ callerJkt: verifyRes.callerJkt,
+ })
+ } catch (err) {
+ return c.json({ error: 'invalid_person_token', detail: (err as Error).message }, 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),
@@ -282,67 +329,63 @@ 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)
const now = Math.floor(Date.now() / 1000)
const rtHeader = {
- alg: 'EdDSA',
+ alg: 'Ed25519',
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
+ }
+ // Copied from the person token when it carried one. §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 for a person who has a tenant,
+ // omitting it here is not a lost hint, it fails the whole exchange.
+ if (personPayload.tenant !== undefined) {
+ rtPayload.tenant = personPayload.tenant
}
const resourceToken = await signJWT(rtHeader, rtPayload, privateKey)
@@ -367,9 +410,16 @@ 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 stated, not defaulted: this endpoint takes an auth token
+ // and nothing else. A person token comes from the same PS with the same
+ // iss, dwk, aud, sub and cnf, so every other check here passes for one —
+ // and it carries no authorization at all (§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, {
- verifyInner: psJwksVerifier(),
+ verifyInner: psJwksVerifier(['auth']),
})
if (verifyRes instanceof Response) return verifyRes
diff --git a/test/crypto.test.ts b/test/crypto.test.ts
index 0c9771c..935b17f 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.
@@ -93,7 +94,7 @@ describe('computeJwkThumbprint', () => {
crv: 'Ed25519',
x: 'Hf8svifsJ7N3rWuXZF4qFv8aS6JxKtHKQg5cFv7SOZw',
} as JsonWebKey
- const withExtras = { ...base, kid: 'some-kid', use: 'sig', alg: 'EdDSA' } as JsonWebKey
+ const withExtras = { ...base, kid: 'some-kid', use: 'sig', alg: 'Ed25519' } as JsonWebKey
expect(await computeJwkThumbprint(base)).toBe(await computeJwkThumbprint(withExtras))
})
@@ -174,7 +175,7 @@ describe('signJWT + decodeJWTPayload', () => {
const privateKey = await importSigningKey(signingKeyJson)
const publicJwk = await getPublicJWK(signingKeyJson)
- const header = { alg: 'EdDSA', typ: 'aa-agent+jwt', kid: publicJwk.kid }
+ const header = { alg: 'Ed25519', typ: 'aa-agent+jwt', kid: publicJwk.kid }
const payload = {
iss: 'https://example.test',
sub: 'aauth:test@example.test',
@@ -214,7 +215,7 @@ describe('signJWT + decodeJWTPayload', () => {
iat: 1700000000,
exp: 1700003600,
}
- const jwt = await signJWT({ alg: 'EdDSA', typ: 'aa-agent+jwt', kid: 'k' }, payload, privateKey)
+ const jwt = await signJWT({ alg: 'Ed25519', typ: 'aa-agent+jwt', kid: 'k' }, payload, privateKey)
const decoded = decodeJWTPayload(jwt)
expect(decoded).toEqual(payload)
})
@@ -237,7 +238,7 @@ describe('agent_jkt computation (resource-token claim)', () => {
// Mint an agent token that embeds the ephemeral pubkey in cnf.jwk.
const privateKey = await importSigningKey(signingKeyJson)
const agentToken = await signJWT(
- { alg: 'EdDSA', typ: 'aa-agent+jwt', kid: 'k' },
+ { alg: 'Ed25519', typ: 'aa-agent+jwt', kid: 'k' },
{
iss: 'https://example.test',
sub: 'aauth:test@example.test',
@@ -253,3 +254,64 @@ describe('agent_jkt computation (resource-token claim)', () => {
expect(computed).toBe(expected)
})
})
+
+// ── Algorithm determination (§Signature Algorithms) ──
+//
+// The two halves of the -11 alg rule, and they are about different
+// things. What we EMIT and ACCEPT is the fully-specified `Ed25519`; the
+// polymorphic `EdDSA` appears in neither direction. Separately, the `alg`
+// MEMBER is stripped off a JWK before importKey, because workerd rejects
+// an OKP key carrying one — that strip is not an alg-value question and
+// must survive any tidy-up here.
+
+describe('verifyJWT alg handling', () => {
+ 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 () => {
+ // "The polymorphic EdDSA identifier MUST NOT be used" — no transition
+ // allowance. Issuers still emitting it (Wallet's svr/issuer/sign.js)
+ // move in the same window; this worker does not compensate for them.
+ 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/)
+ })
+
+ it('verifies against a JWKS key whose alg member workerd would reject', async () => {
+ // Regression guard for the strip in verifyJWT: signature-key -08 JWKS
+ // entries carry alg "Ed25519", which workerd's importKey refuses on an
+ // OKP key. The strip is what makes this work on deploy, and Node
+ // accepts it either way — so only the intent is testable here.
+ const privateKey = await importSigningKey(signingKeyJson)
+ const publicJwk = await getPublicJWK(signingKeyJson)
+ expect(publicJwk.alg).toBe('Ed25519')
+ 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')
+ })
+})
diff --git a/test/server.test.ts b/test/server.test.ts
index e98e3f9..1770790 100644
--- a/test/server.test.ts
+++ b/test/server.test.ts
@@ -115,75 +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
-}> {
+// ── 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
- // httpsig 2.0 requires signing JWKs to carry a fully-specified alg
- // (RFC 9864); WebCrypto's exportKey does not set one.
- const publicJwk = { ...(await webcrypto.subtle.exportKey('jwk', kp.publicKey)), alg: 'Ed25519' }
- const privateJwk = { ...(await webcrypto.subtle.exportKey('jwk', kp.privateKey)), alg: 'Ed25519' }
+ 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.
+ async mintPersonToken(opts: {
+ aud: string
+ agentPublicJwk: JsonWebKey
+ sub?: string
+ jti?: string
+ missionS256?: string
+ tenant?: string
+ typ?: string
+ dwk?: string
+ exp?: number
+ // Extra claims, for minting the auth-token shape from the same PS
+ // key — an auth token differs from a person token only in `typ`
+ // plus what authorization it carries.
+ 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,
+ }
+ if (opts.missionS256) payload.mission_s256 = opts.missionS256
+ if (opts.tenant) payload.tenant = opts.tenant
+ Object.assign(payload, opts.extra ?? {})
+ 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: 'EdDSA', 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)}`,
- // httpsig 2.0 verifiers require cnf.jwk to carry a fully-specified alg.
- 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 }
@@ -192,188 +293,369 @@ 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)), alg: 'Ed25519' }
- 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')
+
const payload = decodeJWTPayload(resBody.resource_token)
expect(payload.iss).toBe('https://playground.test')
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,
+ // "rejecting the resource token on any mismatch or omission". For a
+ // person who has a tenant, omitting it fails the whole exchange.
+ const { app, env, agent, personToken } = await setup({ tenant: 'acme.example' })
+ 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).tenant).toBe('acme.example')
+ 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 resBody = await res.json() as any
+ expect(decodeJWTPayload(resBody.resource_token).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('ignores a ps body parameter — the person token iss is the person server', async () => {
+ // -11 dropped `ps` from the request. A stale agent that still sends it
+ // must not be able to redirect the resource token's audience.
+ const { app, env, agent, personToken } = await setup()
+ const res = await post(
+ app, env, { ps: 'https://attacker.test', scope: 'playground.demo' }, personToken, agent.privateJwk,
+ )
+ expect(res.status).toBe(200)
+ const resBody = await res.json() as any
+ expect(resBody.resource_token_decoded.ps).toBe(PS_ORIGIN)
+ expect(resBody.resource_token_decoded.aud).toBe(PS_ORIGIN)
+ 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()
+ })
+})
+
+// ── Resource API: /api/demo ──
+//
+// The endpoint gated by `playground.demo`, reached with an auth token.
+// A person token and a PS-issued auth token are near-identical — same
+// iss, dwk, aud, sub, cnf — so `typ` is the whole of what separates a
+// credential that carries authorization from one that carries none.
+
+describe('GET /api/demo', () => {
+ const TEST_URL = 'http://localhost/api/demo'
+
+ async function signedGet(app: any, env: any, jwt: string, privateJwk: JsonWebKey) {
+ const dry = await sigFetch(TEST_URL, {
+ method: 'GET',
+ signingKey: privateJwk,
+ signatureKey: { type: 'jwt', jwt },
+ components: ['@method', '@authority', '@path', 'signature-key'],
+ dryRun: true,
+ }) as { headers: Headers }
+ const headers: Record = {}
+ dry.headers.forEach((v, k) => { headers[k] = v })
+ return app.request('/api/demo', { method: 'GET', headers }, env)
+ }
+
+ it('serves an aa-auth+jwt carrying playground.demo', async () => {
+ const app = await loadApp()
+ const { env } = await makeEnv()
+ const agent = await makeAgentKey()
+ const ps = await makePersonServer()
+ ps.install()
+ const authToken = await ps.mintPersonToken({
+ aud: env.ORIGIN,
+ agentPublicJwk: agent.publicJwk,
+ typ: 'aa-auth+jwt',
+ extra: { scope: 'openid playground.demo', name: 'Ada' },
+ })
+ const res = await signedGet(app, env, authToken, agent.privateJwk)
+ expect(res.status).toBe(200)
+ const body = await res.json() as any
+ expect(body.hello).toBe('Ada')
+ expect(body.granted_scopes).toContain('playground.demo')
+ vi.unstubAllGlobals()
+ })
+
+ it('rejects an aa-person+jwt where an auth token is required', async () => {
+ // "A recipient MUST reject an aa-person+jwt wherever an auth token is
+ // required" (§Person Token Verification). Everything else about this
+ // token checks out — signature, issuer, aud, cnf — so without the typ
+ // check the request would reach the scope gate and read as a mere
+ // insufficient_scope 403 rather than the wrong kind of credential.
+ const app = await loadApp()
+ const { env } = await makeEnv()
+ const agent = await makeAgentKey()
+ const ps = await makePersonServer()
+ ps.install()
+ const personToken = await ps.mintPersonToken({
+ aud: env.ORIGIN,
+ agentPublicJwk: agent.publicJwk,
+ })
+ const res = await signedGet(app, env, personToken, agent.privateJwk)
+ expect(res.status).toBe(401)
+ expect((await res.json() as any).error).toMatch(/aa-person\+jwt/)
+ vi.unstubAllGlobals()
+ })
+
+ it('rejects an aa-agent+jwt where an auth token is required', async () => {
+ const app = await loadApp()
+ const { env } = await makeEnv()
+ const agent = await makeAgentKey()
+ const ps = await makePersonServer()
+ ps.install()
+ const agentShaped = await ps.mintPersonToken({
+ aud: env.ORIGIN,
+ agentPublicJwk: agent.publicJwk,
+ typ: 'aa-agent+jwt',
+ extra: { scope: 'playground.demo' },
+ })
+ const res = await signedGet(app, env, agentShaped, agent.privateJwk)
+ expect(res.status).toBe(401)
vi.unstubAllGlobals()
})
})