From 1f97ef5af42c500ff6c73b938ebd002fb2d3de41 Mon Sep 17 00:00:00 2001 From: dickhardt Date: Tue, 11 Aug 2026 22:42:49 +0100 Subject: [PATCH 1/5] AAuth -11: person token endpoint, PS metadata rename, jti binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mockin is the mock Person Server the fleet tests against, so it has to speak -11 before anything agent-side can be exercised end to end. PS metadata (§Person Server Metadata) token_endpoint → auth_token_endpoint, and person_token_endpoint is published — REQUIRED of every PS under -11, and what @aauth/bootstrap 2.0.0 hard-fails without. POST /aauth/person (§Person Token Endpoint) Signed POST, agent token via Signature-Key: sig=jwt. Body takes resource (REQUIRED), mission_s256, subagent_token, and the same consent-flow parameters the auth token endpoint takes — justification, login_hint, tenant, domain_hint, prompt, platform, device, capabilities — parsed once in request-parameters.js and shared by both endpoints. tenant is what selects a person's context (AAuth #88); capabilities is how an agent says it can drive an interaction (#89), and an agent that declares capabilities without it gets a terminal user_unreachable instead of a 202 it cannot complete. upstream_token is rejected: call chaining is deferred fleet-wide. Issues an aa-person+jwt with iss, dwk, aud, a directed sub, cnf.jwk, jti, iat and exp — capped at 1 hour and never beyond the presented agent token. Optional mission_s256 and tenant. Never scope or account. subject.js One derivation of the directed sub, used by the person token, the auth token and the bootstrap token. Two values here would make every resource token fail the PS's own step-6 comparison. person-token-store.js (§Resource Token Verification step 6, AAuth #87) Issued person tokens are kept by jti and expire with the token. The auth token endpoint resolves person_token_jti and rejects the resource token unless ps, sub, mission_s256 and tenant match exactly — absent vs present included, which is the mission-stripping case the binding exists to catch. /aauth/token Resource tokens carry ps, sub, person_token_jti and agent_jkt; the agent claim is gone. Issued auth tokens carry ps and a REQUIRED sub copied from the resource token, and no agent or act claim. Sub-agent authorization compares agent_jkt against the subagent_token's cnf.jwk and binds the auth token to the sub-agent's key (interop surface 5). Both consent paths mock.person_requirement defers the person token with 202 + Location + Retry-After + AAuth-Requirement. mock.auto_approve = false makes the interaction real: poll → 202, GET /aauth/consent?code=… , poll → 200. Ed25519 only Every JWT header, JWKS key and cnf.jwk emits Ed25519; verifiers accept Ed25519 and reject the polymorphic EdDSA. Mockin never branched on token type the way Wallet's svr/issuer/sign.js does — its OIDC RS256 path is a separate module. R3 -02 r3_conditional → r3_per_call. Documents are validated: no version field, the openapi-gateway vocabulary is gone, and a per-call proposal is a full R3 document with a REQUIRED parameters object. Body signing A body-carrying request to a PS endpoint must cover content-digest and content-type. Enforced on /aauth/person, /aauth/token, /aauth/permission, /aauth/audit, /aauth/interaction, /aauth/bootstrap and POST /aauth/pending/:id; mock.require_body_signing = false relaxes it for clients that have not cut over. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FmiCqDjRUSx6zb1N4gZPXE --- README.md | 13 +- src/aauth/algorithms.js | 28 ++ src/aauth/bootstrap.js | 35 ++- src/aauth/index.js | 1 + src/aauth/issue-auth-token.js | 47 ++- src/aauth/issue-person-token.js | 88 ++++++ src/aauth/metadata.js | 13 +- src/aauth/mock.js | 19 +- src/aauth/pending.js | 73 +++-- src/aauth/person-token-store.js | 53 ++++ src/aauth/person.js | 214 +++++++++++++ src/aauth/r3.js | 77 ++++- src/aauth/request-parameters.js | 155 ++++++++++ src/aauth/subject.js | 25 ++ src/aauth/token.js | 122 ++++++-- src/aauth/verify-agent-token.js | 61 ++++ src/aauth/verify-request.js | 154 ++++++---- src/aauth/verify-resource-token.js | 94 +++++- src/aauth/verify-sig.js | 30 -- src/api.js | 8 +- test/aauth/bootstrap.spec.js | 2 + test/aauth/end-to-end.spec.js | 3 +- test/aauth/helpers.js | 135 ++++++++- test/aauth/metadata.spec.js | 16 +- test/aauth/pending.spec.js | 7 +- test/aauth/person.spec.js | 472 +++++++++++++++++++++++++++++ test/aauth/r3.fetch.spec.js | 2 +- test/aauth/token.errors.spec.js | 243 ++++++++++----- test/aauth/token.identity.spec.js | 123 ++++---- test/aauth/token.r3.spec.js | 139 +++++---- 30 files changed, 2027 insertions(+), 425 deletions(-) create mode 100644 src/aauth/algorithms.js create mode 100644 src/aauth/issue-person-token.js create mode 100644 src/aauth/person-token-store.js create mode 100644 src/aauth/person.js create mode 100644 src/aauth/request-parameters.js create mode 100644 src/aauth/subject.js create mode 100644 src/aauth/verify-agent-token.js delete mode 100644 src/aauth/verify-sig.js create mode 100644 test/aauth/person.spec.js diff --git a/README.md b/README.md index 746c61e..2aedaff 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,18 @@ The mock API can change the returned claims, simulate errors, and invalid ID Tok ## AAuth -Mockin also acts as a mock **Person Server** for [draft-hardt-aauth-protocol](https://datatracker.ietf.org/doc/draft-hardt-aauth-protocol/) — useful for testing agent clients without spinning up a real PS. Endpoints include `/aauth/bootstrap`, `/aauth/token`, `/aauth/permission`, `/aauth/audit`, `/aauth/interaction`, plus R3 (Rich Resource Requests) support. Auto-approves all consent steps in default mode. See the [docs](https://www.hello.dev/docs/mockin#aauth-agent-auth) for details. +Mockin also acts as a mock **Person Server** for [draft-hardt-oauth-aauth-protocol](https://datatracker.ietf.org/doc/draft-hardt-oauth-aauth-protocol/) — useful for testing agent clients without spinning up a real PS. Endpoints include `/aauth/bootstrap`, `/aauth/person` (`person_token_endpoint`), `/aauth/token` (`auth_token_endpoint`), `/aauth/permission`, `/aauth/audit`, `/aauth/interaction`, plus R3 (Rich Resource Requests) support. Auto-approves all consent steps in default mode. See the [docs](https://www.hello.dev/docs/mockin#aauth-agent-auth) for details. + +The mock API at `PUT /mock/aauth` switches the simulated behaviours: + +| Key | Effect | +|-----|--------| +| `requirement` | `interaction` \| `approval` \| `clarification` — defers `/aauth/token` with a `202` | +| `person_requirement` | `interaction` \| `approval` — defers `/aauth/person` with a `202` | +| `auto_approve` | `false` makes a deferred `interaction` wait for `GET /aauth/consent?code=…` instead of resolving on the first poll | +| `error` / `error_endpoint` | inject a token endpoint error code, optionally scoped to `token`, `person`, `bootstrap` or `permission` | +| `token_lifetime`, `claims`, `r3_grants`, `tenant` | shape the issued tokens (`r3_grants` takes `{ granted, per_call }`) | +| `require_body_signing` | `false` accepts a body signature that does not cover `content-digest` and `content-type` | ## Invite diff --git a/src/aauth/algorithms.js b/src/aauth/algorithms.js new file mode 100644 index 0000000..a3da6b7 --- /dev/null +++ b/src/aauth/algorithms.js @@ -0,0 +1,28 @@ +// aauth/algorithms.js — the accepted-algorithm table. +// +// Protocol -10/-11 §Signature Algorithms: `alg` is REQUIRED, MUST be fully +// specified, and implementations MUST NOT accept `none`, the polymorphic +// `EdDSA` identifier, or any symmetric algorithm. There is no transition +// allowance, so mockin emits and accepts `Ed25519` only — an `EdDSA` JWT +// is rejected rather than tolerated. +// +// `@hellocoop/httpsig` 2.0 already excludes `EdDSA` from its own +// SignatureAlgorithm table, so this list covers the JWTs mockin verifies +// itself: agent tokens, sub-agent tokens, and resource tokens. + +export const SIGNING_ALG = 'Ed25519' + +export const ACCEPTED_JWT_ALGS = [SIGNING_ALG] + +/** + * jose accepts `EdDSA` and would happily verify it. Reject it (and any + * other unlisted alg) explicitly so the failure names the algorithm. + * @returns {string|null} an error message, or null when acceptable. + */ +export function checkJwtAlg(alg) { + if (!alg) return 'JWT header missing alg' + if (!ACCEPTED_JWT_ALGS.includes(alg)) { + return `unacceptable alg "${alg}": ${ACCEPTED_JWT_ALGS.join(', ')} required (a fully-specified identifier; the polymorphic "EdDSA" MUST NOT be accepted)` + } + return null +} diff --git a/src/aauth/bootstrap.js b/src/aauth/bootstrap.js index a3ab916..689b254 100644 --- a/src/aauth/bootstrap.js +++ b/src/aauth/bootstrap.js @@ -15,7 +15,7 @@ // don't want to drive the full redirect can flip mock.auto_approve = true // to short-circuit and pre-mark the pending entry as approved on creation. -import { randomUUID, createHash } from 'crypto' +import { randomUUID } from 'crypto' import { SignJWT } from 'jose' import { verify as httpSigVerify, @@ -26,13 +26,21 @@ import { import { ISSUER } from '../config.js' import { privateKey, kid } from './keys.js' +import { SIGNING_ALG } from './algorithms.js' +import { directedSub } from './subject.js' import { getConfig, mockErrorFor } from './mock.js' -import defaultUser from '../users.js' +import { checkBodySigning } from './verify-request.js' import { createPending, updatePending } from './state.js' +// -11: a body-carrying request to a PS endpoint covers content-digest and +// content-type on top of the base profile. const ACCEPT_SIG = generateAcceptSignatureHeader({ label: 'sig', - components: ['@method', '@authority', '@path', 'content-type', 'signature-key'], + components: [ + '@method', '@authority', '@path', + 'content-type', 'content-digest', + 'signature-key', + ], }) // Bootstrap accepts hwk (initial) or jwt (completion announcement). @@ -40,26 +48,20 @@ const ACCEPT_SIG_SCHEME = generateAcceptSignatureSchemeHeader(['hwk', 'jwt']) const BOOTSTRAP_TOKEN_TTL = 300 // 5 minutes -// Pairwise sub directed at agent_server: hash(user.sub || agent_server). -function directedSub(userSub, agentServer) { - return createHash('sha256') - .update(`${userSub}|${agentServer}`) - .digest('base64url') -} - export async function issueBootstrapToken({ agent_server, ephemeral_jwk }) { const iat = Math.floor(Date.now() / 1000) const payload = { iss: ISSUER, dwk: 'aauth-person.json', aud: agent_server, - sub: directedSub(defaultUser.sub, agent_server), + // Same derivation as person and auth tokens — see subject.js. + sub: directedSub(agent_server), cnf: { jwk: ephemeral_jwk }, iat, exp: iat + BOOTSTRAP_TOKEN_TTL, } const bootstrap_token = await new SignJWT(payload) - .setProtectedHeader({ alg: 'Ed25519', typ: 'aa-bootstrap+jwt', kid }) + .setProtectedHeader({ alg: SIGNING_ALG, typ: 'aa-bootstrap+jwt', kid }) .setJti(randomUUID()) .sign(privateKey) return { bootstrap_token, expires_in: BOOTSTRAP_TOKEN_TTL } @@ -104,6 +106,15 @@ export const bootstrap = async (req, reply) => { }) } + // -11: the body signature must cover content-digest and content-type. + const bodyFailure = checkBodySigning(req, sigResult) + if (bodyFailure) { + for (const [k, v] of Object.entries(bodyFailure.headers || {})) { + reply.header(k, v) + } + return reply.code(bodyFailure.status).send(bodyFailure.body) + } + // Two valid request flavours per the wallet PS pattern: // 1. sig=hwk — initial bootstrap (no agent_token yet) // 2. sig=jwt + aa-agent+jwt — Bootstrap Completion Announcement, diff --git a/src/aauth/index.js b/src/aauth/index.js index 2bf27ee..21b1945 100644 --- a/src/aauth/index.js +++ b/src/aauth/index.js @@ -3,6 +3,7 @@ export { metadata } from './metadata.js' export { jwks } from './jwks.js' export { token } from './token.js' +export { person } from './person.js' export { pendingGet, pendingPost, pendingDelete } from './pending.js' export { permission } from './permission.js' export { audit } from './audit.js' diff --git a/src/aauth/issue-auth-token.js b/src/aauth/issue-auth-token.js index 9e65a0c..f5d8504 100644 --- a/src/aauth/issue-auth-token.js +++ b/src/aauth/issue-auth-token.js @@ -11,9 +11,14 @@ import { SignJWT } from 'jose' import { ISSUER } from '../config.js' import { privateKey, kid } from './keys.js' +import { SIGNING_ALG } from './algorithms.js' +import { directedSub } from './subject.js' import { getConfig } from './mock.js' import defaultUser from '../users.js' +// "Auth tokens MUST NOT have a lifetime exceeding 1 hour." +const MAX_AUTH_TOKEN_TTL = 3600 + const IDENTITY_SCOPES = new Set([ 'openid', 'profile', 'name', 'nickname', 'given_name', 'family_name', 'preferred_username', 'picture', 'email', 'phone', 'phone_number', @@ -75,22 +80,35 @@ function releaseFor(identityScopes) { } /** + * -11 §Auth Token Structure. REQUIRED: iss, dwk, aud, jti, ps, sub, cnf, + * iat, exp. No `agent` claim, no `act`, no delegation chain — the resource + * enforces against `sub` and `scope`, and `cnf` binds the key. + * * @param {object} args - * @param {string} args.agent_id - * @param {object} args.agent_public_key ephemeral JWK for cnf - * @param {string} args.resource_url resource_token.iss - * @param {string} args.scope raw scope string from resource_token - * @param {object} [args.r3] { uri, s256, granted, conditional } + * @param {object} args.agent_public_key ephemeral JWK for cnf + * @param {string} args.resource_url resource_token.iss — becomes `aud` + * @param {string} args.scope raw scope string from resource_token + * @param {string} [args.sub] copied from the resource token, which + * the PS verified against the person + * token it issued. Falls back to the + * same derivation the person token used. + * @param {string} [args.mission_s256] copied from the resource token + * @param {string} [args.tenant] copied from the resource token + * @param {string} [args.account] copied from the resource token + * @param {object} [args.r3] { uri, s256, granted, per_call } */ export async function issueAuthToken({ - agent_id, agent_public_key, resource_url, scope, + sub, + mission_s256, + tenant, + account, r3, }) { const cfg = getConfig() - const lifetime = cfg.token_lifetime || 3600 + const lifetime = Math.min(cfg.token_lifetime || MAX_AUTH_TOKEN_TTL, MAX_AUTH_TOKEN_TTL) const { identity, resource } = classifyScopes(scope) const release = releaseFor(identity) @@ -100,10 +118,10 @@ export async function issueAuthToken({ const tokenPayload = { iss: ISSUER, dwk: 'aauth-person.json', - sub: defaultUser.sub, + ps: ISSUER, + // Same value the person token carried for this aud — see subject.js. + sub: sub || directedSub(resource_url), aud: resource_url, - agent: agent_id, - act: { sub: agent_id }, scope: resource.join(' '), cnf: agent_public_key ? { jwk: agent_public_key } : undefined, ...release, @@ -112,15 +130,20 @@ export async function issueAuthToken({ exp: iat + lifetime, } + if (mission_s256) tokenPayload.mission_s256 = mission_s256 + if (tenant) tokenPayload.tenant = tenant + if (account) tokenPayload.account = account + if (r3) { if (r3.uri) tokenPayload.r3_uri = r3.uri if (r3.s256) tokenPayload.r3_s256 = r3.s256 if (r3.granted) tokenPayload.r3_granted = r3.granted - if (r3.conditional) tokenPayload.r3_conditional = r3.conditional + // R3 -02 renamed r3_conditional → r3_per_call. + if (r3.per_call) tokenPayload.r3_per_call = r3.per_call } const auth_token = await new SignJWT(tokenPayload) - .setProtectedHeader({ alg: 'Ed25519', typ: 'aa-auth+jwt', kid }) + .setProtectedHeader({ alg: SIGNING_ALG, typ: 'aa-auth+jwt', kid }) .setJti(randomUUID()) .sign(privateKey) diff --git a/src/aauth/issue-person-token.js b/src/aauth/issue-person-token.js new file mode 100644 index 0000000..5a26ee7 --- /dev/null +++ b/src/aauth/issue-person-token.js @@ -0,0 +1,88 @@ +// aauth/issue-person-token.js — sign an aa-person+jwt for the agent. +// +// Protocol -11 §Person Token Structure. A person token identifies the +// person to ONE resource and carries no authorization: no `scope`, no +// `account`, no permission. Whether identity alone is enough to serve a +// request is the resource's decision. +// +// `sub` comes from subject.js, the same derivation issue-auth-token.js +// uses, so the value in this token is byte-equal to the one the auth +// token will carry for the same `aud`. That equality is what the PS's own +// step-6 check (§Resource Token Verification) compares against. + +import { randomUUID } from 'crypto' +import { SignJWT } from 'jose' + +import { ISSUER } from '../config.js' +import { privateKey, kid } from './keys.js' +import { SIGNING_ALG } from './algorithms.js' +import { directedSub } from './subject.js' +import { recordPersonToken } from './person-token-store.js' + +// "Person tokens MUST NOT have a lifetime exceeding 1 hour." +export const MAX_PERSON_TOKEN_TTL = 3600 + +/** + * @param {object} args + * @param {object} args.agent_public_key cnf.jwk — the key that will sign + * requests bearing this token (the + * sub-agent's key when a + * subagent_token was presented) + * @param {string} args.resource becomes `aud` + * @param {number} [args.lifetime] requested seconds, clamped to 1 hour + * @param {number} [args.agent_token_exp] the presented agent token's `exp`; + * the person token never outlives it + * @param {string} [args.mission_s256] + * @param {string} [args.tenant] + * @param {number} [args.mission_expires_at] mission clamp, when known + */ +export async function issuePersonToken({ + agent_public_key, + resource, + lifetime = MAX_PERSON_TOKEN_TTL, + agent_token_exp, + mission_s256, + tenant, + mission_expires_at, +}) { + const iat = Math.floor(Date.now() / 1000) + + // exp ≤ 1 hour, ≤ the agent token's exp, ≤ the mission's expires_at. + let exp = iat + Math.min(lifetime, MAX_PERSON_TOKEN_TTL) + if (Number.isFinite(agent_token_exp)) exp = Math.min(exp, agent_token_exp) + if (Number.isFinite(mission_expires_at)) exp = Math.min(exp, mission_expires_at) + + const jti = randomUUID() + + // REQUIRED claims only, plus the two OPTIONAL ones. No `scope`, no + // `account` — a person token conveys identity, never authorization. + const payload = { + iss: ISSUER, + dwk: 'aauth-person.json', + aud: resource, + sub: directedSub(resource), + cnf: { jwk: agent_public_key }, + jti, + iat, + exp, + } + if (mission_s256) payload.mission_s256 = mission_s256 + if (tenant) payload.tenant = tenant + + const person_token = await new SignJWT(payload) + .setProtectedHeader({ alg: SIGNING_ALG, typ: 'aa-person+jwt', kid }) + .sign(privateKey) + + // §Resource Token Verification step 6 needs this later. + recordPersonToken({ + jti, + ps: ISSUER, + sub: payload.sub, + aud: resource, + mission_s256: mission_s256 || undefined, + tenant: tenant || undefined, + exp, + }) + + return { person_token, expires_in: exp - iat } +} diff --git a/src/aauth/metadata.js b/src/aauth/metadata.js index 1dd0f2b..3387db2 100644 --- a/src/aauth/metadata.js +++ b/src/aauth/metadata.js @@ -1,7 +1,11 @@ // aauth/metadata.js — GET /.well-known/aauth-person.json // -// Person Server metadata. Per draft-hardt-aauth-protocol §Metadata Documents -// the PS publishes its endpoints here so agents can discover them. +// Person Server metadata. Per draft-hardt-aauth-protocol §Person Server +// Metadata the PS publishes its endpoints here so agents can discover them. +// +// -11 renamed `token_endpoint` to `auth_token_endpoint` and made +// `person_token_endpoint` REQUIRED of every PS. Both are published here; +// @aauth/bootstrap 2.0.0 hard-fails against a PS missing either. import { ISSUER } from '../config.js' @@ -10,8 +14,11 @@ export const metadata = async (req, res) => { res.header('Cache-Control', 'public, max-age=3600') return res.send({ issuer: ISSUER, + name: 'Mockin Person Server', + description: '**Mockin** — a mock Person Server for AAuth testing.', jwks_uri: `${ISSUER}/aauth/jwks.json`, - token_endpoint: `${ISSUER}/aauth/token`, + auth_token_endpoint: `${ISSUER}/aauth/token`, + person_token_endpoint: `${ISSUER}/aauth/person`, permission_endpoint: `${ISSUER}/aauth/permission`, audit_endpoint: `${ISSUER}/aauth/audit`, interaction_endpoint: `${ISSUER}/aauth/interaction`, diff --git a/src/aauth/mock.js b/src/aauth/mock.js index 930a656..49ffe11 100644 --- a/src/aauth/mock.js +++ b/src/aauth/mock.js @@ -11,19 +11,28 @@ import { clearPendingRequests } from './state.js' import { resetEntityCache } from './entity-cache.js' +import { clearPersonTokens } from './person-token-store.js' const DEFAULTS = () => ({ - // Token endpoint behaviour + // Auth token endpoint behaviour auto_approve: true, // 200 + auth_token directly requirement: null, // null | 'interaction' | 'approval' | 'clarification' clarification: null, // markdown question to return + // Person token endpoint behaviour — its own switch, so a test can + // defer the person token while the auth token stays immediate. + person_requirement: null, // null | 'interaction' | 'approval' // Error injection error: null, // applies to /aauth/token unless scoped error_endpoint: null, // restrict error to a specific endpoint // Token shape overrides token_lifetime: 3600, claims: null, // identity claims to merge into auth_token - r3_grants: null, // { granted, conditional } override + r3_grants: null, // { granted, per_call } override + tenant: null, // stamped on issued person tokens + // Signing policy + // -11 requires content-digest + content-type coverage on bodies sent + // to a PS endpoint. Set false for clients that have not cut over. + require_body_signing: true, // Auxiliary endpoints permission: 'granted', // 'granted' | 'denied' permission_reason: null, @@ -46,6 +55,7 @@ export function resetConfig() { CONFIG = DEFAULTS() clearPendingRequests() resetEntityCache() + clearPersonTokens() } export const get = async (req, res) => { @@ -56,9 +66,10 @@ export const put = async (req, res) => { const body = req.body || {} const next = { ...CONFIG } const passthrough = [ - 'auto_approve', 'requirement', 'clarification', + 'auto_approve', 'requirement', 'clarification', 'person_requirement', 'error', 'error_endpoint', - 'token_lifetime', 'claims', 'r3_grants', + 'token_lifetime', 'claims', 'r3_grants', 'tenant', + 'require_body_signing', 'permission', 'permission_reason', ] for (const k of passthrough) { diff --git a/src/aauth/pending.js b/src/aauth/pending.js index d8d1872..21e381a 100644 --- a/src/aauth/pending.js +++ b/src/aauth/pending.js @@ -7,7 +7,6 @@ // Verification therefore runs inside the handler — after we look up the // entry — rather than in a generic preHandler. -import * as jose from 'jose' import { calculateJwkThumbprint } from 'jose' import { verify as httpSigVerify, @@ -19,8 +18,10 @@ import { import { ISSUER } from '../config.js' import { getPending, updatePending, deletePending } from './state.js' import { issueAuthToken } from './issue-auth-token.js' +import { issuePersonToken } from './issue-person-token.js' import { issueBootstrapToken } from './bootstrap.js' -import { getEntity, AGENT_DWK } from './entity-cache.js' +import { verifyAgentToken } from './verify-agent-token.js' +import { checkBodySigning } from './verify-request.js' const ACCEPT_SIG_GET = generateAcceptSignatureHeader({ label: 'sig', @@ -74,6 +75,19 @@ async function verifyForEntry(request, reply, entry) { }) } + // -11: a body-carrying request to a PS endpoint covers content-digest + // and content-type (POST /aauth/pending/:id carries a clarification + // response or an updated resource token). + if (request.method === 'POST') { + const bodyFailure = checkBodySigning(request, sigResult) + if (bodyFailure) { + for (const [k, v] of Object.entries(bodyFailure.headers || {})) { + reply.header(k, v) + } + return reply.code(bodyFailure.status).send(bodyFailure.body) + } + } + if (entry.kind === 'bootstrap') { if (sigResult.keyType !== 'hwk') { return reply.code(401).send({ @@ -99,33 +113,11 @@ async function verifyForEntry(request, reply, entry) { }) } const { header, payload, raw } = sigResult.jwt - if (header.typ !== 'aa-agent+jwt') { - return reply.code(401).send({ - error: 'invalid_jwt', - error_description: `expected aa-agent+jwt, got ${header.typ}`, - }) - } - if (!payload.iss) { - return reply.code(401).send({ - error: 'invalid_jwt', - error_description: 'agent_token missing iss', - }) - } - let entity - try { - entity = await getEntity(payload.iss, payload.dwk || AGENT_DWK) - } catch (err) { - return reply.code(401).send({ - error: 'invalid_jwt', - error_description: `agent server discovery failed: ${err.message}`, - }) - } - try { - await jose.jwtVerify(raw, jose.createLocalJWKSet(entity.jwks)) - } catch (err) { + const verified = await verifyAgentToken(raw, { header, payload }) + if (verified.error) { return reply.code(401).send({ error: 'invalid_jwt', - error_description: `agent_token signature: ${err.message}`, + error_description: verified.error, }) } return null // ok @@ -163,8 +155,12 @@ export const pendingGet = async (req, reply) => { timeout: 120, }) } - // Bootstrap polls before consent — keep them pending. - if (entry.kind === 'bootstrap' && !entry.preApprove) { + // requirement=interaction means the person has to visit the URL + // we handed the agent. Until /aauth/consent?code=… is hit, the + // entry stays pending — unless mock.auto_approve pre-marked it + // approved at creation, which is the default and what every + // auto-approve test relies on. Bootstrap works the same way. + if (entry.kind === 'bootstrap' || entry.requirement === 'interaction') { const location = `${ISSUER}/aauth/pending/${entry.id}` reply.code(202) reply.header('Location', location) @@ -172,18 +168,21 @@ export const pendingGet = async (req, reply) => { reply.header('Cache-Control', 'no-store') return reply.send({ status: 'pending', location }) } - // Default auto-resolve path for token / approval / interaction. + // Approval: the PS reaches the person out of band, so the next + // poll resolves. updatePending(entry.id, { status: 'approved' }) } if (entry.kind === 'token') { - const issued = await issueAuthToken({ - agent_id: entry.agent_id, - agent_public_key: entry.agent_public_key, - resource_url: entry.resource_url, - scope: entry.scope, - r3: entry.r3, - }) + const issued = await issueAuthToken(entry.issueArgs) + deletePending(entry.id) + return reply.code(200).send(issued) + } + + // Deferred person token — the consent path the fleet needs and can + // test nowhere else. Resolves exactly like the auth token above. + if (entry.kind === 'person') { + const issued = await issuePersonToken(entry.issueArgs) deletePending(entry.id) return reply.code(200).send(issued) } diff --git a/src/aauth/person-token-store.js b/src/aauth/person-token-store.js new file mode 100644 index 0000000..4580378 --- /dev/null +++ b/src/aauth/person-token-store.js @@ -0,0 +1,53 @@ +// aauth/person-token-store.js — issued person tokens, keyed by `jti`. +// +// Protocol -11 §Resource Token Verification step 6: +// +// "A PS MUST look up the person token identified by `person_token_jti` +// among those it issued, and MUST verify that `ps`, `sub`, +// `mission_s256`, and `tenant` match that token exactly, rejecting the +// resource token on any mismatch or omission." +// +// The spec implies this store without stating it (AAuth issue #87). It is +// what makes mission stripping detectable: comparing claims alone would +// not do, because an agent running concurrent missions holds several +// person tokens for the same resource. +// +// A mock keeps it in memory and expires entries with the token itself. + +const issued = new Map() // jti → record + +/** + * @param {object} record + * @param {string} record.jti + * @param {string} record.ps the PS that issued it (our ISSUER) + * @param {string} record.sub directed subject + * @param {string} record.aud the resource the token names + * @param {string} [record.mission_s256] + * @param {string} [record.tenant] + * @param {string} [record.agent_jkt] thumbprint of cnf.jwk + * @param {number} record.exp seconds since epoch + */ +export function recordPersonToken(record) { + issued.set(record.jti, record) + return record +} + +/** Returns the record, or null when unknown or expired (expired entries are dropped). */ +export function getPersonToken(jti) { + const record = issued.get(jti) + if (!record) return null + if (record.exp * 1000 <= Date.now()) { + issued.delete(jti) + return null + } + return record +} + +export function clearPersonTokens() { + issued.clear() +} + +/** Test/introspection helper — the jtis currently held. */ +export function issuedPersonTokenIds() { + return [...issued.keys()] +} diff --git a/src/aauth/person.js b/src/aauth/person.js new file mode 100644 index 0000000..41dd50a --- /dev/null +++ b/src/aauth/person.js @@ -0,0 +1,214 @@ +// aauth/person.js — POST /aauth/person (PS person_token_endpoint). +// +// Protocol -11 §Person Token Endpoint. Signed POST; the agent presents its +// agent token via `Signature-Key: sig=jwt;jwt="…"` (verified by the +// preHandler, which also enforces content-digest + content-type coverage). +// +// Request body: +// resource REQUIRED — the resource the token is for; becomes `aud` +// mission_s256 OPTIONAL — stamped on the issued token +// subagent_token OPTIONAL — a sub-agent's agent token; the issued token's +// `cnf` is the sub-agent's key (§Sub-Agents, interop +// surface 5) +// upstream_token OPTIONAL in the spec — NOT implemented; call chaining is +// deferred fleet-wide, and mockin rejects rather than +// silently ignoring it +// +// Auto-approve (default): 200 { person_token, expires_in }. +// Deferred (mock.person_requirement): 202 + Location + Retry-After + +// AAuth-Requirement, resolved by polling /aauth/pending/:id. + +import { ISSUER } from '../config.js' +import { getConfig, mockErrorFor } from './mock.js' +import { issuePersonToken } from './issue-person-token.js' +import { verifyAgentToken } from './verify-agent-token.js' +import { parseRequestParameters, canDriveInteraction } from './request-parameters.js' +import { createPending, updatePending } from './state.js' + +const ERROR_STATUS = { + invalid_request: 400, + invalid_agent_token: 400, + expired_agent_token: 400, + denied: 403, + user_unreachable: 403, + server_error: 500, +} + +// §Server Identifiers: https, host only, lowercase, no port/path/query/ +// fragment, no trailing slash. Mockin is a local mock, so http on a +// loopback host (with a port) is also accepted — otherwise nothing running +// on 127.0.0.1 could be named as a resource. +export function validateResourceIdentifier(value) { + if (typeof value !== 'string' || !value) return 'resource is required' + let url + try { + url = new URL(value) + } catch { + return `resource "${value}" is not an absolute URL` + } + const loopback = ['localhost', '127.0.0.1', '[::1]', '::1'].includes(url.hostname) + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) { + return `resource "${value}" must use the https scheme` + } + if (url.search) return `resource "${value}" must not contain a query` + if (url.hash) return `resource "${value}" must not contain a fragment` + if (url.pathname !== '/' || value.endsWith('/')) { + return `resource "${value}" must contain only scheme and host, with no trailing slash` + } + if (url.port && !loopback) return `resource "${value}" must not contain a port` + if (value !== value.toLowerCase()) return `resource "${value}" must be lowercase` + return null +} + +export const person = async (req, reply) => { + const cfg = getConfig() + const aauth = req.aauth // set by verifyPreHandler + const body = req.body || {} + + const mockErr = mockErrorFor('person') + if (mockErr) { + return reply.code(ERROR_STATUS[mockErr] || 400).send({ + error: mockErr, + error_description: `Mock error: ${mockErr}`, + }) + } + + const resourceError = validateResourceIdentifier(body.resource) + if (resourceError) { + return reply.code(400).send({ + error: 'invalid_request', + error_description: resourceError, + }) + } + + if (body.upstream_token !== undefined) { + return reply.code(400).send({ + error: 'invalid_request', + error_description: + 'upstream_token is not supported: call chaining is not implemented by mockin', + }) + } + + if (body.mission_s256 !== undefined && typeof body.mission_s256 !== 'string') { + return reply.code(400).send({ + error: 'invalid_request', + error_description: 'mission_s256 must be a string', + }) + } + // mission_endpoint is unimplemented, so there is no mission to look up. + // The value is accepted, stamped on the token, and later compared + // against the resource token — which is what the fleet tests. + + // The consent-flow parameters, the same set the auth token endpoint + // takes: justification, login_hint, tenant, domain_hint, prompt, + // platform, device, capabilities. + const parsed = parseRequestParameters(body) + if (parsed.error) { + return reply.code(400).send({ + error: 'invalid_request', + error_description: parsed.error, + }) + } + const params = parsed.params + + // The token binds the requesting agent's key, unless a subagent_token + // names one — then it binds the sub-agent's, and the parent must be + // named by the sub-agent token's `parent_agent`. + let cnfJwk = aauth.agent_public_key + let subject_agent_exp = aauth.agent_payload?.exp + + if (body.subagent_token !== undefined) { + if (typeof body.subagent_token !== 'string') { + return reply.code(400).send({ + error: 'invalid_request', + error_description: 'subagent_token must be a string', + }) + } + const sub = await verifyAgentToken(body.subagent_token) + if (sub.error) { + return reply.code(400).send({ + error: 'invalid_agent_token', + error_description: `subagent_token: ${sub.error}`, + }) + } + if (sub.payload.parent_agent !== aauth.agent_id) { + return reply.code(400).send({ + error: 'invalid_agent_token', + error_description: + `subagent_token parent_agent "${sub.payload.parent_agent}" does not name the signing agent "${aauth.agent_id}"`, + }) + } + cnfJwk = sub.payload.cnf.jwk + subject_agent_exp = Math.min( + subject_agent_exp ?? Infinity, + sub.payload.exp ?? Infinity, + ) + } + + const issueArgs = { + agent_public_key: cnfJwk, + resource: body.resource, + lifetime: cfg.token_lifetime || undefined, + agent_token_exp: subject_agent_exp, + mission_s256: body.mission_s256, + // The agent names the tenant: nothing else selects which of a + // person's contexts — personal, or one of several managed — the + // issued token carries (AAuth issue #88). + tenant: params.tenant || cfg.tenant || undefined, + } + + // Deferred consent path — the one the fleet cannot exercise anywhere + // else. mock.person_requirement selects it. + if (cfg.person_requirement) { + // An agent that declared its capabilities without `interaction` + // cannot drive the url + code we would hand it, so a 202 would be + // unsatisfiable. Terminal, per §Token Endpoint Error Codes. + if (cfg.person_requirement === 'interaction' && !canDriveInteraction(params)) { + return reply.code(403).send({ + error: 'user_unreachable', + error_description: + 'user interaction is required and the agent did not declare the interaction capability', + }) + } + const { id, code } = createPending({ + kind: 'person', + agent_id: aauth.agent_id, + issueArgs, + requirement: cfg.person_requirement, + // Recorded for the consent display and the connected-agents + // entry a real PS would create here. + params, + }) + // auto_approve (the default) short-circuits the interaction so the + // first poll returns the token. Set it false to make a test drive + // it: poll → 202, GET /aauth/consent?code=… , poll → 200. + if (cfg.auto_approve && cfg.person_requirement === 'interaction') { + updatePending(id, { status: 'approved' }) + } + const location = `${ISSUER}/aauth/pending/${id}` + reply.code(202) + reply.header('Location', location) + reply.header('Retry-After', '0') + reply.header('Cache-Control', 'no-store') + + if (cfg.person_requirement === 'interaction') { + const interactionUrl = `${ISSUER}/aauth/consent` + reply.header( + 'AAuth-Requirement', + `requirement=interaction; url="${interactionUrl}"; code="${code}"`, + ) + return reply.send({ + status: 'pending', + location, + requirement: 'interaction', + code, + }) + } + reply.header('AAuth-Requirement', `requirement=${cfg.person_requirement}`) + return reply.send({ status: 'pending', location }) + } + + const issued = await issuePersonToken(issueArgs) + reply.header('Cache-Control', 'no-store') + return reply.code(200).send(issued) +} diff --git a/src/aauth/r3.js b/src/aauth/r3.js index 42cbf52..87f7bf7 100644 --- a/src/aauth/r3.js +++ b/src/aauth/r3.js @@ -3,7 +3,7 @@ // When a resource_token carries r3_uri + r3_s256, the PS fetches the R3 // document, verifies SHA-256 of the raw bytes matches r3_s256, and uses // the document's `operations` to populate the auth_token's r3_granted / -// r3_conditional claims. +// r3_per_call claims. (R3 -02 renamed `r3_conditional` to `r3_per_call`.) // // Per draft-hardt-aauth-r3 §Security Considerations, the resource MUST // require a valid HTTP Message Signature on R3 document URIs and reject @@ -26,6 +26,51 @@ export function sha256B64url(bytes) { return createHash('sha256').update(buf).digest('base64url') } +// The seven standard vocabularies of R3 -02. `openapi-gateway` was removed +// in -02 and is no longer accepted; third-party vocabularies use their own +// URI namespace and are passed through. +export const STANDARD_VOCABULARIES = new Set([ + 'urn:aauth:vocabulary:mcp', + 'urn:aauth:vocabulary:openapi', + 'urn:aauth:vocabulary:grpc', + 'urn:aauth:vocabulary:graphql', + 'urn:aauth:vocabulary:asyncapi', + 'urn:aauth:vocabulary:wsdl', + 'urn:aauth:vocabulary:odata', +]) + +/** @returns {string|null} an error message, or null when the document is valid. */ +export function validateR3Document(document) { + if (!document || typeof document !== 'object' || Array.isArray(document)) { + return 'r3 document must be a JSON object' + } + if (typeof document.vocabulary !== 'string' || !document.vocabulary) { + return 'r3 document missing vocabulary' + } + if ( + document.vocabulary.startsWith('urn:aauth:vocabulary:') && + !STANDARD_VOCABULARIES.has(document.vocabulary) + ) { + return `unknown vocabulary "${document.vocabulary}" (R3 -02 defines seven; openapi-gateway was removed)` + } + if (!Array.isArray(document.operations) || document.operations.length === 0) { + return 'r3 document missing operations' + } + // A per-call proposal is a full R3 document plus a REQUIRED + // `parameters` object. Nothing else distinguishes it, so a document + // carrying a non-object `parameters` is malformed rather than a class + // document with a stray field. + if ( + 'parameters' in document && + (typeof document.parameters !== 'object' || + document.parameters === null || + Array.isArray(document.parameters)) + ) { + return 'r3 per-call proposal parameters must be an object' + } + return null +} + export async function fetchR3Document({ r3_uri, expected_s256 }) { const trusted = getConfig().trusted_servers || {} // Tests can preload an R3 doc by URI to bypass network. @@ -41,7 +86,10 @@ export async function fetchR3Document({ r3_uri, expected_s256 }) { `r3_s256 mismatch (preloaded): expected ${expected_s256}, got ${actual}`, ) } - return { document: JSON.parse(bytes.toString('utf8')), bytes } + const preloaded = JSON.parse(bytes.toString('utf8')) + const invalid = validateR3Document(preloaded) + if (invalid) return new Error(invalid) + return { document: preloaded, bytes } } } @@ -79,27 +127,44 @@ export async function fetchR3Document({ r3_uri, expected_s256 }) { } catch (err) { return new Error(`r3 document not JSON: ${err.message}`) } + const invalid = validateR3Document(document) + if (invalid) return new Error(invalid) return { document, bytes } } +// R3 -02 §Per-Call Proposals: a proposal is a full R3 document for one +// pending call, distinguished by a REQUIRED `parameters` object carrying +// that call's concrete arguments. `version` is no longer a document field, +// and the `openapi-gateway` vocabulary no longer exists. +export function isPerCallProposal(document) { + return Boolean( + document && + typeof document.parameters === 'object' && + document.parameters !== null && + !Array.isArray(document.parameters), + ) +} + // Mockin's auto-grant: every op in the document goes into r3_granted, -// r3_conditional stays empty. Override via mock.r3_grants for tests. +// r3_per_call stays empty. A per-call proposal is approved for the one +// call it describes — the flow ends with the proposed operation in +// r3_granted, not in r3_per_call. Override via mock.r3_grants for tests. export function autoGrantR3({ document }) { const override = getConfig().r3_grants if (override) { return { granted: override.granted || null, - conditional: override.conditional || null, + per_call: override.per_call || null, } } if (!document?.operations || !Array.isArray(document.operations)) { - return { granted: null, conditional: null } + return { granted: null, per_call: null } } return { granted: { vocabulary: document.vocabulary, operations: document.operations, }, - conditional: null, + per_call: null, } } diff --git a/src/aauth/request-parameters.js b/src/aauth/request-parameters.js new file mode 100644 index 0000000..85d39eb --- /dev/null +++ b/src/aauth/request-parameters.js @@ -0,0 +1,155 @@ +// aauth/request-parameters.js — the consent-flow request parameters both +// PS token endpoints take. +// +// -11 §Agent Token Request lists these on the auth token endpoint. The +// person token endpoint has the same deferred-consent shape — either can +// return 202 with requirement=interaction, either identifies the person, +// either renders a consent screen and creates a connected-agents entry — +// so mockin accepts the same set at both, and parses them in one place. +// +// Not here: `resource_token` (what makes the auth token endpoint the auth +// token endpoint) and `upstream_token` (call chaining, not implemented). + +// AAuth Platform Value Registry. +export const PLATFORM_VALUES = new Set([ + 'web', 'mobile', 'desktop', 'workload', 'self-hosted', +]) + +// §AAuth-Capabilities. "Recipients MUST ignore unrecognized capability +// values" — filter, never reject. +export const CAPABILITY_VALUES = new Set([ + 'interaction', 'clarification', 'payment', +]) + +// [@!OpenID.Core] Section 3.1.2.1 defined values. +export const PROMPT_VALUES = new Set([ + 'none', 'login', 'consent', 'select_account', +]) + +const DEVICE_MAX_LENGTH = 64 +// eslint-disable-next-line no-control-regex +const CONTROL_CHARS = /[\u0000-\u001F\u007F]/ + +/** + * @param {object} body + * @returns {{params?: object, error?: string}} + */ +export function parseRequestParameters(body = {}) { + const params = {} + + // justification (OPTIONAL): a Markdown string declaring why access is + // being requested. The PS SHOULD present this value to the user during + // consent, and MUST sanitize the Markdown before rendering. + if (body.justification !== undefined) { + if (typeof body.justification !== 'string') { + return { error: 'justification must be a string' } + } + params.justification = body.justification + } + + // login_hint (OPTIONAL): hint about who to authorize, per + // [@!OpenID.Core] Section 3.1.2.1. It matters more at the person token + // endpoint than at the auth token endpoint: this is first contact, and + // the PS may not yet know which person the agent acts for. + if (body.login_hint !== undefined) { + if (typeof body.login_hint !== 'string') { + return { error: 'login_hint must be a string' } + } + params.login_hint = body.login_hint + } + + // tenant (OPTIONAL): tenant identifier, per OpenID Connect Enterprise + // Extensions. This is what selects which tenant an issued token carries + // when a person holds a personal context plus several managed ones. + if (body.tenant !== undefined) { + if (typeof body.tenant !== 'string') { + return { error: 'tenant must be a string' } + } + params.tenant = body.tenant + } + + // domain_hint (OPTIONAL): per OpenID Connect Enterprise Extensions. + if (body.domain_hint !== undefined) { + if (typeof body.domain_hint !== 'string') { + return { error: 'domain_hint must be a string' } + } + params.domain_hint = body.domain_hint + } + + // prompt (OPTIONAL): space-delimited, case-sensitive list of values + // specifying whether the PS prompts for reauthentication and consent, + // per [@!OpenID.Core] Section 3.1.2.1. + if (body.prompt !== undefined) { + if (typeof body.prompt !== 'string') { + return { error: 'prompt must be a space-delimited string' } + } + const values = body.prompt.split(/\s+/).filter(Boolean) + const unknown = values.filter((v) => !PROMPT_VALUES.has(v)) + if (unknown.length) { + return { error: `unknown prompt value(s): ${unknown.join(' ')}` } + } + params.prompt = values + } + + // platform (OPTIONAL): identifier for the runtime platform the agent + // runs on. The value MUST be from the AAuth Platform Value Registry. + // Agent-attested; used for display only. + if (body.platform !== undefined) { + if (typeof body.platform !== 'string' || !PLATFORM_VALUES.has(body.platform)) { + return { + error: `platform must be one of ${[...PLATFORM_VALUES].join(', ')}`, + } + } + params.platform = body.platform + } + + // device (OPTIONAL): short human-readable string identifying the + // device or browser, for the connected-agents dashboard. UTF-8 + // printable characters only, at most 64 of them. Opaque to receivers. + if (body.device !== undefined) { + if (typeof body.device !== 'string') { + return { error: 'device must be a string' } + } + if (body.device.length > DEVICE_MAX_LENGTH) { + return { error: `device must not exceed ${DEVICE_MAX_LENGTH} characters` } + } + if (CONTROL_CHARS.test(body.device)) { + return { error: 'device must not contain control characters' } + } + params.device = body.device + } + + // capabilities (OPTIONAL): the capability values the agent can handle + // for this request — the request-body equivalent of the + // AAuth-Capabilities header, which is not used on PS endpoints. + // Without a mission, this is how the PS learns whether the agent can + // drive requirement=interaction. + if (body.capabilities !== undefined) { + if ( + !Array.isArray(body.capabilities) || + body.capabilities.some((c) => typeof c !== 'string') + ) { + return { error: 'capabilities must be an array of strings' } + } + params.capabilities = body.capabilities.filter((c) => CAPABILITY_VALUES.has(c)) + } + + return { params } +} + +/** + * Whether a deferred response the agent has to drive itself can be sent. + * + * `requirement=interaction` hands the agent a url + code and expects it to + * put the person in front of them. An agent that declared its capabilities + * and did not name `interaction` has said it cannot. Sending it a 202 it + * can never complete is the gap AAuth issue #89 describes; the terminal + * answer is `user_unreachable` (403). + * + * Capabilities omitted means unknown, not "cannot" — the PS may know them + * from mission approval — so the deferred path stays open. + */ +export function canDriveInteraction(params) { + if (!params?.capabilities) return true + return params.capabilities.includes('interaction') +} diff --git a/src/aauth/subject.js b/src/aauth/subject.js new file mode 100644 index 0000000..dcfff84 --- /dev/null +++ b/src/aauth/subject.js @@ -0,0 +1,25 @@ +// aauth/subject.js — directed (pairwise) subject derivation. +// +// Protocol -11 §Person Token Structure: the person token's `sub` MUST be +// "the same value the PS uses in the `sub` claim of auth tokens it issues +// for this `aud`". Every token mockin issues therefore derives its `sub` +// here and nowhere else — two derivations would make every resource token +// fail the PS's own step-6 comparison (§Resource Token Verification). +// +// The derivation is a mock, not a security boundary: SHA-256 over +// `{user.sub}|{aud}`, base64url. Stable across restarts for a given user +// and audience, and different per audience so resources cannot correlate. + +import { createHash } from 'crypto' + +import defaultUser from '../users.js' + +/** + * @param {string} aud the audience the identifier is directed at — a + * resource URL for person/auth tokens, the agent + * server URL for bootstrap tokens. + * @param {string} [userSub] + */ +export function directedSub(aud, userSub = defaultUser.sub) { + return createHash('sha256').update(`${userSub}|${aud}`).digest('base64url') +} diff --git a/src/aauth/token.js b/src/aauth/token.js index e0b1278..3ac1f8c 100644 --- a/src/aauth/token.js +++ b/src/aauth/token.js @@ -1,8 +1,10 @@ -// aauth/token.js — POST /aauth/token (PS token endpoint). +// aauth/token.js — POST /aauth/token (PS auth_token_endpoint). // // Auto-approve flow (default): // 1. HTTPSig + agent_token verified by preHandler (request.aauth) -// 2. Verify resource_token from request body +// 2. Verify resource_token from the body, including -11 step 6: the +// person token named by `person_token_jti` must be one this PS +// issued, with matching ps / sub / mission_s256 / tenant // 3. If R3, fetch + hash-verify the document // 4. Inject mock errors / deferred response if configured // 5. Issue auth_token immediately, 200 @@ -12,19 +14,26 @@ // - Return 202 + AAuth-Requirement header + Location // - Agent polls /aauth/pending/:id; first poll resolves and returns token +import { calculateJwkThumbprint } from 'jose' + import { ISSUER } from '../config.js' import { getConfig, mockErrorFor } from './mock.js' import { verifyResourceToken } from './verify-resource-token.js' import { fetchR3Document, autoGrantR3 } from './r3.js' import { issueAuthToken } from './issue-auth-token.js' -import { createPending } from './state.js' +import { parseRequestParameters, canDriveInteraction } from './request-parameters.js' +import { verifyAgentToken } from './verify-agent-token.js' +import { createPending, updatePending } from './state.js' const ERROR_STATUS = { invalid_request: 400, + invalid_agent_token: 400, + expired_agent_token: 400, invalid_resource_token: 400, + expired_resource_token: 400, invalid_scope: 400, - user_unreachable: 400, denied: 403, + user_unreachable: 403, server_error: 500, } @@ -50,14 +59,61 @@ export const token = async (req, reply) => { }) } - const rt = await verifyResourceToken( - body.resource_token, - aauth.agent_id, - aauth.agent_jkt, - ) + // upstream_token is call chaining — deferred fleet-wide, not implemented. + if (body.upstream_token !== undefined) { + return reply.code(400).send({ + error: 'invalid_request', + error_description: + 'upstream_token is not supported: call chaining is not implemented by mockin', + }) + } + + // justification, login_hint, tenant, domain_hint, prompt, platform, + // device, capabilities — parsed once, shared with the person endpoint. + const parsed = parseRequestParameters(body) + if (parsed.error) { + return reply.code(400).send({ + error: 'invalid_request', + error_description: parsed.error, + }) + } + const params = parsed.params + + // Parent-mediated sub-agent authorization (interop surface 5): the + // parent signs the request, so §Resource Token Verification step 5 + // compares agent_jkt against the SUB-agent's cnf.jwk instead, and the + // issued auth token binds the sub-agent's key. + let expectedJkt = aauth.agent_jkt + let cnfJwk = aauth.agent_public_key + if (body.subagent_token !== undefined) { + if (typeof body.subagent_token !== 'string') { + return reply.code(400).send({ + error: 'invalid_request', + error_description: 'subagent_token must be a string', + }) + } + const sub = await verifyAgentToken(body.subagent_token) + if (sub.error) { + return reply.code(400).send({ + error: 'invalid_agent_token', + error_description: `subagent_token: ${sub.error}`, + }) + } + if (sub.payload.parent_agent !== aauth.agent_id) { + return reply.code(400).send({ + error: 'invalid_agent_token', + error_description: + `subagent_token parent_agent "${sub.payload.parent_agent}" does not name the signing agent "${aauth.agent_id}"`, + }) + } + cnfJwk = sub.payload.cnf.jwk + expectedJkt = await calculateJwkThumbprint(cnfJwk) + } + + const rt = await verifyResourceToken(body.resource_token, expectedJkt) if (rt.error) { return reply.code(400).send({ - error: 'invalid_resource_token', + error: rt.expired ? 'expired_resource_token' : 'invalid_resource_token', error_description: rt.error, }) } @@ -78,18 +134,43 @@ export const token = async (req, reply) => { r3 = { uri: rt.r3.uri, s256: rt.r3.s256, ...grants } } + const issueArgs = { + agent_public_key: cnfJwk, + resource_url: rt.resource_url, + scope: rt.scope, + sub: rt.sub, + mission_s256: rt.mission_s256 || undefined, + tenant: rt.tenant || undefined, + account: rt.account || undefined, + r3, + } + // Deferred response if (cfg.requirement) { + // Same gate as the person token endpoint: an agent that declared + // capabilities without `interaction` cannot complete one. + if (cfg.requirement === 'interaction' && !canDriveInteraction(params)) { + return reply.code(403).send({ + error: 'user_unreachable', + error_description: + 'user interaction is required and the agent did not declare the interaction capability', + }) + } const { id, code } = createPending({ kind: 'token', agent_id: aauth.agent_id, - agent_public_key: aauth.agent_public_key, - resource_url: rt.resource_url, - scope: rt.scope, - r3, + issueArgs, requirement: cfg.requirement, - justification: body.justification || null, + justification: params.justification || null, + params, }) + // auto_approve (the default) short-circuits the interaction so the + // first poll returns the token. Set it false to make a test drive + // it: poll → 202, GET /aauth/consent?code=… , poll → 200. + // Clarification is unaffected — it waits for the agent's answer. + if (cfg.auto_approve && cfg.requirement === 'interaction') { + updatePending(id, { status: 'approved' }) + } const location = `${ISSUER}/aauth/pending/${id}` reply.code(202) reply.header('Location', location) @@ -97,7 +178,7 @@ export const token = async (req, reply) => { reply.header('Cache-Control', 'no-store') if (cfg.requirement === 'interaction') { - const interactionUrl = `${ISSUER}/aauth/interaction-ui` + const interactionUrl = `${ISSUER}/aauth/consent` reply.header( 'AAuth-Requirement', `requirement=interaction; url="${interactionUrl}"; code="${code}"`, @@ -120,12 +201,7 @@ export const token = async (req, reply) => { return reply.send({ status: 'pending', location }) } - const issued = await issueAuthToken({ - agent_id: aauth.agent_id, - agent_public_key: aauth.agent_public_key, - resource_url: rt.resource_url, - scope: rt.scope, - r3, - }) + const issued = await issueAuthToken(issueArgs) + reply.header('Cache-Control', 'no-store') return reply.code(200).send(issued) } diff --git a/src/aauth/verify-agent-token.js b/src/aauth/verify-agent-token.js new file mode 100644 index 0000000..4242c2f --- /dev/null +++ b/src/aauth/verify-agent-token.js @@ -0,0 +1,61 @@ +// aauth/verify-agent-token.js — verify an aa-agent+jwt. +// +// Shared by the request preHandler (the agent token presented in +// Signature-Key) and by the person/auth token endpoints when a +// `subagent_token` is presented in the request body. +// +// 1. typ is aa-agent+jwt and alg is acceptable (§Signature Algorithms) +// 2. iss present; the issuer's JWKS is discovered via {iss}/.well-known/{dwk} +// 3. the JWT signature verifies against that JWKS +// 4. sub and cnf.jwk are present + +import * as jose from 'jose' + +import { getEntity, AGENT_DWK } from './entity-cache.js' +import { ACCEPTED_JWT_ALGS, checkJwtAlg } from './algorithms.js' + +/** + * @param {string} raw the compact JWT + * @param {object} [decoded] { header, payload } when the caller already has them + * @returns {Promise<{payload?: object, header?: object, error?: string}>} + */ +export async function verifyAgentToken(raw, decoded) { + let header = decoded?.header + let payload = decoded?.payload + if (!header || !payload) { + try { + header = jose.decodeProtectedHeader(raw) + payload = jose.decodeJwt(raw) + } catch { + return { error: 'malformed agent_token' } + } + } + + if (header.typ !== 'aa-agent+jwt') { + return { error: `expected aa-agent+jwt, got ${header.typ}` } + } + const algError = checkJwtAlg(header.alg) + if (algError) return { error: `agent_token ${algError}` } + + if (!payload.iss) return { error: 'agent_token missing iss' } + + let entity + try { + entity = await getEntity(payload.iss, payload.dwk || AGENT_DWK) + } catch (err) { + return { error: `agent server discovery failed: ${err.message}` } + } + + try { + await jose.jwtVerify(raw, jose.createLocalJWKSet(entity.jwks), { + algorithms: ACCEPTED_JWT_ALGS, + }) + } catch (err) { + return { error: `agent_token signature: ${err.message}` } + } + + if (!payload.sub) return { error: 'agent_token missing sub' } + if (!payload.cnf?.jwk) return { error: 'agent_token missing cnf.jwk' } + + return { header, payload, metadata: entity.metadata } +} diff --git a/src/aauth/verify-request.js b/src/aauth/verify-request.js index c1f17dc..a27a8cf 100644 --- a/src/aauth/verify-request.js +++ b/src/aauth/verify-request.js @@ -3,42 +3,113 @@ // Every agent-facing AAuth endpoint runs the incoming request through this: // // 1. RFC9421 signature verification (Signature-Input + Signature) -// 2. The Signature-Key MUST use scheme=jwt and present an aa-agent+jwt -// 3. The agent_token's signature MUST verify against its issuer's JWKS -// 4. Header/claim sanity (typ, iss, exp, cnf.jwk presence) +// 2. Covered components: a request with a body to a PS endpoint MUST +// sign `content-digest` and `content-type` (-11 §HTTP Message +// Signatures Profile). httpsig verifies the digest itself once the +// component is covered; it is the *coverage* we have to insist on. +// 3. The Signature-Key MUST use scheme=jwt and present an aa-agent+jwt +// 4. The agent_token's signature MUST verify against its issuer's JWKS +// 5. Header/claim sanity (typ, alg, iss, exp, cnf.jwk presence) // // On failure the helper returns a tuple describing the response so the // handler can `return reply.code(...).headers(...).send(...)`. On success // it returns identity/key material derived from the verified JWT. -import * as jose from 'jose' import { verify as httpSigVerify, generateSignatureErrorHeader, generateAcceptSignatureHeader, generateAcceptSignatureSchemeHeader, + generateAcceptSignatureAlgHeader, } from '@hellocoop/httpsig' -import { getEntity, AGENT_DWK } from './entity-cache.js' - -const ACCEPT_SIG_BODY = generateAcceptSignatureHeader({ +import { verifyAgentToken } from './verify-agent-token.js' +import { ACCEPTED_JWT_ALGS } from './algorithms.js' +import { getConfig } from './mock.js' + +// -11: PS endpoints taking a body require content-digest and content-type +// in the covered components, on top of the base profile. +export const BODY_COMPONENTS = [ + '@method', '@authority', '@path', + 'content-type', 'content-digest', + 'signature-key', +] +export const NOBODY_COMPONENTS = [ + '@method', '@authority', '@path', 'signature-key', +] + +export const ACCEPT_SIG_BODY = generateAcceptSignatureHeader({ label: 'sig', - components: ['@method', '@authority', '@path', 'content-type', 'signature-key'], + components: BODY_COMPONENTS, }) -const ACCEPT_SIG_NOBODY = generateAcceptSignatureHeader({ +export const ACCEPT_SIG_NOBODY = generateAcceptSignatureHeader({ label: 'sig', - components: ['@method', '@authority', '@path', 'signature-key'], + components: NOBODY_COMPONENTS, }) // -08 replaced Accept-Signature's sigkey parameter with a separate // Accept-Signature-Scheme header. These endpoints require sig=jwt. -const ACCEPT_SIG_SCHEME = generateAcceptSignatureSchemeHeader(['jwt']) +export const ACCEPT_SIG_SCHEME = generateAcceptSignatureSchemeHeader(['jwt']) + +// -10 admits no polymorphic EdDSA; say so when we decline one. +export const ACCEPT_SIG_ALG = generateAcceptSignatureAlgHeader(ACCEPTED_JWT_ALGS) function fail(status, body, headers = {}) { return { ok: false, status, body, headers } } +/** + * The components covered by the `sig` signature, read straight off + * Signature-Input. httpsig does not report them on its result, and the + * profile requirement is about coverage, so we parse them here. + * sig=("@method" "@authority" "@path" "content-digest");created=… + */ +export function coveredComponents(signatureInput, label = 'sig') { + if (!signatureInput) return [] + const match = new RegExp(`(?:^|,\\s*)${label}=\\(([^)]*)\\)`).exec( + Array.isArray(signatureInput) ? signatureInput.join(',') : signatureInput, + ) + if (!match) return [] + return (match[1].match(/"[^"]*"/g) || []).map((s) => s.slice(1, -1).toLowerCase()) +} + +/** + * -11: a body-carrying request to a PS endpoint MUST cover + * `content-digest` and `content-type`. httpsig verifies the digest itself + * once the component is covered, so it is the coverage we check here. + * mock.require_body_signing = false relaxes it for clients that have not + * cut over yet. + * + * @returns a failure tuple, or null when the coverage is acceptable. + */ +export function checkBodySigning(request, sigResult) { + if (getConfig().require_body_signing === false) return null + const covered = coveredComponents( + request.headers['signature-input'], + sigResult?.label || 'sig', + ) + const missing = ['content-type', 'content-digest'].filter( + (c) => !covered.includes(c), + ) + if (!missing.length) return null + return fail( + 401, + { + error: 'signature_verification_failed', + error_description: + `request body signature must cover ${missing.join(' and ')}`, + }, + { + 'Accept-Signature': ACCEPT_SIG_BODY, + 'Signature-Error': generateSignatureErrorHeader({ + error: 'invalid_input', + required_input: missing, + }), + }, + ) +} + export async function verifyRequest(request) { const url = new URL( request.url, @@ -66,6 +137,7 @@ export async function verifyRequest(request) { { 'Accept-Signature': hasBody ? ACCEPT_SIG_BODY : ACCEPT_SIG_NOBODY, 'Accept-Signature-Scheme': ACCEPT_SIG_SCHEME, + 'Accept-Signature-Alg': ACCEPT_SIG_ALG, }, ) } @@ -75,6 +147,11 @@ export async function verifyRequest(request) { sigResult.signatureError, ) } + if (sigResult.acceptSignatureAlg) { + headers['Accept-Signature-Alg'] = generateAcceptSignatureAlgHeader( + sigResult.acceptSignatureAlg, + ) + } return fail( 401, { @@ -85,6 +162,11 @@ export async function verifyRequest(request) { ) } + if (hasBody) { + const bodyFailure = checkBodySigning(request, sigResult) + if (bodyFailure) return bodyFailure + } + if (sigResult.keyType !== 'jwt' || !sigResult.jwt) { return fail(401, { error: 'invalid_key', @@ -94,62 +176,22 @@ export async function verifyRequest(request) { const { header, payload, raw } = sigResult.jwt - if (header.typ !== 'aa-agent+jwt') { - return fail(401, { - error: 'invalid_jwt', - error_description: `expected aa-agent+jwt, got ${header.typ}`, - }) - } - - const agentIss = payload.iss - if (!agentIss) { - return fail(401, { - error: 'invalid_jwt', - error_description: 'agent_token missing iss', - }) - } - const dwk = payload.dwk || AGENT_DWK - - let entity - try { - entity = await getEntity(agentIss, dwk) - } catch (err) { - return fail(401, { - error: 'invalid_jwt', - error_description: `agent server discovery failed: ${err.message}`, - }) - } - - try { - await jose.jwtVerify(raw, jose.createLocalJWKSet(entity.jwks)) - } catch (err) { - return fail(401, { - error: 'invalid_jwt', - error_description: `agent_token signature: ${err.message}`, - }) - } - - if (!payload.sub) { - return fail(401, { - error: 'invalid_jwt', - error_description: 'agent_token missing sub', - }) - } - if (!payload.cnf?.jwk) { + const verified = await verifyAgentToken(raw, { header, payload }) + if (verified.error) { return fail(401, { error: 'invalid_jwt', - error_description: 'agent_token missing cnf.jwk', + error_description: verified.error, }) } return { ok: true, agent_id: payload.sub, - agent_iss: agentIss, + agent_iss: payload.iss, agent_jkt: sigResult.thumbprint, agent_public_key: payload.cnf.jwk, agent_payload: payload, - agent_metadata: entity.metadata, + agent_metadata: verified.metadata, } } diff --git a/src/aauth/verify-resource-token.js b/src/aauth/verify-resource-token.js index 69fea13..56c9d5b 100644 --- a/src/aauth/verify-resource-token.js +++ b/src/aauth/verify-resource-token.js @@ -2,23 +2,31 @@ // // The agent obtains the resource_token from the resource server (via // AAuth-Requirement: requirement=auth-token) and presents it to the PS -// in the /aauth/token request body. The PS verifies that: +// in the /aauth/token request body. -11 §Resource Token Verification: // -// - typ === aa-resource+jwt -// - JWT signature verifies against the resource server's JWKS -// - aud matches the PS issuer -// - agent / agent_jkt match the agent identity from HTTPSig -// - r3_uri and r3_s256 either both present or both absent +// 1. typ === aa-resource+jwt (and a fully-specified alg) +// 2. dwk === aauth-resource.json; signature verifies against the +// resource's JWKS discovered at {iss}/.well-known/{dwk} +// 3. exp in the future, iat not in the future +// 4. aud === this PS +// 5. agent_jkt === thumbprint of the key that signed the HTTP request +// (or, with a subagent_token, of the sub-agent's cnf.jwk) +// 6. the person token named by `person_token_jti` is one WE issued, and +// its `ps`, `sub`, `mission_s256` and `tenant` match exactly +// 7. mission active — mockin has no mission store, see the note below // -// Returns the resource server URL, scope, R3 claims (if any), or an error. +// Resource tokens no longer carry an `agent` claim: `agent_jkt` binds the +// key and the PS learns the agent from the agent token that signed the +// request. import * as jose from 'jose' import { ISSUER } from '../config.js' import { getEntity, RESOURCE_DWK } from './entity-cache.js' +import { ACCEPTED_JWT_ALGS, checkJwtAlg } from './algorithms.js' +import { getPersonToken } from './person-token-store.js' export async function verifyResourceToken( resourceTokenStr, - expectedAgentId, expectedJkt, ) { let header, payload @@ -34,7 +42,13 @@ export async function verifyResourceToken( error: `invalid resource_token typ: expected aa-resource+jwt, got ${header.typ}`, } } + const algError = checkJwtAlg(header.alg) + if (algError) return { error: `resource_token ${algError}` } + const dwk = payload.dwk || RESOURCE_DWK + if (dwk !== RESOURCE_DWK) { + return { error: `resource_token dwk must be ${RESOURCE_DWK}, got ${dwk}` } + } const resourceUrl = payload.iss if (!resourceUrl) return { error: 'resource_token missing iss' } @@ -49,26 +63,71 @@ export async function verifyResourceToken( await jose.jwtVerify( resourceTokenStr, jose.createLocalJWKSet(entity.jwks), + { algorithms: ACCEPTED_JWT_ALGS }, ) } catch (err) { - return { error: `resource_token signature: ${err.message}` } + // jose checks exp/iat for us; surface expiry as its own error code + // so the endpoint can return `expired_resource_token`. + return { + error: `resource_token ${err.code === 'ERR_JWT_EXPIRED' ? 'expired' : `signature: ${err.message}`}`, + expired: err.code === 'ERR_JWT_EXPIRED', + } } - if (payload.aud && payload.aud !== ISSUER) { + if (payload.aud !== ISSUER) { return { error: `resource_token aud mismatch: expected ${ISSUER}, got ${payload.aud}`, } } - if (payload.agent && payload.agent !== expectedAgentId) { + if (!payload.agent_jkt) { + return { error: 'resource_token missing agent_jkt' } + } + if (payload.agent_jkt !== expectedJkt) { return { - error: `resource_token agent mismatch: expected ${expectedAgentId}, got ${payload.agent}`, + error: `resource_token agent_jkt mismatch: expected ${expectedJkt}, got ${payload.agent_jkt}`, } } - if (payload.agent_jkt && payload.agent_jkt !== expectedJkt) { + + // ── Step 6 ───────────────────────────────────────────────────────── + // The claims a resource copies out of the person token it verified. + if (!payload.person_token_jti) { + return { error: 'resource_token missing person_token_jti' } + } + if (!payload.ps) return { error: 'resource_token missing ps' } + if (!payload.sub) return { error: 'resource_token missing sub' } + + const issued = getPersonToken(payload.person_token_jti) + if (!issued) { return { - error: `resource_token agent_jkt mismatch: expected ${expectedJkt}, got ${payload.agent_jkt}`, + error: `person_token_jti "${payload.person_token_jti}" names no person token this PS issued (or it has expired)`, + } + } + if (payload.ps !== issued.ps) { + return { + error: `resource_token ps mismatch: person token has ${issued.ps}, resource_token has ${payload.ps}`, + } + } + if (payload.sub !== issued.sub) { + return { + error: `resource_token sub mismatch: person token has ${issued.sub}, resource_token has ${payload.sub}`, + } + } + // "rejecting the resource token on any mismatch or omission" — a + // dropped mission_s256 is exactly the stripping this check exists to + // catch, so absent-vs-present is a mismatch in both directions. + if ((payload.mission_s256 || null) !== (issued.mission_s256 || null)) { + return { + error: `resource_token mission_s256 mismatch: person token has ${issued.mission_s256 || '(none)'}, resource_token has ${payload.mission_s256 || '(none)'}`, + } + } + if ((payload.tenant || null) !== (issued.tenant || null)) { + return { + error: `resource_token tenant mismatch: person token has ${issued.tenant || '(none)'}, resource_token has ${payload.tenant || '(none)'}`, } } + // Step 7 (mission active, before its expires_at) needs a mission + // store. mission_endpoint is unimplemented fleet-wide, so there is no + // mission to look up; the binding above is the part the fleet tests. const r3Uri = typeof payload.r3_uri === 'string' && payload.r3_uri ? payload.r3_uri : null const r3S256 = typeof payload.r3_s256 === 'string' && payload.r3_s256 ? payload.r3_s256 : null @@ -82,6 +141,13 @@ export async function verifyResourceToken( resource_url: resourceUrl, resource_metadata: entity.metadata, scope: typeof payload.scope === 'string' ? payload.scope : '', + ps: payload.ps, + sub: payload.sub, + person_token_jti: payload.person_token_jti, + mission_s256: payload.mission_s256 || null, + tenant: payload.tenant || null, + account: payload.account || null, + interaction: payload.interaction || null, r3: r3Uri ? { uri: r3Uri, s256: r3S256 } : null, } } diff --git a/src/aauth/verify-sig.js b/src/aauth/verify-sig.js deleted file mode 100644 index 8ce6288..0000000 --- a/src/aauth/verify-sig.js +++ /dev/null @@ -1,30 +0,0 @@ -// aauth/verify-sig.js — HTTPSig verification preHandler for Fastify - -import { verify } from '@hellocoop/httpsig' - -export const verifySig = async (request, reply) => { - const url = new URL(request.url, `http://${request.headers.host || 'localhost'}`) - - const verifyRequest = { - method: request.method, - authority: url.host, - path: url.pathname, - query: url.search ? url.search.slice(1) : undefined, - headers: request.headers, - body: request.rawBody, - } - - // httpsig 2.0 removed strictAAuth — signature-key coverage is always - // enforced now. - const result = await verify(verifyRequest) - - if (!result.verified) { - reply.code(401).send({ - error: 'invalid_signature', - error_description: result.error || 'HTTP signature verification failed', - }) - return - } - - request.aauth = result -} diff --git a/src/api.js b/src/api.js index 46e6860..6fd8bae 100644 --- a/src/api.js +++ b/src/api.js @@ -43,12 +43,18 @@ export default function (fastify) { fastify.get('/.well-known/aauth-person.json', aauth.metadata) fastify.get('/aauth/jwks.json', aauth.jwks) - // AAuth: token endpoint + // AAuth: auth token endpoint (metadata: auth_token_endpoint) fastify.post('/aauth/token', { preParsing: captureRawBody, preHandler: aauth.verifyPreHandler, }, aauth.token) + // AAuth: person token endpoint (metadata: person_token_endpoint) + fastify.post('/aauth/person', { + preParsing: captureRawBody, + preHandler: aauth.verifyPreHandler, + }, aauth.person) + // AAuth: pending endpoint (poll, clarify, cancel) — verification runs // inside the handler since bootstrap polls use hwk and others use jwt. fastify.get('/aauth/pending/:id', aauth.pendingGet) diff --git a/test/aauth/bootstrap.spec.js b/test/aauth/bootstrap.spec.js index cb9e12a..c902017 100644 --- a/test/aauth/bootstrap.spec.js +++ b/test/aauth/bootstrap.spec.js @@ -20,6 +20,7 @@ import { ISSUER } from '../../src/config.js' import { installMocks, signedHwkRequest, + PS_BODY_COMPONENTS, ephemeralPublicJwk, } from './helpers.js' @@ -230,6 +231,7 @@ describe('AAuth /aauth/bootstrap', function () { body: '{}', signingKey: ephPrivJwk, signatureKey: { type: 'jwt', jwt: wrong }, + components: PS_BODY_COMPONENTS, dryRun: true, }) const out = {} diff --git a/test/aauth/end-to-end.spec.js b/test/aauth/end-to-end.spec.js index 4dd01a7..4e81dfb 100644 --- a/test/aauth/end-to-end.spec.js +++ b/test/aauth/end-to-end.spec.js @@ -13,7 +13,7 @@ import { fetch as httpsigFetch, verify as httpsigVerify } from '@hellocoop/https import Fastify from 'fastify' import api from '../../src/api.js' -import { installMocks, signedHwkRequest } from './helpers.js' +import { installMocks, PS_BODY_COMPONENTS } from './helpers.js' const fastify = Fastify() api(fastify) @@ -44,6 +44,7 @@ describe('AAuth bootstrap end-to-end', function () { body: JSON.stringify({ agent_server: 'http://agent.example' }), signingKey: ephemeralPrivateJwk, signatureKey: { type: 'hwk' }, + components: PS_BODY_COMPONENTS, dryRun: true, }) const out = {} diff --git a/test/aauth/helpers.js b/test/aauth/helpers.js index 6370d2c..f13a147 100644 --- a/test/aauth/helpers.js +++ b/test/aauth/helpers.js @@ -15,7 +15,7 @@ import { createHash, randomUUID } from 'crypto' import { - generateKeyPair, exportJWK, SignJWT, calculateJwkThumbprint, + generateKeyPair, exportJWK, SignJWT, calculateJwkThumbprint, decodeJwt, } from 'jose' import { fetch as httpsigFetch } from '@hellocoop/httpsig' @@ -62,12 +62,14 @@ export const DEFAULT_AGENT_ID = `aauth:agent@${new URL(AGENT_SERVER_URL).host}` export async function installMocks(fastify) { await fastify.inject({ method: 'DELETE', url: '/mock' }) + // Agent and resource metadata publish `name`, never `client_name` — + // the RFC 7591 borrowing appears nowhere in the AAuth specs. const trusted = { [AGENT_SERVER_URL]: { metadata: { issuer: AGENT_SERVER_URL, jwks_uri: `${AGENT_SERVER_URL}/.well-known/jwks.json`, - client_name: 'Mock Agent Server', + name: 'Mock Agent Server', }, jwks: { keys: [agentServer.publicJwk] }, }, @@ -75,7 +77,7 @@ export async function installMocks(fastify) { metadata: { issuer: RESOURCE_SERVER_URL, jwks_uri: `${RESOURCE_SERVER_URL}/.well-known/jwks.json`, - client_name: 'Mock Resource Server', + name: 'Mock Resource Server', scope_descriptions: { whoami: 'Read identity' }, }, jwks: { keys: [resourceServer.publicJwk] }, @@ -95,10 +97,14 @@ export async function mintAgentToken({ sub = DEFAULT_AGENT_ID, ps = ISSUER, cnf_jwk = ephemeralPublicJwk, + parent_agent = undefined, ttl = 600, + // -10 forbids the polymorphic 'EdDSA'; tests override this to prove + // mockin declines it. + alg = 'Ed25519', } = {}) { const now = Math.floor(Date.now() / 1000) - return await new SignJWT({ + const payload = { iss: AGENT_SERVER_URL, dwk: 'aauth-agent.json', sub, @@ -107,32 +113,62 @@ export async function mintAgentToken({ iat: now, exp: now + ttl, jti: randomUUID(), - }) - .setProtectedHeader({ alg: 'Ed25519', typ: 'aa-agent+jwt', kid: agentServer.kid }) + } + if (parent_agent) payload.parent_agent = parent_agent + return await new SignJWT(payload) + .setProtectedHeader({ alg, typ: 'aa-agent+jwt', kid: agentServer.kid }) .sign(agentServer.privateKey) } +// -11 §Resource Token Structure: `ps`, `sub` and `person_token_jti` are +// copied from the person token the resource verified, `agent_jkt` binds +// the agent's key. There is no `agent` claim any more. export async function mintResourceToken({ scope = 'openid email', aud = ISSUER, - agent = DEFAULT_AGENT_ID, + ps = ISSUER, + sub, + person_token_jti, agent_jkt = ephemeralJkt, + mission_s256 = null, + tenant = null, + account = null, r3_uri = null, r3_s256 = null, ttl = 300, + personToken = null, } = {}) { + // Given a person token, copy from it — the normal case. Pass `false` + // for any field to omit it deliberately (what a resource stripping a + // claim would produce), or a value to make it disagree. + if (personToken) { + const pt = decodeJwt(personToken) + sub = sub ?? pt.sub + person_token_jti = person_token_jti ?? pt.jti + ps = ps ?? pt.iss + if (mission_s256 === null && pt.mission_s256) mission_s256 = pt.mission_s256 + if (tenant === null && pt.tenant) tenant = pt.tenant + } const now = Math.floor(Date.now() / 1000) const payload = { iss: RESOURCE_SERVER_URL, dwk: 'aauth-resource.json', aud, - agent, + ps, + sub, + person_token_jti, agent_jkt, scope, iat: now, exp: now + ttl, jti: randomUUID(), } + for (const k of ['ps', 'sub', 'person_token_jti']) { + if (!payload[k]) delete payload[k] + } + if (mission_s256) payload.mission_s256 = mission_s256 + if (tenant) payload.tenant = tenant + if (account) payload.account = account if (r3_uri) payload.r3_uri = r3_uri if (r3_s256) payload.r3_s256 = r3_s256 return await new SignJWT(payload) @@ -140,6 +176,66 @@ export async function mintResourceToken({ .sign(resourceServer.privateKey) } +// ── Person tokens ────────────────────────────────────────────────────── +// +// Almost every /aauth/token test now needs one first: the PS will only +// accept a resource token whose person_token_jti names a person token it +// issued (§Resource Token Verification step 6). + +export async function requestPersonToken(fastify, { + resource = RESOURCE_SERVER_URL, + agentToken, + ...rest +} = {}) { + const token = agentToken || (await mintAgentToken()) + const { headers, payload } = await signedRequest({ + method: 'POST', + path: '/aauth/person', + body: { resource, ...rest }, + agentToken: token, + }) + return fastify.inject({ + method: 'POST', + url: '/aauth/person', + headers, + payload, + }) +} + +/** 200-path convenience: returns { person_token, claims, agentToken }. */ +export async function getPersonToken(fastify, options = {}) { + const agentToken = options.agentToken || (await mintAgentToken()) + const res = await requestPersonToken(fastify, { ...options, agentToken }) + if (res.statusCode !== 200) { + throw new Error( + `person token request failed: ${res.statusCode} ${res.payload}`, + ) + } + const { person_token, expires_in } = res.json() + return { + person_token, + expires_in, + claims: decodeJwt(person_token), + agentToken, + } +} + +/** + * The common setup: get a person token, then a resource token copied from + * it. Overrides let a test corrupt exactly one copied claim. + */ +export async function personAndResourceToken(fastify, { + person = {}, + resource = {}, +} = {}) { + const { person_token, claims, agentToken } = await getPersonToken(fastify, person) + const resourceToken = await mintResourceToken({ + personToken: person_token, + ...resource, + }) + return { agentToken, person_token, personClaims: claims, resourceToken } +} + // ── R3 doc helpers ───────────────────────────────────────────────────── export function r3Hash(bodyStr) { @@ -174,7 +270,17 @@ export async function registerR3Document(fastify, r3_uri, document) { const issuerHost = new URL(ISSUER).host -async function sigHeaders({ method, path, body, signatureKey }) { +// -11: a request carrying a body to a PS endpoint MUST sign +// `content-digest` and `content-type`. httpsig only generates +// Content-Digest when the covered-component list names it, and its +// DEFAULT_COMPONENTS_BODY does not — so pass the list explicitly. +export const PS_BODY_COMPONENTS = [ + '@method', '@authority', '@path', + 'content-type', 'content-digest', + 'signature-key', +] + +async function sigHeaders({ method, path, body, signatureKey, components }) { const url = `${ISSUER}${path}` const opts = { method, @@ -185,6 +291,9 @@ async function sigHeaders({ method, path, body, signatureKey }) { if (body !== undefined) { opts.headers = { 'content-type': 'application/json' } opts.body = body + opts.components = components || PS_BODY_COMPONENTS + } else if (components) { + opts.components = components } const { headers } = await httpsigFetch(url, opts) const out = {} @@ -193,9 +302,9 @@ async function sigHeaders({ method, path, body, signatureKey }) { return out } -// JWT scheme — token, pending, permission, audit, interaction. +// JWT scheme — person, token, pending, permission, audit, interaction. export async function signedRequest({ - method, path, body, agentToken, + method, path, body, agentToken, components, }) { const bodyStr = body === undefined ? undefined @@ -204,13 +313,14 @@ export async function signedRequest({ method, path, body: bodyStr, + components, signatureKey: { type: 'jwt', jwt: agentToken }, }) return { headers, payload: bodyStr } } // HWK scheme — bootstrap. -export async function signedHwkRequest({ method, path, body }) { +export async function signedHwkRequest({ method, path, body, components }) { const bodyStr = body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body) @@ -218,6 +328,7 @@ export async function signedHwkRequest({ method, path, body }) { method, path, body: bodyStr, + components, signatureKey: { type: 'hwk' }, }) return { headers, payload: bodyStr } diff --git a/test/aauth/metadata.spec.js b/test/aauth/metadata.spec.js index bf0adb2..10dd619 100644 --- a/test/aauth/metadata.spec.js +++ b/test/aauth/metadata.spec.js @@ -17,13 +17,27 @@ describe('AAuth Metadata & JWKS', function () { const data = response.json() expect(data.issuer).to.equal(ISSUER) expect(data.jwks_uri).to.equal(`${ISSUER}/aauth/jwks.json`) - expect(data.token_endpoint).to.equal(`${ISSUER}/aauth/token`) + // -11 renamed token_endpoint → auth_token_endpoint and made + // person_token_endpoint REQUIRED of every PS. + expect(data.auth_token_endpoint).to.equal(`${ISSUER}/aauth/token`) + expect(data.person_token_endpoint).to.equal(`${ISSUER}/aauth/person`) + expect(data).to.not.have.property('token_endpoint') expect(data.permission_endpoint).to.equal(`${ISSUER}/aauth/permission`) expect(data.audit_endpoint).to.equal(`${ISSUER}/aauth/audit`) expect(data.interaction_endpoint).to.equal(`${ISSUER}/aauth/interaction`) expect(data.bootstrap_endpoint).to.equal(`${ISSUER}/aauth/bootstrap`) }) + it('publishes both token endpoints @aauth/bootstrap 2.0.0 requires', async function () { + const data = (await fastify.inject({ + method: 'GET', + url: '/.well-known/aauth-person.json', + })).json() + for (const field of ['auth_token_endpoint', 'person_token_endpoint']) { + expect(data[field], field).to.be.a('string') + } + }) + it('sets Cache-Control', async function () { const response = await fastify.inject({ method: 'GET', diff --git a/test/aauth/pending.spec.js b/test/aauth/pending.spec.js index 507b8a8..2281bbe 100644 --- a/test/aauth/pending.spec.js +++ b/test/aauth/pending.spec.js @@ -10,8 +10,8 @@ import api from '../../src/api.js' import { installMocks, mintAgentToken, - mintResourceToken, signedRequest, + personAndResourceToken, } from './helpers.js' const fastify = Fastify() @@ -27,8 +27,9 @@ async function setRequirement(req) { } async function startTokenRequest() { - const agentToken = await mintAgentToken() - const resourceToken = await mintResourceToken({ scope: 'openid email' }) + const { agentToken, resourceToken } = await personAndResourceToken(fastify, { + resource: { scope: 'openid email' }, + }) const { headers, payload } = await signedRequest({ method: 'POST', path: '/aauth/token', diff --git a/test/aauth/person.spec.js b/test/aauth/person.spec.js new file mode 100644 index 0000000..8445069 --- /dev/null +++ b/test/aauth/person.spec.js @@ -0,0 +1,472 @@ +// POST /aauth/person — the PS person_token_endpoint (-11 §Person Token +// Endpoint, interop demo profile surface 2). + +import { expect } from 'chai' +import { + decodeProtectedHeader, decodeJwt, jwtVerify, createLocalJWKSet, + generateKeyPair, exportJWK, calculateJwkThumbprint, +} from 'jose' +import Fastify from 'fastify' + +import api from '../../src/api.js' +import { ISSUER } from '../../src/config.js' +import { + installMocks, + mintAgentToken, + signedRequest, + requestPersonToken, + getPersonToken, + ephemeralPublicJwk, + ephemeralJkt, + RESOURCE_SERVER_URL, + DEFAULT_AGENT_ID, +} from './helpers.js' + +const fastify = Fastify() +api(fastify) + +const MISSION_S256 = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' + +async function setMock(patch) { + await fastify.inject({ + method: 'PUT', + url: '/mock/aauth', + headers: { 'content-type': 'application/json' }, + payload: JSON.stringify(patch), + }) +} + +describe('AAuth /aauth/person — person token endpoint', function () { + beforeEach(async function () { + await installMocks(fastify) + }) + + it('issues an aa-person+jwt with the -11 claim set', async function () { + const res = await requestPersonToken(fastify, { + resource: RESOURCE_SERVER_URL, + }) + expect(res.statusCode).to.equal(200) + const { person_token, expires_in } = res.json() + expect(person_token).to.be.a('string') + expect(expires_in).to.be.a('number') + + const header = decodeProtectedHeader(person_token) + expect(header.alg).to.equal('Ed25519') + expect(header.typ).to.equal('aa-person+jwt') + expect(header.kid).to.be.a('string') + + const claims = decodeJwt(person_token) + expect(claims.iss).to.equal(ISSUER) + expect(claims.dwk).to.equal('aauth-person.json') + expect(claims.aud).to.equal(RESOURCE_SERVER_URL) + expect(claims.sub).to.be.a('string') + expect(claims.jti).to.be.a('string') + expect(claims.iat).to.be.a('number') + expect(claims.exp).to.be.a('number') + expect(claims.cnf?.jwk).to.deep.include({ + kty: ephemeralPublicJwk.kty, + crv: ephemeralPublicJwk.crv, + x: ephemeralPublicJwk.x, + alg: 'Ed25519', + }) + // "A person token MUST NOT contain scope or account." + expect(claims).to.not.have.property('scope') + expect(claims).to.not.have.property('account') + expect(claims).to.not.have.property('agent') + }) + + it('verifies with the published PS JWKS', async function () { + const jwks = (await fastify.inject({ + method: 'GET', url: '/aauth/jwks.json', + })).json() + const { person_token } = await getPersonToken(fastify) + const { payload } = await jwtVerify(person_token, createLocalJWKSet(jwks), { + algorithms: ['Ed25519'], + }) + expect(payload.iss).to.equal(ISSUER) + }) + + it('sub matches the auth token sub for the same aud', async function () { + // The single most consequential invariant: two different values + // make every resource token fail the PS's own step-6 check. + const { person_token, claims } = await getPersonToken(fastify) + const { mintResourceToken } = await import('./helpers.js') + const agentToken = await mintAgentToken() + const resourceToken = await mintResourceToken({ personToken: person_token }) + const { headers, payload } = await signedRequest({ + method: 'POST', + path: '/aauth/token', + body: { resource_token: resourceToken }, + agentToken, + }) + const res = await fastify.inject({ + method: 'POST', url: '/aauth/token', headers, payload, + }) + expect(res.statusCode).to.equal(200) + const auth = decodeJwt(res.json().auth_token) + expect(auth.sub).to.equal(claims.sub) + expect(auth.aud).to.equal(claims.aud) + }) + + it('directs sub per resource', async function () { + const a = await getPersonToken(fastify, { resource: RESOURCE_SERVER_URL }) + const b = await getPersonToken(fastify, { resource: 'https://other.example' }) + expect(a.claims.sub).to.not.equal(b.claims.sub) + // …and is stable for the same resource. + const again = await getPersonToken(fastify, { resource: RESOURCE_SERVER_URL }) + expect(again.claims.sub).to.equal(a.claims.sub) + }) + + it('stamps mission_s256 when the request names one', async function () { + const { claims } = await getPersonToken(fastify, { + mission_s256: MISSION_S256, + }) + expect(claims.mission_s256).to.equal(MISSION_S256) + }) + + it('stamps tenant when the agent names one', async function () { + // AAuth issue #88: the agent naming the tenant is what selects + // which of a person's contexts the token carries. + const { claims } = await getPersonToken(fastify, { tenant: 'acme' }) + expect(claims.tenant).to.equal('acme') + }) + + it('caps exp at one hour', async function () { + await setMock({ token_lifetime: 7200 }) + const agentToken = await mintAgentToken({ ttl: 7200 }) + const { claims } = await getPersonToken(fastify, { agentToken }) + expect(claims.exp - claims.iat).to.equal(3600) + }) + + it('never outlives the presented agent token', async function () { + const agentToken = await mintAgentToken({ ttl: 120 }) + const { claims } = await getPersonToken(fastify, { agentToken }) + const agentClaims = decodeJwt(agentToken) + expect(claims.exp).to.be.at.most(agentClaims.exp) + }) + + describe('request validation', function () { + it('400 when resource is missing', async function () { + const res = await requestPersonToken(fastify, { resource: null }) + expect(res.statusCode).to.equal(400) + expect(res.json().error).to.equal('invalid_request') + }) + + it('400 when resource is not a server identifier', async function () { + for (const bad of [ + 'not-a-url', + 'http://rs.example', // not https + 'https://rs.example/v1', // path + 'https://rs.example/', // trailing slash + 'https://RS.example', // not lowercase + 'https://rs.example?x=1', // query + ]) { + const res = await requestPersonToken(fastify, { resource: bad }) + expect(res.statusCode, bad).to.equal(400) + expect(res.json().error, bad).to.equal('invalid_request') + } + }) + + it('400 on upstream_token — call chaining is not implemented', async function () { + const res = await requestPersonToken(fastify, { + upstream_token: 'eyJhbGciOiJFZDI1NTE5In0.e30.x', + }) + expect(res.statusCode).to.equal(400) + expect(res.json().error).to.equal('invalid_request') + expect(res.json().error_description).to.match(/upstream_token/) + }) + + it('401 when the body signature does not cover content-digest', async function () { + const agentToken = await mintAgentToken() + const { headers, payload } = await signedRequest({ + method: 'POST', + path: '/aauth/person', + body: { resource: RESOURCE_SERVER_URL }, + agentToken, + // httpsig's own default list for a body — no content-digest. + components: ['@method', '@authority', '@path', 'content-type', 'signature-key'], + }) + const res = await fastify.inject({ + method: 'POST', url: '/aauth/person', headers, payload, + }) + expect(res.statusCode).to.equal(401) + expect(res.json().error_description).to.match(/content-digest/) + expect(res.headers['accept-signature']).to.match(/content-digest/) + }) + + it('accepts an uncovered body when require_body_signing is off', async function () { + await setMock({ require_body_signing: false }) + const agentToken = await mintAgentToken() + const { headers, payload } = await signedRequest({ + method: 'POST', + path: '/aauth/person', + body: { resource: RESOURCE_SERVER_URL }, + agentToken, + components: ['@method', '@authority', '@path', 'content-type', 'signature-key'], + }) + const res = await fastify.inject({ + method: 'POST', url: '/aauth/person', headers, payload, + }) + expect(res.statusCode).to.equal(200) + }) + + it('401 when the agent token is signed with the polymorphic EdDSA', async function () { + // -10: implementations MUST NOT accept `EdDSA`, and there is no + // transition allowance. The key is the same Ed25519 key, so + // only the alg identifier is wrong. + const agentToken = await mintAgentToken({ alg: 'EdDSA' }) + const res = await requestPersonToken(fastify, { agentToken }) + expect(res.statusCode).to.equal(401) + expect(res.json().error).to.equal('invalid_jwt') + expect(res.json().error_description).to.match(/EdDSA/) + }) + + it('401 when no signature is present', async function () { + const res = await fastify.inject({ + method: 'POST', + url: '/aauth/person', + headers: { 'content-type': 'application/json' }, + payload: JSON.stringify({ resource: RESOURCE_SERVER_URL }), + }) + expect(res.statusCode).to.equal(401) + expect(res.json().error).to.equal('signature_required') + expect(res.headers['accept-signature-alg']).to.match(/Ed25519/) + }) + + it('rejects an unknown platform and an over-long device', async function () { + const bad = await requestPersonToken(fastify, { platform: 'toaster' }) + expect(bad.statusCode).to.equal(400) + const long = await requestPersonToken(fastify, { device: 'x'.repeat(65) }) + expect(long.statusCode).to.equal(400) + }) + + it('accepts the auth-token endpoint parameter set', async function () { + const res = await requestPersonToken(fastify, { + justification: '## Why\nTo read your calendar.', + login_hint: 'john.smith@example.com', + domain_hint: 'example.com', + prompt: 'consent', + platform: 'desktop', + device: 'Chrome on macOS', + capabilities: ['interaction', 'payment', 'teleportation'], + }) + expect(res.statusCode).to.equal(200) + }) + + it('returns a mock-injected error', async function () { + await setMock({ error: 'denied', error_endpoint: 'person' }) + const res = await requestPersonToken(fastify) + expect(res.statusCode).to.equal(403) + expect(res.json().error).to.equal('denied') + }) + }) + + describe('sub-agent tokens', function () { + async function mintSubAgent(parent = DEFAULT_AGENT_ID) { + const kp = await generateKeyPair('Ed25519') + const jwk = await exportJWK(kp.publicKey) + jwk.alg = 'Ed25519' + const token = await mintAgentToken({ + sub: 'aauth:subagent@as.example', + cnf_jwk: jwk, + parent_agent: parent, + }) + return { token, jwk, jkt: await calculateJwkThumbprint(jwk) } + } + + it('binds cnf to the sub-agent key when subagent_token is presented', async function () { + const sub = await mintSubAgent() + const res = await requestPersonToken(fastify, { + subagent_token: sub.token, + }) + expect(res.statusCode).to.equal(200) + const claims = decodeJwt(res.json().person_token) + expect(claims.cnf.jwk.x).to.equal(sub.jwk.x) + expect(claims.cnf.jwk.x).to.not.equal(ephemeralPublicJwk.x) + }) + + it('completes surface 5: sub-agent person token → resource token → auth token', async function () { + const sub = await mintSubAgent() + const { mintResourceToken } = await import('./helpers.js') + + // 1. The parent obtains a person token bound to the sub-agent's key. + const ptRes = await requestPersonToken(fastify, { subagent_token: sub.token }) + expect(ptRes.statusCode).to.equal(200) + const person_token = ptRes.json().person_token + + // 2. The resource issues a resource token bound to the + // sub-agent's key (agent_jkt = its thumbprint). + const resourceToken = await mintResourceToken({ + personToken: person_token, + agent_jkt: sub.jkt, + }) + + // 3. The parent presents both to the auth token endpoint. + const agentToken = await mintAgentToken() + const { headers, payload } = await signedRequest({ + method: 'POST', + path: '/aauth/token', + body: { resource_token: resourceToken, subagent_token: sub.token }, + agentToken, + }) + const res = await fastify.inject({ + method: 'POST', url: '/aauth/token', headers, payload, + }) + expect(res.statusCode).to.equal(200) + // 4. The auth token binds the sub-agent's key, not the parent's. + const claims = decodeJwt(res.json().auth_token) + expect(claims.cnf.jwk.x).to.equal(sub.jwk.x) + expect(claims.sub).to.equal(decodeJwt(person_token).sub) + }) + + it('rejects a subagent_token whose parent_agent is not the signer', async function () { + const sub = await mintSubAgent('aauth:someone-else@as.example') + const res = await requestPersonToken(fastify, { + subagent_token: sub.token, + }) + expect(res.statusCode).to.equal(400) + expect(res.json().error).to.equal('invalid_agent_token') + expect(res.json().error_description).to.match(/parent_agent/) + }) + }) + + describe('deferred consent path', function () { + it('202 + AAuth-Requirement, then poll → person_token', async function () { + await setMock({ person_requirement: 'interaction' }) + + const agentToken = await mintAgentToken() + const init = await requestPersonToken(fastify, { agentToken }) + expect(init.statusCode).to.equal(202) + expect(init.headers.location).to.match(/\/aauth\/pending\//) + expect(init.headers['retry-after']).to.be.a('string') + expect(init.headers['cache-control']).to.equal('no-store') + const requirement = init.headers['aauth-requirement'] + expect(requirement).to.match(/requirement=interaction/) + expect(requirement).to.match(/url="/) + expect(requirement).to.match(/code="/) + + const path = new URL(init.headers.location).pathname + const { headers: pollHeaders } = await signedRequest({ + method: 'GET', path, agentToken, + }) + const poll = await fastify.inject({ + method: 'GET', url: path, headers: pollHeaders, + }) + expect(poll.statusCode).to.equal(200) + const claims = decodeJwt(poll.json().person_token) + expect(claims.aud).to.equal(RESOURCE_SERVER_URL) + expect(claims.cnf.jwk.x).to.equal(ephemeralPublicJwk.x) + }) + + it('drives the full interaction when auto_approve is off', async function () { + await setMock({ person_requirement: 'interaction', auto_approve: false }) + + const agentToken = await mintAgentToken() + const init = await requestPersonToken(fastify, { agentToken }) + expect(init.statusCode).to.equal(202) + const code = /code="([^"]+)"/.exec(init.headers['aauth-requirement'])[1] + const path = new URL(init.headers.location).pathname + + // Poll before consent: still pending. + const { headers: pollHeaders } = await signedRequest({ + method: 'GET', path, agentToken, + }) + const early = await fastify.inject({ + method: 'GET', url: path, headers: pollHeaders, + }) + expect(early.statusCode).to.equal(202) + expect(early.json().status).to.equal('pending') + + // The person visits the consent URL with the code. + const consent = await fastify.inject({ + method: 'GET', url: `/aauth/consent?code=${encodeURIComponent(code)}`, + }) + expect(consent.statusCode).to.equal(200) + + const done = await fastify.inject({ + method: 'GET', url: path, headers: pollHeaders, + }) + expect(done.statusCode).to.equal(200) + expect(done.json().person_token).to.be.a('string') + }) + + it('mission_s256 survives the deferred round trip', async function () { + await setMock({ person_requirement: 'interaction' }) + const agentToken = await mintAgentToken() + const init = await requestPersonToken(fastify, { + agentToken, mission_s256: MISSION_S256, + }) + const path = new URL(init.headers.location).pathname + const { headers: pollHeaders } = await signedRequest({ + method: 'GET', path, agentToken, + }) + const poll = await fastify.inject({ + method: 'GET', url: path, headers: pollHeaders, + }) + expect(decodeJwt(poll.json().person_token).mission_s256) + .to.equal(MISSION_S256) + }) + + it('403 user_unreachable when the agent cannot drive an interaction', async function () { + // AAuth issue #89: the agent says what it can handle via + // `capabilities`; a 202 it cannot complete is not an answer. + await setMock({ person_requirement: 'interaction' }) + const res = await requestPersonToken(fastify, { + capabilities: ['payment'], + }) + expect(res.statusCode).to.equal(403) + expect(res.json().error).to.equal('user_unreachable') + }) + + it('defers when the agent declares the interaction capability', async function () { + await setMock({ person_requirement: 'interaction' }) + const res = await requestPersonToken(fastify, { + capabilities: ['interaction'], + }) + expect(res.statusCode).to.equal(202) + }) + }) + + describe('the jti store', function () { + it('records the issued token under its jti', async function () { + const { claims } = await getPersonToken(fastify, { + mission_s256: MISSION_S256, + tenant: 'acme', + }) + const { getPersonToken: lookup } = + await import('../../src/aauth/person-token-store.js') + const record = lookup(claims.jti) + expect(record).to.not.be.null + expect(record.ps).to.equal(ISSUER) + expect(record.sub).to.equal(claims.sub) + expect(record.aud).to.equal(RESOURCE_SERVER_URL) + expect(record.mission_s256).to.equal(MISSION_S256) + expect(record.tenant).to.equal('acme') + expect(record.exp).to.equal(claims.exp) + }) + + it('drops expired records', async function () { + const store = await import('../../src/aauth/person-token-store.js') + store.recordPersonToken({ + jti: 'stale', ps: ISSUER, sub: 'x', aud: RESOURCE_SERVER_URL, + exp: Math.floor(Date.now() / 1000) - 1, + }) + expect(store.getPersonToken('stale')).to.be.null + }) + + it('is cleared by DELETE /mock', async function () { + const { claims } = await getPersonToken(fastify) + await fastify.inject({ method: 'DELETE', url: '/mock' }) + const { getPersonToken: lookup } = + await import('../../src/aauth/person-token-store.js') + expect(lookup(claims.jti)).to.be.null + }) + }) + + it('binds agent_jkt through to the resource token', async function () { + // Surface 2: the resource copies ps/sub/jti and binds its own + // token to the key that signed its request. + const { claims } = await getPersonToken(fastify) + expect(await calculateJwkThumbprint(claims.cnf.jwk)).to.equal(ephemeralJkt) + }) +}) diff --git a/test/aauth/r3.fetch.spec.js b/test/aauth/r3.fetch.spec.js index 99cdc2c..feca8fe 100644 --- a/test/aauth/r3.fetch.spec.js +++ b/test/aauth/r3.fetch.spec.js @@ -23,8 +23,8 @@ import { publicJwk, kid as MOCKIN_KID } from '../../src/aauth/keys.js' import { ISSUER } from '../../src/config.js' const R3_URI = 'https://rs.test.example/r3/abc' +// R3 -02 removed the document's `version` field. const SAMPLE_DOC = { - version: '1', vocabulary: 'urn:aauth:vocabulary:openapi', operations: [{ operationId: 'getProfile' }], } diff --git a/test/aauth/token.errors.spec.js b/test/aauth/token.errors.spec.js index 072bdbd..e023930 100644 --- a/test/aauth/token.errors.spec.js +++ b/test/aauth/token.errors.spec.js @@ -1,20 +1,40 @@ -// /aauth/token error paths — bad signatures, mismatched claims, mock errors. +// /aauth/token error paths — bad signatures, mismatched claims, mock +// errors, and the -11 §Resource Token Verification step-6 binding: the +// resource token must name a person token this PS issued, and its `ps`, +// `sub`, `mission_s256` and `tenant` must match that token exactly. import { expect } from 'chai' +import { randomUUID } from 'crypto' import Fastify from 'fastify' import api from '../../src/api.js' -import { ISSUER } from '../../src/config.js' import { installMocks, mintAgentToken, mintResourceToken, signedRequest, + getPersonToken, + personAndResourceToken, } from './helpers.js' const fastify = Fastify() api(fastify) +const MISSION_S256 = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' + +async function postResourceToken(resourceToken, agentToken) { + const token = agentToken || (await mintAgentToken()) + const { headers, payload } = await signedRequest({ + method: 'POST', + path: '/aauth/token', + body: { resource_token: resourceToken }, + agentToken: token, + }) + return fastify.inject({ + method: 'POST', url: '/aauth/token', headers, payload, + }) +} + describe('AAuth /aauth/token — errors', function () { beforeEach(async function () { await installMocks(fastify) @@ -29,6 +49,8 @@ describe('AAuth /aauth/token — errors', function () { }) expect(response.statusCode).to.equal(401) expect(response.headers['accept-signature']).to.be.a('string') + // -11: the body signature must cover content-digest and content-type. + expect(response.headers['accept-signature']).to.match(/content-digest/) expect(response.json().error).to.equal('signature_required') }) @@ -39,19 +61,10 @@ describe('AAuth /aauth/token — errors', function () { segs[2] = 'AAAAAAAAAAAAAAAAAAAAAA' const tampered = segs.join('.') - const resourceToken = await mintResourceToken({ scope: 'openid' }) - const { headers, payload } = await signedRequest({ - method: 'POST', - path: '/aauth/token', - body: { resource_token: resourceToken }, - agentToken: tampered, - }) - const response = await fastify.inject({ - method: 'POST', - url: '/aauth/token', - headers, - payload, + const resourceToken = await mintResourceToken({ + scope: 'openid', sub: 'x', person_token_jti: 'y', }) + const response = await postResourceToken(resourceToken, tampered) // The HTTPSig step verifies the HTTP signature using cnf.jwk from // the JWT — that still passes because we used the real ephemeral // key — and then mockin rejects because the JWT signature itself @@ -78,71 +91,163 @@ describe('AAuth /aauth/token — errors', function () { expect(response.json().error).to.equal('invalid_request') }) - it('400 invalid_resource_token when aud != PS', async function () { - const agentToken = await mintAgentToken() - const resourceToken = await mintResourceToken({ - scope: 'openid', - aud: 'https://wrong-ps.example', - }) + it('400 on upstream_token — call chaining is not implemented', async function () { + const { agentToken, resourceToken } = await personAndResourceToken(fastify) const { headers, payload } = await signedRequest({ method: 'POST', path: '/aauth/token', - body: { resource_token: resourceToken }, + body: { resource_token: resourceToken, upstream_token: 'eyJ.e30.x' }, agentToken, }) const response = await fastify.inject({ - method: 'POST', - url: '/aauth/token', - headers, - payload, + method: 'POST', url: '/aauth/token', headers, payload, }) expect(response.statusCode).to.equal(400) + expect(response.json().error_description).to.match(/upstream_token/) + }) + + it('400 invalid_resource_token when aud != PS', async function () { + const { person_token } = await getPersonToken(fastify) + const resourceToken = await mintResourceToken({ + personToken: person_token, + aud: 'https://wrong-ps.example', + }) + const response = await postResourceToken(resourceToken) + expect(response.statusCode).to.equal(400) expect(response.json().error).to.equal('invalid_resource_token') expect(response.json().error_description).to.match(/aud/) }) it('400 invalid_resource_token when agent_jkt mismatches HTTPSig key', async function () { - const agentToken = await mintAgentToken() + const { person_token } = await getPersonToken(fastify) const resourceToken = await mintResourceToken({ - scope: 'openid', + personToken: person_token, agent_jkt: 'wrongthumbprint', }) - const { headers, payload } = await signedRequest({ - method: 'POST', - path: '/aauth/token', - body: { resource_token: resourceToken }, - agentToken, - }) - const response = await fastify.inject({ - method: 'POST', - url: '/aauth/token', - headers, - payload, - }) + const response = await postResourceToken(resourceToken) expect(response.statusCode).to.equal(400) expect(response.json().error_description).to.match(/agent_jkt/) }) - it('400 invalid_resource_token when agent claim mismatches agent_token sub', async function () { - const agentToken = await mintAgentToken() + it('400 expired_resource_token when the resource token has expired', async function () { + const { person_token } = await getPersonToken(fastify) const resourceToken = await mintResourceToken({ - scope: 'openid', - agent: 'aauth:other@somewhere.example', + personToken: person_token, + ttl: -60, }) - const { headers, payload } = await signedRequest({ - method: 'POST', - path: '/aauth/token', - body: { resource_token: resourceToken }, - agentToken, + const response = await postResourceToken(resourceToken) + expect(response.statusCode).to.equal(400) + expect(response.json().error).to.equal('expired_resource_token') + }) + + describe('person token binding (§Resource Token Verification step 6)', function () { + it('rejects a resource token with no person_token_jti', async function () { + const { person_token } = await getPersonToken(fastify) + const resourceToken = await mintResourceToken({ + personToken: person_token, + person_token_jti: false, + }) + const response = await postResourceToken(resourceToken) + expect(response.statusCode).to.equal(400) + expect(response.json().error_description).to.match(/person_token_jti/) }) - const response = await fastify.inject({ - method: 'POST', - url: '/aauth/token', - headers, - payload, + + it('rejects a person_token_jti this PS never issued', async function () { + const { person_token } = await getPersonToken(fastify) + const resourceToken = await mintResourceToken({ + personToken: person_token, + person_token_jti: randomUUID(), + }) + const response = await postResourceToken(resourceToken) + expect(response.statusCode).to.equal(400) + expect(response.json().error_description) + .to.match(/names no person token this PS issued/) + }) + + it('rejects a mismatched sub', async function () { + const { person_token } = await getPersonToken(fastify) + const resourceToken = await mintResourceToken({ + personToken: person_token, + sub: 'some-other-subject', + }) + const response = await postResourceToken(resourceToken) + expect(response.statusCode).to.equal(400) + expect(response.json().error_description).to.match(/sub mismatch/) + }) + + it('rejects a mismatched ps', async function () { + const { person_token } = await getPersonToken(fastify) + const resourceToken = await mintResourceToken({ + personToken: person_token, + ps: 'https://other-ps.example', + }) + const response = await postResourceToken(resourceToken) + expect(response.statusCode).to.equal(400) + expect(response.json().error_description).to.match(/ps mismatch/) + }) + + it('rejects a stripped mission_s256', async function () { + // The mission-stripping case this binding exists to catch: the + // person token carried a mission, the resource token dropped it. + const { person_token } = await getPersonToken(fastify, { + mission_s256: MISSION_S256, + }) + const resourceToken = await mintResourceToken({ + personToken: person_token, + mission_s256: false, // falsy → omitted from the token + }) + const response = await postResourceToken(resourceToken) + expect(response.statusCode).to.equal(400) + expect(response.json().error_description).to.match(/mission_s256 mismatch/) + }) + + it('rejects an invented mission_s256', async function () { + const { person_token } = await getPersonToken(fastify) + const resourceToken = await mintResourceToken({ + personToken: person_token, + mission_s256: MISSION_S256, + }) + const response = await postResourceToken(resourceToken) + expect(response.statusCode).to.equal(400) + expect(response.json().error_description).to.match(/mission_s256 mismatch/) + }) + + it('rejects a mismatched tenant', async function () { + const { person_token } = await getPersonToken(fastify, { tenant: 'acme' }) + const resourceToken = await mintResourceToken({ + personToken: person_token, + tenant: 'globex', + }) + const response = await postResourceToken(resourceToken) + expect(response.statusCode).to.equal(400) + expect(response.json().error_description).to.match(/tenant mismatch/) + }) + + it('accepts the matching set, mission and tenant included', async function () { + const { person_token } = await getPersonToken(fastify, { + mission_s256: MISSION_S256, + tenant: 'acme', + }) + const resourceToken = await mintResourceToken({ personToken: person_token }) + const response = await postResourceToken(resourceToken) + expect(response.statusCode).to.equal(200) }) + }) + + it('rejects a resource token signed with the polymorphic EdDSA', async function () { + // -10: implementations MUST NOT accept `EdDSA`. Re-sign the header + // is not possible without the resource key, so mint via the helper + // and swap the header — the alg check runs before signature + // verification, so the error names the algorithm. + const { person_token } = await getPersonToken(fastify) + const resourceToken = await mintResourceToken({ personToken: person_token }) + const [, body, sig] = resourceToken.split('.') + const header = Buffer.from( + JSON.stringify({ alg: 'EdDSA', typ: 'aa-resource+jwt', kid: 'rs-key-1' }), + ).toString('base64url') + const response = await postResourceToken(`${header}.${body}.${sig}`) expect(response.statusCode).to.equal(400) - expect(response.json().error_description).to.match(/agent/) + expect(response.json().error_description).to.match(/EdDSA/) }) it('returns mock-injected error code', async function () { @@ -153,25 +258,14 @@ describe('AAuth /aauth/token — errors', function () { payload: JSON.stringify({ error: 'denied' }), }) - const agentToken = await mintAgentToken() const resourceToken = await mintResourceToken({ scope: 'openid' }) - const { headers, payload } = await signedRequest({ - method: 'POST', - path: '/aauth/token', - body: { resource_token: resourceToken }, - agentToken, - }) - const response = await fastify.inject({ - method: 'POST', - url: '/aauth/token', - headers, - payload, - }) + const response = await postResourceToken(resourceToken) expect(response.statusCode).to.equal(403) expect(response.json().error).to.equal('denied') }) it('scopes mock error to a specific endpoint', async function () { + const { agentToken, resourceToken } = await personAndResourceToken(fastify) await fastify.inject({ method: 'PUT', url: '/mock/aauth', @@ -182,20 +276,7 @@ describe('AAuth /aauth/token — errors', function () { }), }) - const agentToken = await mintAgentToken() - const resourceToken = await mintResourceToken({ scope: 'openid' }) - const { headers, payload } = await signedRequest({ - method: 'POST', - path: '/aauth/token', - body: { resource_token: resourceToken }, - agentToken, - }) - const response = await fastify.inject({ - method: 'POST', - url: '/aauth/token', - headers, - payload, - }) + const response = await postResourceToken(resourceToken, agentToken) // Token endpoint not impacted; permission endpoint would be. expect(response.statusCode).to.equal(200) }) diff --git a/test/aauth/token.identity.spec.js b/test/aauth/token.identity.spec.js index 01aa9b5..d1ddc78 100644 --- a/test/aauth/token.identity.spec.js +++ b/test/aauth/token.identity.spec.js @@ -12,10 +12,8 @@ import { ISSUER } from '../../src/config.js' import defaultUser from '../../src/users.js' import { installMocks, - mintAgentToken, - mintResourceToken, signedRequest, - DEFAULT_AGENT_ID, + personAndResourceToken, ephemeralPublicJwk, RESOURCE_SERVER_URL, } from './helpers.js' @@ -23,29 +21,31 @@ import { const fastify = Fastify() api(fastify) +// Every token request now starts from a person token: the PS only accepts +// a resource token whose person_token_jti names one it issued. +async function postToken({ person = {}, resource = {}, body = {} } = {}) { + const { agentToken, resourceToken, personClaims } = + await personAndResourceToken(fastify, { person, resource }) + const { headers, payload } = await signedRequest({ + method: 'POST', + path: '/aauth/token', + body: { resource_token: resourceToken, ...body }, + agentToken, + }) + const response = await fastify.inject({ + method: 'POST', url: '/aauth/token', headers, payload, + }) + return { response, personClaims } +} + describe('AAuth /aauth/token — identity flow (no R3)', function () { beforeEach(async function () { await installMocks(fastify) }) it('issues a verifiable auth_token in auto-approve mode', async function () { - const agentToken = await mintAgentToken() - const resourceToken = await mintResourceToken({ - scope: 'openid email whoami', - }) - - const { headers, payload } = await signedRequest({ - method: 'POST', - path: '/aauth/token', - body: { resource_token: resourceToken }, - agentToken, - }) - - const response = await fastify.inject({ - method: 'POST', - url: '/aauth/token', - headers, - payload, + const { response, personClaims } = await postToken({ + resource: { scope: 'openid email whoami' }, }) expect(response.statusCode).to.equal(200) @@ -62,9 +62,12 @@ describe('AAuth /aauth/token — identity flow (no R3)', function () { expect(claims.iss).to.equal(ISSUER) expect(claims.dwk).to.equal('aauth-person.json') expect(claims.aud).to.equal(RESOURCE_SERVER_URL) - expect(claims.sub).to.equal(defaultUser.sub) - expect(claims.agent).to.equal(DEFAULT_AGENT_ID) - expect(claims.act).to.deep.equal({ sub: DEFAULT_AGENT_ID }) + // -11: `ps` REQUIRED, `sub` REQUIRED and equal to the person + // token's; no `agent` claim and no `act`. + expect(claims.ps).to.equal(ISSUER) + expect(claims.sub).to.equal(personClaims.sub) + expect(claims).to.not.have.property('agent') + expect(claims).to.not.have.property('act') expect(claims.cnf?.jwk).to.deep.include({ kty: ephemeralPublicJwk.kty, crv: ephemeralPublicJwk.crv, @@ -81,33 +84,31 @@ describe('AAuth /aauth/token — identity flow (no R3)', function () { method: 'GET', url: '/aauth/jwks.json', }) - const jwks = jwksRes.json() - const localJwks = createLocalJWKSet(jwks) + const localJwks = createLocalJWKSet(jwksRes.json()) - const agentToken = await mintAgentToken() - const resourceToken = await mintResourceToken({ scope: 'openid' }) - - const { headers, payload } = await signedRequest({ - method: 'POST', - path: '/aauth/token', - body: { resource_token: resourceToken }, - agentToken, - }) - const response = await fastify.inject({ - method: 'POST', - url: '/aauth/token', - headers, - payload, - }) + const { response } = await postToken({ resource: { scope: 'openid' } }) expect(response.statusCode).to.equal(200) const { payload: verified } = await jwtVerify( response.json().auth_token, localJwks, + { algorithms: ['Ed25519'] }, ) expect(verified.iss).to.equal(ISSUER) }) + it('copies mission_s256 and tenant from the resource token', async function () { + const mission_s256 = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' + const { response } = await postToken({ + person: { mission_s256, tenant: 'acme' }, + resource: { scope: 'whoami' }, + }) + expect(response.statusCode).to.equal(200) + const claims = decodeJwt(response.json().auth_token) + expect(claims.mission_s256).to.equal(mission_s256) + expect(claims.tenant).to.equal('acme') + }) + it('honours mock token_lifetime override', async function () { await fastify.inject({ method: 'PUT', @@ -116,20 +117,7 @@ describe('AAuth /aauth/token — identity flow (no R3)', function () { payload: JSON.stringify({ token_lifetime: 60 }), }) - const agentToken = await mintAgentToken() - const resourceToken = await mintResourceToken({ scope: 'openid' }) - const { headers, payload } = await signedRequest({ - method: 'POST', - path: '/aauth/token', - body: { resource_token: resourceToken }, - agentToken, - }) - const response = await fastify.inject({ - method: 'POST', - url: '/aauth/token', - headers, - payload, - }) + const { response } = await postToken({ resource: { scope: 'openid' } }) expect(response.statusCode).to.equal(200) expect(response.json().expires_in).to.equal(60) @@ -137,6 +125,18 @@ describe('AAuth /aauth/token — identity flow (no R3)', function () { expect(claims.exp - claims.iat).to.equal(60) }) + it('caps the auth token lifetime at one hour', async function () { + await fastify.inject({ + method: 'PUT', + url: '/mock/aauth', + headers: { 'content-type': 'application/json' }, + payload: JSON.stringify({ token_lifetime: 7200 }), + }) + const { response } = await postToken({ resource: { scope: 'openid' } }) + const claims = decodeJwt(response.json().auth_token) + expect(claims.exp - claims.iat).to.equal(3600) + }) + it('honours mock claims override', async function () { await fastify.inject({ method: 'PUT', @@ -147,19 +147,8 @@ describe('AAuth /aauth/token — identity flow (no R3)', function () { }), }) - const agentToken = await mintAgentToken() - const resourceToken = await mintResourceToken({ scope: 'openid email' }) - const { headers, payload } = await signedRequest({ - method: 'POST', - path: '/aauth/token', - body: { resource_token: resourceToken }, - agentToken, - }) - const response = await fastify.inject({ - method: 'POST', - url: '/aauth/token', - headers, - payload, + const { response } = await postToken({ + resource: { scope: 'openid email' }, }) const claims = decodeJwt(response.json().auth_token) expect(claims.email).to.equal('override@example.com') diff --git a/test/aauth/token.r3.spec.js b/test/aauth/token.r3.spec.js index 7445455..2dd8ddd 100644 --- a/test/aauth/token.r3.spec.js +++ b/test/aauth/token.r3.spec.js @@ -1,6 +1,10 @@ // R3 PS token flow — resource_token carries r3_uri + r3_s256. The PS // fetches the document, hash-verifies it, and embeds r3_granted / -// r3_conditional / r3_uri / r3_s256 on the issued auth_token. +// r3_per_call / r3_uri / r3_s256 on the issued auth_token. +// +// R3 -02: the claim is `r3_per_call` (was `r3_conditional`), the document +// has no `version` field, and a per-call proposal is a full R3 document +// carrying a REQUIRED `parameters` object. import { expect } from 'chai' import { decodeJwt } from 'jose' @@ -9,10 +13,9 @@ import Fastify from 'fastify' import api from '../../src/api.js' import { installMocks, - mintAgentToken, - mintResourceToken, registerR3Document, signedRequest, + personAndResourceToken, } from './helpers.js' const fastify = Fastify() @@ -26,6 +29,22 @@ const sampleR3 = { ], } +// Every request needs a person token first — the resource token has to +// name one this PS issued. +async function postToken(resource) { + const { agentToken, resourceToken } = + await personAndResourceToken(fastify, { resource }) + const { headers, payload } = await signedRequest({ + method: 'POST', + path: '/aauth/token', + body: { resource_token: resourceToken }, + agentToken, + }) + return fastify.inject({ + method: 'POST', url: '/aauth/token', headers, payload, + }) +} + describe('AAuth /aauth/token — R3 flow', function () { beforeEach(async function () { await installMocks(fastify) @@ -35,25 +54,7 @@ describe('AAuth /aauth/token — R3 flow', function () { const r3_uri = 'https://rs.example/r3/abc' const { r3_s256 } = await registerR3Document(fastify, r3_uri, sampleR3) - const agentToken = await mintAgentToken() - const resourceToken = await mintResourceToken({ - scope: 'whoami', - r3_uri, - r3_s256, - }) - const { headers, payload } = await signedRequest({ - method: 'POST', - path: '/aauth/token', - body: { resource_token: resourceToken }, - agentToken, - }) - - const response = await fastify.inject({ - method: 'POST', - url: '/aauth/token', - headers, - payload, - }) + const response = await postToken({ scope: 'whoami', r3_uri, r3_s256 }) expect(response.statusCode).to.equal(200) const claims = decodeJwt(response.json().auth_token) @@ -63,31 +64,18 @@ describe('AAuth /aauth/token — R3 flow', function () { vocabulary: sampleR3.vocabulary, operations: sampleR3.operations, }) - expect(claims.r3_conditional).to.be.undefined + expect(claims.r3_per_call).to.be.undefined }) it('rejects when r3_s256 does not match the served document', async function () { const r3_uri = 'https://rs.example/r3/mismatch' await registerR3Document(fastify, r3_uri, sampleR3) - const agentToken = await mintAgentToken() - const resourceToken = await mintResourceToken({ + const response = await postToken({ scope: 'whoami', r3_uri, r3_s256: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', }) - const { headers, payload } = await signedRequest({ - method: 'POST', - path: '/aauth/token', - body: { resource_token: resourceToken }, - agentToken, - }) - const response = await fastify.inject({ - method: 'POST', - url: '/aauth/token', - headers, - payload, - }) expect(response.statusCode).to.equal(400) expect(response.json().error).to.equal('invalid_resource_token') expect(response.json().error_description).to.match(/r3_s256/i) @@ -107,7 +95,7 @@ describe('AAuth /aauth/token — R3 flow', function () { vocabulary: sampleR3.vocabulary, operations: [sampleR3.operations[0]], }, - conditional: { + per_call: { vocabulary: sampleR3.vocabulary, operations: [sampleR3.operations[1]], }, @@ -115,40 +103,71 @@ describe('AAuth /aauth/token — R3 flow', function () { }), }) - const agentToken = await mintAgentToken() - const resourceToken = await mintResourceToken({ - scope: 'whoami', r3_uri, r3_s256, - }) - const { headers, payload } = await signedRequest({ - method: 'POST', path: '/aauth/token', - body: { resource_token: resourceToken }, - agentToken, - }) - const response = await fastify.inject({ - method: 'POST', url: '/aauth/token', headers, payload, - }) + const response = await postToken({ scope: 'whoami', r3_uri, r3_s256 }) expect(response.statusCode).to.equal(200) const claims = decodeJwt(response.json().auth_token) expect(claims.r3_granted.operations).to.have.lengthOf(1) - expect(claims.r3_conditional.operations).to.have.lengthOf(1) + expect(claims.r3_per_call.operations).to.have.lengthOf(1) }) it('rejects when only one of r3_uri/r3_s256 is set', async function () { - const agentToken = await mintAgentToken() - const resourceToken = await mintResourceToken({ + const response = await postToken({ scope: 'whoami', r3_uri: 'https://rs.example/r3/x', // r3_s256 missing }) - const { headers, payload } = await signedRequest({ - method: 'POST', path: '/aauth/token', - body: { resource_token: resourceToken }, - agentToken, + expect(response.statusCode).to.equal(400) + expect(response.json().error).to.equal('invalid_resource_token') + }) + + it('grants the proposed operation of a per-call proposal', async function () { + // R3 -02 §Per-Call Proposals: a proposal is an R3 document with a + // REQUIRED `parameters` object carrying that call's arguments. + const r3_uri = 'https://rs.example/r3/proposal-1' + const proposal = { + vocabulary: 'urn:aauth:vocabulary:mcp', + operations: [{ tool: 'send_email' }], + parameters: { + to: 'mom@example.com', + subject: 'Dinner Sunday?', + body: { s256: 'aBcD', excerpt: 'Hi Mom…', media_type: 'text/plain' }, + }, + display: { summary: 'Send an email as you' }, + } + const { r3_s256 } = await registerR3Document(fastify, r3_uri, proposal) + + const response = await postToken({ scope: 'whoami', r3_uri, r3_s256 }) + expect(response.statusCode).to.equal(200) + const claims = decodeJwt(response.json().auth_token) + expect(claims.r3_s256).to.equal(r3_s256) + // Approving a proposal grants the one call it describes. + expect(claims.r3_granted).to.deep.equal({ + vocabulary: proposal.vocabulary, + operations: proposal.operations, }) - const response = await fastify.inject({ - method: 'POST', url: '/aauth/token', headers, payload, + expect(claims.r3_per_call).to.be.undefined + }) + + it('rejects a proposal whose parameters is not an object', async function () { + const r3_uri = 'https://rs.example/r3/proposal-bad' + const { r3_s256 } = await registerR3Document(fastify, r3_uri, { + vocabulary: 'urn:aauth:vocabulary:mcp', + operations: [{ tool: 'send_email' }], + parameters: ['mom@example.com'], }) + const response = await postToken({ scope: 'whoami', r3_uri, r3_s256 }) expect(response.statusCode).to.equal(400) - expect(response.json().error).to.equal('invalid_resource_token') + expect(response.json().error_description).to.match(/parameters/) + }) + + it('rejects the openapi-gateway vocabulary removed in R3 -02', async function () { + const r3_uri = 'https://rs.example/r3/gateway' + const { r3_s256 } = await registerR3Document(fastify, r3_uri, { + vocabulary: 'urn:aauth:vocabulary:openapi-gateway', + operations: [{ operationId: 'listNotes' }], + }) + const response = await postToken({ scope: 'whoami', r3_uri, r3_s256 }) + expect(response.statusCode).to.equal(400) + expect(response.json().error_description).to.match(/openapi-gateway/) }) }) From bb0dcd7bbea8dbd1c292cf48e4209cd4bcfb9167 Mon Sep 17 00:00:00 2001 From: dickhardt Date: Tue, 11 Aug 2026 22:43:25 +0100 Subject: [PATCH 2/5] Expose Accept-Signature-Alg and Retry-After to browser agents via CORS Browser-based agents read Retry-After to pace polling and Accept-Signature-Alg to learn mockin declines the polymorphic EdDSA; neither is readable cross-origin unless exposed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FmiCqDjRUSx6zb1N4gZPXE --- src/api.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api.js b/src/api.js index 6fd8bae..5c851d6 100644 --- a/src/api.js +++ b/src/api.js @@ -27,7 +27,7 @@ export default function (fastify) { fastify.register(cors, { exposedHeaders: [ 'AAuth-Requirement', 'Accept-Signature', 'Accept-Signature-Scheme', - 'Signature-Error', 'Location', + 'Accept-Signature-Alg', 'Signature-Error', 'Location', 'Retry-After', ], }) // mock APIs From 18a7513ecb7c8ce4935a186dff59a02f7191737c Mon Sep 17 00:00:00 2001 From: dickhardt Date: Wed, 12 Aug 2026 07:27:35 +0100 Subject: [PATCH 3/5] AAuth errors are RFC 9457 problem details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -09 adopted HTTP problem details for AAuth error bodies (§Error Response Format): Content-Type: application/problem+json, the AAuth code in the `error` extension member, and `detail` in place of `error_description`. Mockin emitted plain application/json with `error_description`, so the integration suite's error parser had to accept both spellings — leniency mockin taught it. Emitting one shape makes that a compatibility shim rather than a requirement, so `detail` replaces `error_description` outright; the two are never both sent. Swept every AAuth error path through one helper, problem.js: /aauth/person, /aauth/token, /aauth/bootstrap, /aauth/permission, /aauth/audit, /aauth/interaction, /aauth/consent, the pending routes (including the §Polling Error Codes responses) and the signature failures verify-request.js builds before it has a reply to send on. The R3 fetch reads the resource's problem body when one comes back, so a rejected fetch names the resource's error code instead of reporting a bare status. Mockin's OIDC endpoints keep the OAuth 2.0 {error, error_description} shape they are specified to use — RFC 9457 applies to AAuth. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FmiCqDjRUSx6zb1N4gZPXE --- README.md | 2 + src/aauth/audit.js | 12 ++--- src/aauth/bootstrap.js | 41 ++++++--------- src/aauth/consent.js | 16 ++---- src/aauth/interaction.js | 9 ++-- src/aauth/pending.js | 66 +++++++++++------------- src/aauth/permission.js | 6 +-- src/aauth/person.js | 65 ++++++++++------------- src/aauth/problem.js | 48 +++++++++++++++++ src/aauth/r3.js | 20 +++++++- src/aauth/token.js | 74 +++++++++++---------------- src/aauth/verify-request.js | 45 ++++++++-------- test/aauth/person.spec.js | 91 +++++++++++++++++++++++++++++++-- test/aauth/token.errors.spec.js | 37 ++++++++++---- test/aauth/token.r3.spec.js | 6 +-- 15 files changed, 323 insertions(+), 215 deletions(-) create mode 100644 src/aauth/problem.js diff --git a/README.md b/README.md index 2aedaff..37ecb5d 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ The mock API at `PUT /mock/aauth` switches the simulated behaviours: | `token_lifetime`, `claims`, `r3_grants`, `tenant` | shape the issued tokens (`r3_grants` takes `{ granted, per_call }`) | | `require_body_signing` | `false` accepts a body signature that does not cover `content-digest` and `content-type` | +AAuth errors are RFC 9457 problem details — `Content-Type: application/problem+json` with the AAuth error code in `error` and the explanation in `detail`. The OIDC endpoints keep the OAuth 2.0 `{error, error_description}` shape they are specified to use. + ## Invite Mockin also mirrors Hellō's invite flow — useful for testing how your app handles the `events_uri` SET (Security Event Token) JWT and the `initiate_login_uri` redirect for newly invited users. Endpoints include `POST /invite`, `GET /invitation/:id`, `PUT /invitation/:id` (accept), `DELETE /invitation/:id` (decline), `DELETE /invite/:id` (retract), and `POST /invitation/:id/report` (abuse). SET JWT is RS256-signed and delivered to `events_uri` on accept. See the [docs](https://www.hello.dev/docs/mockin#invite) for details. diff --git a/src/aauth/audit.js b/src/aauth/audit.js index 6c86279..922e729 100644 --- a/src/aauth/audit.js +++ b/src/aauth/audit.js @@ -2,19 +2,15 @@ // // Fire-and-forget log endpoint. Mockin acknowledges with 201 and discards. +import { problem } from './problem.js' + export const audit = async (req, reply) => { const body = req.body || {} if (!body.action || typeof body.action !== 'string') { - return reply.code(400).send({ - error: 'invalid_request', - error_description: 'missing action', - }) + return problem(reply, 400, 'invalid_request', 'missing action') } if (!body.mission || typeof body.mission !== 'object') { - return reply.code(400).send({ - error: 'invalid_request', - error_description: 'missing mission', - }) + return problem(reply, 400, 'invalid_request', 'missing mission') } return reply.code(201).send() } diff --git a/src/aauth/bootstrap.js b/src/aauth/bootstrap.js index 689b254..bae80eb 100644 --- a/src/aauth/bootstrap.js +++ b/src/aauth/bootstrap.js @@ -30,6 +30,7 @@ import { SIGNING_ALG } from './algorithms.js' import { directedSub } from './subject.js' import { getConfig, mockErrorFor } from './mock.js' import { checkBodySigning } from './verify-request.js' +import { problem } from './problem.js' import { createPending, updatePending } from './state.js' // -11: a body-carrying request to a PS endpoint covers content-digest and @@ -87,11 +88,10 @@ export const bootstrap = async (req, reply) => { if (!sigResult.verified) { const noSig = !req.headers.signature && !req.headers['signature-input'] if (noSig) { - return reply - .code(401) + reply .header('Accept-Signature', ACCEPT_SIG) .header('Accept-Signature-Scheme', ACCEPT_SIG_SCHEME) - .send({ error: 'signature_required' }) + return problem(reply, 401, 'signature_required') } const headers = {} if (sigResult.signatureError) { @@ -100,10 +100,7 @@ export const bootstrap = async (req, reply) => { ) } for (const [k, v] of Object.entries(headers)) reply.header(k, v) - return reply.code(401).send({ - error: 'signature_verification_failed', - error_description: sigResult.error, - }) + return problem(reply, 401, 'signature_verification_failed', sigResult.error) } // -11: the body signature must cover content-digest and content-type. @@ -112,7 +109,10 @@ export const bootstrap = async (req, reply) => { for (const [k, v] of Object.entries(bodyFailure.headers || {})) { reply.header(k, v) } - return reply.code(bodyFailure.status).send(bodyFailure.body) + return problem( + reply, bodyFailure.status, + bodyFailure.body.error, bodyFailure.body.detail, + ) } // Two valid request flavours per the wallet PS pattern: @@ -124,18 +124,17 @@ export const bootstrap = async (req, reply) => { if (sigResult.keyType === 'jwt' && sigResult.jwt) { const typ = sigResult.jwt.header?.typ if (typ !== 'aa-agent+jwt') { - return reply.code(401).send({ - error: 'invalid_jwt', - error_description: `expected aa-agent+jwt, got ${typ}`, - }) + return problem( + reply, 401, 'invalid_jwt', `expected aa-agent+jwt, got ${typ}`, + ) } return reply.code(204).send() } if (sigResult.keyType !== 'hwk' || !sigResult.publicKey) { - return reply.code(401).send({ - error: 'invalid_key', - error_description: 'bootstrap requires hwk or jwt Signature-Key scheme', - }) + return problem( + reply, 401, 'invalid_key', + 'bootstrap requires hwk or jwt Signature-Key scheme', + ) } // Keep cnf.jwk minimal — { kty, crv, x, alg }. httpsig 2.0 (RFC 9864) // requires every JWK to carry a fully-specified alg; the hwk scheme now @@ -147,18 +146,12 @@ export const bootstrap = async (req, reply) => { const body = req.body || {} if (!body.agent_server || typeof body.agent_server !== 'string') { - return reply.code(400).send({ - error: 'invalid_request', - error_description: 'missing agent_server', - }) + return problem(reply, 400, 'invalid_request', 'missing agent_server') } const mockErr = mockErrorFor('bootstrap') if (mockErr) { - return reply.code(400).send({ - error: mockErr, - error_description: `Mock error: ${mockErr}`, - }) + return problem(reply, 400, mockErr, `Mock error: ${mockErr}`) } // Always go through the deferred flow — that's what the spec mandates diff --git a/src/aauth/consent.js b/src/aauth/consent.js index 3048885..1e68960 100644 --- a/src/aauth/consent.js +++ b/src/aauth/consent.js @@ -10,23 +10,18 @@ // authorization handle. import { getPendingByCode, updatePending } from './state.js' +import { problem } from './problem.js' export const consent = async (req, reply) => { const { code, callback } = req.query || {} if (!code) { - return reply.code(400).send({ - error: 'invalid_request', - error_description: 'missing code', - }) + return problem(reply, 400, 'invalid_request', 'missing code') } const entry = getPendingByCode(code) if (!entry) { - return reply.code(400).send({ - error: 'invalid_request', - error_description: 'unknown code', - }) + return problem(reply, 400, 'invalid_request', 'unknown code') } updatePending(entry.id, { status: 'approved' }) @@ -36,10 +31,7 @@ export const consent = async (req, reply) => { try { safe = new URL(callback).toString() } catch { - return reply.code(400).send({ - error: 'invalid_request', - error_description: 'invalid callback url', - }) + return problem(reply, 400, 'invalid_request', 'invalid callback url') } return reply.redirect(safe) } diff --git a/src/aauth/interaction.js b/src/aauth/interaction.js index 98343fc..b15c989 100644 --- a/src/aauth/interaction.js +++ b/src/aauth/interaction.js @@ -5,6 +5,7 @@ import { ISSUER } from '../config.js' import { createPending } from './state.js' +import { problem } from './problem.js' const VALID_TYPES = new Set(['interaction', 'payment', 'question', 'completion']) @@ -13,10 +14,10 @@ export const interaction = async (req, reply) => { const body = req.body || {} if (!body.type || !VALID_TYPES.has(body.type)) { - return reply.code(400).send({ - error: 'invalid_request', - error_description: 'type must be interaction|payment|question|completion', - }) + return problem( + reply, 400, 'invalid_request', + 'type must be interaction|payment|question|completion', + ) } if (body.type === 'completion') { diff --git a/src/aauth/pending.js b/src/aauth/pending.js index 21e381a..fa5bf31 100644 --- a/src/aauth/pending.js +++ b/src/aauth/pending.js @@ -22,6 +22,7 @@ import { issuePersonToken } from './issue-person-token.js' import { issueBootstrapToken } from './bootstrap.js' import { verifyAgentToken } from './verify-agent-token.js' import { checkBodySigning } from './verify-request.js' +import { problem } from './problem.js' const ACCEPT_SIG_GET = generateAcceptSignatureHeader({ label: 'sig', @@ -49,12 +50,9 @@ async function runHttpSig(request) { function noSig(reply) { reply - .code(401) .header('Accept-Signature', ACCEPT_SIG_GET) .header('Accept-Signature-Scheme', ACCEPT_SIG_SCHEME) - .send({ - error: 'signature_required', - }) + return problem(reply, 401, 'signature_required') } async function verifyForEntry(request, reply, entry) { @@ -69,10 +67,7 @@ async function verifyForEntry(request, reply, entry) { generateSignatureErrorHeader(sigResult.signatureError), ) } - return reply.code(401).send({ - error: 'signature_verification_failed', - error_description: sigResult.error, - }) + return problem(reply, 401, 'signature_verification_failed', sigResult.error) } // -11: a body-carrying request to a PS endpoint covers content-digest @@ -84,59 +79,56 @@ async function verifyForEntry(request, reply, entry) { for (const [k, v] of Object.entries(bodyFailure.headers || {})) { reply.header(k, v) } - return reply.code(bodyFailure.status).send(bodyFailure.body) + return problem( + reply, bodyFailure.status, + bodyFailure.body.error, bodyFailure.body.detail, + ) } } if (entry.kind === 'bootstrap') { if (sigResult.keyType !== 'hwk') { - return reply.code(401).send({ - error: 'invalid_key', - error_description: 'bootstrap polling requires hwk scheme', - }) + return problem( + reply, 401, 'invalid_key', 'bootstrap polling requires hwk scheme', + ) } const expectedJkt = await calculateJwkThumbprint(entry.ephemeral_jwk) if (sigResult.thumbprint !== expectedJkt) { - return reply.code(401).send({ - error: 'invalid_key', - error_description: 'hwk key does not match bootstrap binding', - }) + return problem( + reply, 401, 'invalid_key', + 'hwk key does not match bootstrap binding', + ) } return null // ok } // Non-bootstrap entries require an agent_token (JWT scheme). if (sigResult.keyType !== 'jwt' || !sigResult.jwt) { - return reply.code(401).send({ - error: 'invalid_key', - error_description: 'expected sig=jwt with agent_token', - }) + return problem( + reply, 401, 'invalid_key', 'expected sig=jwt with agent_token', + ) } const { header, payload, raw } = sigResult.jwt const verified = await verifyAgentToken(raw, { header, payload }) if (verified.error) { - return reply.code(401).send({ - error: 'invalid_jwt', - error_description: verified.error, - }) + return problem(reply, 401, 'invalid_jwt', verified.error) } return null // ok } export const pendingGet = async (req, reply) => { const entry = getPending(req.params.id) - if (!entry) return reply.code(404).send({ error: 'not_found' }) + if (!entry) return problem(reply, 404, 'not_found') const verifyErr = await verifyForEntry(req, reply, entry) if (verifyErr) return verifyErr + // §Polling Error Codes. if (entry.status === 'cancelled') { - return reply.code(410).send({ error: 'cancelled' }) + return problem(reply, 410, 'cancelled') } if (entry.status === 'error') { - return reply - .code(403) - .send({ error: entry.error || 'denied' }) + return problem(reply, 403, entry.error || 'denied') } if (entry.status === 'pending') { @@ -205,12 +197,12 @@ export const pendingGet = async (req, reply) => { return reply.code(200).send({ status: 'completed' }) } - return reply.code(500).send({ error: 'server_error' }) + return problem(reply, 500, 'server_error') } export const pendingPost = async (req, reply) => { const entry = getPending(req.params.id) - if (!entry) return reply.code(404).send({ error: 'not_found' }) + if (!entry) return problem(reply, 404, 'not_found') const verifyErr = await verifyForEntry(req, reply, entry) if (verifyErr) return verifyErr @@ -238,15 +230,15 @@ export const pendingPost = async (req, reply) => { return reply.code(202).send({ status: 'pending' }) } - return reply.code(400).send({ - error: 'invalid_request', - error_description: 'expected clarification_response or resource_token', - }) + return problem( + reply, 400, 'invalid_request', + 'expected clarification_response or resource_token', + ) } export const pendingDelete = async (req, reply) => { const entry = getPending(req.params.id) - if (!entry) return reply.code(404).send({ error: 'not_found' }) + if (!entry) return problem(reply, 404, 'not_found') const verifyErr = await verifyForEntry(req, reply, entry) if (verifyErr) return verifyErr updatePending(entry.id, { status: 'cancelled' }) diff --git a/src/aauth/permission.js b/src/aauth/permission.js index 244152d..58819ce 100644 --- a/src/aauth/permission.js +++ b/src/aauth/permission.js @@ -5,16 +5,14 @@ // flips to a refusal with reason. import { getConfig } from './mock.js' +import { problem } from './problem.js' export const permission = async (req, reply) => { const cfg = getConfig() const body = req.body || {} if (!body.action || typeof body.action !== 'string') { - return reply.code(400).send({ - error: 'invalid_request', - error_description: 'missing action', - }) + return problem(reply, 400, 'invalid_request', 'missing action') } if (cfg.permission === 'denied') { diff --git a/src/aauth/person.js b/src/aauth/person.js index 41dd50a..e18b3a7 100644 --- a/src/aauth/person.js +++ b/src/aauth/person.js @@ -24,6 +24,7 @@ import { issuePersonToken } from './issue-person-token.js' import { verifyAgentToken } from './verify-agent-token.js' import { parseRequestParameters, canDriveInteraction } from './request-parameters.js' import { createPending, updatePending } from './state.js' +import { problem } from './problem.js' const ERROR_STATUS = { invalid_request: 400, @@ -67,33 +68,26 @@ export const person = async (req, reply) => { const mockErr = mockErrorFor('person') if (mockErr) { - return reply.code(ERROR_STATUS[mockErr] || 400).send({ - error: mockErr, - error_description: `Mock error: ${mockErr}`, - }) + return problem( + reply, ERROR_STATUS[mockErr] || 400, mockErr, + `Mock error: ${mockErr}`, + ) } const resourceError = validateResourceIdentifier(body.resource) if (resourceError) { - return reply.code(400).send({ - error: 'invalid_request', - error_description: resourceError, - }) + return problem(reply, 400, 'invalid_request', resourceError) } if (body.upstream_token !== undefined) { - return reply.code(400).send({ - error: 'invalid_request', - error_description: - 'upstream_token is not supported: call chaining is not implemented by mockin', - }) + return problem( + reply, 400, 'invalid_request', + 'upstream_token is not supported: call chaining is not implemented by mockin', + ) } if (body.mission_s256 !== undefined && typeof body.mission_s256 !== 'string') { - return reply.code(400).send({ - error: 'invalid_request', - error_description: 'mission_s256 must be a string', - }) + return problem(reply, 400, 'invalid_request', 'mission_s256 must be a string') } // mission_endpoint is unimplemented, so there is no mission to look up. // The value is accepted, stamped on the token, and later compared @@ -104,10 +98,7 @@ export const person = async (req, reply) => { // platform, device, capabilities. const parsed = parseRequestParameters(body) if (parsed.error) { - return reply.code(400).send({ - error: 'invalid_request', - error_description: parsed.error, - }) + return problem(reply, 400, 'invalid_request', parsed.error) } const params = parsed.params @@ -119,24 +110,21 @@ export const person = async (req, reply) => { if (body.subagent_token !== undefined) { if (typeof body.subagent_token !== 'string') { - return reply.code(400).send({ - error: 'invalid_request', - error_description: 'subagent_token must be a string', - }) + return problem( + reply, 400, 'invalid_request', 'subagent_token must be a string', + ) } const sub = await verifyAgentToken(body.subagent_token) if (sub.error) { - return reply.code(400).send({ - error: 'invalid_agent_token', - error_description: `subagent_token: ${sub.error}`, - }) + return problem( + reply, 400, 'invalid_agent_token', `subagent_token: ${sub.error}`, + ) } if (sub.payload.parent_agent !== aauth.agent_id) { - return reply.code(400).send({ - error: 'invalid_agent_token', - error_description: - `subagent_token parent_agent "${sub.payload.parent_agent}" does not name the signing agent "${aauth.agent_id}"`, - }) + return problem( + reply, 400, 'invalid_agent_token', + `subagent_token parent_agent "${sub.payload.parent_agent}" does not name the signing agent "${aauth.agent_id}"`, + ) } cnfJwk = sub.payload.cnf.jwk subject_agent_exp = Math.min( @@ -164,11 +152,10 @@ export const person = async (req, reply) => { // cannot drive the url + code we would hand it, so a 202 would be // unsatisfiable. Terminal, per §Token Endpoint Error Codes. if (cfg.person_requirement === 'interaction' && !canDriveInteraction(params)) { - return reply.code(403).send({ - error: 'user_unreachable', - error_description: - 'user interaction is required and the agent did not declare the interaction capability', - }) + return problem( + reply, 403, 'user_unreachable', + 'user interaction is required and the agent did not declare the interaction capability', + ) } const { id, code } = createPending({ kind: 'person', diff --git a/src/aauth/problem.js b/src/aauth/problem.js new file mode 100644 index 0000000..dc34708 --- /dev/null +++ b/src/aauth/problem.js @@ -0,0 +1,48 @@ +// aauth/problem.js — RFC 9457 error responses. +// +// -09 adopted HTTP problem details for AAuth error bodies +// (§Error Response Format): +// +// Content-Type: application/problem+json +// { "error": "", "detail": "" } +// +// `error` is the REQUIRED extension member and is what a receiver keys on +// — AAuth defines no problem type URIs, so `type` says nothing. `detail` +// replaced `error_description`; the two are never both sent, because a +// reference implementation emitting both is what teaches every client to +// guess. +// +// This is the AAuth surface only. Mockin's OIDC endpoints keep the OAuth +// 2.0 error shape they are specified to use. + +export const PROBLEM_CONTENT_TYPE = 'application/problem+json' + +/** + * Send an RFC 9457 problem response. + * + * @param {object} reply Fastify reply + * @param {number} status + * @param {string} error the AAuth error code + * @param {string} [detail] human-readable, specific to this occurrence + * @param {object} [extra] further members (e.g. mission_status) + */ +export function problem(reply, status, error, detail, extra) { + const body = { error } + if (detail) body.detail = detail + if (extra) Object.assign(body, extra) + return reply + .code(status) + .header('Content-Type', PROBLEM_CONTENT_TYPE) + .send(body) +} + +/** + * The same body as a plain object, for the places that build a response + * tuple before they have a reply to send it on (verify-request.js). + */ +export function problemBody(error, detail, extra) { + const body = { error } + if (detail) body.detail = detail + if (extra) Object.assign(body, extra) + return body +} diff --git a/src/aauth/r3.js b/src/aauth/r3.js index 87f7bf7..b782459 100644 --- a/src/aauth/r3.js +++ b/src/aauth/r3.js @@ -71,6 +71,18 @@ export function validateR3Document(document) { return null } +// Read the AAuth error code out of an RFC 9457 body, if there is one. +async function problemCode(response) { + const type = response.headers?.get?.('content-type') || '' + if (!/json/.test(type)) return null + try { + const body = await response.json() + return typeof body?.error === 'string' ? body.error : null + } catch { + return null + } +} + export async function fetchR3Document({ r3_uri, expected_s256 }) { const trusted = getConfig().trusted_servers || {} // Tests can preload an R3 doc by URI to bypass network. @@ -111,7 +123,13 @@ export async function fetchR3Document({ r3_uri, expected_s256 }) { return new Error(`r3 fetch error: ${err.message}`) } if (!response.ok) { - return new Error(`r3 fetch returned ${response.status}`) + // The resource's own error body is RFC 9457 too (§Error Response + // Format), so name its code when it sent one — "returned 401" on + // its own tells the operator nothing about why. + const code = await problemCode(response) + return new Error( + `r3 fetch returned ${response.status}${code ? `: ${code}` : ''}`, + ) } const ab = await response.arrayBuffer() const bytes = Buffer.from(ab) diff --git a/src/aauth/token.js b/src/aauth/token.js index 3ac1f8c..663a9ab 100644 --- a/src/aauth/token.js +++ b/src/aauth/token.js @@ -24,6 +24,7 @@ import { issueAuthToken } from './issue-auth-token.js' import { parseRequestParameters, canDriveInteraction } from './request-parameters.js' import { verifyAgentToken } from './verify-agent-token.js' import { createPending, updatePending } from './state.js' +import { problem } from './problem.js' const ERROR_STATUS = { invalid_request: 400, @@ -46,36 +47,26 @@ export const token = async (req, reply) => { const mockErr = mockErrorFor('token') if (mockErr) { const status = ERROR_STATUS[mockErr] || 400 - return reply.code(status).send({ - error: mockErr, - error_description: `Mock error: ${mockErr}`, - }) + return problem(reply, status, mockErr, `Mock error: ${mockErr}`) } if (!body.resource_token) { - return reply.code(400).send({ - error: 'invalid_request', - error_description: 'missing resource_token', - }) + return problem(reply, 400, 'invalid_request', 'missing resource_token') } // upstream_token is call chaining — deferred fleet-wide, not implemented. if (body.upstream_token !== undefined) { - return reply.code(400).send({ - error: 'invalid_request', - error_description: - 'upstream_token is not supported: call chaining is not implemented by mockin', - }) + return problem( + reply, 400, 'invalid_request', + 'upstream_token is not supported: call chaining is not implemented by mockin', + ) } // justification, login_hint, tenant, domain_hint, prompt, platform, // device, capabilities — parsed once, shared with the person endpoint. const parsed = parseRequestParameters(body) if (parsed.error) { - return reply.code(400).send({ - error: 'invalid_request', - error_description: parsed.error, - }) + return problem(reply, 400, 'invalid_request', parsed.error) } const params = parsed.params @@ -87,24 +78,21 @@ export const token = async (req, reply) => { let cnfJwk = aauth.agent_public_key if (body.subagent_token !== undefined) { if (typeof body.subagent_token !== 'string') { - return reply.code(400).send({ - error: 'invalid_request', - error_description: 'subagent_token must be a string', - }) + return problem( + reply, 400, 'invalid_request', 'subagent_token must be a string', + ) } const sub = await verifyAgentToken(body.subagent_token) if (sub.error) { - return reply.code(400).send({ - error: 'invalid_agent_token', - error_description: `subagent_token: ${sub.error}`, - }) + return problem( + reply, 400, 'invalid_agent_token', `subagent_token: ${sub.error}`, + ) } if (sub.payload.parent_agent !== aauth.agent_id) { - return reply.code(400).send({ - error: 'invalid_agent_token', - error_description: - `subagent_token parent_agent "${sub.payload.parent_agent}" does not name the signing agent "${aauth.agent_id}"`, - }) + return problem( + reply, 400, 'invalid_agent_token', + `subagent_token parent_agent "${sub.payload.parent_agent}" does not name the signing agent "${aauth.agent_id}"`, + ) } cnfJwk = sub.payload.cnf.jwk expectedJkt = await calculateJwkThumbprint(cnfJwk) @@ -112,10 +100,11 @@ export const token = async (req, reply) => { const rt = await verifyResourceToken(body.resource_token, expectedJkt) if (rt.error) { - return reply.code(400).send({ - error: rt.expired ? 'expired_resource_token' : 'invalid_resource_token', - error_description: rt.error, - }) + return problem( + reply, 400, + rt.expired ? 'expired_resource_token' : 'invalid_resource_token', + rt.error, + ) } let r3 = null @@ -125,10 +114,10 @@ export const token = async (req, reply) => { expected_s256: rt.r3.s256, }) if (fetched instanceof Error) { - return reply.code(400).send({ - error: 'invalid_resource_token', - error_description: `r3 fetch failed: ${fetched.message}`, - }) + return problem( + reply, 400, 'invalid_resource_token', + `r3 fetch failed: ${fetched.message}`, + ) } const grants = autoGrantR3({ document: fetched.document }) r3 = { uri: rt.r3.uri, s256: rt.r3.s256, ...grants } @@ -150,11 +139,10 @@ export const token = async (req, reply) => { // Same gate as the person token endpoint: an agent that declared // capabilities without `interaction` cannot complete one. if (cfg.requirement === 'interaction' && !canDriveInteraction(params)) { - return reply.code(403).send({ - error: 'user_unreachable', - error_description: - 'user interaction is required and the agent did not declare the interaction capability', - }) + return problem( + reply, 403, 'user_unreachable', + 'user interaction is required and the agent did not declare the interaction capability', + ) } const { id, code } = createPending({ kind: 'token', diff --git a/src/aauth/verify-request.js b/src/aauth/verify-request.js index a27a8cf..b294711 100644 --- a/src/aauth/verify-request.js +++ b/src/aauth/verify-request.js @@ -26,6 +26,7 @@ import { import { verifyAgentToken } from './verify-agent-token.js' import { ACCEPTED_JWT_ALGS } from './algorithms.js' import { getConfig } from './mock.js' +import { problemBody, PROBLEM_CONTENT_TYPE } from './problem.js' // -11: PS endpoints taking a body require content-digest and content-type // in the covered components, on top of the base profile. @@ -55,8 +56,11 @@ export const ACCEPT_SIG_SCHEME = generateAcceptSignatureSchemeHeader(['jwt']) // -10 admits no polymorphic EdDSA; say so when we decline one. export const ACCEPT_SIG_ALG = generateAcceptSignatureAlgHeader(ACCEPTED_JWT_ALGS) -function fail(status, body, headers = {}) { - return { ok: false, status, body, headers } +// Failures carry an RFC 9457 problem body (§Error Response Format): the +// AAuth code in `error`, the explanation in `detail`. verifyPreHandler +// sets the content type when it sends one. +function fail(status, error, detail, headers = {}) { + return { ok: false, status, body: problemBody(error, detail), headers } } /** @@ -95,11 +99,8 @@ export function checkBodySigning(request, sigResult) { if (!missing.length) return null return fail( 401, - { - error: 'signature_verification_failed', - error_description: - `request body signature must cover ${missing.join(' and ')}`, - }, + 'signature_verification_failed', + `request body signature must cover ${missing.join(' and ')}`, { 'Accept-Signature': ACCEPT_SIG_BODY, 'Signature-Error': generateSignatureErrorHeader({ @@ -133,7 +134,8 @@ export async function verifyRequest(request) { if (noSig) { return fail( 401, - { error: 'signature_required' }, + 'signature_required', + undefined, { 'Accept-Signature': hasBody ? ACCEPT_SIG_BODY : ACCEPT_SIG_NOBODY, 'Accept-Signature-Scheme': ACCEPT_SIG_SCHEME, @@ -152,14 +154,7 @@ export async function verifyRequest(request) { sigResult.acceptSignatureAlg, ) } - return fail( - 401, - { - error: 'signature_verification_failed', - error_description: sigResult.error, - }, - headers, - ) + return fail(401, 'signature_verification_failed', sigResult.error, headers) } if (hasBody) { @@ -168,20 +163,17 @@ export async function verifyRequest(request) { } if (sigResult.keyType !== 'jwt' || !sigResult.jwt) { - return fail(401, { - error: 'invalid_key', - error_description: 'Signature-Key must use scheme=jwt with an agent_token', - }) + return fail( + 401, 'invalid_key', + 'Signature-Key must use scheme=jwt with an agent_token', + ) } const { header, payload, raw } = sigResult.jwt const verified = await verifyAgentToken(raw, { header, payload }) if (verified.error) { - return fail(401, { - error: 'invalid_jwt', - error_description: verified.error, - }) + return fail(401, 'invalid_jwt', verified.error) } return { @@ -203,7 +195,10 @@ export async function verifyPreHandler(request, reply) { for (const [k, v] of Object.entries(result.headers || {})) { reply.header(k, v) } - reply.code(result.status).send(result.body) + reply + .code(result.status) + .header('Content-Type', PROBLEM_CONTENT_TYPE) + .send(result.body) return reply } request.aauth = result diff --git a/test/aauth/person.spec.js b/test/aauth/person.spec.js index 8445069..84c9bc5 100644 --- a/test/aauth/person.spec.js +++ b/test/aauth/person.spec.js @@ -173,7 +173,7 @@ describe('AAuth /aauth/person — person token endpoint', function () { }) expect(res.statusCode).to.equal(400) expect(res.json().error).to.equal('invalid_request') - expect(res.json().error_description).to.match(/upstream_token/) + expect(res.json().detail).to.match(/upstream_token/) }) it('401 when the body signature does not cover content-digest', async function () { @@ -190,7 +190,7 @@ describe('AAuth /aauth/person — person token endpoint', function () { method: 'POST', url: '/aauth/person', headers, payload, }) expect(res.statusCode).to.equal(401) - expect(res.json().error_description).to.match(/content-digest/) + expect(res.json().detail).to.match(/content-digest/) expect(res.headers['accept-signature']).to.match(/content-digest/) }) @@ -218,7 +218,7 @@ describe('AAuth /aauth/person — person token endpoint', function () { const res = await requestPersonToken(fastify, { agentToken }) expect(res.statusCode).to.equal(401) expect(res.json().error).to.equal('invalid_jwt') - expect(res.json().error_description).to.match(/EdDSA/) + expect(res.json().detail).to.match(/EdDSA/) }) it('401 when no signature is present', async function () { @@ -261,6 +261,89 @@ describe('AAuth /aauth/person — person token endpoint', function () { }) }) + // -09 adopted RFC 9457 for AAuth error bodies (§Error Response + // Format). Mockin is the reference PS, so whatever it emits is what + // clients get written to parse — it emits `detail`, never + // `error_description`, and never both. + describe('RFC 9457 error responses', function () { + const cases = [ + ['no signature', () => fastify.inject({ + method: 'POST', + url: '/aauth/person', + headers: { 'content-type': 'application/json' }, + payload: JSON.stringify({ resource: RESOURCE_SERVER_URL }), + })], + ['bad request', () => requestPersonToken(fastify, { resource: 'nope' })], + ['injected error', async () => { + await setMock({ error: 'denied', error_endpoint: 'person' }) + return requestPersonToken(fastify) + }], + ['unknown pending id', async () => { + const agentToken = await mintAgentToken() + const { headers } = await signedRequest({ + method: 'GET', path: '/aauth/pending/nope', agentToken, + }) + return fastify.inject({ + method: 'GET', url: '/aauth/pending/nope', headers, + }) + }], + ['bootstrap without agent_server', async () => { + const { signedHwkRequest } = await import('./helpers.js') + const { headers, payload } = await signedHwkRequest({ + method: 'POST', path: '/aauth/bootstrap', body: {}, + }) + return fastify.inject({ + method: 'POST', url: '/aauth/bootstrap', headers, payload, + }) + }], + ['permission without action', async () => { + const agentToken = await mintAgentToken() + const { headers, payload } = await signedRequest({ + method: 'POST', path: '/aauth/permission', body: {}, agentToken, + }) + return fastify.inject({ + method: 'POST', url: '/aauth/permission', headers, payload, + }) + }], + ['audit without action', async () => { + const agentToken = await mintAgentToken() + const { headers, payload } = await signedRequest({ + method: 'POST', path: '/aauth/audit', body: {}, agentToken, + }) + return fastify.inject({ + method: 'POST', url: '/aauth/audit', headers, payload, + }) + }], + ['interaction with an unknown type', async () => { + const agentToken = await mintAgentToken() + const { headers, payload } = await signedRequest({ + method: 'POST', + path: '/aauth/interaction', + body: { type: 'telepathy' }, + agentToken, + }) + return fastify.inject({ + method: 'POST', url: '/aauth/interaction', headers, payload, + }) + }], + ['consent without a code', () => fastify.inject({ + method: 'GET', url: '/aauth/consent', + })], + ] + + for (const [name, run] of cases) { + it(`${name}: problem+json with error and detail`, async function () { + const res = await run() + expect(res.statusCode).to.be.at.least(400) + expect(res.headers['content-type']) + .to.match(/^application\/problem\+json/) + const body = res.json() + expect(body.error).to.be.a('string') + expect(body).to.not.have.property('error_description') + }) + } + }) + describe('sub-agent tokens', function () { async function mintSubAgent(parent = DEFAULT_AGENT_ID) { const kp = await generateKeyPair('Ed25519') @@ -326,7 +409,7 @@ describe('AAuth /aauth/person — person token endpoint', function () { }) expect(res.statusCode).to.equal(400) expect(res.json().error).to.equal('invalid_agent_token') - expect(res.json().error_description).to.match(/parent_agent/) + expect(res.json().detail).to.match(/parent_agent/) }) }) diff --git a/test/aauth/token.errors.spec.js b/test/aauth/token.errors.spec.js index e023930..cc29d46 100644 --- a/test/aauth/token.errors.spec.js +++ b/test/aauth/token.errors.spec.js @@ -73,6 +73,21 @@ describe('AAuth /aauth/token — errors', function () { expect(response.json().error).to.equal('invalid_jwt') }) + it('returns RFC 9457 problem details, not error_description', async function () { + const { person_token } = await getPersonToken(fastify) + const resourceToken = await mintResourceToken({ + personToken: person_token, + aud: 'https://wrong-ps.example', + }) + const response = await postResourceToken(resourceToken) + expect(response.headers['content-type']) + .to.match(/^application\/problem\+json/) + const body = response.json() + expect(body.error).to.equal('invalid_resource_token') + expect(body.detail).to.be.a('string') + expect(body).to.not.have.property('error_description') + }) + it('400 invalid_request when resource_token missing', async function () { const agentToken = await mintAgentToken() const { headers, payload } = await signedRequest({ @@ -103,7 +118,7 @@ describe('AAuth /aauth/token — errors', function () { method: 'POST', url: '/aauth/token', headers, payload, }) expect(response.statusCode).to.equal(400) - expect(response.json().error_description).to.match(/upstream_token/) + expect(response.json().detail).to.match(/upstream_token/) }) it('400 invalid_resource_token when aud != PS', async function () { @@ -115,7 +130,7 @@ describe('AAuth /aauth/token — errors', function () { const response = await postResourceToken(resourceToken) expect(response.statusCode).to.equal(400) expect(response.json().error).to.equal('invalid_resource_token') - expect(response.json().error_description).to.match(/aud/) + expect(response.json().detail).to.match(/aud/) }) it('400 invalid_resource_token when agent_jkt mismatches HTTPSig key', async function () { @@ -126,7 +141,7 @@ describe('AAuth /aauth/token — errors', function () { }) const response = await postResourceToken(resourceToken) expect(response.statusCode).to.equal(400) - expect(response.json().error_description).to.match(/agent_jkt/) + expect(response.json().detail).to.match(/agent_jkt/) }) it('400 expired_resource_token when the resource token has expired', async function () { @@ -149,7 +164,7 @@ describe('AAuth /aauth/token — errors', function () { }) const response = await postResourceToken(resourceToken) expect(response.statusCode).to.equal(400) - expect(response.json().error_description).to.match(/person_token_jti/) + expect(response.json().detail).to.match(/person_token_jti/) }) it('rejects a person_token_jti this PS never issued', async function () { @@ -160,7 +175,7 @@ describe('AAuth /aauth/token — errors', function () { }) const response = await postResourceToken(resourceToken) expect(response.statusCode).to.equal(400) - expect(response.json().error_description) + expect(response.json().detail) .to.match(/names no person token this PS issued/) }) @@ -172,7 +187,7 @@ describe('AAuth /aauth/token — errors', function () { }) const response = await postResourceToken(resourceToken) expect(response.statusCode).to.equal(400) - expect(response.json().error_description).to.match(/sub mismatch/) + expect(response.json().detail).to.match(/sub mismatch/) }) it('rejects a mismatched ps', async function () { @@ -183,7 +198,7 @@ describe('AAuth /aauth/token — errors', function () { }) const response = await postResourceToken(resourceToken) expect(response.statusCode).to.equal(400) - expect(response.json().error_description).to.match(/ps mismatch/) + expect(response.json().detail).to.match(/ps mismatch/) }) it('rejects a stripped mission_s256', async function () { @@ -198,7 +213,7 @@ describe('AAuth /aauth/token — errors', function () { }) const response = await postResourceToken(resourceToken) expect(response.statusCode).to.equal(400) - expect(response.json().error_description).to.match(/mission_s256 mismatch/) + expect(response.json().detail).to.match(/mission_s256 mismatch/) }) it('rejects an invented mission_s256', async function () { @@ -209,7 +224,7 @@ describe('AAuth /aauth/token — errors', function () { }) const response = await postResourceToken(resourceToken) expect(response.statusCode).to.equal(400) - expect(response.json().error_description).to.match(/mission_s256 mismatch/) + expect(response.json().detail).to.match(/mission_s256 mismatch/) }) it('rejects a mismatched tenant', async function () { @@ -220,7 +235,7 @@ describe('AAuth /aauth/token — errors', function () { }) const response = await postResourceToken(resourceToken) expect(response.statusCode).to.equal(400) - expect(response.json().error_description).to.match(/tenant mismatch/) + expect(response.json().detail).to.match(/tenant mismatch/) }) it('accepts the matching set, mission and tenant included', async function () { @@ -247,7 +262,7 @@ describe('AAuth /aauth/token — errors', function () { ).toString('base64url') const response = await postResourceToken(`${header}.${body}.${sig}`) expect(response.statusCode).to.equal(400) - expect(response.json().error_description).to.match(/EdDSA/) + expect(response.json().detail).to.match(/EdDSA/) }) it('returns mock-injected error code', async function () { diff --git a/test/aauth/token.r3.spec.js b/test/aauth/token.r3.spec.js index 2dd8ddd..708e1b6 100644 --- a/test/aauth/token.r3.spec.js +++ b/test/aauth/token.r3.spec.js @@ -78,7 +78,7 @@ describe('AAuth /aauth/token — R3 flow', function () { }) expect(response.statusCode).to.equal(400) expect(response.json().error).to.equal('invalid_resource_token') - expect(response.json().error_description).to.match(/r3_s256/i) + expect(response.json().detail).to.match(/r3_s256/i) }) it('honours r3_grants override (mock)', async function () { @@ -157,7 +157,7 @@ describe('AAuth /aauth/token — R3 flow', function () { }) const response = await postToken({ scope: 'whoami', r3_uri, r3_s256 }) expect(response.statusCode).to.equal(400) - expect(response.json().error_description).to.match(/parameters/) + expect(response.json().detail).to.match(/parameters/) }) it('rejects the openapi-gateway vocabulary removed in R3 -02', async function () { @@ -168,6 +168,6 @@ describe('AAuth /aauth/token — R3 flow', function () { }) const response = await postToken({ scope: 'whoami', r3_uri, r3_s256 }) expect(response.statusCode).to.equal(400) - expect(response.json().error_description).to.match(/openapi-gateway/) + expect(response.json().detail).to.match(/openapi-gateway/) }) }) From 884b782620a6ff59d4b9f76c4f9800fb021f15b6 Mon Sep 17 00:00:00 2001 From: dickhardt Date: Wed, 12 Aug 2026 12:30:56 +0100 Subject: [PATCH 4/5] Serve both token endpoints under a shared /aauth/token prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auth_token_endpoint moves to /aauth/token/auth and person_token_endpoint to /aauth/token/person, matching how Wallet is being built. Paths are a deployment choice and agents read the URLs from the metadata either way, but the reference PS and the production PS disagreeing is what makes docs, demos and copied examples confusing. /aauth/token is now a prefix, not a route. Fastify would have let it fall into the generic 404, which reads as "maybe it went somewhere"; it answers explicitly instead, with an RFC 9457 body naming both endpoints and the metadata document they are published in. The tests now resolve both paths from /.well-known/aauth-person.json through helpers.endpointPath, the way an agent does — a test that hard-codes a path is testing a deployment choice rather than the protocol, and breaks the day the path moves, which is precisely what just happened. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FmiCqDjRUSx6zb1N4gZPXE --- README.md | 6 ++-- src/aauth/index.js | 2 +- src/aauth/metadata.js | 6 ++-- src/aauth/token.js | 10 +++++++ src/api.js | 14 ++++++--- test/aauth/helpers.js | 47 +++++++++++++++++++++++++++---- test/aauth/metadata.spec.js | 40 ++++++++++++++++++++++++-- test/aauth/pending.spec.js | 21 ++++++-------- test/aauth/person.spec.js | 39 +++++++++++-------------- test/aauth/token.errors.spec.js | 36 ++++++----------------- test/aauth/token.identity.spec.js | 11 ++------ test/aauth/token.r3.spec.js | 11 ++------ 12 files changed, 146 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index 37ecb5d..835c03c 100644 --- a/README.md +++ b/README.md @@ -24,14 +24,14 @@ The mock API can change the returned claims, simulate errors, and invalid ID Tok ## AAuth -Mockin also acts as a mock **Person Server** for [draft-hardt-oauth-aauth-protocol](https://datatracker.ietf.org/doc/draft-hardt-oauth-aauth-protocol/) — useful for testing agent clients without spinning up a real PS. Endpoints include `/aauth/bootstrap`, `/aauth/person` (`person_token_endpoint`), `/aauth/token` (`auth_token_endpoint`), `/aauth/permission`, `/aauth/audit`, `/aauth/interaction`, plus R3 (Rich Resource Requests) support. Auto-approves all consent steps in default mode. See the [docs](https://www.hello.dev/docs/mockin#aauth-agent-auth) for details. +Mockin also acts as a mock **Person Server** for [draft-hardt-oauth-aauth-protocol](https://datatracker.ietf.org/doc/draft-hardt-oauth-aauth-protocol/) — useful for testing agent clients without spinning up a real PS. Endpoints include `/aauth/bootstrap`, `/aauth/token/person` (`person_token_endpoint`), `/aauth/token/auth` (`auth_token_endpoint`), `/aauth/permission`, `/aauth/audit`, `/aauth/interaction`, plus R3 (Rich Resource Requests) support. Agents should read the endpoint URLs from `/.well-known/aauth-person.json` rather than hard-coding paths. Auto-approves all consent steps in default mode. See the [docs](https://www.hello.dev/docs/mockin#aauth-agent-auth) for details. The mock API at `PUT /mock/aauth` switches the simulated behaviours: | Key | Effect | |-----|--------| -| `requirement` | `interaction` \| `approval` \| `clarification` — defers `/aauth/token` with a `202` | -| `person_requirement` | `interaction` \| `approval` — defers `/aauth/person` with a `202` | +| `requirement` | `interaction` \| `approval` \| `clarification` — defers `/aauth/token/auth` with a `202` | +| `person_requirement` | `interaction` \| `approval` — defers `/aauth/token/person` with a `202` | | `auto_approve` | `false` makes a deferred `interaction` wait for `GET /aauth/consent?code=…` instead of resolving on the first poll | | `error` / `error_endpoint` | inject a token endpoint error code, optionally scoped to `token`, `person`, `bootstrap` or `permission` | | `token_lifetime`, `claims`, `r3_grants`, `tenant` | shape the issued tokens (`r3_grants` takes `{ granted, per_call }`) | diff --git a/src/aauth/index.js b/src/aauth/index.js index 21b1945..e87842a 100644 --- a/src/aauth/index.js +++ b/src/aauth/index.js @@ -2,7 +2,7 @@ export { metadata } from './metadata.js' export { jwks } from './jwks.js' -export { token } from './token.js' +export { token, tokenPrefix } from './token.js' export { person } from './person.js' export { pendingGet, pendingPost, pendingDelete } from './pending.js' export { permission } from './permission.js' diff --git a/src/aauth/metadata.js b/src/aauth/metadata.js index 3387db2..d92b296 100644 --- a/src/aauth/metadata.js +++ b/src/aauth/metadata.js @@ -17,8 +17,10 @@ export const metadata = async (req, res) => { name: 'Mockin Person Server', description: '**Mockin** — a mock Person Server for AAuth testing.', jwks_uri: `${ISSUER}/aauth/jwks.json`, - auth_token_endpoint: `${ISSUER}/aauth/token`, - person_token_endpoint: `${ISSUER}/aauth/person`, + // Both token endpoints sit under a shared /aauth/token prefix, as + // Wallet does. The bare prefix is not a route. + auth_token_endpoint: `${ISSUER}/aauth/token/auth`, + person_token_endpoint: `${ISSUER}/aauth/token/person`, permission_endpoint: `${ISSUER}/aauth/permission`, audit_endpoint: `${ISSUER}/aauth/audit`, interaction_endpoint: `${ISSUER}/aauth/interaction`, diff --git a/src/aauth/token.js b/src/aauth/token.js index 663a9ab..1fe98a7 100644 --- a/src/aauth/token.js +++ b/src/aauth/token.js @@ -38,6 +38,16 @@ const ERROR_STATUS = { server_error: 500, } +// /aauth/token is the prefix the two token endpoints sit under, not an +// endpoint itself. A request that lands here is reading a path rather +// than the metadata, so name both URLs in the answer. +export const tokenPrefix = async (req, reply) => { + return problem( + reply, 404, 'not_found', + `/aauth/token is a path prefix, not an endpoint: the auth token endpoint is ${ISSUER}/aauth/token/auth and the person token endpoint is ${ISSUER}/aauth/token/person. Both are published in ${ISSUER}/.well-known/aauth-person.json.`, + ) +} + export const token = async (req, reply) => { const cfg = getConfig() const aauth = req.aauth // set by verifyPreHandler diff --git a/src/api.js b/src/api.js index 5c851d6..dee7544 100644 --- a/src/api.js +++ b/src/api.js @@ -43,18 +43,24 @@ export default function (fastify) { fastify.get('/.well-known/aauth-person.json', aauth.metadata) fastify.get('/aauth/jwks.json', aauth.jwks) - // AAuth: auth token endpoint (metadata: auth_token_endpoint) - fastify.post('/aauth/token', { + // AAuth: the two token endpoints, under a shared /aauth/token prefix + // (matching Wallet). Agents read the URLs from the PS metadata; the + // paths themselves are a deployment choice. + fastify.post('/aauth/token/auth', { preParsing: captureRawBody, preHandler: aauth.verifyPreHandler, }, aauth.token) - // AAuth: person token endpoint (metadata: person_token_endpoint) - fastify.post('/aauth/person', { + fastify.post('/aauth/token/person', { preParsing: captureRawBody, preHandler: aauth.verifyPreHandler, }, aauth.person) + // /aauth/token is a prefix, not an endpoint. Say so explicitly rather + // than letting a request for the old path fall into the generic 404 — + // or, worse, look like it might have been routed somewhere. + fastify.all('/aauth/token', aauth.tokenPrefix) + // AAuth: pending endpoint (poll, clarify, cancel) — verification runs // inside the handler since bootstrap polls use hwk and others use jwt. fastify.get('/aauth/pending/:id', aauth.pendingGet) diff --git a/test/aauth/helpers.js b/test/aauth/helpers.js index f13a147..8022d63 100644 --- a/test/aauth/helpers.js +++ b/test/aauth/helpers.js @@ -176,9 +176,36 @@ export async function mintResourceToken({ .sign(resourceServer.privateKey) } +// ── Endpoint discovery ───────────────────────────────────────────────── +// +// Agents read endpoint URLs from the PS metadata, and so do these tests — +// a test that hard-codes a path is testing a deployment choice rather +// than the protocol, and breaks the day the path moves. + +let metadataCache = null + +export async function psMetadata(fastify) { + if (!metadataCache) { + const res = await fastify.inject({ + method: 'GET', + url: '/.well-known/aauth-person.json', + }) + metadataCache = res.json() + } + return metadataCache +} + +/** The pathname of a published endpoint, e.g. 'person_token_endpoint'. */ +export async function endpointPath(fastify, field) { + const metadata = await psMetadata(fastify) + const url = metadata[field] + if (!url) throw new Error(`PS metadata publishes no ${field}`) + return new URL(url).pathname +} + // ── Person tokens ────────────────────────────────────────────────────── // -// Almost every /aauth/token test now needs one first: the PS will only +// Almost every auth token test now needs one first: the PS will only // accept a resource token whose person_token_jti names a person token it // issued (§Resource Token Verification step 6). @@ -188,18 +215,26 @@ export async function requestPersonToken(fastify, { ...rest } = {}) { const token = agentToken || (await mintAgentToken()) + const path = await endpointPath(fastify, 'person_token_endpoint') const { headers, payload } = await signedRequest({ method: 'POST', - path: '/aauth/person', + path, body: { resource, ...rest }, agentToken: token, }) - return fastify.inject({ + return fastify.inject({ method: 'POST', url: path, headers, payload }) +} + +/** Sign and POST a body to the auth token endpoint. */ +export async function postAuthToken(fastify, { body, agentToken }) { + const path = await endpointPath(fastify, 'auth_token_endpoint') + const { headers, payload } = await signedRequest({ method: 'POST', - url: '/aauth/person', - headers, - payload, + path, + body, + agentToken, }) + return fastify.inject({ method: 'POST', url: path, headers, payload }) } /** 200-path convenience: returns { person_token, claims, agentToken }. */ diff --git a/test/aauth/metadata.spec.js b/test/aauth/metadata.spec.js index 10dd619..21b4ea4 100644 --- a/test/aauth/metadata.spec.js +++ b/test/aauth/metadata.spec.js @@ -18,9 +18,10 @@ describe('AAuth Metadata & JWKS', function () { expect(data.issuer).to.equal(ISSUER) expect(data.jwks_uri).to.equal(`${ISSUER}/aauth/jwks.json`) // -11 renamed token_endpoint → auth_token_endpoint and made - // person_token_endpoint REQUIRED of every PS. - expect(data.auth_token_endpoint).to.equal(`${ISSUER}/aauth/token`) - expect(data.person_token_endpoint).to.equal(`${ISSUER}/aauth/person`) + // person_token_endpoint REQUIRED of every PS. Both sit under a + // shared /aauth/token prefix, matching Wallet. + expect(data.auth_token_endpoint).to.equal(`${ISSUER}/aauth/token/auth`) + expect(data.person_token_endpoint).to.equal(`${ISSUER}/aauth/token/person`) expect(data).to.not.have.property('token_endpoint') expect(data.permission_endpoint).to.equal(`${ISSUER}/aauth/permission`) expect(data.audit_endpoint).to.equal(`${ISSUER}/aauth/audit`) @@ -28,6 +29,39 @@ describe('AAuth Metadata & JWKS', function () { expect(data.bootstrap_endpoint).to.equal(`${ISSUER}/aauth/bootstrap`) }) + it('serves both token endpoints under the /aauth/token prefix', async function () { + const data = (await fastify.inject({ + method: 'GET', + url: '/.well-known/aauth-person.json', + })).json() + expect(new URL(data.auth_token_endpoint).pathname) + .to.equal('/aauth/token/auth') + expect(new URL(data.person_token_endpoint).pathname) + .to.equal('/aauth/token/person') + }) + + it('the bare /aauth/token prefix is not an endpoint', async function () { + // It must not silently route to either of the two, and the 404 + // should say where they actually are. + for (const method of ['POST', 'GET']) { + const res = await fastify.inject({ + method, + url: '/aauth/token', + headers: { 'content-type': 'application/json' }, + payload: method === 'POST' ? '{}' : undefined, + }) + expect(res.statusCode, method).to.equal(404) + expect(res.headers['content-type']) + .to.match(/^application\/problem\+json/) + const body = res.json() + expect(body.error).to.equal('not_found') + expect(body.detail).to.match(/\/aauth\/token\/auth/) + expect(body.detail).to.match(/\/aauth\/token\/person/) + expect(body).to.not.have.property('auth_token') + expect(body).to.not.have.property('person_token') + } + }) + it('publishes both token endpoints @aauth/bootstrap 2.0.0 requires', async function () { const data = (await fastify.inject({ method: 'GET', diff --git a/test/aauth/pending.spec.js b/test/aauth/pending.spec.js index 2281bbe..1822754 100644 --- a/test/aauth/pending.spec.js +++ b/test/aauth/pending.spec.js @@ -1,4 +1,5 @@ -// Deferred-mode polling — when mock.requirement is set, /aauth/token +// Deferred-mode polling — when mock.requirement is set, the auth token +// endpoint // returns 202 with a pending Location. The first poll auto-resolves and // returns the auth_token (mockin auto-approves on the agent's behalf). @@ -11,6 +12,7 @@ import { installMocks, mintAgentToken, signedRequest, + postAuthToken, personAndResourceToken, } from './helpers.js' @@ -30,18 +32,13 @@ async function startTokenRequest() { const { agentToken, resourceToken } = await personAndResourceToken(fastify, { resource: { scope: 'openid email' }, }) - const { headers, payload } = await signedRequest({ - method: 'POST', - path: '/aauth/token', - body: { resource_token: resourceToken }, + return { agentToken, - }) - return { agentToken, response: await fastify.inject({ - method: 'POST', - url: '/aauth/token', - headers, - payload, - }) } + response: await postAuthToken(fastify, { + body: { resource_token: resourceToken }, + agentToken, + }), + } } describe('AAuth /aauth/pending — deferred mode', function () { diff --git a/test/aauth/person.spec.js b/test/aauth/person.spec.js index 84c9bc5..c021e1f 100644 --- a/test/aauth/person.spec.js +++ b/test/aauth/person.spec.js @@ -1,5 +1,6 @@ -// POST /aauth/person — the PS person_token_endpoint (-11 §Person Token -// Endpoint, interop demo profile surface 2). +// The PS person_token_endpoint (-11 §Person Token Endpoint, interop demo +// profile surface 2). Paths come from the published metadata, the way an +// agent gets them — never hard-coded here. import { expect } from 'chai' import { @@ -16,6 +17,8 @@ import { signedRequest, requestPersonToken, getPersonToken, + postAuthToken, + endpointPath, ephemeralPublicJwk, ephemeralJkt, RESOURCE_SERVER_URL, @@ -36,7 +39,7 @@ async function setMock(patch) { }) } -describe('AAuth /aauth/person — person token endpoint', function () { +describe('AAuth person_token_endpoint', function () { beforeEach(async function () { await installMocks(fastify) }) @@ -93,15 +96,10 @@ describe('AAuth /aauth/person — person token endpoint', function () { const { mintResourceToken } = await import('./helpers.js') const agentToken = await mintAgentToken() const resourceToken = await mintResourceToken({ personToken: person_token }) - const { headers, payload } = await signedRequest({ - method: 'POST', - path: '/aauth/token', + const res = await postAuthToken(fastify, { body: { resource_token: resourceToken }, agentToken, }) - const res = await fastify.inject({ - method: 'POST', url: '/aauth/token', headers, payload, - }) expect(res.statusCode).to.equal(200) const auth = decodeJwt(res.json().auth_token) expect(auth.sub).to.equal(claims.sub) @@ -178,16 +176,17 @@ describe('AAuth /aauth/person — person token endpoint', function () { it('401 when the body signature does not cover content-digest', async function () { const agentToken = await mintAgentToken() + const path = await endpointPath(fastify, 'person_token_endpoint') const { headers, payload } = await signedRequest({ method: 'POST', - path: '/aauth/person', + path, body: { resource: RESOURCE_SERVER_URL }, agentToken, // httpsig's own default list for a body — no content-digest. components: ['@method', '@authority', '@path', 'content-type', 'signature-key'], }) const res = await fastify.inject({ - method: 'POST', url: '/aauth/person', headers, payload, + method: 'POST', url: path, headers, payload, }) expect(res.statusCode).to.equal(401) expect(res.json().detail).to.match(/content-digest/) @@ -197,15 +196,16 @@ describe('AAuth /aauth/person — person token endpoint', function () { it('accepts an uncovered body when require_body_signing is off', async function () { await setMock({ require_body_signing: false }) const agentToken = await mintAgentToken() + const path = await endpointPath(fastify, 'person_token_endpoint') const { headers, payload } = await signedRequest({ method: 'POST', - path: '/aauth/person', + path, body: { resource: RESOURCE_SERVER_URL }, agentToken, components: ['@method', '@authority', '@path', 'content-type', 'signature-key'], }) const res = await fastify.inject({ - method: 'POST', url: '/aauth/person', headers, payload, + method: 'POST', url: path, headers, payload, }) expect(res.statusCode).to.equal(200) }) @@ -224,7 +224,7 @@ describe('AAuth /aauth/person — person token endpoint', function () { it('401 when no signature is present', async function () { const res = await fastify.inject({ method: 'POST', - url: '/aauth/person', + url: await endpointPath(fastify, 'person_token_endpoint'), headers: { 'content-type': 'application/json' }, payload: JSON.stringify({ resource: RESOURCE_SERVER_URL }), }) @@ -267,9 +267,9 @@ describe('AAuth /aauth/person — person token endpoint', function () { // `error_description`, and never both. describe('RFC 9457 error responses', function () { const cases = [ - ['no signature', () => fastify.inject({ + ['no signature', async () => fastify.inject({ method: 'POST', - url: '/aauth/person', + url: await endpointPath(fastify, 'person_token_endpoint'), headers: { 'content-type': 'application/json' }, payload: JSON.stringify({ resource: RESOURCE_SERVER_URL }), })], @@ -386,15 +386,10 @@ describe('AAuth /aauth/person — person token endpoint', function () { // 3. The parent presents both to the auth token endpoint. const agentToken = await mintAgentToken() - const { headers, payload } = await signedRequest({ - method: 'POST', - path: '/aauth/token', + const res = await postAuthToken(fastify, { body: { resource_token: resourceToken, subagent_token: sub.token }, agentToken, }) - const res = await fastify.inject({ - method: 'POST', url: '/aauth/token', headers, payload, - }) expect(res.statusCode).to.equal(200) // 4. The auth token binds the sub-agent's key, not the parent's. const claims = decodeJwt(res.json().auth_token) diff --git a/test/aauth/token.errors.spec.js b/test/aauth/token.errors.spec.js index cc29d46..4ac4551 100644 --- a/test/aauth/token.errors.spec.js +++ b/test/aauth/token.errors.spec.js @@ -1,4 +1,4 @@ -// /aauth/token error paths — bad signatures, mismatched claims, mock +// auth_token_endpoint error paths — bad signatures, mismatched claims, mock // errors, and the -11 §Resource Token Verification step-6 binding: the // resource token must name a person token this PS issued, and its `ps`, // `sub`, `mission_s256` and `tenant` must match that token exactly. @@ -12,7 +12,8 @@ import { installMocks, mintAgentToken, mintResourceToken, - signedRequest, + postAuthToken, + endpointPath, getPersonToken, personAndResourceToken, } from './helpers.js' @@ -24,18 +25,13 @@ const MISSION_S256 = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' async function postResourceToken(resourceToken, agentToken) { const token = agentToken || (await mintAgentToken()) - const { headers, payload } = await signedRequest({ - method: 'POST', - path: '/aauth/token', + return postAuthToken(fastify, { body: { resource_token: resourceToken }, agentToken: token, }) - return fastify.inject({ - method: 'POST', url: '/aauth/token', headers, payload, - }) } -describe('AAuth /aauth/token — errors', function () { +describe('AAuth auth_token_endpoint — errors', function () { beforeEach(async function () { await installMocks(fastify) }) @@ -43,7 +39,7 @@ describe('AAuth /aauth/token — errors', function () { it('401 + Accept-Signature when no signature is present', async function () { const response = await fastify.inject({ method: 'POST', - url: '/aauth/token', + url: await endpointPath(fastify, 'auth_token_endpoint'), headers: { 'content-type': 'application/json' }, payload: JSON.stringify({ resource_token: 'x' }), }) @@ -90,33 +86,17 @@ describe('AAuth /aauth/token — errors', function () { it('400 invalid_request when resource_token missing', async function () { const agentToken = await mintAgentToken() - const { headers, payload } = await signedRequest({ - method: 'POST', - path: '/aauth/token', - body: {}, - agentToken, - }) - const response = await fastify.inject({ - method: 'POST', - url: '/aauth/token', - headers, - payload, - }) + const response = await postAuthToken(fastify, { body: {}, agentToken }) expect(response.statusCode).to.equal(400) expect(response.json().error).to.equal('invalid_request') }) it('400 on upstream_token — call chaining is not implemented', async function () { const { agentToken, resourceToken } = await personAndResourceToken(fastify) - const { headers, payload } = await signedRequest({ - method: 'POST', - path: '/aauth/token', + const response = await postAuthToken(fastify, { body: { resource_token: resourceToken, upstream_token: 'eyJ.e30.x' }, agentToken, }) - const response = await fastify.inject({ - method: 'POST', url: '/aauth/token', headers, payload, - }) expect(response.statusCode).to.equal(400) expect(response.json().detail).to.match(/upstream_token/) }) diff --git a/test/aauth/token.identity.spec.js b/test/aauth/token.identity.spec.js index d1ddc78..faf578f 100644 --- a/test/aauth/token.identity.spec.js +++ b/test/aauth/token.identity.spec.js @@ -12,7 +12,7 @@ import { ISSUER } from '../../src/config.js' import defaultUser from '../../src/users.js' import { installMocks, - signedRequest, + postAuthToken, personAndResourceToken, ephemeralPublicJwk, RESOURCE_SERVER_URL, @@ -26,19 +26,14 @@ api(fastify) async function postToken({ person = {}, resource = {}, body = {} } = {}) { const { agentToken, resourceToken, personClaims } = await personAndResourceToken(fastify, { person, resource }) - const { headers, payload } = await signedRequest({ - method: 'POST', - path: '/aauth/token', + const response = await postAuthToken(fastify, { body: { resource_token: resourceToken, ...body }, agentToken, }) - const response = await fastify.inject({ - method: 'POST', url: '/aauth/token', headers, payload, - }) return { response, personClaims } } -describe('AAuth /aauth/token — identity flow (no R3)', function () { +describe('AAuth auth_token_endpoint — identity flow (no R3)', function () { beforeEach(async function () { await installMocks(fastify) }) diff --git a/test/aauth/token.r3.spec.js b/test/aauth/token.r3.spec.js index 708e1b6..6a262d9 100644 --- a/test/aauth/token.r3.spec.js +++ b/test/aauth/token.r3.spec.js @@ -14,7 +14,7 @@ import api from '../../src/api.js' import { installMocks, registerR3Document, - signedRequest, + postAuthToken, personAndResourceToken, } from './helpers.js' @@ -34,18 +34,13 @@ const sampleR3 = { async function postToken(resource) { const { agentToken, resourceToken } = await personAndResourceToken(fastify, { resource }) - const { headers, payload } = await signedRequest({ - method: 'POST', - path: '/aauth/token', + return postAuthToken(fastify, { body: { resource_token: resourceToken }, agentToken, }) - return fastify.inject({ - method: 'POST', url: '/aauth/token', headers, payload, - }) } -describe('AAuth /aauth/token — R3 flow', function () { +describe('AAuth auth_token_endpoint — R3 flow', function () { beforeEach(async function () { await installMocks(fastify) }) From 68da4de89babc4c86279933f46d967803dfa0774 Mon Sep 17 00:00:00 2001 From: dickhardt Date: Wed, 12 Aug 2026 17:17:43 +0100 Subject: [PATCH 5/5] =?UTF-8?q?Release=202.0.0=20=E2=80=94=20major:=20the?= =?UTF-8?q?=20-11=20surface=20is=20breaking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four independent breaks for anything testing against 1.7.0: - both token endpoints moved (/aauth/person -> /aauth/token/person, /aauth/token -> /aauth/token/auth) - AAuth errors are RFC 9457 problem details: error_description -> detail, Content-Type: application/problem+json - the polymorphic EdDSA is rejected; Ed25519 is required - a resource token is not issued without a verified person token A major keeps ^1.7.0 consumers on the -10 surface and makes the upgrade a deliberate act. --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index dbe0846..1daa5d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@hellocoop/mockin", - "version": "1.7.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@hellocoop/mockin", - "version": "1.7.0", + "version": "2.0.0", "license": "MIT", "dependencies": { "@fastify/cors": "^10.0.0", diff --git a/package.json b/package.json index dd23cc0..52f25db 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@hellocoop/mockin", "private": false, - "version": "1.7.0", + "version": "2.0.0", "description": "Hellō Mock Login OpenID Connect Server", "engines": { "node": "~22"