diff --git a/README.md b/README.md index 8671c0e..df8dcd2 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,9 @@ Live at [web-agent.aauth.dev](https://web-agent.aauth.dev). - Registers and authenticates users via WebAuthn passkeys - Issues ephemeral `aa-agent+jwt` agent tokens bound to a browser-generated Ed25519 key pair - Publishes `/.well-known/aauth-agent.json` and `/.well-known/jwks.json` +- Wearing its resource hat, requires an `aa-person+jwt` person token at + `/authorize` and issues `aa-resource+jwt` resource tokens naming the + person that token identified ## Getting started diff --git a/client/protocol.js b/client/protocol.js index d4f4c65..7e0666f 100644 --- a/client/protocol.js +++ b/client/protocol.js @@ -624,6 +624,152 @@ function getHints() { return {} } +// ── Person Server metadata ── +// +// Every PS publishes /.well-known/aauth-person.json. `person_token_endpoint` +// is where the agent asks for a person token; `auth_token_endpoint` is where +// it later trades a resource token for an auth token. (Through AAuth -10 the +// latter was called `token_endpoint`; -11 renamed it, because there are now +// two token endpoints.) +async function fetchPsMetadata(bindingPs, requiredField) { + const psMetadataUrl = `${bindingPs.replace(/\/$/, '')}/.well-known/aauth-person.json` + try { + const metaRes = await fetch(psMetadataUrl) + const psMetadata = await metaRes.json().catch(() => null) + if (!metaRes.ok || !psMetadata?.[requiredField]) { + addLogStep('Person Server metadata fetch failed', 'error', + `

The Person Server's metadata is missing ${escapeHtml(requiredField)}.

` + + formatResponse(metaRes.status, null, psMetadata) + anotherRequestButton()) + return null + } + return psMetadata + } catch (err) { + addLogStep('Person Server metadata fetch failed', 'error', + `

${escapeHtml(err.message)}

` + anotherRequestButton()) + return null + } +} + +// ── Person token ── +// +// The first hop of every resource call under AAuth -11. A resource MUST +// have verified a person token before it issues a resource token, so the +// agent asks its Person Server for one naming the resource it is about to +// call. The request is signed with the agent's key and presents the +// agent_token; the PS returns an `aa-person+jwt` whose `aud` is that +// resource, whose `sub` is the person's directed identifier there, and +// whose `cnf.jwk` is this agent's signing key. The agent then presents it +// in the Signature-Key header in place of its agent_token — which is how +// the resource learns who the agent acts for without the agent asserting +// it. Nothing about the agent's own identity travels in that token. +// +// The PS MAY require the person to approve first, in which case it answers +// 202 and the agent long-polls (#person-token-endpoint). That is the +// ordinary first run, not an edge case: the question the PS puts to the +// person is whether this agent may act at the resource AS THEM, and Wallet +// asks it on first contact with any resource the person has not used. So +// the very first Whoami/Notes click of a fresh demo goes through consent +// here, before any resource_token exists. +// +// Returns { personToken, psMetadata } or null (the failure is logged). +// Does not resolve at all when the person approves after a same-tab +// redirect — resumePendingAuthorize continues the flow on return. +async function fetchPersonToken({ + resource, + bindingPs, + keyPair, + agentToken, + signingJwk, + missionS256, + consentKey, + pendingRecord, +}) { + const psMetadata = await fetchPsMetadata(bindingPs, 'person_token_endpoint') + if (!psMetadata) return null + + const endpoint = psMetadata.person_token_endpoint + const path = new URL(endpoint).pathname + // `mission_s256` scopes the token to a mission when the agent is + // working under one; the resource copies it into the resource token so + // the PS can confirm the mission was never stripped. + const requestBody = missionS256 ? { resource, mission_s256: missionS256 } : { resource } + + const step = addLogStep( + fmt(copy('person_token.request.label_template'), { path }), + 'pending', + desc('person_token.request'), + ) + try { + const { response: res, sent } = await sigFetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + signingKey: signingJwk, + signingCryptoKey: keyPair.privateKey, + signatureKey: { type: 'jwt', jwt: agentToken }, + // A request with a body to a PS endpoint signs content-digest and + // content-type as well, so the body is covered by the signature. + components: SIGNED_COMPONENTS_WITH_BODY, + returnSent: true, + }) + appendStepBody(step, formatRequest(sent.method, sent.url, headersToObject(sent.headers), tryParseBody(sent.body))) + const body = await res.json().catch(() => null) + const respHeaders = {} + for (const key of ['location', 'retry-after', 'aauth-requirement']) { + const v = res.headers.get(key) + if (v) respHeaders[key] = v + } + // 202 is a success at this step — the request was accepted and the + // Person Server is asking you first. A 200 without a person_token is + // not, however it is dressed up. + const accepted = (res.status === 200 && !!body?.person_token) || res.status === 202 + resolveStep(step, accepted ? 'success' : 'error', + fmt(copy('person_token.request.label_resolved_template'), { path, status: res.status })) + appendStepBody(step, formatResponse(res.status, respHeaders, body)) + + if (res.status === 200 && body?.person_token) { + appendStepBody(step, formatDecoded(decodeJWTBrowser(body.person_token), 'person_token decoded')) + return { personToken: body.person_token, psMetadata } + } + + if (res.status === 202) { + const personToken = await runDeferredResponse({ + res, + body, + endpoint, + psMetadata, + consentKey: `${consentKey}-person`, + copyPrefix: 'person_token', + tokenField: 'person_token', + consentLabel: copy('person_token.ps_consent_prompt.label'), + consentDescription: desc('person_token.ps_consent_prompt'), + pendingRecord: { ...pendingRecord, stage: 'person-token', psUrl: bindingPs }, + }) + if (!personToken) return null + showPersonTokenReceived(personToken) + return { personToken, psMetadata } + } + + appendStepBody(step, anotherRequestButton()) + return null + } catch (err) { + resolveStep(step, 'error', fmt(copy('person_token.request.label_error_network_template'), { path })) + appendStepBody(step, `

${escapeHtml(err.message)}

` + anotherRequestButton()) + return null + } +} + +// The 200 path renders the decoded person_token inline on the request +// step. The 202 path arrives via long-poll long after that step resolved, +// so without this the token the person just approved would never surface. +function showPersonTokenReceived(personToken) { + addLogStep(copy('person_token.received.label'), 'success', + desc('person_token.received') + + formatDecoded(decodeJWTBrowser(personToken), 'person_token decoded'), + { kind: 'response' }, + ) +} + // ── Bootstrap ── // // Per draft-hardt-aauth-bootstrap, the agent provider issues an agent @@ -772,7 +918,6 @@ async function rebindPs(psUrl) { clearAllPersistedLogs() localStorage.removeItem(NOTES_AUTH_TOKEN_KEY) localStorage.removeItem(PENDING_AUTHZ_KEY) - localStorage.removeItem('aauth-pending-whoami') document.getElementById('bootstrap-artifacts')?.classList.remove('hidden') setActiveLog('bootstrap-log') @@ -846,14 +991,18 @@ function getBoundPs() { // ── Whoami resource call ── // -// Three-step ceremony that demonstrates the full resource-call flow: +// Four-step ceremony that demonstrates the full resource-call flow: // -// 1. Agent GETs whoami with its agent_token. Whoami responds 401 with -// a minted resource_token in AAuth-Requirement — it knows who the -// agent is, but the agent hasn't presented a user-released token yet. -// 2. Agent exchanges the resource_token at the PS's /token endpoint. -// Returns auth_token on 200 (user already consented to this scope -// pair) or 202 + interaction on first-time consent. +// 0. Agent asks its Person Server for a person token naming whoami. +// The PS returns an aa-person+jwt bound to the agent's key. +// 1. Agent GETs whoami presenting that person token. Whoami verifies +// it, learns who the agent acts for, and responds 401 with a minted +// resource_token in AAuth-Requirement — it now knows the person, but +// no authorization has been released yet. +// 2. Agent exchanges the resource_token at the PS's auth_token_endpoint, +// signing that request with its agent_token. Returns auth_token on +// 200 (user already consented to this scope pair) or 202 + +// interaction on first-time consent. // 3. Agent retries the GET with auth_token. Whoami verifies the token // against the PS's JWKS, checks 'whoami' scope, and returns the // identity claims encoded in the payload. @@ -912,14 +1061,47 @@ async function runWhoamiCall(whoamiUrl, bindingPs, hints) { addLogSection(copy('sections.whoami')) + // Step 0: get a person token for whoami. Without one the resource can't + // issue a resource_token — it would have no PS-asserted person to name + // in it, and only the PS can act on a resource token. This may block on + // the person's approval at the PS; if that approval arrives after a + // same-tab redirect, this call never returns and resumePendingAuthorize + // re-enters at continueWhoami below. + const personResult = await fetchPersonToken({ + resource: new URL(whoamiUrl).origin, + bindingPs, + keyPair, + agentToken, + signingJwk, + consentKey: 'whoami', + pendingRecord: { whoamiUrl }, + }) + if (!personResult) return + + await continueWhoami({ + whoamiUrl, + bindingPs, + hints, + keyPair, + agentToken, + signingJwk, + personToken: personResult.personToken, + psMetadata: personResult.psMetadata, + }) +} + +// Everything after the person token is in hand. Called directly by +// runWhoamiCall, and by resumePendingAuthorize when the person approved +// the person token after a same-tab redirect to the PS. +async function continueWhoami({ whoamiUrl, bindingPs, hints, keyPair, agentToken, signingJwk, personToken, psMetadata }) { const urlObj = new URL(whoamiUrl) const whoamiPathDisplay = urlObj.pathname + urlObj.search - // Step 1: unauthenticated-for-user GET. Agent token proves the agent's - // identity but carries no user claims, so whoami bounces with a - // resource_token the agent can trade at the PS. + // Step 1: GET whoami presenting the person token. It identifies the + // person to this one resource but releases no authorization, so whoami + // bounces with a resource_token the agent can trade at the PS. const step1 = addLogStep(`Agent → Whoami`, 'pending', - `

Agent calls whoami with its agent_token. The resource knows the agent but has no user claims yet, so it returns 401 with a resource_token the agent can exchange at the Person Server.

`) + `

Agent calls whoami presenting the person token in place of its agent_token. The resource now knows who the agent acts for, but identity is not authorization — so it returns 401 with a resource_token the agent can exchange at the Person Server.

`) let resourceToken try { @@ -927,7 +1109,7 @@ async function runWhoamiCall(whoamiUrl, bindingPs, hints) { method: 'GET', signingKey: signingJwk, signingCryptoKey: keyPair.privateKey, - signatureKey: { type: 'jwt', jwt: agentToken }, + signatureKey: { type: 'jwt', jwt: personToken }, components: SIGNED_COMPONENTS, returnSent: true, }) @@ -940,15 +1122,15 @@ async function runWhoamiCall(whoamiUrl, bindingPs, hints) { resourceToken = parseInteractionHeader(requirement)['resource-token'] } - // 200 with no scope requested: whoami returns the agent identity - // (sub + ps) directly off the agent_token without needing user - // claims. No PS exchange step — render the body as the final - // response and end the flow. + // 200 with no scope requested: whoami answers on identity alone, + // reading `ps` + `sub` straight off the person token without + // releasing any claims. No PS exchange step — render the body as the + // final response and end the flow. if (res.status === 200) { resolveStep(step1, 'success', `Agent → Whoami`) appendStepBody(step1, formatResponse(200, respHeaders, body)) - addLogStep('Agent identity received', 'success', - `

No scopes were requested, so whoami returned the agent's own identity straight from the agent_token — no Person Server exchange needed.

` + + addLogStep('Person identity received', 'success', + `

No scopes were requested, so whoami answered on identity alone — the ps and sub off the person token. No auth token, no Person Server exchange.

` + tokenWrap(renderJSON(body)) + anotherRequestButton(), { kind: 'response' } @@ -982,6 +1164,7 @@ async function runWhoamiCall(whoamiUrl, bindingPs, hints) { await runPSTokenExchange({ resourceToken, bindingPs, + psMetadata, hints, keyPair, agentToken, @@ -993,12 +1176,11 @@ async function runWhoamiCall(whoamiUrl, bindingPs, hints) { ? `Agent → Person Server` : `Agent → Person Server → ${status}`, postLabelNetworkError: (path) => `Agent → Person Server (network error)`, - postDescription: `

Agent presents the resource_token and its agent_token to the Person Server's token endpoint. The PS either releases an auth_token immediately (cached consent) or returns a 202 with a consent prompt.

`, - pollLabel: (path) => `Agent → Person Server (long-poll)`, - pollDescription: `

Agent keeps a request open while you decide, instead of polling. The Person Server answers the moment you approve or deny.

`, + postDescription: `

Agent presents the resource_token and its agent_token to the Person Server's auth token endpoint. The PS looks up the person token named by person_token_jti, checks the resource_token's ps and sub against it, then either releases an auth_token immediately (cached consent) or returns a 202 with a consent prompt.

`, consentLabel: copy('authorize.ps_consent_prompt.label'), consentDescription: desc('authorize.ps_consent_prompt'), }, + copyPrefix: 'authorize', consentKey: 'whoami', pendingExtra: { whoamiUrl }, onAuthToken: async (token, { viaPolling }) => { @@ -1166,9 +1348,19 @@ function clearPendingAuthorize() { // skew at the 60s tolerance boundary. let _resumeAuthorizePolling = false -// Called on page load: if we have a persisted pending-authorize, resume -// polling the PS for auth_token. Mounted after app.js init so the -// signing key + agent_token are already restored. +// Called on page load: if we have a persisted pending record, resume +// polling the PS for whichever token was deferred. Mounted after app.js +// init so the signing key + agent_token are already restored. +// +// Two stages can defer, and the record's `stage` says which: +// 'person-token' — the PS asked whether this agent may act at the +// resource as the person. Resuming polls for the +// person token and then re-enters the ceremony at +// continueWhoami / continueNotesAuthorize, which run +// the resource call and the auth-token exchange. +// 'auth-token' — the PS asked which scopes to release. Resuming +// polls for the auth token and finishes the flow. +// Records written before `stage` existed are treated as 'auth-token'. async function resumePendingAuthorize() { let saved try { saved = JSON.parse(localStorage.getItem(PENDING_AUTHZ_KEY) || 'null') } catch { saved = null } @@ -1217,7 +1409,12 @@ async function resumePendingAuthorize() { // notesAuthorize) tell us which branch to rehydrate. Default to // whoami for records saved before the notes flow existed. const isNotes = !!saved.notesAuthorize - const promptKey = isNotes ? 'notes_resumed.ps_consent_prompt' : 'whoami_resumed.ps_consent_prompt' + // The person-token leg asks a different question than the auth-token + // leg — whether the agent may act as you at the resource, not which + // scopes to release — so it gets its own resumed copy. + const promptKey = saved.stage === 'person-token' + ? 'person_token_resumed.ps_consent_prompt' + : (isNotes ? 'notes_resumed.ps_consent_prompt' : 'whoami_resumed.ps_consent_prompt') // Persisted log (restored at init) should already carry the in-progress // Notes/Whoami section; append into it rather than branching a new // "(resumed)" section. Fallback opens a fresh section if nothing's @@ -1233,43 +1430,78 @@ async function resumePendingAuthorize() { // stops the flare, turning it into a completed record without // blowing out the section. Fallback creates a fresh step if the // persisted log was cleared mid-flow. - const consentKey = isNotes ? 'notes' : 'whoami' + const isPersonStage = saved.stage === 'person-token' + // The person-token leg tags its steps with a '-person' suffix so both + // consent cards can coexist in one ceremony's log without colliding. + const consentKey = `${isNotes ? 'notes' : 'whoami'}${isPersonStage ? '-person' : ''}` let interactionStep = log.querySelector(`[data-consent-key="${consentKey}"]`) if (!interactionStep) { interactionStep = addLogStep(copy(`${promptKey}.label`), 'pending', desc(promptKey)) } - // On auth_token arrival, route to the flow-specific handler: + // Reuse pre-redirect pollStep if the persisted log carries it — + // otherwise the poll loop would create a fresh one, leaving the + // original stuck pending. + const existingPollStep = log.querySelector(`[data-poll-key="${consentKey}"]`) + const signingJwk = await exportSigningJwk(keyPair) + + const token = await startDeferredPolling( + saved.pollUrl, saved.tokenEndpoint, interactionStep, existingPollStep || null, + { + tokenField: isPersonStage ? 'person_token' : 'auth_token', + copyPrefix: isPersonStage ? 'person_token' : (isNotes ? 'notes' : 'authorize'), + // No continuation below for a record with neither marker — fall + // back to the generic "Authorization Granted" step. + renderGranted: !isPersonStage && !isNotes && !saved.whoamiUrl, + }, + ) + if (!token) return true + + if (isPersonStage) { + // The person approved the agent acting as them at this resource. + // Pick the ceremony back up where fetchPersonToken left off. Pass no + // psMetadata — the continuation re-fetches the PS document rather + // than carrying it across a page load. + showPersonTokenReceived(token) + if (isNotes) { + await continueNotesAuthorize({ + authzEndpoint: saved.authzEndpoint || `${window.NOTES_ORIGIN}/authorize`, + operations: saved.operations || [], + bindingPs: saved.psUrl, + hints: getHints(), + keyPair, + agentToken, + signingJwk, + personToken: token, + psMetadata: null, + }) + } else if (saved.whoamiUrl) { + await continueWhoami({ + whoamiUrl: saved.whoamiUrl, + bindingPs: saved.psUrl, + hints: getHints(), + keyPair, + agentToken, + signingJwk, + personToken: token, + psMetadata: null, + }) + } + return true + } + + // Auth-token stage: route to the flow-specific finish. // notes → finalizeNotesAuthToken persists the token and mounts the // Notes app. // whoami → retryWhoami replays the GET whoami/?scope=… signed with // the fresh auth_token and renders identity claims. - // (neither marker) → startAuthTokenPolling falls through to the - // generic "Authorization Granted" step. - let options = {} if (isNotes) { - options = { - onAuthToken: async (tokenFromPoll) => { - await finalizeNotesAuthToken(tokenFromPoll) - }, - } + await finalizeNotesAuthToken(token) } else if (saved.whoamiUrl) { const urlObj = new URL(saved.whoamiUrl) - const whoamiPathDisplay = urlObj.pathname + urlObj.search - const signingJwk = await exportSigningJwk(keyPair) - options = { - onAuthToken: async (tokenFromPoll) => { - showWhoamiAuthTokenReceived(tokenFromPoll) - await retryWhoami(saved.whoamiUrl, whoamiPathDisplay, tokenFromPoll, keyPair, signingJwk) - }, - } + showWhoamiAuthTokenReceived(token) + await retryWhoami(saved.whoamiUrl, urlObj.pathname + urlObj.search, token, keyPair, signingJwk) } - - // Reuse pre-redirect pollStep if persisted log carries it — otherwise - // startAuthTokenPolling would create a fresh one, leaving the - // original stuck pending. - const existingPollStep = log.querySelector(`[data-poll-key="${consentKey}"]`) - startAuthTokenPolling(saved.pollUrl, saved.tokenEndpoint, interactionStep, existingPollStep || null, options) return true } window.resumePendingAuthorize = resumePendingAuthorize @@ -1301,14 +1533,17 @@ if (document.readyState === 'complete') { // The pre-PS phase differs (whoami: 401 bounce on the resource itself; // notes: discovery + openapi + POST /authorize) and the post-token // phase differs (whoami: retry the GET, render identity claims; notes: -// mount the Notes app, refresh the list), but PS metadata fetch + -// POST /aauth/token + the 200/202 split + savePendingAuthorize + +// mount the Notes app, refresh the list), but the POST to the PS's +// auth_token_endpoint + the 200/202 split + savePendingAuthorize + // kicking off the long-poll are identical. Per-flow log strings are // passed in via `labels` so the rendered output stays exactly what // each flow had before. async function runPSTokenExchange({ resourceToken, bindingPs, + // PS metadata already fetched for the person-token hop. Both flows + // pass it through rather than re-fetching the same document. + psMetadata, hints, keyPair, agentToken, @@ -1316,6 +1551,10 @@ async function runPSTokenExchange({ // Per-flow labels/descriptions. Functions where the value depends // on runtime state (path, status); plain strings/HTML otherwise. labels, + // log-text.json block the deferred (202) leg reads its long-poll and + // denied/timed-out narration from: 'authorize' for whoami, 'notes' for + // notes. + copyPrefix, // 'whoami' | 'notes' — written to data-poll-key / data-consent-key // so resumePendingAuthorize can re-locate the steps after a same-tab // PS redirect. @@ -1332,23 +1571,16 @@ async function runPSTokenExchange({ // ignores it — its finalizeNotesAuthToken always emits its own step. onAuthToken, }) { - const psMetadataUrl = `${bindingPs.replace(/\/$/, '')}/.well-known/aauth-person.json` - let psMetadata - try { - const metaRes = await fetch(psMetadataUrl) - psMetadata = await metaRes.json() - if (!metaRes.ok || !psMetadata?.token_endpoint) { - addLogStep('Person Server metadata fetch failed', 'error', - formatResponse(metaRes.status, null, psMetadata) + anotherRequestButton()) - return - } - } catch (err) { + if (!psMetadata) psMetadata = await fetchPsMetadata(bindingPs, 'auth_token_endpoint') + if (!psMetadata) return + if (!psMetadata.auth_token_endpoint) { addLogStep('Person Server metadata fetch failed', 'error', - `

${escapeHtml(err.message)}

` + anotherRequestButton()) + `

The Person Server's metadata is missing auth_token_endpoint.

` + + tokenWrap(renderJSON(psMetadata)) + anotherRequestButton()) return } - const tokenEndpoint = psMetadata.token_endpoint + const tokenEndpoint = psMetadata.auth_token_endpoint const psPath = new URL(tokenEndpoint).pathname const psBody = { resource_token: resourceToken, @@ -1371,6 +1603,8 @@ async function runPSTokenExchange({ signingKey: signingJwk, signingCryptoKey: keyPair.privateKey, signatureKey: { type: 'jwt', jwt: agentToken }, + // Body-carrying request to a PS endpoint: cover content-digest and + // content-type so the resource_token being exchanged is signed over. components: SIGNED_COMPONENTS_WITH_BODY, returnSent: true, }) @@ -1397,52 +1631,20 @@ async function runPSTokenExchange({ resolveStep(step2, 'success', labels.postLabelResolved(psPath, 202)) appendStepBody(step2, formatResponse(202, respHeaders, psResBody)) - const reqHeader = psRes.headers.get('aauth-requirement') || '' - const fromHeader = parseInteractionHeader(reqHeader) - const interaction = { - requirement: fromHeader.requirement || psResBody?.requirement, - code: fromHeader.code || psResBody?.code, - url: fromHeader.url || psMetadata.interaction_endpoint, - } - const pollUrl = psRes.headers.get('location') || psResBody?.location - - let pollStep = null - if (pollUrl) { - const absolutePollUrl = new URL(pollUrl, tokenEndpoint).href - pollStep = addLogStep(labels.pollLabel(new URL(absolutePollUrl).pathname), 'pending', - labels.pollDescription) - // Real signed-request headers + body are filled in by pollPendingAuthorize - // on its first cycle so we don't repeat fabricated placeholders here. - if (pollStep) { - pollStep.dataset.pollKey = consentKey - persistActiveLog() - } - } - const interactionStep = addLogStep(labels.consentLabel, 'pending', - labels.consentDescription + renderInteraction(interaction, pollUrl, 'authorize')) - // Tag so resumePendingAuthorize can reuse this step on return - // from the PS instead of leaving the stale "Continue with Hellō / - // QR" card alongside the fresh poll step. - if (interactionStep) { - interactionStep.dataset.consentKey = consentKey - persistActiveLog() - } - - if (pollUrl) { - const absolutePollUrl = new URL(pollUrl, tokenEndpoint).href - savePendingAuthorize({ - pollUrl: absolutePollUrl, - tokenEndpoint, - psUrl: bindingPs, - ...pendingExtra, - }) - startAuthTokenPolling(pollUrl, tokenEndpoint, interactionStep, pollStep, { - onAuthToken: async (tokenFromPoll) => { - await onAuthToken(tokenFromPoll, { viaPolling: true }) - }, - }) - } - return // polling handles the rest + const tokenFromPoll = await runDeferredResponse({ + res: psRes, + body: psResBody, + endpoint: tokenEndpoint, + psMetadata, + consentKey, + copyPrefix, + tokenField: 'auth_token', + consentLabel: labels.consentLabel, + consentDescription: labels.consentDescription, + pendingRecord: { ...pendingExtra, stage: 'auth-token', psUrl: bindingPs }, + }) + if (tokenFromPoll) await onAuthToken(tokenFromPoll, { viaPolling: true }) + return // the deferred leg handled the rest } else { resolveStep(step2, 'error', labels.postLabelResolved(psPath, psRes.status)) appendStepBody(step2, formatResponse(psRes.status, respHeaders, psResBody) + anotherRequestButton()) @@ -1459,32 +1661,112 @@ async function runPSTokenExchange({ await onAuthToken(authToken, { viaPolling: false }) } -// ── Auth-token polling (for PS /token interaction flow) ── +// ── Deferred-response polling ── +// +// Both PS token endpoints answer 202 when the person has to decide first: +// `person_token_endpoint` asks whether this agent may act at the resource +// as them, `auth_token_endpoint` asks which scopes to release. The shape +// is identical either way — Location, Retry-After, an +// `AAuth-Requirement: requirement=interaction` with url + code, and a +// long-poll until terminal — so one loop drives both. `tokenField` names +// the member the 200 carries (`person_token` or `auth_token`) and +// `copyPrefix` selects the narration block in log-text.json. +// +// Long-poll pattern: send `Prefer: wait=POLL_WAIT_SECONDS` and loop +// immediately on 202. Agent token + ephemeral key are snapshotted once at +// start; the polling is signed with sig=jwt using them. // -// Long-poll pattern: send `Prefer: wait=POLL_WAIT_SECONDS` -// and loop immediately on 202. Agent token + ephemeral key are snapshotted -// once at start; the polling is signed with sig=jwt using them. - -// Module-level guard: at most one authz poll loop ever running. Callers -// (runWhoamiCall, resumePendingAuthorize) may each invoke us -// independently; without this flag their loops interleave and one loop's -// signature stamps trail the other's by 30s+, which the PS sees as stale -// signatures and rejects with skew-at-tolerance-boundary 401s. Clear on -// terminal status (200 / 403 / 408) so a follow-up authorization can -// start fresh. -let _authzPollRunning = false - -async function startAuthTokenPolling(pollUrl, baseUrl, interactionStep, pollStep, options = {}) { - if (_authzPollRunning) return - _authzPollRunning = true +// Resolves with the token string, or null if the interaction was denied, +// expired, or timed out. Never resolves when the person approves after a +// same-tab redirect — the page is gone by then, and resumePendingAuthorize +// picks the flow back up on return. + +// Module-level guard: at most one poll loop ever running. Callers +// (fetchPersonToken, runPSTokenExchange, resumePendingAuthorize) may each +// invoke us independently; without this flag their loops interleave and +// one loop's signature stamps trail the other's by 30s+, which the PS sees +// as stale signatures and rejects with skew-at-tolerance-boundary 401s. +// Cleared on terminal status (200 / 403 / 404 / 408) so the next leg of +// the same ceremony — and any follow-up authorization — can start fresh. +let _deferredPollRunning = false + +async function startDeferredPolling(pollUrl, baseUrl, interactionStep, pollStep, options = {}) { + if (_deferredPollRunning) return null + _deferredPollRunning = true try { - await _startAuthTokenPollingImpl(pollUrl, baseUrl, interactionStep, pollStep, options) + return await _deferredPollingImpl(pollUrl, baseUrl, interactionStep, pollStep, options) } finally { - _authzPollRunning = false + _deferredPollRunning = false + } +} + +// Render a 202 from either PS token endpoint and drive it to a token: +// long-poll step, the consent card the person acts on, and a persisted +// pending record so a same-tab redirect to the PS can be resumed on +// return. Resolves with the token, or null if the person declined or the +// interaction lapsed. +async function runDeferredResponse({ + res, + body, + endpoint, + psMetadata, + // 'whoami' | 'notes', suffixed per leg — written to data-poll-key / + // data-consent-key so resumePendingAuthorize can re-locate both steps + // after the redirect instead of orphaning them as stale pending rows. + consentKey, + copyPrefix, + tokenField, + consentLabel, + consentDescription, + // Merged into the persisted record; carries `stage` plus whatever the + // resumed flow needs to pick up where it left off. + pendingRecord, +}) { + const fromHeader = parseInteractionHeader(res.headers.get('aauth-requirement') || '') + const interaction = { + requirement: fromHeader.requirement || body?.requirement, + code: fromHeader.code || body?.code, + url: fromHeader.url || psMetadata?.interaction_endpoint, + } + const pollUrl = res.headers.get('location') || body?.location + if (!pollUrl) { + addLogStep('Deferred response missing Location', 'error', + `

The Person Server answered 202 without a Location to poll, so the agent has nowhere to wait.

` + + anotherRequestButton()) + return null + } + const absolutePollUrl = new URL(pollUrl, endpoint).href + + const pollStep = addLogStep( + fmt(copy(`${copyPrefix}.ps_pending_longpoll.label_template`), { path: new URL(absolutePollUrl).pathname }), + 'pending', + desc(`${copyPrefix}.ps_pending_longpoll`), + ) + // Real signed-request headers + body are filled in by the poll loop on + // its first cycle so we don't repeat fabricated placeholders here. + if (pollStep) { + pollStep.dataset.pollKey = consentKey + persistActiveLog() + } + + const interactionStep = addLogStep(consentLabel, 'pending', + consentDescription + renderInteraction(interaction, pollUrl, 'authorize')) + if (interactionStep) { + interactionStep.dataset.consentKey = consentKey + persistActiveLog() } + + savePendingAuthorize({ ...pendingRecord, pollUrl: absolutePollUrl, tokenEndpoint: endpoint }) + + return startDeferredPolling(absolutePollUrl, endpoint, interactionStep, pollStep, { + tokenField, + copyPrefix, + }) } -async function _startAuthTokenPollingImpl(pollUrl, baseUrl, interactionStep, pollStep, options = {}) { +async function _deferredPollingImpl(pollUrl, baseUrl, interactionStep, pollStep, options = {}) { + const tokenField = options.tokenField || 'auth_token' + const copyPrefix = options.copyPrefix || 'authorize' // Pin the log container this poll loop writes into BEFORE any await. // While the long-poll awaits user interaction, other code may run // that flips __activeLogContainer (e.g. restoreNotesApp → @@ -1493,25 +1775,25 @@ async function _startAuthTokenPollingImpl(pollUrl, baseUrl, interactionStep, pol // may no longer point at the log this flow started in — terminal // steps would then land in the wrong tab and read as a "stuck" // flow. Capture synchronously here, restore right before every - // terminal addLogStep / onAuthToken handoff. Capturing after any + // terminal addLogStep and before resolving. Capturing after any // await is too late: the clobber can have already happened. const targetLog = currentLog() const pinLog = () => { if (targetLog) __activeLogContainer = targetLog } const absolutePollUrl = new URL(pollUrl, baseUrl).href const keyPair = window.aauthEphemeral.get() const agentToken = localStorage.getItem('aauth-agent-token') - if (!keyPair || !agentToken) return + if (!keyPair || !agentToken) return null const signingJwk = await exportSigningJwk(keyPair) const pollPath = new URL(absolutePollUrl).pathname // Caller can pre-create the pollStep so the log orders as - // POST /aauth/token → 202 - // GET /aauth/pending (long-poll) + // POST {endpoint} → 202 + // GET {pending} (long-poll) // User at PS: consent prompt // When not provided (resume paths), fall back to creating it inline. if (!pollStep) { - pollStep = addLogStep(fmt(copy('authorize.ps_pending_longpoll.label_template'), { path: pollPath }), 'pending', - desc('authorize.ps_pending_longpoll')) + pollStep = addLogStep(fmt(copy(`${copyPrefix}.ps_pending_longpoll.label_template`), { path: pollPath }), 'pending', + desc(`${copyPrefix}.ps_pending_longpoll`)) } let cycle = 0 @@ -1551,46 +1833,43 @@ async function _startAuthTokenPollingImpl(pollUrl, baseUrl, interactionStep, pol } if (res.status === 200) { clearPendingAuthorize() - resolveStep(pollStep, 'success', fmt(copy('authorize.ps_pending_longpoll.label_resolved_template'), { path: pollPath, status: 200 })) + resolveStep(pollStep, 'success', fmt(copy(`${copyPrefix}.ps_pending_longpoll.label_resolved_template`), { path: pollPath, status: 200 })) // Resolve only — the interaction-box body stays in place as // a record of the consent interface the user was handed. CSS // (.log-step.success .interaction-box) stops the flare and // overlays an approved check mark across the box. resolveStep(interactionStep, 'success', 'Interaction Completed') pinLog() - // If a caller supplied onAuthToken (e.g. whoami needs to retry the - // resource call with the freshly-minted token), hand off to them. - // Otherwise render the generic "Authorization Granted" step. - if (options.onAuthToken && body?.auth_token) { - await options.onAuthToken(body.auth_token) - } else { - addLogStep(copy('authorize.authorization_granted.label'), 'success', - (body?.auth_token ? formatAuthToken(body.auth_token) : '') + - anotherRequestButton(), - { kind: 'response' }) - } - return + const token = body?.[tokenField] + // The caller resumes its ceremony with the token. The generic + // "granted" step is only for the auth-token leg with no + // continuation — the person-token leg always has one. + if (!options.renderGranted) return token || null + addLogStep(copy(`${copyPrefix}.authorization_granted.label`), 'success', + (token ? formatAuthToken(token) : '') + anotherRequestButton(), + { kind: 'response' }) + return token || null } if (res.status === 404) { clearPendingAuthorize() - resolveStep(pollStep, 'error', fmt(copy('authorize.ps_pending_longpoll.label_resolved_template'), { path: pollPath, status: 404 })) + resolveStep(pollStep, 'error', fmt(copy(`${copyPrefix}.ps_pending_longpoll.label_resolved_template`), { path: pollPath, status: 404 })) resolveStep(interactionStep, 'error', 'Interaction Expired') pinLog() addLogStep('Interaction expired', 'error', formatResponse(404, null, body) + anotherRequestButton(), { kind: 'response' }) - return + return null } if (res.status === 403 || res.status === 408) { clearPendingAuthorize() const label = res.status === 403 ? 'Interaction Denied' : 'Interaction Timed Out' - resolveStep(pollStep, 'error', fmt(copy('authorize.ps_pending_longpoll.label_resolved_template'), { path: pollPath, status: res.status })) + resolveStep(pollStep, 'error', fmt(copy(`${copyPrefix}.ps_pending_longpoll.label_resolved_template`), { path: pollPath, status: res.status })) resolveStep(interactionStep, 'error', label) pinLog() - addLogStep(copy(res.status === 403 ? 'authorize.authorization_denied.label' : 'authorize.authorization_timed_out.label'), 'error', + addLogStep(copy(res.status === 403 ? `${copyPrefix}.authorization_denied.label` : `${copyPrefix}.authorization_timed_out.label`), 'error', formatResponse(res.status, null, body) + anotherRequestButton(), { kind: 'response' }) - return + return null } // 202 → loop immediately (server already held up to 30s) } catch (err) { @@ -1632,13 +1911,14 @@ function decodeJWTBrowser(jwt) { // • /.well-known/aauth-resource.json — advertises authorization_endpoint // and r3_vocabularies[urn:aauth:vocabulary:openapi] pointing at an // OpenAPI spec enumerating the API's operations. -// • /authorize — POST signed with agent_token + r3_operations body +// • /authorize — POST signed with a person token + r3_operations body // naming the operationIds we want; returns a resource_token. // • /notes* — CRUD API gated by auth_token.r3_granted. // // Flow: tab activation fetches the metadata + OpenAPI (once per page) -// and renders a checkbox per operationId. "Notes with Hellō" signs the -// /authorize POST, exchanges the resource_token at the user's PS, and +// and renders a checkbox per operationId. "Notes with Hellō" gets a +// person token for notes.aauth.dev, signs the /authorize POST with it, +// exchanges the resource_token at the user's PS, and // either gets a 200 auth_token (cached consent) or 202 + interaction // that drives the existing auth-token polling loop. Once an auth_token // lands we persist it, reveal the Notes fieldset, and render a @@ -1901,6 +2181,42 @@ async function runNotesAuthorize(operations, bindingPs, hints) { } _notesMetadata = discovery.metadata const authzEndpoint = discovery.metadata.authorization_endpoint || `${window.NOTES_ORIGIN}/authorize` + + // Step 1: get a person token for notes.aauth.dev. The authorization + // endpoint requires one — a resource that can't name a person can only + // issue a resource token nobody can redeem. May block on the person's + // approval; if that lands after a same-tab redirect this never returns + // and resumePendingAuthorize re-enters at continueNotesAuthorize. + const personResult = await fetchPersonToken({ + resource: new URL(authzEndpoint).origin, + bindingPs, + keyPair, + agentToken, + signingJwk, + consentKey: 'notes', + pendingRecord: { notesAuthorize: true, operations, authzEndpoint }, + }) + if (!personResult) return + + await continueNotesAuthorize({ + authzEndpoint, + operations, + bindingPs, + hints, + keyPair, + agentToken, + signingJwk, + personToken: personResult.personToken, + psMetadata: personResult.psMetadata, + }) +} + +// Everything after the person token is in hand. Called directly by +// runNotesAuthorize, and by resumePendingAuthorize when the person +// approved the person token after a same-tab redirect to the PS. +async function continueNotesAuthorize({ + authzEndpoint, operations, bindingPs, hints, keyPair, agentToken, signingJwk, personToken, psMetadata, +}) { const authzPath = new URL(authzEndpoint).pathname const requestBody = { r3_operations: { @@ -1909,7 +2225,7 @@ async function runNotesAuthorize(operations, bindingPs, hints) { }, } - // Step 1: POST /authorize to notes.aauth.dev, signed with agent_token. + // Step 2: POST /authorize to notes.aauth.dev, presenting the person token. const step1 = addLogStep( fmt(copy('notes.authorize_request.label_template'), { path: authzPath }), 'pending', @@ -1923,7 +2239,7 @@ async function runNotesAuthorize(operations, bindingPs, hints) { body: JSON.stringify(requestBody), signingKey: signingJwk, signingCryptoKey: keyPair.privateKey, - signatureKey: { type: 'jwt', jwt: agentToken }, + signatureKey: { type: 'jwt', jwt: personToken }, components: SIGNED_COMPONENTS_WITH_BODY, returnSent: true, }) @@ -1946,14 +2262,15 @@ async function runNotesAuthorize(operations, bindingPs, hints) { return } - // Step 2: hand off to the shared resource flow. Notes' R3-specific + // Step 3: hand off to the shared resource flow. Notes' R3-specific // behavior (the resource_token names an R3 document; the PS fetches // it and emits an auth_token with r3_granted) is captured in the - // copy keys passed below — the protocol shape from POST /aauth/token - // onward is identical to whoami's. + // copy keys passed below — the protocol shape from the POST to the + // auth_token_endpoint onward is identical to whoami's. await runPSTokenExchange({ resourceToken, bindingPs, + psMetadata, hints, keyPair, agentToken, @@ -1965,11 +2282,10 @@ async function runNotesAuthorize(operations, bindingPs, hints) { postLabelNetworkError: (path) => fmt(copy('notes.ps_token_request.label_error_network_template'), { path }), postDescription: desc('notes.ps_token_request'), - pollLabel: (path) => fmt(copy('notes.ps_pending_longpoll.label_template'), { path }), - pollDescription: desc('notes.ps_pending_longpoll'), consentLabel: copy('notes.ps_consent_prompt.label'), consentDescription: desc('notes.ps_consent_prompt'), }, + copyPrefix: 'notes', consentKey: 'notes', pendingExtra: { notesAuthorize: true }, onAuthToken: async (token) => { diff --git a/public/log-text.json b/public/log-text.json index 5b0d97e..82500d4 100644 --- a/public/log-text.json +++ b/public/log-text.json @@ -37,6 +37,47 @@ } }, + "person_token": { + "request": { + "label_template": "Agent → Person Server", + "label_resolved_template": "Agent → Person Server", + "label_error_network_template": "Agent → Person Server (network error)", + "description": "Before calling a resource, the agent asks your Person Server for a person token naming that resource. The Person Server returns an aa-person+jwt whose aud is the resource, whose sub is your directed identifier there, and whose cnf holds the agent's signing key. The agent presents it in place of its agent_token — the resource learns who the agent acts for from your Person Server, not from the agent. A person token is identity, not authorization: it carries no scope. A 200 means your Person Server already knows you use this resource; a 202 means it wants to ask you first." + }, + "ps_pending_longpoll": { + "label_template": "Agent → Person Server (long-poll)", + "label_resolved_template": "Agent → Person Server", + "description": "The agent keeps one request open while you decide, instead of polling. The Person Server answers the moment you approve or deny." + }, + "ps_consent_prompt": { + "label": "User at Person Server: recognition prompt", + "description": "Your Person Server asks whether this agent may act at this resource as you. This is not a scope question — no permissions are being released yet. Because a resource may serve requests on identity alone, naming you to it is itself the decision. Approve here, or scan the QR to approve on another device." + }, + "received": { + "label": "Person Token received", + "description": "You approved this agent acting as you at this resource, and the Person Server released a person token. The agent now presents it to the resource in place of its agent_token." + }, + "authorization_granted": { + "label": "Person Token Granted", + "description": "" + }, + "authorization_denied": { + "label": "Person Token Denied", + "description": "" + }, + "authorization_timed_out": { + "label": "Person Token Request Timed Out", + "description": "" + } + }, + + "person_token_resumed": { + "ps_consent_prompt": { + "label": "User at Person Server: recognition prompt (resumed)", + "description": "You returned mid-approval. The agent picks up the same pending person token request instead of starting over, then carries on with the resource call." + } + }, + "authorize": { "missing_context": { "label": "Missing agent_token or signing key", @@ -52,7 +93,7 @@ "label_template": "Agent → Person Server", "label_resolved_template": "Agent → Person Server", "label_error_network_template": "Agent → Person Server (network error)", - "description": "The agent trades that resource token with your Person Server for an auth token. A 200 means you've already consented to this scope; 202 means the Person Server needs your approval for a new one." + "description": "The agent trades that resource token at your Person Server's auth token endpoint for an auth token, signing the request with its agent_token. The Person Server resolves the person token the resource token names and confirms its ps, sub, and mission match. A 200 means you've already consented to this scope; 202 means the Person Server needs your approval for a new one." }, "ps_pending_longpoll": { "label_template": "Agent → Person Server (long-poll)", @@ -104,7 +145,7 @@ "label_template": "Agent → Notes Resource", "label_resolved_template": "Agent → Notes Resource", "label_error_network_template": "Agent → Notes Resource (network error)", - "description": "The agent POSTs the operations it wants to the resource's authorize endpoint, signed with its agent_token. The resource responds with a resource_token naming an R3 document the Person Server will fetch during token exchange." + "description": "The agent POSTs the operations it wants to the resource's authorize endpoint, presenting the person token it just obtained. The resource verifies that token, then responds with a resource_token carrying the person's ps and sub plus an R3 document the Person Server will fetch during token exchange." }, "r3_document_request": { "label_template": "Demo (R3 document)", @@ -116,7 +157,7 @@ "label_template": "Agent → Person Server", "label_resolved_template": "Agent → Person Server", "label_error_network_template": "Agent → Person Server (network error)", - "description": "The agent trades the resource_token at the Person Server's token endpoint. A 200 means consent was already on file; a 202 triggers a consent prompt. The Person Server fetches the R3 document, then emits an auth_token carrying r3_granted — the operations it's releasing." + "description": "The agent trades the resource_token at the Person Server's auth token endpoint, signing the request with its agent_token. The Person Server matches the resource_token against the person token it names, then fetches the R3 document and emits an auth_token carrying r3_granted — the operations it's releasing. A 200 means consent was already on file; a 202 triggers a consent prompt." }, "ps_pending_longpoll": { "label_template": "Agent → Person Server (long-poll)", diff --git a/public/protocol.js b/public/protocol.js index 9318748..55a61a8 100644 --- a/public/protocol.js +++ b/public/protocol.js @@ -3187,6 +3187,45 @@ description: "The agent signs a refresh request with the same hwk key the Agent Provider already has on file. The Agent Provider looks up the agent name by thumbprint and mints a fresh agent_token bound to the same key." } }, + person_token: { + request: { + label_template: "Agent \u2192 Person Server", + label_resolved_template: "Agent \u2192 Person Server", + label_error_network_template: "Agent \u2192 Person Server (network error)", + description: "Before calling a resource, the agent asks your Person Server for a person token naming that resource. The Person Server returns an aa-person+jwt whose aud is the resource, whose sub is your directed identifier there, and whose cnf holds the agent's signing key. The agent presents it in place of its agent_token \u2014 the resource learns who the agent acts for from your Person Server, not from the agent. A person token is identity, not authorization: it carries no scope. A 200 means your Person Server already knows you use this resource; a 202 means it wants to ask you first." + }, + ps_pending_longpoll: { + label_template: "Agent \u2192 Person Server (long-poll)", + label_resolved_template: "Agent \u2192 Person Server", + description: "The agent keeps one request open while you decide, instead of polling. The Person Server answers the moment you approve or deny." + }, + ps_consent_prompt: { + label: "User at Person Server: recognition prompt", + description: "Your Person Server asks whether this agent may act at this resource as you. This is not a scope question \u2014 no permissions are being released yet. Because a resource may serve requests on identity alone, naming you to it is itself the decision. Approve here, or scan the QR to approve on another device." + }, + received: { + label: "Person Token received", + description: "You approved this agent acting as you at this resource, and the Person Server released a person token. The agent now presents it to the resource in place of its agent_token." + }, + authorization_granted: { + label: "Person Token Granted", + description: "" + }, + authorization_denied: { + label: "Person Token Denied", + description: "" + }, + authorization_timed_out: { + label: "Person Token Request Timed Out", + description: "" + } + }, + person_token_resumed: { + ps_consent_prompt: { + label: "User at Person Server: recognition prompt (resumed)", + description: "You returned mid-approval. The agent picks up the same pending person token request instead of starting over, then carries on with the resource call." + } + }, authorize: { missing_context: { label: "Missing agent_token or signing key", @@ -3202,7 +3241,7 @@ label_template: "Agent \u2192 Person Server", label_resolved_template: "Agent \u2192 Person Server", label_error_network_template: "Agent \u2192 Person Server (network error)", - description: "The agent trades that resource token with your Person Server for an auth token. A 200 means you've already consented to this scope; 202 means the Person Server needs your approval for a new one." + description: "The agent trades that resource token at your Person Server's auth token endpoint for an auth token, signing the request with its agent_token. The Person Server resolves the person token the resource token names and confirms its ps, sub, and mission match. A 200 means you've already consented to this scope; 202 means the Person Server needs your approval for a new one." }, ps_pending_longpoll: { label_template: "Agent \u2192 Person Server (long-poll)", @@ -3252,7 +3291,7 @@ label_template: "Agent \u2192 Notes Resource", label_resolved_template: "Agent \u2192 Notes Resource", label_error_network_template: "Agent \u2192 Notes Resource (network error)", - description: "The agent POSTs the operations it wants to the resource's authorize endpoint, signed with its agent_token. The resource responds with a resource_token naming an R3 document the Person Server will fetch during token exchange." + description: "The agent POSTs the operations it wants to the resource's authorize endpoint, presenting the person token it just obtained. The resource verifies that token, then responds with a resource_token carrying the person's ps and sub plus an R3 document the Person Server will fetch during token exchange." }, r3_document_request: { label_template: "Demo (R3 document)", @@ -3264,7 +3303,7 @@ label_template: "Agent \u2192 Person Server", label_resolved_template: "Agent \u2192 Person Server", label_error_network_template: "Agent \u2192 Person Server (network error)", - description: "The agent trades the resource_token at the Person Server's token endpoint. A 200 means consent was already on file; a 202 triggers a consent prompt. The Person Server fetches the R3 document, then emits an auth_token carrying r3_granted \u2014 the operations it's releasing." + description: "The agent trades the resource_token at the Person Server's auth token endpoint, signing the request with its agent_token. The Person Server matches the resource_token against the person token it names, then fetches the R3 document and emits an auth_token carrying r3_granted \u2014 the operations it's releasing. A 200 means consent was already on file; a 202 triggers a consent prompt." }, ps_pending_longpoll: { label_template: "Agent \u2192 Person Server (long-poll)", @@ -3724,6 +3763,113 @@ ${renderJSON(body)}`; function getHints() { return {}; } + async function fetchPsMetadata(bindingPs, requiredField) { + const psMetadataUrl = `${bindingPs.replace(/\/$/, "")}/.well-known/aauth-person.json`; + try { + const metaRes = await fetch(psMetadataUrl); + const psMetadata = await metaRes.json().catch(() => null); + if (!metaRes.ok || !psMetadata?.[requiredField]) { + addLogStep( + "Person Server metadata fetch failed", + "error", + `

The Person Server's metadata is missing ${escapeHtml(requiredField)}.

` + formatResponse(metaRes.status, null, psMetadata) + anotherRequestButton() + ); + return null; + } + return psMetadata; + } catch (err) { + addLogStep( + "Person Server metadata fetch failed", + "error", + `

${escapeHtml(err.message)}

` + anotherRequestButton() + ); + return null; + } + } + async function fetchPersonToken({ + resource, + bindingPs, + keyPair, + agentToken, + signingJwk, + missionS256, + consentKey, + pendingRecord + }) { + const psMetadata = await fetchPsMetadata(bindingPs, "person_token_endpoint"); + if (!psMetadata) return null; + const endpoint = psMetadata.person_token_endpoint; + const path = new URL(endpoint).pathname; + const requestBody = missionS256 ? { resource, mission_s256: missionS256 } : { resource }; + const step = addLogStep( + fmt(copy("person_token.request.label_template"), { path }), + "pending", + desc("person_token.request") + ); + try { + const { response: res, sent } = await (0, import_httpsig.fetch)(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(requestBody), + signingKey: signingJwk, + signingCryptoKey: keyPair.privateKey, + signatureKey: { type: "jwt", jwt: agentToken }, + // A request with a body to a PS endpoint signs content-digest and + // content-type as well, so the body is covered by the signature. + components: SIGNED_COMPONENTS_WITH_BODY, + returnSent: true + }); + appendStepBody(step, formatRequest(sent.method, sent.url, headersToObject(sent.headers), tryParseBody(sent.body))); + const body = await res.json().catch(() => null); + const respHeaders = {}; + for (const key of ["location", "retry-after", "aauth-requirement"]) { + const v = res.headers.get(key); + if (v) respHeaders[key] = v; + } + const accepted = res.status === 200 && !!body?.person_token || res.status === 202; + resolveStep( + step, + accepted ? "success" : "error", + fmt(copy("person_token.request.label_resolved_template"), { path, status: res.status }) + ); + appendStepBody(step, formatResponse(res.status, respHeaders, body)); + if (res.status === 200 && body?.person_token) { + appendStepBody(step, formatDecoded(decodeJWTBrowser(body.person_token), "person_token decoded")); + return { personToken: body.person_token, psMetadata }; + } + if (res.status === 202) { + const personToken = await runDeferredResponse({ + res, + body, + endpoint, + psMetadata, + consentKey: `${consentKey}-person`, + copyPrefix: "person_token", + tokenField: "person_token", + consentLabel: copy("person_token.ps_consent_prompt.label"), + consentDescription: desc("person_token.ps_consent_prompt"), + pendingRecord: { ...pendingRecord, stage: "person-token", psUrl: bindingPs } + }); + if (!personToken) return null; + showPersonTokenReceived(personToken); + return { personToken, psMetadata }; + } + appendStepBody(step, anotherRequestButton()); + return null; + } catch (err) { + resolveStep(step, "error", fmt(copy("person_token.request.label_error_network_template"), { path })); + appendStepBody(step, `

${escapeHtml(err.message)}

` + anotherRequestButton()); + return null; + } + } + function showPersonTokenReceived(personToken) { + addLogStep( + copy("person_token.received.label"), + "success", + desc("person_token.received") + formatDecoded(decodeJWTBrowser(personToken), "person_token decoded"), + { kind: "response" } + ); + } async function runBootstrap(psUrl) { addLogSection(copy("sections.bootstrap")); const { keyPair, publicJwk } = await window.aauthEphemeral.rotate(); @@ -3836,7 +3982,6 @@ ${renderJSON(body)}`; clearAllPersistedLogs(); localStorage.removeItem(NOTES_AUTH_TOKEN_KEY); localStorage.removeItem(PENDING_AUTHZ_KEY); - localStorage.removeItem("aauth-pending-whoami"); document.getElementById("bootstrap-artifacts")?.classList.remove("hidden"); setActiveLog("bootstrap-log"); clearLog(); @@ -3916,12 +4061,34 @@ ${renderJSON(body)}`; } const signingJwk = await exportSigningJwk(keyPair); addLogSection(copy("sections.whoami")); + const personResult = await fetchPersonToken({ + resource: new URL(whoamiUrl).origin, + bindingPs, + keyPair, + agentToken, + signingJwk, + consentKey: "whoami", + pendingRecord: { whoamiUrl } + }); + if (!personResult) return; + await continueWhoami({ + whoamiUrl, + bindingPs, + hints, + keyPair, + agentToken, + signingJwk, + personToken: personResult.personToken, + psMetadata: personResult.psMetadata + }); + } + async function continueWhoami({ whoamiUrl, bindingPs, hints, keyPair, agentToken, signingJwk, personToken, psMetadata }) { const urlObj = new URL(whoamiUrl); const whoamiPathDisplay = urlObj.pathname + urlObj.search; const step1 = addLogStep( `Agent \u2192 Whoami`, "pending", - `

Agent calls whoami with its agent_token. The resource knows the agent but has no user claims yet, so it returns 401 with a resource_token the agent can exchange at the Person Server.

` + `

Agent calls whoami presenting the person token in place of its agent_token. The resource now knows who the agent acts for, but identity is not authorization \u2014 so it returns 401 with a resource_token the agent can exchange at the Person Server.

` ); let resourceToken; try { @@ -3929,7 +4096,7 @@ ${renderJSON(body)}`; method: "GET", signingKey: signingJwk, signingCryptoKey: keyPair.privateKey, - signatureKey: { type: "jwt", jwt: agentToken }, + signatureKey: { type: "jwt", jwt: personToken }, components: SIGNED_COMPONENTS, returnSent: true }); @@ -3945,9 +4112,9 @@ ${renderJSON(body)}`; resolveStep(step1, "success", `Agent \u2192 Whoami`); appendStepBody(step1, formatResponse(200, respHeaders, body)); addLogStep( - "Agent identity received", + "Person identity received", "success", - `

No scopes were requested, so whoami returned the agent's own identity straight from the agent_token \u2014 no Person Server exchange needed.

` + tokenWrap(renderJSON(body)) + anotherRequestButton(), + `

No scopes were requested, so whoami answered on identity alone \u2014 the ps and sub off the person token. No auth token, no Person Server exchange.

` + tokenWrap(renderJSON(body)) + anotherRequestButton(), { kind: "response" } ); return; @@ -3969,6 +4136,7 @@ ${renderJSON(body)}`; await runPSTokenExchange({ resourceToken, bindingPs, + psMetadata, hints, keyPair, agentToken, @@ -3977,12 +4145,11 @@ ${renderJSON(body)}`; postLabel: (path) => `Agent \u2192 Person Server`, postLabelResolved: (path, status) => status === 200 || status === 202 ? `Agent \u2192 Person Server` : `Agent \u2192 Person Server \u2192 ${status}`, postLabelNetworkError: (path) => `Agent \u2192 Person Server (network error)`, - postDescription: `

Agent presents the resource_token and its agent_token to the Person Server's token endpoint. The PS either releases an auth_token immediately (cached consent) or returns a 202 with a consent prompt.

`, - pollLabel: (path) => `Agent \u2192 Person Server (long-poll)`, - pollDescription: `

Agent keeps a request open while you decide, instead of polling. The Person Server answers the moment you approve or deny.

`, + postDescription: `

Agent presents the resource_token and its agent_token to the Person Server's auth token endpoint. The PS looks up the person token named by person_token_jti, checks the resource_token's ps and sub against it, then either releases an auth_token immediately (cached consent) or returns a 202 with a consent prompt.

`, consentLabel: copy("authorize.ps_consent_prompt.label"), consentDescription: desc("authorize.ps_consent_prompt") }, + copyPrefix: "authorize", consentKey: "whoami", pendingExtra: { whoamiUrl }, onAuthToken: async (token, { viaPolling }) => { @@ -4133,36 +4300,68 @@ ${renderJSON(body)}`; showLog(); currentLog()?.querySelectorAll(":scope > details.log-section").forEach((s) => s.setAttribute("open", "")); const isNotes = !!saved.notesAuthorize; - const promptKey = isNotes ? "notes_resumed.ps_consent_prompt" : "whoami_resumed.ps_consent_prompt"; + const promptKey = saved.stage === "person-token" ? "person_token_resumed.ps_consent_prompt" : isNotes ? "notes_resumed.ps_consent_prompt" : "whoami_resumed.ps_consent_prompt"; const log = currentLog(); if (!log.querySelector(":scope > details.log-section")) { addLogSection(copy(isNotes ? "sections.notes" : "sections.whoami")); } - const consentKey = isNotes ? "notes" : "whoami"; + const isPersonStage = saved.stage === "person-token"; + const consentKey = `${isNotes ? "notes" : "whoami"}${isPersonStage ? "-person" : ""}`; let interactionStep = log.querySelector(`[data-consent-key="${consentKey}"]`); if (!interactionStep) { interactionStep = addLogStep(copy(`${promptKey}.label`), "pending", desc(promptKey)); } - let options = {}; + const existingPollStep = log.querySelector(`[data-poll-key="${consentKey}"]`); + const signingJwk = await exportSigningJwk(keyPair); + const token = await startDeferredPolling( + saved.pollUrl, + saved.tokenEndpoint, + interactionStep, + existingPollStep || null, + { + tokenField: isPersonStage ? "person_token" : "auth_token", + copyPrefix: isPersonStage ? "person_token" : isNotes ? "notes" : "authorize", + // No continuation below for a record with neither marker — fall + // back to the generic "Authorization Granted" step. + renderGranted: !isPersonStage && !isNotes && !saved.whoamiUrl + } + ); + if (!token) return true; + if (isPersonStage) { + showPersonTokenReceived(token); + if (isNotes) { + await continueNotesAuthorize({ + authzEndpoint: saved.authzEndpoint || `${window.NOTES_ORIGIN}/authorize`, + operations: saved.operations || [], + bindingPs: saved.psUrl, + hints: getHints(), + keyPair, + agentToken, + signingJwk, + personToken: token, + psMetadata: null + }); + } else if (saved.whoamiUrl) { + await continueWhoami({ + whoamiUrl: saved.whoamiUrl, + bindingPs: saved.psUrl, + hints: getHints(), + keyPair, + agentToken, + signingJwk, + personToken: token, + psMetadata: null + }); + } + return true; + } if (isNotes) { - options = { - onAuthToken: async (tokenFromPoll) => { - await finalizeNotesAuthToken(tokenFromPoll); - } - }; + await finalizeNotesAuthToken(token); } else if (saved.whoamiUrl) { const urlObj = new URL(saved.whoamiUrl); - const whoamiPathDisplay = urlObj.pathname + urlObj.search; - const signingJwk = await exportSigningJwk(keyPair); - options = { - onAuthToken: async (tokenFromPoll) => { - showWhoamiAuthTokenReceived(tokenFromPoll); - await retryWhoami(saved.whoamiUrl, whoamiPathDisplay, tokenFromPoll, keyPair, signingJwk); - } - }; + showWhoamiAuthTokenReceived(token); + await retryWhoami(saved.whoamiUrl, urlObj.pathname + urlObj.search, token, keyPair, signingJwk); } - const existingPollStep = log.querySelector(`[data-poll-key="${consentKey}"]`); - startAuthTokenPolling(saved.pollUrl, saved.tokenEndpoint, interactionStep, existingPollStep || null, options); return true; } window.resumePendingAuthorize = resumePendingAuthorize; @@ -4183,6 +4382,9 @@ ${renderJSON(body)}`; async function runPSTokenExchange({ resourceToken, bindingPs, + // PS metadata already fetched for the person-token hop. Both flows + // pass it through rather than re-fetching the same document. + psMetadata, hints, keyPair, agentToken, @@ -4190,6 +4392,10 @@ ${renderJSON(body)}`; // Per-flow labels/descriptions. Functions where the value depends // on runtime state (path, status); plain strings/HTML otherwise. labels, + // log-text.json block the deferred (202) leg reads its long-poll and + // denied/timed-out narration from: 'authorize' for whoami, 'notes' for + // notes. + copyPrefix, // 'whoami' | 'notes' — written to data-poll-key / data-consent-key // so resumePendingAuthorize can re-locate the steps after a same-tab // PS redirect. @@ -4206,28 +4412,17 @@ ${renderJSON(body)}`; // ignores it — its finalizeNotesAuthToken always emits its own step. onAuthToken }) { - const psMetadataUrl = `${bindingPs.replace(/\/$/, "")}/.well-known/aauth-person.json`; - let psMetadata; - try { - const metaRes = await fetch(psMetadataUrl); - psMetadata = await metaRes.json(); - if (!metaRes.ok || !psMetadata?.token_endpoint) { - addLogStep( - "Person Server metadata fetch failed", - "error", - formatResponse(metaRes.status, null, psMetadata) + anotherRequestButton() - ); - return; - } - } catch (err) { + if (!psMetadata) psMetadata = await fetchPsMetadata(bindingPs, "auth_token_endpoint"); + if (!psMetadata) return; + if (!psMetadata.auth_token_endpoint) { addLogStep( "Person Server metadata fetch failed", "error", - `

${escapeHtml(err.message)}

` + anotherRequestButton() + `

The Person Server's metadata is missing auth_token_endpoint.

` + tokenWrap(renderJSON(psMetadata)) + anotherRequestButton() ); return; } - const tokenEndpoint = psMetadata.token_endpoint; + const tokenEndpoint = psMetadata.auth_token_endpoint; const psPath = new URL(tokenEndpoint).pathname; const psBody = { resource_token: resourceToken, @@ -4248,6 +4443,8 @@ ${renderJSON(body)}`; signingKey: signingJwk, signingCryptoKey: keyPair.privateKey, signatureKey: { type: "jwt", jwt: agentToken }, + // Body-carrying request to a PS endpoint: cover content-digest and + // content-type so the resource_token being exchanged is signed over. components: SIGNED_COMPONENTS_WITH_BODY, returnSent: true }); @@ -4267,50 +4464,19 @@ ${renderJSON(body)}`; } else if (psRes.status === 202) { resolveStep(step2, "success", labels.postLabelResolved(psPath, 202)); appendStepBody(step2, formatResponse(202, respHeaders, psResBody)); - const reqHeader = psRes.headers.get("aauth-requirement") || ""; - const fromHeader = parseInteractionHeader(reqHeader); - const interaction = { - requirement: fromHeader.requirement || psResBody?.requirement, - code: fromHeader.code || psResBody?.code, - url: fromHeader.url || psMetadata.interaction_endpoint - }; - const pollUrl = psRes.headers.get("location") || psResBody?.location; - let pollStep = null; - if (pollUrl) { - const absolutePollUrl = new URL(pollUrl, tokenEndpoint).href; - pollStep = addLogStep( - labels.pollLabel(new URL(absolutePollUrl).pathname), - "pending", - labels.pollDescription - ); - if (pollStep) { - pollStep.dataset.pollKey = consentKey; - persistActiveLog(); - } - } - const interactionStep = addLogStep( - labels.consentLabel, - "pending", - labels.consentDescription + renderInteraction(interaction, pollUrl, "authorize") - ); - if (interactionStep) { - interactionStep.dataset.consentKey = consentKey; - persistActiveLog(); - } - if (pollUrl) { - const absolutePollUrl = new URL(pollUrl, tokenEndpoint).href; - savePendingAuthorize({ - pollUrl: absolutePollUrl, - tokenEndpoint, - psUrl: bindingPs, - ...pendingExtra - }); - startAuthTokenPolling(pollUrl, tokenEndpoint, interactionStep, pollStep, { - onAuthToken: async (tokenFromPoll) => { - await onAuthToken(tokenFromPoll, { viaPolling: true }); - } - }); - } + const tokenFromPoll = await runDeferredResponse({ + res: psRes, + body: psResBody, + endpoint: tokenEndpoint, + psMetadata, + consentKey, + copyPrefix, + tokenField: "auth_token", + consentLabel: labels.consentLabel, + consentDescription: labels.consentDescription, + pendingRecord: { ...pendingExtra, stage: "auth-token", psUrl: bindingPs } + }); + if (tokenFromPoll) await onAuthToken(tokenFromPoll, { viaPolling: true }); return; } else { resolveStep(step2, "error", labels.postLabelResolved(psPath, psRes.status)); @@ -4324,17 +4490,76 @@ ${renderJSON(body)}`; } await onAuthToken(authToken, { viaPolling: false }); } - var _authzPollRunning = false; - async function startAuthTokenPolling(pollUrl, baseUrl, interactionStep, pollStep, options = {}) { - if (_authzPollRunning) return; - _authzPollRunning = true; + var _deferredPollRunning = false; + async function startDeferredPolling(pollUrl, baseUrl, interactionStep, pollStep, options = {}) { + if (_deferredPollRunning) return null; + _deferredPollRunning = true; try { - await _startAuthTokenPollingImpl(pollUrl, baseUrl, interactionStep, pollStep, options); + return await _deferredPollingImpl(pollUrl, baseUrl, interactionStep, pollStep, options); } finally { - _authzPollRunning = false; + _deferredPollRunning = false; + } + } + async function runDeferredResponse({ + res, + body, + endpoint, + psMetadata, + // 'whoami' | 'notes', suffixed per leg — written to data-poll-key / + // data-consent-key so resumePendingAuthorize can re-locate both steps + // after the redirect instead of orphaning them as stale pending rows. + consentKey, + copyPrefix, + tokenField, + consentLabel, + consentDescription, + // Merged into the persisted record; carries `stage` plus whatever the + // resumed flow needs to pick up where it left off. + pendingRecord + }) { + const fromHeader = parseInteractionHeader(res.headers.get("aauth-requirement") || ""); + const interaction = { + requirement: fromHeader.requirement || body?.requirement, + code: fromHeader.code || body?.code, + url: fromHeader.url || psMetadata?.interaction_endpoint + }; + const pollUrl = res.headers.get("location") || body?.location; + if (!pollUrl) { + addLogStep( + "Deferred response missing Location", + "error", + `

The Person Server answered 202 without a Location to poll, so the agent has nowhere to wait.

` + anotherRequestButton() + ); + return null; + } + const absolutePollUrl = new URL(pollUrl, endpoint).href; + const pollStep = addLogStep( + fmt(copy(`${copyPrefix}.ps_pending_longpoll.label_template`), { path: new URL(absolutePollUrl).pathname }), + "pending", + desc(`${copyPrefix}.ps_pending_longpoll`) + ); + if (pollStep) { + pollStep.dataset.pollKey = consentKey; + persistActiveLog(); + } + const interactionStep = addLogStep( + consentLabel, + "pending", + consentDescription + renderInteraction(interaction, pollUrl, "authorize") + ); + if (interactionStep) { + interactionStep.dataset.consentKey = consentKey; + persistActiveLog(); } + savePendingAuthorize({ ...pendingRecord, pollUrl: absolutePollUrl, tokenEndpoint: endpoint }); + return startDeferredPolling(absolutePollUrl, endpoint, interactionStep, pollStep, { + tokenField, + copyPrefix + }); } - async function _startAuthTokenPollingImpl(pollUrl, baseUrl, interactionStep, pollStep, options = {}) { + async function _deferredPollingImpl(pollUrl, baseUrl, interactionStep, pollStep, options = {}) { + const tokenField = options.tokenField || "auth_token"; + const copyPrefix = options.copyPrefix || "authorize"; const targetLog = currentLog(); const pinLog = () => { if (targetLog) __activeLogContainer = targetLog; @@ -4342,14 +4567,14 @@ ${renderJSON(body)}`; const absolutePollUrl = new URL(pollUrl, baseUrl).href; const keyPair = window.aauthEphemeral.get(); const agentToken = localStorage.getItem("aauth-agent-token"); - if (!keyPair || !agentToken) return; + if (!keyPair || !agentToken) return null; const signingJwk = await exportSigningJwk(keyPair); const pollPath = new URL(absolutePollUrl).pathname; if (!pollStep) { pollStep = addLogStep( - fmt(copy("authorize.ps_pending_longpoll.label_template"), { path: pollPath }), + fmt(copy(`${copyPrefix}.ps_pending_longpoll.label_template`), { path: pollPath }), "pending", - desc("authorize.ps_pending_longpoll") + desc(`${copyPrefix}.ps_pending_longpoll`) ); } let cycle = 0; @@ -4384,24 +4609,22 @@ ${renderJSON(body)}`; } if (res.status === 200) { clearPendingAuthorize(); - resolveStep(pollStep, "success", fmt(copy("authorize.ps_pending_longpoll.label_resolved_template"), { path: pollPath, status: 200 })); + resolveStep(pollStep, "success", fmt(copy(`${copyPrefix}.ps_pending_longpoll.label_resolved_template`), { path: pollPath, status: 200 })); resolveStep(interactionStep, "success", "Interaction Completed"); pinLog(); - if (options.onAuthToken && body?.auth_token) { - await options.onAuthToken(body.auth_token); - } else { - addLogStep( - copy("authorize.authorization_granted.label"), - "success", - (body?.auth_token ? formatAuthToken(body.auth_token) : "") + anotherRequestButton(), - { kind: "response" } - ); - } - return; + const token = body?.[tokenField]; + if (!options.renderGranted) return token || null; + addLogStep( + copy(`${copyPrefix}.authorization_granted.label`), + "success", + (token ? formatAuthToken(token) : "") + anotherRequestButton(), + { kind: "response" } + ); + return token || null; } if (res.status === 404) { clearPendingAuthorize(); - resolveStep(pollStep, "error", fmt(copy("authorize.ps_pending_longpoll.label_resolved_template"), { path: pollPath, status: 404 })); + resolveStep(pollStep, "error", fmt(copy(`${copyPrefix}.ps_pending_longpoll.label_resolved_template`), { path: pollPath, status: 404 })); resolveStep(interactionStep, "error", "Interaction Expired"); pinLog(); addLogStep( @@ -4410,21 +4633,21 @@ ${renderJSON(body)}`; formatResponse(404, null, body) + anotherRequestButton(), { kind: "response" } ); - return; + return null; } if (res.status === 403 || res.status === 408) { clearPendingAuthorize(); const label = res.status === 403 ? "Interaction Denied" : "Interaction Timed Out"; - resolveStep(pollStep, "error", fmt(copy("authorize.ps_pending_longpoll.label_resolved_template"), { path: pollPath, status: res.status })); + resolveStep(pollStep, "error", fmt(copy(`${copyPrefix}.ps_pending_longpoll.label_resolved_template`), { path: pollPath, status: res.status })); resolveStep(interactionStep, "error", label); pinLog(); addLogStep( - copy(res.status === 403 ? "authorize.authorization_denied.label" : "authorize.authorization_timed_out.label"), + copy(res.status === 403 ? `${copyPrefix}.authorization_denied.label` : `${copyPrefix}.authorization_timed_out.label`), "error", formatResponse(res.status, null, body) + anotherRequestButton(), { kind: "response" } ); - return; + return null; } } catch (err) { console.log("Poll error:", err.message); @@ -4662,6 +4885,39 @@ ${renderJSON(body)}`; } _notesMetadata = discovery.metadata; const authzEndpoint = discovery.metadata.authorization_endpoint || `${window.NOTES_ORIGIN}/authorize`; + const personResult = await fetchPersonToken({ + resource: new URL(authzEndpoint).origin, + bindingPs, + keyPair, + agentToken, + signingJwk, + consentKey: "notes", + pendingRecord: { notesAuthorize: true, operations, authzEndpoint } + }); + if (!personResult) return; + await continueNotesAuthorize({ + authzEndpoint, + operations, + bindingPs, + hints, + keyPair, + agentToken, + signingJwk, + personToken: personResult.personToken, + psMetadata: personResult.psMetadata + }); + } + async function continueNotesAuthorize({ + authzEndpoint, + operations, + bindingPs, + hints, + keyPair, + agentToken, + signingJwk, + personToken, + psMetadata + }) { const authzPath = new URL(authzEndpoint).pathname; const requestBody = { r3_operations: { @@ -4682,7 +4938,7 @@ ${renderJSON(body)}`; body: JSON.stringify(requestBody), signingKey: signingJwk, signingCryptoKey: keyPair.privateKey, - signatureKey: { type: "jwt", jwt: agentToken }, + signatureKey: { type: "jwt", jwt: personToken }, components: SIGNED_COMPONENTS_WITH_BODY, returnSent: true }); @@ -4707,6 +4963,7 @@ ${renderJSON(body)}`; await runPSTokenExchange({ resourceToken, bindingPs, + psMetadata, hints, keyPair, agentToken, @@ -4716,11 +4973,10 @@ ${renderJSON(body)}`; postLabelResolved: (path, status) => fmt(copy("notes.ps_token_request.label_resolved_template"), { path, status }), postLabelNetworkError: (path) => fmt(copy("notes.ps_token_request.label_error_network_template"), { path }), postDescription: desc("notes.ps_token_request"), - pollLabel: (path) => fmt(copy("notes.ps_pending_longpoll.label_template"), { path }), - pollDescription: desc("notes.ps_pending_longpoll"), consentLabel: copy("notes.ps_consent_prompt.label"), consentDescription: desc("notes.ps_consent_prompt") }, + copyPrefix: "notes", consentKey: "notes", pendingExtra: { notesAuthorize: true }, onAuthToken: async (token) => { diff --git a/src/crypto.ts b/src/crypto.ts index 7dfb752..c3e699b 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -108,16 +108,24 @@ export function decodeJWTHeader(jwt: string): Record { } // Verify a signed JWT against a JWKS. Finds the verification key by `kid` -// (falling back to first key if no kid), rejects unknown algs, and checks the -// signature. Callers are responsible for payload claim checks +// (falling back to first key if no kid), rejects unsupported algs, and +// checks the signature. Callers are responsible for payload claim checks // (iss/aud/exp/nbf/jti). // Maps JWT alg → WebCrypto import/verify parameters. Extend here for new -// algorithms. Hellō's issuer JWKS uses RS256; we sign with the fully-specified -// alg "Ed25519" (RFC 9864). "EdDSA" stays accepted for tokens minted by peers -// that have not moved to the fully-specified name. +// algorithms. Hellō's issuer JWKS uses RS256; our own JWKS uses Ed25519. +// +// `EdDSA` is deliberately absent, on both sides. We emit the +// fully-specified `Ed25519`, and we refuse the polymorphic identifier on +// input: "Implementations MUST NOT accept none, the polymorphic EdDSA +// identifier, or any symmetric algorithm" +// (draft-hardt-oauth-aauth-protocol §Signature Algorithms). There is no +// transition allowance in the spec, so there is none here. +// +// Flag day: Wallet's svr/issuer/sign.js:32 still heads every aa-auth+jwt +// and aa-person+jwt with `EdDSA`, so /api/demo rejects live PS auth tokens +// until that ships `Ed25519` for AAuth token types. const JWT_ALG_PARAMS: Record = { Ed25519: { importAlgo: { name: 'Ed25519' }, verifyAlgo: 'Ed25519' }, - EdDSA: { importAlgo: { name: 'Ed25519' }, verifyAlgo: 'Ed25519' }, RS256: { importAlgo: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, verifyAlgo: 'RSASSA-PKCS1-v1_5', @@ -157,6 +165,12 @@ export async function verifyJWT( // fully-specified alg "Ed25519" (RFC 9864), which workerd's importKey // rejects for OKP keys (it only accepts "EdDSA" or no alg). The // algorithm is passed explicitly via importAlgo, so alg is redundant. + // + // DO NOT REMOVE while fixing an emit-side alg. Emitting "Ed25519" + // (headers, JWKS, cnf.jwk) and stripping it before importKey are + // opposite ends of the same pipe. Tests run under environment: + // 'node', where this import succeeds either way — breaking it passes + // CI and fails on deploy to workerd. const { alg: _alg, ...importJwk } = jwk as JsonWebKey & { alg?: string } const key = await crypto.subtle.importKey( 'jwk', diff --git a/src/httpsig-verify.ts b/src/httpsig-verify.ts index e8a3aba..f583c38 100644 --- a/src/httpsig-verify.ts +++ b/src/httpsig-verify.ts @@ -1,18 +1,44 @@ import { verify as httpSigVerify } from '@hellocoop/httpsig' import type { Context } from 'hono' -import { verifyJWT } from './crypto' +import { computeJwkThumbprint, decodeJWTHeader, decodeJWTPayload, verifyJWT } from './crypto' // Shared RFC 9421 verification for endpoints that accept sig=jwt. Reads -// the body once, runs httpSigVerify, enforces the jwt scheme, and -// optionally runs a caller-supplied JWT verification on the inner token -// (e.g. against our own JWKS or a PS JWKS). +// the body once, runs httpSigVerify, enforces the jwt scheme, enforces the +// set of AAuth token types the endpoint accepts, and optionally runs a +// caller-supplied JWT verification on the inner token (e.g. against our +// own JWKS or a PS JWKS). // // Returns the parsed body as text (callers parse JSON themselves, since // c.req.json() would re-consume the stream) and the inner JWT payload. // // On failure, returns a Hono Response — callers can return it directly. +// The three AAuth token types that can arrive in a Signature-Key header. +export const TOKEN_TYP = { + agent: 'aa-agent+jwt', + person: 'aa-person+jwt', + auth: 'aa-auth+jwt', +} as const + +export type TokenKind = keyof typeof TOKEN_TYP + export interface SigJwtVerifyOptions { + // Which token types this endpoint accepts. REQUIRED, with no default, + // so every call site has to state it. + // + // "A recipient MUST reject an aa-person+jwt wherever an auth token is + // required. Only typ distinguishes the two" (§Person Token + // Verification). A person token and a PS-issued auth token share iss, + // dwk, aud, sub and cnf — every check except this one passes for both, + // so a verifier that omits it accepts a credential carrying no + // authorization as though it carried authorization. Making the accepted + // set an explicit argument is what keeps that from being forgotten. + accept: TokenKind[] + // Response to return when the presented token's typ is not accepted. + // Defaults to a flat 401; endpoints that can tell the agent how to + // recover (e.g. /authorize challenging with requirement=person-token) + // supply their own. + onTypMismatch?: (c: Context, typ: string | undefined) => Response // Optional inner-token verifier: takes the raw JWT string and returns // { payload } if it's valid. Use this to verify against our own JWKS // (agent_token from us) or a PS JWKS (auth_token from the PS). @@ -33,7 +59,7 @@ export interface SigJwtVerifyResult { export async function verifySigJwt( c: Context, - options: SigJwtVerifyOptions = {} + options: SigJwtVerifyOptions ): Promise { // Read body as text — httpSigVerify needs it to reconstruct the // signature base, and c.req.json() would consume the stream first. @@ -56,6 +82,27 @@ export async function verifySigJwt( } const innerJwt = sigResult.jwt.raw + + // Enforce typ before anything else looks at the token. This runs on the + // unverified header, which is fine — it only ever narrows what we go on + // to verify, and a token whose typ we do not accept is rejected whether + // or not its signature would have checked out. + let presentedTyp: string | undefined + try { + presentedTyp = decodeJWTHeader(innerJwt).typ as string | undefined + } catch { + return c.json({ error: 'Signature-Key jwt has an undecodable header' }, 401) + } + const acceptedTyps = options.accept.map((kind) => TOKEN_TYP[kind]) + if (!presentedTyp || !acceptedTyps.includes(presentedTyp as typeof acceptedTyps[number])) { + if (options.onTypMismatch) return options.onTypMismatch(c, presentedTyp) + return c.json({ + error: 'invalid_token_type', + typ: presentedTyp ?? null, + accepted: acceptedTyps, + }, 401) + } + let innerPayload: Record | null = null if (options.verifyInner) { @@ -79,12 +126,6 @@ export async function verifySigJwt( return { rawBody, innerJwt, innerPayload, callerJkt: sigResult.thumbprint } } -// Convenience: build a verifyInner that uses our own JWKS. For tokens we -// issued (agent_token minted at bootstrap/refresh). -export function ourJwksVerifier(ourJwk: JsonWebKey) { - return (jwt: string) => verifyJWT(jwt, { keys: [ourJwk] }) -} - // Result of verifying a sig=hwk request — the public key that signed it, // its JWK thumbprint (for KV lookup), and the raw body so the caller can // JSON.parse without re-consuming the stream. @@ -118,23 +159,106 @@ export async function verifySigHwk(c: Context): Promise + metadataUrl: string + jwks: { keys: JsonWebKey[] } +} + +export async function resolveIssuerKeys(iss: string, dwk: string): Promise { + const metadataUrl = `${iss}/.well-known/${dwk}` + const metaRes = await fetch(metadataUrl) + if (!metaRes.ok) throw new Error(`fetch ${dwk} failed: ${metaRes.status}`) + const metadata = (await metaRes.json()) as Record + const jwksUri = metadata.jwks_uri as string | undefined + if (!jwksUri) throw new Error(`${dwk} missing jwks_uri`) + const jwksRes = await fetch(jwksUri) + if (!jwksRes.ok) throw new Error(`fetch JWKS failed: ${jwksRes.status}`) + const jwks = (await jwksRes.json()) as { keys: JsonWebKey[] } + return { metadata, metadataUrl, jwks } +} + // Build a verifyInner that fetches the PS JWKS (via the JWT's iss+dwk) // and verifies against it. Used for auth_tokens at /api/demo. export function psJwksVerifier() { return async (jwt: string) => { - const { decodeJWTPayload } = await import('./crypto') const unverified = decodeJWTPayload(jwt) const iss = unverified.iss as string | undefined - const dwk = (unverified.dwk as string | undefined) ?? 'aauth-person.json' + const dwk = (unverified.dwk as string | undefined) ?? PERSON_DWK if (!iss) throw new Error('token missing iss') - const metaRes = await fetch(`${iss}/.well-known/${dwk}`) - if (!metaRes.ok) throw new Error(`fetch PS metadata failed: ${metaRes.status}`) - const meta = (await metaRes.json()) as Record - const jwksUri = meta.jwks_uri as string | undefined - if (!jwksUri) throw new Error('PS metadata missing jwks_uri') - const jwksRes = await fetch(jwksUri) - if (!jwksRes.ok) throw new Error(`fetch PS JWKS failed: ${jwksRes.status}`) - const jwks = (await jwksRes.json()) as { keys: JsonWebKey[] } + const { jwks } = await resolveIssuerKeys(iss, dwk) return verifyJWT(jwt, jwks) } } + +// ── Person token verification ── +// +// draft-hardt-oauth-aauth-protocol §Person Token Verification. The agent +// presents the person token in place of its agent token via +// `Signature-Key: sig=jwt`, so the HTTP signature has already proven +// possession of `cnf.jwk` — step 6 is the check that the key which signed +// the request is the key the PS bound the token to. +export const PERSON_TYP = TOKEN_TYP.person +export const PERSON_DWK = 'aauth-person.json' + +export interface PersonTokenResult { + payload: Record + // The PS metadata document fetched for key discovery — reused by the + // caller so a resource token's `aud` (the PS `issuer`) costs no second + // round trip. + psMetadata: Record + psMetadataUrl: string +} + +// Throws Error on any verification failure; the message is the reason. +export async function verifyPersonToken( + jwt: string, + opts: { aud: string; callerJkt: string } +): Promise { + // 1. typ + const header = decodeJWTHeader(jwt) + if (header.typ !== PERSON_TYP) throw new Error(`typ must be ${PERSON_TYP}`) + + // 2. dwk + issuer key discovery + signature + const unverified = decodeJWTPayload(jwt) + if (unverified.dwk !== PERSON_DWK) throw new Error(`dwk must be ${PERSON_DWK}`) + const iss = unverified.iss + if (typeof iss !== 'string') throw new Error('missing iss') + // 4. iss must be an HTTPS server identifier + let issUrl: URL + try { + issUrl = new URL(iss) + } catch { + throw new Error('iss is not a valid URL') + } + if (issUrl.protocol !== 'https:') throw new Error('iss must be HTTPS') + + const { metadata, metadataUrl, jwks } = await resolveIssuerKeys(iss, PERSON_DWK) + const { payload } = await verifyJWT(jwt, jwks) + + // 3. exp / iat + const now = Math.floor(Date.now() / 1000) + if (typeof payload.exp !== 'number' || payload.exp < now) throw new Error('expired') + if (typeof payload.iat === 'number' && payload.iat > now + 60) throw new Error('iat in the future') + + // 5. aud is this resource + if (payload.aud !== opts.aud) throw new Error(`aud is not ${opts.aud}`) + + if (typeof payload.sub !== 'string' || !payload.sub) throw new Error('missing sub') + if (typeof payload.jti !== 'string' || !payload.jti) throw new Error('missing jti') + + // 6. cnf.jwk is the key that signed the HTTP request + const cnfJwk = (payload.cnf as { jwk?: JsonWebKey } | undefined)?.jwk + if (!cnfJwk) throw new Error('missing cnf.jwk') + const cnfJkt = await computeJwkThumbprint(cnfJwk) + if (cnfJkt !== opts.callerJkt) throw new Error('cnf.jwk is not the request signing key') + + return { payload, psMetadata: metadata, psMetadataUrl: metadataUrl } +} diff --git a/src/index.ts b/src/index.ts index 4608509..7e34af9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,13 @@ import { Hono } from 'hono' +import type { Context } from 'hono' import { cors } from 'hono/cors' import type { Env } from './types' -import { verifySigJwt, verifySigHwk, ourJwksVerifier, psJwksVerifier } from './httpsig-verify' +import { + verifySigJwt, + verifySigHwk, + psJwksVerifier, + verifyPersonToken, +} from './httpsig-verify' import { importSigningKey, getPublicJWK, @@ -314,6 +320,8 @@ async function mintAgentToken( const publicJwk = await getPublicJWK(env.SIGNING_KEY) const now = Math.floor(Date.now() / 1000) + // alg is the fully-specified `Ed25519`, not the polymorphic `EdDSA` + // (draft-hardt-oauth-aauth-protocol §Signature Algorithms). const agentHeader = { alg: 'Ed25519', typ: 'aa-agent+jwt', kid: publicJwk.kid } const agentPayload: Record = { iss: origin, @@ -336,40 +344,94 @@ async function mintAgentToken( } // ── Authorization (resource token issuance) ── +// +// Under AAuth -11 the agent presents a PERSON token here, not its agent +// token: a resource MUST have verified a person token before it issues a +// resource token (draft-hardt-oauth-aauth-protocol §Resource Access and +// Resource Tokens). The person token's `iss` IS the person server, so the +// request body carries only `scope` — there is no `ps` parameter to +// believe or disbelieve, and the identity written into the resource token +// is PS-asserted rather than agent-asserted. + +// 401 challenge for a request that presented no person token. The agent +// gets one from its PS's person_token_endpoint and retries +// (§Person Token Required). +function personTokenRequired(c: Context) { + return c.json({ error: 'person token required' }, 401, { + 'AAuth-Requirement': 'requirement=person-token', + }) +} app.post('/authorize', async (c) => { - // sig=jwt;jwt=. Verify the HTTP signature against - // agent_token.cnf.jwk, then verify the agent_token itself against our - // own JWKS — proves both that the token is ours and that the caller - // holds the cnf-bound ephemeral. const ourJwk = await getPublicJWK(c.env.SIGNING_KEY) const origin = c.env.ORIGIN + + // No Signature-Key at all: nothing to verify, so challenge rather than + // reject — the agent can satisfy this by fetching a person token. + if (!c.req.header('Signature-Key')) { + emitVerifyFailed(c, 'person_token_missing', { detail: 'no Signature-Key header' }) + return personTokenRequired(c) + } + + // sig=jwt;jwt=. verifySigJwt proves the caller holds the + // key named in the presented JWT's cnf; the person token's own + // signature and claims are checked below. + // + // An agent token where a person token belongs is the "absent" case + // rather than an error: the agent holds the wrong credential and needs + // to be told which one this endpoint wants, so the typ mismatch becomes + // the requirement=person-token challenge. + let challenged = false const verifyRes = await verifySigJwt(c, { - verifyInner: ourJwksVerifier(ourJwk), - expectedIss: origin, + accept: ['person'], + onTypMismatch: (ctx, typ) => { + challenged = true + emitVerifyFailed(ctx, 'person_token_missing', { + detail: `presented typ ${typ ?? 'none'}`, + }) + return personTokenRequired(ctx as Context) + }, }) if (verifyRes instanceof Response) { - emitVerifyFailed(c, 'sig_jwt_failed', { detail: await readVerifyError(verifyRes) }) + // The challenge already logged its own reason; anything else that + // came back is a signature failure. + if (!challenged) { + emitVerifyFailed(c, 'sig_jwt_failed', { detail: await readVerifyError(verifyRes) }) + } return verifyRes } - const agentPayload = verifyRes.innerPayload as Record + let person: Awaited> + try { + person = await verifyPersonToken(verifyRes.innerJwt, { + aud: origin, + callerJkt: verifyRes.callerJkt, + }) + } catch (err) { + const detail = (err as Error).message + emitVerifyFailed(c, 'person_token_invalid', { detail, caller_jkt: verifyRes.callerJkt }) + return c.json({ error: 'invalid_person_token', detail }, 400) + } + + const personPayload = person.payload + const psMetadata = person.psMetadata + const psMetadataUrl = person.psMetadataUrl - let body: { ps: string; scope: string } + let body: { scope: string } try { - body = JSON.parse(verifyRes.rawBody) as { ps: string; scope: string } + body = JSON.parse(verifyRes.rawBody) as { scope: string } } catch { return c.json({ error: 'invalid JSON body' }, 400) } - if (!body.ps || !body.scope) { - return c.json({ error: 'missing required fields: ps, scope' }, 400) + if (!body.scope) { + return c.json({ error: 'missing required field: scope' }, 400) } // resource_token.scope is the combined identity + resource string the - // PS will classify at /aauth/token. The resource validates its own - // scopes (SCOPE_DESCRIPTIONS) and lets PS-known identity scopes pass - // through; anything else is a typo / spoofing attempt. + // PS will classify at its auth_token_endpoint. The resource validates + // its own scopes (SCOPE_DESCRIPTIONS) and lets PS-known identity scopes + // pass through; anything else is a typo / spoofing attempt. const requestedScopes = body.scope.trim().split(/\s+/).filter(Boolean) const unknown = requestedScopes.filter( (s) => !(s in SCOPE_DESCRIPTIONS) && !PS_IDENTITY_SCOPES.has(s), @@ -378,47 +440,23 @@ app.post('/authorize', async (c) => { return c.json({ error: 'invalid_scope', unknown }, 400) } - // Validate PS URL is HTTPS - let psUrl: URL - try { - psUrl = new URL(body.ps) - if (psUrl.protocol !== 'https:') { - return c.json({ error: 'PS URL must be HTTPS' }, 400) - } - } catch { - return c.json({ error: 'invalid PS URL' }, 400) - } - - // Step 1: Fetch and validate PS metadata - let psMetadata: Record - const psMetadataUrl = `${psUrl.origin}/.well-known/aauth-person.json` - try { - const psRes = await fetch(psMetadataUrl) - if (!psRes.ok) { - return c.json({ - error: `Failed to fetch PS metadata: ${psRes.status}`, - ps_metadata_url: psMetadataUrl, - }, 502) - } - psMetadata = await psRes.json() as Record - } catch (err) { + // The PS metadata was fetched during person-token key discovery. We + // still need `issuer` (the resource token's audience in three-party) and + // `auth_token_endpoint` — renamed from `token_endpoint` in -11 — which + // the agent reads out of the response to place its token request. + if (!psMetadata.issuer || !psMetadata.auth_token_endpoint || !psMetadata.jwks_uri) { return c.json({ - error: `Cannot reach PS: ${(err as Error).message}`, - ps_metadata_url: psMetadataUrl, - }, 502) - } - - // Validate required PS metadata fields - if (!psMetadata.issuer || !psMetadata.token_endpoint || !psMetadata.jwks_uri) { - return c.json({ - error: 'PS metadata missing required fields (issuer, token_endpoint, jwks_uri)', + error: 'PS metadata missing required fields (issuer, auth_token_endpoint, jwks_uri)', ps_metadata: psMetadata, }, 502) } - // Step 2: Create resource token. + // Mint the resource token. It carries no agent identifier: `agent_jkt` + // binds it to the agent's key, and `ps`/`sub`/`person_token_jti` name + // the person and the exact person token this authorization rests on + // (§Resource Token Structure). const agentJkt = await computeJwkThumbprint( - (agentPayload.cnf as { jwk: JsonWebKey }).jwk + (personPayload.cnf as { jwk: JsonWebKey }).jwk ) const privateKey = await importSigningKey(c.env.SIGNING_KEY) @@ -429,16 +467,36 @@ app.post('/authorize', async (c) => { typ: 'aa-resource+jwt', kid: ourJwk.kid, } - const rtPayload = { + const rtPayload: Record = { iss: origin, dwk: 'aauth-resource.json', aud: psMetadata.issuer as string, jti: generateJTI(), - agent: agentPayload.sub as string, + ps: personPayload.iss as string, + sub: personPayload.sub as string, + person_token_jti: personPayload.jti as string, agent_jkt: agentJkt, scope: body.scope, iat: now, - exp: now + 300, // 5 minutes + // 5 minutes, but never past the person token this rests on. The + // person token is itself clamped to the mission's expires_at, so + // this transitively keeps a mission-scoped resource token from + // outliving its mission. + exp: Math.min(now + 300, personPayload.exp as number), + } + // REQUIRED when the person token carried one, copied unchanged — the + // PS re-reads it off the person token it issued, so dropping it here + // would be detected as mission stripping. + if (typeof personPayload.mission_s256 === 'string') { + rtPayload.mission_s256 = personPayload.mission_s256 + } + // Likewise copied when present. §Resource Token Verification step 6 has + // the PS check ps, sub, mission_s256 AND tenant against the person token + // it issued, "rejecting the resource token on any mismatch or omission" + // — so dropping a tenant the person token carried makes every resource + // token we mint for an org-affiliated person unredeemable. + if (typeof personPayload.tenant === 'string') { + rtPayload.tenant = personPayload.tenant } const resourceToken = await signJWT(rtHeader, rtPayload, privateKey) @@ -447,13 +505,14 @@ app.post('/authorize', async (c) => { event: 'aauth.resource_token.minted', msg: 'resource_token minted for agent', route: '/authorize', - agent_sub: agentPayload.sub, agent_jkt: agentJkt, caller_jkt: verifyRes.callerJkt, requested_scope: body.scope, granted_scope: body.scope, - ps: body.ps, + ps: personPayload.iss, ps_issuer: psMetadata.issuer, + person_token_jti: personPayload.jti, + mission_s256: personPayload.mission_s256, }) return c.json({ @@ -476,8 +535,15 @@ app.get('/api/demo', async (c) => { // from Signature-Key and verifies the RFC 9421 signature — proving // possession of the ephemeral. psJwksVerifier fetches the auth_token's // issuer JWKS (the PS) and verifies the token's own JWT signature. + // + // accept is auth only. A person token from the same PS carries the same + // iss, dwk, aud, sub and cnf and would pass every other check here — + // but it asserts identity, not authorization, and this endpoint is + // gated on scope. §Person Token Verification: "A recipient MUST reject + // an aa-person+jwt wherever an auth token is required." const origin = c.env.ORIGIN const verifyRes = await verifySigJwt(c, { + accept: ['auth'], verifyInner: psJwksVerifier(), }) if (verifyRes instanceof Response) { diff --git a/test/crypto.test.ts b/test/crypto.test.ts index c84ad2c..de17781 100644 --- a/test/crypto.test.ts +++ b/test/crypto.test.ts @@ -9,6 +9,7 @@ import { signJWT, generateJTI, decodeJWTPayload, + verifyJWT, } from '../src/crypto' // Make Web Crypto available as a global for the module under test. @@ -224,6 +225,44 @@ describe('signJWT + decodeJWTPayload', () => { }) }) +describe('verifyJWT alg handling', () => { + // §Signature Algorithms: a fully-specified identifier is REQUIRED, and + // implementations MUST NOT accept the polymorphic `EdDSA`. Both halves + // are pinned here — emit Ed25519, and refuse EdDSA on input. + it('accepts a token headed Ed25519', async () => { + const privateKey = await importSigningKey(signingKeyJson) + const publicJwk = await getPublicJWK(signingKeyJson) + const jwt = await signJWT( + { alg: 'Ed25519', typ: 'aa-person+jwt', kid: publicJwk.kid }, + { iss: 'https://ps.test', sub: 'abc' }, + privateKey, + ) + const { payload } = await verifyJWT(jwt, { keys: [publicJwk] }) + expect(payload.sub).toBe('abc') + }) + + it('rejects a token headed with the polymorphic EdDSA', async () => { + // Flag day with Wallet: svr/issuer/sign.js heads every aa-auth+jwt and + // aa-person+jwt with EdDSA today, so /api/demo rejects live PS tokens + // until that ships Ed25519. Spec-correct, deliberately not lenient. + const privateKey = await importSigningKey(signingKeyJson) + const publicJwk = await getPublicJWK(signingKeyJson) + const jwt = await signJWT( + { alg: 'EdDSA', typ: 'aa-auth+jwt', kid: publicJwk.kid }, + { iss: 'https://ps.test', sub: 'abc' }, + privateKey, + ) + await expect(verifyJWT(jwt, { keys: [publicJwk] })).rejects.toThrow(/unsupported alg/) + }) + + it('rejects an unsupported alg', async () => { + const publicJwk = await getPublicJWK(signingKeyJson) + // header={"alg":"none"}; payload={"a":1}; sig="" + await expect(verifyJWT('eyJhbGciOiJub25lIn0.eyJhIjoxfQ.', { keys: [publicJwk] })) + .rejects.toThrow(/unsupported alg/) + }) +}) + describe('agent_jkt computation (resource-token claim)', () => { it('thumbprint of cnf.jwk equals agent_jkt expected by the spec', async () => { // Simulate: agent token contains cnf.jwk for the ephemeral key. diff --git a/test/protocol.test.ts b/test/protocol.test.ts index 4253a4c..f1cc7c7 100644 --- a/test/protocol.test.ts +++ b/test/protocol.test.ts @@ -18,6 +18,51 @@ function extractFn(name: string): Function { return new Function(`${match[0]}\nreturn ${name};`)() } +// The deferred-response poll loop resolves its narration by template — +// copy(`${copyPrefix}.ps_pending_longpoll.label_template`) and friends — +// so a missing key renders as `undefined` in the log rather than throwing. +// Pin every prefix the loop can be driven with. +describe('deferred-poll narration keys', () => { + const LOG_TEXT = JSON.parse( + readFileSync(resolve(__dirname, '../public/log-text.json'), 'utf-8') + ) + const at = (path: string) => + path.split('.').reduce((o, k) => (o == null ? undefined : o[k]), LOG_TEXT) + + // 'person_token' drives the person-token leg, 'authorize' the whoami + // auth-token leg, 'notes' the notes auth-token leg. + for (const prefix of ['person_token', 'authorize', 'notes']) { + it(`resolves every key the poll loop reads for prefix "${prefix}"`, () => { + for (const key of [ + 'ps_pending_longpoll.label_template', + 'ps_pending_longpoll.label_resolved_template', + 'ps_pending_longpoll.description', + 'ps_consent_prompt.label', + 'authorization_denied.label', + 'authorization_timed_out.label', + ]) { + expect(at(`${prefix}.${key}`), `${prefix}.${key}`).toBeTypeOf('string') + } + }) + } + + it('has the person-token request, received, and resumed-consent copy', () => { + for (const key of [ + 'person_token.request.label_template', + 'person_token.request.label_resolved_template', + 'person_token.request.label_error_network_template', + 'person_token.request.description', + 'person_token.received.label', + 'person_token.received.description', + 'person_token.authorization_granted.label', + 'person_token_resumed.ps_consent_prompt.label', + 'person_token_resumed.ps_consent_prompt.description', + ]) { + expect(at(key), key).toBeTypeOf('string') + } + }) +}) + describe('parseInteractionHeader', () => { const parse = extractFn('parseInteractionHeader') as (h: string) => Record diff --git a/test/server.test.ts b/test/server.test.ts index 4c58914..e0ea0b1 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeAll, vi } from 'vitest' import { webcrypto } from 'node:crypto' import { fetch as sigFetch } from '@hellocoop/httpsig' -import { decodeJWTHeader, decodeJWTPayload } from '../src/crypto' +import { decodeJWTPayload } from '../src/crypto' beforeAll(() => { if (!(globalThis as any).crypto) { @@ -115,76 +115,176 @@ describe('GET /.well-known/jwks.json', () => { }) }) -// Mint an agent_token signed by the env's SIGNING_KEY without going -// through any deleted legacy path. Used by /authorize tests to craft a -// valid token whose cnf.jwk matches an ephemeral we control. -async function mintAgentTokenForTest(env: any, opts?: { sub?: string; exp?: number }): Promise<{ - agentToken: string - publicJwk: JsonWebKey - privateJwk: JsonWebKey -}> { - const { computeJwkThumbprint } = await import('../src/crypto') +// ── Token fixtures ── + +const enc = new TextEncoder() +const b64 = (bytes: Uint8Array) => { + let s = '' + for (const b of bytes) s += String.fromCharCode(b) + return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +async function signJwtWith( + header: Record, + payload: Record, + privateKey: CryptoKey, +): Promise { + const headerB64 = b64(enc.encode(JSON.stringify(header))) + const payloadB64 = b64(enc.encode(JSON.stringify(payload))) + const sig = await webcrypto.subtle.sign('Ed25519', privateKey, enc.encode(`${headerB64}.${payloadB64}`)) + return `${headerB64}.${payloadB64}.${b64(new Uint8Array(sig))}` +} + +// The agent's own signing key. Its public half goes in the cnf of every +// token issued to this agent; its private half signs the HTTP requests. +async function makeAgentKey(): Promise<{ publicJwk: JsonWebKey; privateJwk: JsonWebKey }> { const kp = await webcrypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']) as CryptoKeyPair const publicJwk = await webcrypto.subtle.exportKey('jwk', kp.publicKey) publicJwk.alg = 'Ed25519' const privateJwk = await webcrypto.subtle.exportKey('jwk', kp.privateKey) privateJwk.alg = 'Ed25519' + return { publicJwk, privateJwk } +} + +// A stand-in Person Server: its own Ed25519 key, a metadata document at +// /.well-known/aauth-person.json, a JWKS, and the ability to mint person +// tokens. `install()` stubs global fetch so the worker's key discovery +// (iss + dwk → metadata → jwks_uri → JWKS) resolves against it. +const PS_ORIGIN = 'https://ps.test' + +async function makePersonServer(overrides?: Record) { + const { computeJwkThumbprint } = await import('../src/crypto') + const kp = await webcrypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']) as CryptoKeyPair + const publicJwk = await webcrypto.subtle.exportKey('jwk', kp.publicKey) + const { key_ops: _ops, ext: _ext, ...pub } = publicJwk as any + const kid = await computeJwkThumbprint(pub) + const jwksKey = { ...pub, alg: 'Ed25519', key_ops: ['verify'], kid } + + const metadata: Record = { + issuer: PS_ORIGIN, + // -11 renamed `token_endpoint` to `auth_token_endpoint` and added + // `person_token_endpoint`. + auth_token_endpoint: `${PS_ORIGIN}/token`, + person_token_endpoint: `${PS_ORIGIN}/person`, + jwks_uri: `${PS_ORIGIN}/.well-known/jwks.json`, + ...overrides, + } + return { + metadata, + // Mint an aa-person+jwt for `aud`, bound to the agent's key. `typ` + // is overridable because a person token and a PS-issued auth token + // differ ONLY in typ — the tests that matter here mint the same + // payload under both and check the recipient tells them apart. + async mintPersonToken(opts: { + aud: string + agentPublicJwk: JsonWebKey + sub?: string + jti?: string + missionS256?: string + tenant?: string + typ?: string + dwk?: string + exp?: number + extra?: Record + }): Promise { + const now = Math.floor(Date.now() / 1000) + const payload: Record = { + iss: PS_ORIGIN, + dwk: opts.dwk ?? 'aauth-person.json', + aud: opts.aud, + sub: opts.sub ?? '8f14e45fceea167a5a36dedd4bea2543', + jti: opts.jti ?? 'pt-3ab910', + cnf: { + jwk: { + kty: opts.agentPublicJwk.kty, + crv: opts.agentPublicJwk.crv, + x: opts.agentPublicJwk.x, + alg: 'Ed25519', + }, + }, + iat: now, + exp: opts.exp ?? now + 3600, + ...opts.extra, + } + if (opts.missionS256) payload.mission_s256 = opts.missionS256 + if (opts.tenant) payload.tenant = opts.tenant + return signJwtWith( + { alg: 'Ed25519', typ: opts.typ ?? 'aa-person+jwt', kid }, + payload, + kp.privateKey, + ) + }, + // Serve the PS's metadata + JWKS to the worker's fetch calls. + install() { + vi.stubGlobal('fetch', vi.fn(async (url: string) => { + if (url === `${PS_ORIGIN}/.well-known/aauth-person.json`) { + return new Response(JSON.stringify(metadata), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + if (url === metadata.jwks_uri) { + return new Response(JSON.stringify({ keys: [jwksKey] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + return new Response('not found', { status: 404 }) + })) + }, + } +} + +// Mint an agent_token signed by the env's SIGNING_KEY. Under -11 this is +// NOT accepted at /authorize — the test that presents one asserts the +// requirement=person-token challenge. +async function mintAgentTokenForTest(env: any, agentPublicJwk: JsonWebKey): Promise { + const { computeJwkThumbprint } = await import('../src/crypto') const serverJwk = JSON.parse(env.SIGNING_KEY) const serverKey = await webcrypto.subtle.importKey('jwk', serverJwk, { name: 'Ed25519' }, false, ['sign']) const { d: _d, key_ops: _ops, ext: _ext, ...serverPub } = serverJwk const serverKid = await computeJwkThumbprint(serverPub) const now = Math.floor(Date.now() / 1000) - const header = { alg: 'Ed25519', typ: 'aa-agent+jwt', kid: serverKid } - const payload = { - iss: env.ORIGIN, - dwk: 'aauth-agent.json', - sub: opts?.sub ?? 'aauth:test@playground.test', - jti: `jti-${Math.random().toString(36).slice(2)}`, - // cnf.jwk must carry alg — httpsig 2.0 takes the verification - // algorithm from the JWK and rejects a key without one. - cnf: { jwk: { kty: publicJwk.kty, crv: publicJwk.crv, x: publicJwk.x, alg: 'Ed25519' } }, - iat: now, - exp: opts?.exp ?? now + 3600, - } - const enc = new TextEncoder() - const b64 = (bytes: Uint8Array) => { - let s = '' - for (const b of bytes) s += String.fromCharCode(b) - return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') - } - const headerB64 = b64(enc.encode(JSON.stringify(header))) - const payloadB64 = b64(enc.encode(JSON.stringify(payload))) - const sig = await webcrypto.subtle.sign('Ed25519', serverKey, enc.encode(`${headerB64}.${payloadB64}`)) - const agentToken = `${headerB64}.${payloadB64}.${b64(new Uint8Array(sig))}` - - return { agentToken, publicJwk, privateJwk } + return signJwtWith( + { alg: 'Ed25519', typ: 'aa-agent+jwt', kid: serverKid }, + { + iss: env.ORIGIN, + dwk: 'aauth-agent.json', + sub: 'aauth:playground@playground.test', + jti: `jti-${Math.random().toString(36).slice(2)}`, + // cnf.jwk must carry alg — httpsig 2.0 takes the verification + // algorithm from the JWK and rejects a key without one. + cnf: { jwk: { kty: agentPublicJwk.kty, crv: agentPublicJwk.crv, x: agentPublicJwk.x, alg: 'Ed25519' } }, + iat: now, + exp: now + 3600, + }, + serverKey, + ) } // ── Authorize ── +// +// Under AAuth -11 the agent presents a PERSON token in Signature-Key at +// the authorization endpoint. The request body carries only `scope` — the +// person server is the person token's `iss`, not a body parameter. describe('POST /authorize', () => { // The authority that app.request() constructs for the Request URL. Signing // must match so the server's httpsig verify sees the same value on the // @authority component. const TEST_URL = 'http://localhost/authorize' - - // Mint an agent_token + keep the full ephemeral keypair (private + public) - // so we can sign RFC 9421 requests against /authorize. Bootstrap path is - // now the only way, but for unit tests we cut out WebAuthn + PS and sign - // the agent_token directly with the server's own SIGNING_KEY. - const mintAgentTokenWithKey = async (_app: any, env: any, _kv: InMemoryKV) => - mintAgentTokenForTest(env, { sub: 'aauth:playground@playground.test' }) + const MISSION = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' // Produce the header map a signed POST /authorize request carries. - async function signedHeaders(bodyJSON: string, agentToken: string, privateJwk: JsonWebKey): Promise> { + async function signedHeaders(bodyJSON: string, jwt: string, privateJwk: JsonWebKey): Promise> { const dry = await sigFetch(TEST_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: bodyJSON, signingKey: privateJwk, - signatureKey: { type: 'jwt', jwt: agentToken }, + signatureKey: { type: 'jwt', jwt }, components: ['@method', '@authority', '@path', 'content-type', 'signature-key'], dryRun: true, }) as { headers: Headers } @@ -193,122 +293,169 @@ describe('POST /authorize', () => { return out } - it('rejects missing required fields', async () => { + // The common setup: an agent key, a PS serving its metadata + JWKS, and + // a person token for this resource bound to the agent's key. The mission + // rides along on every request so the copy-through is exercised + // throughout, not in one isolated case. + async function setup(opts?: { + aud?: string + missionS256?: string | null + tenant?: string + metadata?: Record + }) { const app = await loadApp() - const { env, kv } = await makeEnv() - const { agentToken, privateJwk } = await mintAgentTokenWithKey(app, env, kv) - const body = JSON.stringify({ ps: 'https://ps.test' }) - const headers = await signedHeaders(body, agentToken, privateJwk) - const res = await app.request('/authorize', { method: 'POST', headers, body }, env) + const { env } = await makeEnv() + const agent = await makeAgentKey() + const ps = await makePersonServer(opts?.metadata) + ps.install() + const personToken = await ps.mintPersonToken({ + aud: opts?.aud ?? env.ORIGIN, + agentPublicJwk: agent.publicJwk, + missionS256: opts?.missionS256 === null ? undefined : (opts?.missionS256 ?? MISSION), + tenant: opts?.tenant, + }) + return { app, env, agent, ps, personToken } + } + + async function post(app: any, env: any, bodyObj: unknown, jwt: string, privateJwk: JsonWebKey) { + const body = JSON.stringify(bodyObj) + const headers = await signedHeaders(body, jwt, privateJwk) + return app.request('/authorize', { method: 'POST', headers, body }, env) + } + + it('rejects a missing scope', async () => { + const { app, env, agent, personToken } = await setup() + const res = await post(app, env, {}, personToken, agent.privateJwk) expect(res.status).toBe(400) + vi.unstubAllGlobals() }) - it('rejects when Signature-Key header is missing', async () => { + it('challenges with requirement=person-token when Signature-Key is missing', async () => { const app = await loadApp() const { env } = await makeEnv() const res = await app.request('/authorize', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ps: 'https://ps.test', scope: 'playground.demo' }), + body: JSON.stringify({ scope: 'playground.demo' }), }, env) expect(res.status).toBe(401) - expect((await res.json() as any).error).toMatch(/signature verification failed/i) + expect(res.headers.get('AAuth-Requirement')).toBe('requirement=person-token') + }) + + it('challenges with requirement=person-token when an agent token is presented', async () => { + const app = await loadApp() + const { env } = await makeEnv() + const agent = await makeAgentKey() + const agentToken = await mintAgentTokenForTest(env, agent.publicJwk) + const res = await post(app, env, { scope: 'playground.demo' }, agentToken, agent.privateJwk) + expect(res.status).toBe(401) + expect(res.headers.get('AAuth-Requirement')).toBe('requirement=person-token') }) - it('rejects invalid agent_token', async () => { + it('rejects a malformed token in Signature-Key', async () => { const app = await loadApp() const { env } = await makeEnv() const res = await app.request('/authorize', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Signature-Key': 'sig=jwt;jwt="not.a.jwt"' }, - body: JSON.stringify({ ps: 'https://ps.test', scope: 'playground.demo' }), + body: JSON.stringify({ scope: 'playground.demo' }), }, env) expect(res.status).toBe(401) }) it('rejects when the httpsig is signed by a key other than the one in cnf.jwk', async () => { - const app = await loadApp() - const { env, kv } = await makeEnv() - const { agentToken } = await mintAgentTokenWithKey(app, env, kv) + const { app, env, personToken } = await setup() // Sign with a DIFFERENT private key so the cnf.jwk → public key doesn't // verify the signature. This is the key guarantee full httpsig gives us // over the old JWT-only check. - const attackerKp = await webcrypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']) as CryptoKeyPair - const attackerPriv = await webcrypto.subtle.exportKey('jwk', attackerKp.privateKey) - const body = JSON.stringify({ ps: 'https://ps.test', scope: 'playground.demo' }) - const headers = await signedHeaders(body, agentToken, attackerPriv) - const res = await app.request('/authorize', { method: 'POST', headers, body }, env) + const attacker = await makeAgentKey() + const res = await post(app, env, { scope: 'playground.demo' }, personToken, attacker.privateJwk) expect(res.status).toBe(401) expect((await res.json() as any).error).toMatch(/signature verification failed/i) + vi.unstubAllGlobals() + }) + + it('rejects a person token whose aud is a different resource', async () => { + const { app, env, agent, personToken } = await setup({ aud: 'https://other.test' }) + const res = await post(app, env, { scope: 'playground.demo' }, personToken, agent.privateJwk) + expect(res.status).toBe(400) + const resBody = await res.json() as any + expect(resBody.error).toBe('invalid_person_token') + expect(resBody.detail).toMatch(/aud/) + vi.unstubAllGlobals() }) - it('rejects non-HTTPS PS URL', async () => { + it('rejects a person token carrying the wrong dwk', async () => { const app = await loadApp() - const { env, kv } = await makeEnv() - const { agentToken, privateJwk } = await mintAgentTokenWithKey(app, env, kv) - const body = JSON.stringify({ ps: 'http://ps.test', scope: 'playground.demo' }) - const headers = await signedHeaders(body, agentToken, privateJwk) - const res = await app.request('/authorize', { method: 'POST', headers, body }, env) + const { env } = await makeEnv() + const agent = await makeAgentKey() + const ps = await makePersonServer() + ps.install() + const token = await ps.mintPersonToken({ + aud: env.ORIGIN, + agentPublicJwk: agent.publicJwk, + dwk: 'aauth-agent.json', + }) + const res = await post(app, env, { scope: 'playground.demo' }, token, agent.privateJwk) expect(res.status).toBe(400) - expect((await res.json() as any).error).toMatch(/HTTPS/) + expect((await res.json() as any).error).toBe('invalid_person_token') + vi.unstubAllGlobals() }) - it('returns 502 when PS metadata fetch fails', async () => { + it('rejects an expired person token', async () => { const app = await loadApp() - const { env, kv } = await makeEnv() - const { agentToken, privateJwk } = await mintAgentTokenWithKey(app, env, kv) - const body = JSON.stringify({ ps: 'https://ps.test', scope: 'playground.demo' }) - const headers = await signedHeaders(body, agentToken, privateJwk) - vi.stubGlobal('fetch', vi.fn(async () => new Response('nope', { status: 404 }))) - const res = await app.request('/authorize', { method: 'POST', headers, body }, env) - expect(res.status).toBe(502) + const { env } = await makeEnv() + const agent = await makeAgentKey() + const ps = await makePersonServer() + ps.install() + const token = await ps.mintPersonToken({ + aud: env.ORIGIN, + agentPublicJwk: agent.publicJwk, + exp: Math.floor(Date.now() / 1000) - 10, + }) + const res = await post(app, env, { scope: 'playground.demo' }, token, agent.privateJwk) + expect(res.status).toBe(400) + expect((await res.json() as any).detail).toMatch(/expired/) vi.unstubAllGlobals() }) - it('returns 502 when PS metadata is missing required fields', async () => { + it('rejects a person token when the PS metadata is unreachable', async () => { const app = await loadApp() - const { env, kv } = await makeEnv() - const { agentToken, privateJwk } = await mintAgentTokenWithKey(app, env, kv) - const body = JSON.stringify({ ps: 'https://ps.test', scope: 'playground.demo' }) - const headers = await signedHeaders(body, agentToken, privateJwk) - vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ issuer: 'https://ps.test' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }))) - const res = await app.request('/authorize', { method: 'POST', headers, body }, env) + const { env } = await makeEnv() + const agent = await makeAgentKey() + const ps = await makePersonServer() + const token = await ps.mintPersonToken({ aud: env.ORIGIN, agentPublicJwk: agent.publicJwk }) + vi.stubGlobal('fetch', vi.fn(async () => new Response('nope', { status: 404 }))) + const res = await post(app, env, { scope: 'playground.demo' }, token, agent.privateJwk) + expect(res.status).toBe(400) + expect((await res.json() as any).error).toBe('invalid_person_token') + vi.unstubAllGlobals() + }) + + it('returns 502 when PS metadata lacks auth_token_endpoint', async () => { + // -10 called this field `token_endpoint`; a PS still publishing the old + // name cannot tell the agent where to place its token request. + const { app, env, agent, personToken } = await setup({ + metadata: { auth_token_endpoint: undefined, token_endpoint: `${PS_ORIGIN}/token` }, + }) + const res = await post(app, env, { scope: 'playground.demo' }, personToken, agent.privateJwk) expect(res.status).toBe(502) - expect((await res.json() as any).error).toMatch(/missing required/) + expect((await res.json() as any).error).toMatch(/auth_token_endpoint/) vi.unstubAllGlobals() }) - it('issues an aa-resource+jwt with correct claims when PS metadata is valid', async () => { - const app = await loadApp() - const { env, kv } = await makeEnv() - const { agentToken, publicJwk, privateJwk } = await mintAgentTokenWithKey(app, env, kv) - - const psMetadata = { - issuer: 'https://ps.test', - token_endpoint: 'https://ps.test/token', - jwks_uri: 'https://ps.test/.well-known/jwks.json', - } - vi.stubGlobal('fetch', vi.fn(async (url: string) => { - expect(url).toBe('https://ps.test/.well-known/aauth-person.json') - return new Response(JSON.stringify(psMetadata), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - })) - - const body = JSON.stringify({ ps: 'https://ps.test', scope: 'playground.demo' }) - const headers = await signedHeaders(body, agentToken, privateJwk) - const res = await app.request('/authorize', { method: 'POST', headers, body }, env) + it('issues an aa-resource+jwt carrying ps, sub, person_token_jti and mission_s256', async () => { + const { app, env, agent, ps, personToken } = await setup() + const res = await post(app, env, { scope: 'playground.demo' }, personToken, agent.privateJwk) expect(res.status).toBe(200) const resBody = await res.json() as any - expect(resBody.ps_metadata).toEqual(psMetadata) + expect(resBody.ps_metadata).toEqual(ps.metadata) expect(resBody.ps_metadata_url).toBe('https://ps.test/.well-known/aauth-person.json') expect(resBody.resource_token).toBeDefined() + const { decodeJWTHeader, computeJwkThumbprint } = await import('../src/crypto') + // Fully-specified alg — never the polymorphic EdDSA. const header = decodeJWTHeader(resBody.resource_token) expect(header.alg).toBe('Ed25519') expect(header.typ).toBe('aa-resource+jwt') @@ -318,67 +465,181 @@ describe('POST /authorize', () => { expect(payload.dwk).toBe('aauth-resource.json') expect(payload.aud).toBe('https://ps.test') expect(payload.scope).toBe('playground.demo') - expect(payload.agent).toBe('aauth:playground@playground.test') - expect(payload.agent_jkt).toBeDefined() expect(payload.exp as number).toBe((payload.iat as number) + 300) - // agent_jkt must be the RFC 7638 thumbprint of the ephemeral JWK - const { computeJwkThumbprint } = await import('../src/crypto') - expect(payload.agent_jkt).toBe(await computeJwkThumbprint(publicJwk)) + // The -11 claims, copied from the verified person token. + expect(payload.ps).toBe('https://ps.test') + expect(payload.sub).toBe('8f14e45fceea167a5a36dedd4bea2543') + expect(payload.person_token_jti).toBe('pt-3ab910') + expect(payload.mission_s256).toBe(MISSION) + // -11 removed the agent identifier from the resource token. + expect(payload.agent).toBeUndefined() + // agent_jkt stays: the RFC 7638 thumbprint of the agent's signing key. + expect(payload.agent_jkt).toBe(await computeJwkThumbprint(agent.publicJwk)) vi.unstubAllGlobals() }) - it('rejects unknown scopes with 400 invalid_scope', async () => { + it('clamps the resource token exp to the person token exp', async () => { + // The person token is itself clamped to the mission's expires_at, so + // never outliving it keeps a mission-scoped resource token inside the + // mission's window without the resource knowing the mission. const app = await loadApp() - const { env, kv } = await makeEnv() - const { agentToken, privateJwk } = await mintAgentTokenWithKey(app, env, kv) + const { env } = await makeEnv() + const agent = await makeAgentKey() + const ps = await makePersonServer() + ps.install() + const shortExp = Math.floor(Date.now() / 1000) + 60 + const token = await ps.mintPersonToken({ + aud: env.ORIGIN, + agentPublicJwk: agent.publicJwk, + missionS256: MISSION, + exp: shortExp, + }) + const res = await post(app, env, { scope: 'playground.demo' }, token, agent.privateJwk) + expect(res.status).toBe(200) + const payload = decodeJWTPayload((await res.json() as any).resource_token) + expect(payload.exp).toBe(shortExp) + vi.unstubAllGlobals() + }) + + it('copies tenant from the person token', async () => { + // §Resource Token Verification step 6: the PS checks ps, sub, + // mission_s256 AND tenant against the person token it issued, and + // rejects on any mismatch or omission. Dropping tenant here makes the + // resource token unredeemable for any org-affiliated person. + const { app, env, agent, personToken } = await setup({ tenant: 'acme-corp' }) + const res = await post(app, env, { scope: 'playground.demo' }, personToken, agent.privateJwk) + expect(res.status).toBe(200) + const payload = decodeJWTPayload((await res.json() as any).resource_token) + expect(payload.tenant).toBe('acme-corp') + vi.unstubAllGlobals() + }) + + it('omits tenant when the person token carried none', async () => { + const { app, env, agent, personToken } = await setup() + const res = await post(app, env, { scope: 'playground.demo' }, personToken, agent.privateJwk) + expect(res.status).toBe(200) + const payload = decodeJWTPayload((await res.json() as any).resource_token) + expect(payload.tenant).toBeUndefined() + vi.unstubAllGlobals() + }) + + it('omits mission_s256 when the person token carried none', async () => { + const { app, env, agent, personToken } = await setup({ missionS256: null }) + const res = await post(app, env, { scope: 'playground.demo' }, personToken, agent.privateJwk) + expect(res.status).toBe(200) + const resBody = await res.json() as any + expect(decodeJWTPayload(resBody.resource_token).mission_s256).toBeUndefined() + vi.unstubAllGlobals() + }) + + it('rejects unknown scopes with 400 invalid_scope', async () => { + const { app, env, agent, personToken } = await setup() // Typo'd resource scope — neither in SCOPE_DESCRIPTIONS nor in // PS_IDENTITY_SCOPES — is the only thing that should still 400. - const body = JSON.stringify({ - ps: 'https://ps.test', - scope: 'playground.demo playground.typo', - }) - const headers = await signedHeaders(body, agentToken, privateJwk) - const res = await app.request('/authorize', { method: 'POST', headers, body }, env) + const res = await post( + app, env, { scope: 'playground.demo playground.typo' }, personToken, agent.privateJwk, + ) expect(res.status).toBe(400) const resBody = await res.json() as any expect(resBody.error).toBe('invalid_scope') expect(resBody.unknown).toEqual(['playground.typo']) + vi.unstubAllGlobals() }) it('passes PS identity scopes through to resource_token.scope', async () => { // Per aauth-claims-plan v3 §4.2 the resource server MUST pass // identity scopes through unmodified — the PS classifies them at - // /aauth/token time. - const app = await loadApp() - const { env, kv } = await makeEnv() - const { agentToken, privateJwk } = await mintAgentTokenWithKey(app, env, kv) - - const psMetadata = { - issuer: 'https://ps.test', - token_endpoint: 'https://ps.test/token', - jwks_uri: 'https://ps.test/.well-known/jwks.json', - } - vi.stubGlobal('fetch', vi.fn(async () => { - return new Response(JSON.stringify(psMetadata), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - })) - - const body = JSON.stringify({ - ps: 'https://ps.test', - scope: 'openid profile playground.demo', - }) - const headers = await signedHeaders(body, agentToken, privateJwk) - const res = await app.request('/authorize', { method: 'POST', headers, body }, env) + // auth-token time. + const { app, env, agent, personToken } = await setup() + const res = await post( + app, env, { scope: 'openid profile playground.demo' }, personToken, agent.privateJwk, + ) expect(res.status).toBe(200) const resBody = await res.json() as any // scope on the resource_token carries the agent's request verbatim. - expect(resBody.resource_token_decoded.scope).toBe( - 'openid profile playground.demo', - ) + expect(resBody.resource_token_decoded.scope).toBe('openid profile playground.demo') vi.unstubAllGlobals() }) }) + +// ── Demo resource API ── +// +// The one endpoint gated on an auth token. A person token from the same +// PS carries the same iss, dwk, aud, sub and cnf as the auth token — only +// `typ` separates them — so this suite exists mainly to pin that the +// difference is actually enforced. + +describe('GET /api/demo', () => { + const DEMO_URL = 'http://localhost/api/demo' + + async function signedGetHeaders(jwt: string, privateJwk: JsonWebKey): Promise> { + const dry = await sigFetch(DEMO_URL, { + method: 'GET', + signingKey: privateJwk, + signatureKey: { type: 'jwt', jwt }, + components: ['@method', '@authority', '@path', 'signature-key'], + dryRun: true, + }) as { headers: Headers } + const out: Record = {} + dry.headers.forEach((v, k) => { out[k] = v }) + return out + } + + // Same PS, same key, same claims — the only difference between the two + // tokens below is the `typ` in the header. + async function setupDemo(typ: string) { + const app = await loadApp() + const { env } = await makeEnv() + const agent = await makeAgentKey() + const ps = await makePersonServer() + ps.install() + const token = await ps.mintPersonToken({ + aud: env.ORIGIN, + agentPublicJwk: agent.publicJwk, + typ, + extra: { scope: 'playground.demo', name: 'Ada' }, + }) + return { app, env, agent, token } + } + + async function get(app: any, env: any, token: string, privateJwk: JsonWebKey) { + const headers = await signedGetHeaders(token, privateJwk) + return app.request('/api/demo', { method: 'GET', headers }, env) + } + + it('serves the demo for a valid auth token', async () => { + const { app, env, agent, token } = await setupDemo('aa-auth+jwt') + const res = await get(app, env, token, agent.privateJwk) + expect(res.status).toBe(200) + const body = await res.json() as any + expect(body.hello).toBe('Ada') + expect(body.granted_scopes).toEqual(['playground.demo']) + vi.unstubAllGlobals() + }) + + it('rejects a person token where an auth token is required', async () => { + // §Person Token Verification: "A recipient MUST reject an + // aa-person+jwt wherever an auth token is required." Identical + // payload to the passing case above, aa-person+jwt in the header. + const { app, env, agent, token } = await setupDemo('aa-person+jwt') + const res = await get(app, env, token, agent.privateJwk) + expect(res.status).toBe(401) + const body = await res.json() as any + expect(body.error).toBe('invalid_token_type') + expect(body.typ).toBe('aa-person+jwt') + expect(body.accepted).toEqual(['aa-auth+jwt']) + vi.unstubAllGlobals() + }) + + it('rejects an agent token where an auth token is required', async () => { + const app = await loadApp() + const { env } = await makeEnv() + const agent = await makeAgentKey() + const agentToken = await mintAgentTokenForTest(env, agent.publicJwk) + const res = await get(app, env, agentToken, agent.privateJwk) + expect(res.status).toBe(401) + expect((await res.json() as any).error).toBe('invalid_token_type') + }) +})