diff --git a/README.md b/README.md index 746c61e..835c03c 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,20 @@ 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/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/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 }`) | +| `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 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" 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/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 a3ab916..bae80eb 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,22 @@ 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 { problem } from './problem.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 +49,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 } @@ -85,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) { @@ -98,10 +100,19 @@ 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. + const bodyFailure = checkBodySigning(req, sigResult) + if (bodyFailure) { + for (const [k, v] of Object.entries(bodyFailure.headers || {})) { + reply.header(k, v) + } + return problem( + reply, bodyFailure.status, + bodyFailure.body.error, bodyFailure.body.detail, + ) } // Two valid request flavours per the wallet PS pattern: @@ -113,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 @@ -136,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/index.js b/src/aauth/index.js index 2bf27ee..e87842a 100644 --- a/src/aauth/index.js +++ b/src/aauth/index.js @@ -2,7 +2,8 @@ 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' export { audit } from './audit.js' 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/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..d92b296 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,13 @@ 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`, + // 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/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..fa5bf31 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,11 @@ 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' +import { problem } from './problem.js' const ACCEPT_SIG_GET = generateAcceptSignatureHeader({ label: 'sig', @@ -48,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) { @@ -68,83 +67,68 @@ 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 + // 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 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 - 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) { - return reply.code(401).send({ - error: 'invalid_jwt', - error_description: `agent_token signature: ${err.message}`, - }) + const verified = await verifyAgentToken(raw, { header, payload }) + if (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') { @@ -163,8 +147,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 +160,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) } @@ -206,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 @@ -239,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-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..e18b3a7 --- /dev/null +++ b/src/aauth/person.js @@ -0,0 +1,201 @@ +// 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' +import { problem } from './problem.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 problem( + reply, ERROR_STATUS[mockErr] || 400, mockErr, + `Mock error: ${mockErr}`, + ) + } + + const resourceError = validateResourceIdentifier(body.resource) + if (resourceError) { + return problem(reply, 400, 'invalid_request', resourceError) + } + + if (body.upstream_token !== undefined) { + 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 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 + // 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 problem(reply, 400, 'invalid_request', 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 problem( + reply, 400, 'invalid_request', 'subagent_token must be a string', + ) + } + const sub = await verifyAgentToken(body.subagent_token) + if (sub.error) { + return problem( + reply, 400, 'invalid_agent_token', `subagent_token: ${sub.error}`, + ) + } + if (sub.payload.parent_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( + 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 problem( + reply, 403, 'user_unreachable', + '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/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 42cbf52..b782459 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,63 @@ 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 +} + +// 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. @@ -41,7 +98,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 } } } @@ -63,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) @@ -79,27 +145,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..1fe98a7 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,22 +14,40 @@ // - 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' +import { problem } from './problem.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, } +// /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 @@ -37,29 +57,64 @@ 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') } - 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 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 problem(reply, 400, 'invalid_request', 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 problem( + reply, 400, 'invalid_request', 'subagent_token must be a string', + ) + } + const sub = await verifyAgentToken(body.subagent_token) + if (sub.error) { + return problem( + reply, 400, 'invalid_agent_token', `subagent_token: ${sub.error}`, + ) + } + if (sub.payload.parent_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) + } + + const rt = await verifyResourceToken(body.resource_token, expectedJkt) if (rt.error) { - return reply.code(400).send({ - error: 'invalid_resource_token', - error_description: rt.error, - }) + return problem( + reply, 400, + rt.expired ? 'expired_resource_token' : 'invalid_resource_token', + rt.error, + ) } let r3 = null @@ -69,27 +124,51 @@ 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 } } + 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 problem( + reply, 403, 'user_unreachable', + '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 +176,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 +199,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..b294711 100644 --- a/src/aauth/verify-request.js +++ b/src/aauth/verify-request.js @@ -3,40 +3,112 @@ // 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' +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. +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 } +// 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 } +} + +/** + * 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, + 'signature_verification_failed', + `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) { @@ -62,10 +134,12 @@ 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, + 'Accept-Signature-Alg': ACCEPT_SIG_ALG, }, ) } @@ -75,81 +149,41 @@ export async function verifyRequest(request) { sigResult.signatureError, ) } - return fail( - 401, - { - error: 'signature_verification_failed', - error_description: sigResult.error, - }, - headers, - ) - } - - if (sigResult.keyType !== 'jwt' || !sigResult.jwt) { - return fail(401, { - error: 'invalid_key', - error_description: 'Signature-Key must use scheme=jwt with an agent_token', - }) + if (sigResult.acceptSignatureAlg) { + headers['Accept-Signature-Alg'] = generateAcceptSignatureAlgHeader( + sigResult.acceptSignatureAlg, + ) + } + return fail(401, 'signature_verification_failed', sigResult.error, headers) } - 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}`, - }) + if (hasBody) { + const bodyFailure = checkBodySigning(request, sigResult) + if (bodyFailure) return bodyFailure } - 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}`, - }) + if (sigResult.keyType !== 'jwt' || !sigResult.jwt) { + return fail( + 401, 'invalid_key', + 'Signature-Key must use scheme=jwt with an agent_token', + ) } - try { - await jose.jwtVerify(raw, jose.createLocalJWKSet(entity.jwks)) - } catch (err) { - return fail(401, { - error: 'invalid_jwt', - error_description: `agent_token signature: ${err.message}`, - }) - } + const { header, payload, raw } = sigResult.jwt - if (!payload.sub) { - return fail(401, { - error: 'invalid_jwt', - error_description: 'agent_token missing sub', - }) - } - if (!payload.cnf?.jwk) { - return fail(401, { - error: 'invalid_jwt', - error_description: 'agent_token missing cnf.jwk', - }) + const verified = await verifyAgentToken(raw, { header, payload }) + if (verified.error) { + return fail(401, 'invalid_jwt', 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, } } @@ -161,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/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..dee7544 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 @@ -43,12 +43,24 @@ export default function (fastify) { fastify.get('/.well-known/aauth-person.json', aauth.metadata) fastify.get('/aauth/jwks.json', aauth.jwks) - // AAuth: 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) + 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/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..8022d63 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,101 @@ 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 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). + +export async function requestPersonToken(fastify, { + resource = RESOURCE_SERVER_URL, + agentToken, + ...rest +} = {}) { + const token = agentToken || (await mintAgentToken()) + const path = await endpointPath(fastify, 'person_token_endpoint') + const { headers, payload } = await signedRequest({ + method: 'POST', + path, + body: { resource, ...rest }, + agentToken: token, + }) + 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', + path, + body, + agentToken, + }) + return fastify.inject({ method: 'POST', url: path, 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 +305,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 +326,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 +337,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 +348,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 +363,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..21b4ea4 100644 --- a/test/aauth/metadata.spec.js +++ b/test/aauth/metadata.spec.js @@ -17,13 +17,61 @@ 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. 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`) expect(data.interaction_endpoint).to.equal(`${ISSUER}/aauth/interaction`) 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', + 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..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). @@ -10,8 +11,9 @@ import api from '../../src/api.js' import { installMocks, mintAgentToken, - mintResourceToken, signedRequest, + postAuthToken, + personAndResourceToken, } from './helpers.js' const fastify = Fastify() @@ -27,20 +29,16 @@ async function setRequirement(req) { } async function startTokenRequest() { - 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 { agentToken, resourceToken } = await personAndResourceToken(fastify, { + resource: { scope: 'openid email' }, }) - return { agentToken, response: await fastify.inject({ - method: 'POST', - url: '/aauth/token', - headers, - payload, - }) } + return { + agentToken, + 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 new file mode 100644 index 0000000..c021e1f --- /dev/null +++ b/test/aauth/person.spec.js @@ -0,0 +1,550 @@ +// 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 { + 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, + postAuthToken, + endpointPath, + 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 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 res = await postAuthToken(fastify, { + body: { resource_token: resourceToken }, + agentToken, + }) + 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().detail).to.match(/upstream_token/) + }) + + 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, + 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: path, headers, payload, + }) + expect(res.statusCode).to.equal(401) + expect(res.json().detail).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 path = await endpointPath(fastify, 'person_token_endpoint') + const { headers, payload } = await signedRequest({ + method: 'POST', + path, + body: { resource: RESOURCE_SERVER_URL }, + agentToken, + components: ['@method', '@authority', '@path', 'content-type', 'signature-key'], + }) + const res = await fastify.inject({ + method: 'POST', url: path, 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().detail).to.match(/EdDSA/) + }) + + it('401 when no signature is present', async function () { + const res = await fastify.inject({ + method: 'POST', + url: await endpointPath(fastify, 'person_token_endpoint'), + 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') + }) + }) + + // -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', async () => fastify.inject({ + method: 'POST', + url: await endpointPath(fastify, 'person_token_endpoint'), + 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') + 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 res = await postAuthToken(fastify, { + body: { resource_token: resourceToken, subagent_token: sub.token }, + agentToken, + }) + 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().detail).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..4ac4551 100644 --- a/test/aauth/token.errors.spec.js +++ b/test/aauth/token.errors.spec.js @@ -1,21 +1,37 @@ -// /aauth/token error paths — bad signatures, mismatched claims, mock errors. +// 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. 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, + postAuthToken, + endpointPath, + getPersonToken, + personAndResourceToken, } from './helpers.js' const fastify = Fastify() api(fastify) -describe('AAuth /aauth/token — errors', function () { +const MISSION_S256 = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' + +async function postResourceToken(resourceToken, agentToken) { + const token = agentToken || (await mintAgentToken()) + return postAuthToken(fastify, { + body: { resource_token: resourceToken }, + agentToken: token, + }) +} + +describe('AAuth auth_token_endpoint — errors', function () { beforeEach(async function () { await installMocks(fastify) }) @@ -23,12 +39,14 @@ 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' }), }) 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 +57,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 @@ -60,89 +69,180 @@ 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({ - method: 'POST', - path: '/aauth/token', - body: {}, + 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 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().error).to.equal('invalid_request') + expect(response.json().detail).to.match(/upstream_token/) }) it('400 invalid_resource_token when aud != PS', async function () { - const agentToken = await mintAgentToken() + const { person_token } = await getPersonToken(fastify) const resourceToken = await mintResourceToken({ - scope: 'openid', + personToken: person_token, aud: 'https://wrong-ps.example', }) - 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).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 () { - 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/) + expect(response.json().detail).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().detail).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().detail) + .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().detail).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().detail).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().detail).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().detail).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().detail).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().detail).to.match(/EdDSA/) }) it('returns mock-injected error code', async function () { @@ -153,25 +253,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 +271,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..faf578f 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, + postAuthToken, + personAndResourceToken, ephemeralPublicJwk, RESOURCE_SERVER_URL, } from './helpers.js' @@ -23,29 +21,26 @@ import { const fastify = Fastify() api(fastify) -describe('AAuth /aauth/token — identity flow (no R3)', function () { +// 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 response = await postAuthToken(fastify, { + body: { resource_token: resourceToken, ...body }, + agentToken, + }) + return { response, personClaims } +} + +describe('AAuth auth_token_endpoint — 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 +57,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 +79,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 agentToken = await mintAgentToken() - const resourceToken = await mintResourceToken({ scope: 'openid' }) + const localJwks = createLocalJWKSet(jwksRes.json()) - 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 +112,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 +120,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 +142,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..6a262d9 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, + postAuthToken, + personAndResourceToken, } from './helpers.js' const fastify = Fastify() @@ -26,7 +29,18 @@ const sampleR3 = { ], } -describe('AAuth /aauth/token — R3 flow', function () { +// 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 }) + return postAuthToken(fastify, { + body: { resource_token: resourceToken }, + agentToken, + }) +} + +describe('AAuth auth_token_endpoint — R3 flow', function () { beforeEach(async function () { await installMocks(fastify) }) @@ -35,25 +49,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,34 +59,21 @@ 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) + expect(response.json().detail).to.match(/r3_s256/i) }) it('honours r3_grants override (mock)', async function () { @@ -107,7 +90,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 +98,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, + }) + 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 fastify.inject({ - method: 'POST', url: '/aauth/token', headers, payload, + const response = await postToken({ scope: 'whoami', r3_uri, r3_s256 }) + expect(response.statusCode).to.equal(400) + expect(response.json().detail).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).to.equal('invalid_resource_token') + expect(response.json().detail).to.match(/openapi-gateway/) }) })