diff --git a/README.md b/README.md index 835c03c..639d4e0 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ The mock API at `PUT /mock/aauth` switches the simulated behaviours: | `token_lifetime`, `claims`, `r3_grants`, `tenant` | shape the issued tokens (`r3_grants` takes `{ granted, per_call }`) | | `require_body_signing` | `false` accepts a body signature that does not cover `content-digest` and `content-type` | +The auth token request (`POST /aauth/token/auth`) follows AAuth -11: `resource_token` and `presented_token` are REQUIRED. `presented_token` is the token the agent presented to the resource — the person token from `/aauth/token/person`, or on a step-up the auth token — and the resource token's `presented_jti` must name it. Mockin verifies the presented token under its own key (`aud` = the resource, `cnf.jwk` = the resource token's `agent_jkt`) and rejects any `ps` / `sub` / `mission_s256` / `tenant` mismatch with `invalid_resource_token`; a bad or missing presented token is `invalid_request`, `invalid_presented_token` or `expired_presented_token`. The auth token never outlives the presented token. + 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/src/aauth/issue-auth-token.js b/src/aauth/issue-auth-token.js index f5d8504..1172f31 100644 --- a/src/aauth/issue-auth-token.js +++ b/src/aauth/issue-auth-token.js @@ -95,6 +95,10 @@ function releaseFor(identityScopes) { * @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 {number} [args.presented_exp] exp of the presented_token the + * request carried — the auth token + * MUST NOT expire later (-11 §Auth + * Token Structure, issue #152) * @param {object} [args.r3] { uri, s256, granted, per_call } */ export async function issueAuthToken({ @@ -105,16 +109,20 @@ export async function issueAuthToken({ mission_s256, tenant, account, + presented_exp, r3, }) { const cfg = getConfig() - const lifetime = Math.min(cfg.token_lifetime || MAX_AUTH_TOKEN_TTL, MAX_AUTH_TOKEN_TTL) + const iat = Math.floor(Date.now() / 1000) + let lifetime = Math.min(cfg.token_lifetime || MAX_AUTH_TOKEN_TTL, MAX_AUTH_TOKEN_TTL) + if (Number.isFinite(presented_exp)) { + lifetime = Math.max(1, Math.min(lifetime, presented_exp - iat)) + } const { identity, resource } = classifyScopes(scope) const release = releaseFor(identity) const claimsOverride = cfg.claims || {} - const iat = Math.floor(Date.now() / 1000) const tokenPayload = { iss: ISSUER, dwk: 'aauth-person.json', diff --git a/src/aauth/issue-person-token.js b/src/aauth/issue-person-token.js index 5a26ee7..5c70876 100644 --- a/src/aauth/issue-person-token.js +++ b/src/aauth/issue-person-token.js @@ -7,8 +7,9 @@ // // `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. +// token will carry for the same `aud`. The agent presents this token back +// with its auth token request (`presented_token`); step 6 of §Resource +// Token Verification checks the resource token against it. import { randomUUID } from 'crypto' import { SignJWT } from 'jose' @@ -73,7 +74,7 @@ export async function issuePersonToken({ .setProtectedHeader({ alg: SIGNING_ALG, typ: 'aa-person+jwt', kid }) .sign(privateKey) - // §Resource Token Verification step 6 needs this later. + // §Person Token Endpoint retention (jti, aud, exp) — for revocation. recordPersonToken({ jti, ps: ISSUER, diff --git a/src/aauth/person-token-store.js b/src/aauth/person-token-store.js index 4580378..4c7fda0 100644 --- a/src/aauth/person-token-store.js +++ b/src/aauth/person-token-store.js @@ -1,18 +1,16 @@ // aauth/person-token-store.js — issued person tokens, keyed by `jti`. // -// Protocol -11 §Resource Token Verification step 6: +// Protocol -11 §Person Token Endpoint (issue #152): "A PS MUST record, for +// each person token it issues, the `jti`, the `aud`, and the `exp`, and, +// once it has presented the token to an access server, which one, and MUST +// keep the record until the token's `exp` plus clock skew." The record is +// for revocation, not verification — resource token verification step 6 +// verifies the `presented_token` the agent sends, under this PS's own +// signature (verify-presented-token.js), and consults no record. // -// "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. +// mockin has no revocation endpoint and no AS federation, so the store is +// introspection only: tests can ask which jtis are live. A mock keeps it +// in memory and expires entries with the token itself. const issued = new Map() // jti → record diff --git a/src/aauth/token.js b/src/aauth/token.js index 1fe98a7..8a534a1 100644 --- a/src/aauth/token.js +++ b/src/aauth/token.js @@ -2,9 +2,11 @@ // // Auto-approve flow (default): // 1. HTTPSig + agent_token verified by preHandler (request.aauth) -// 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 +// 2. Verify resource_token from the body, then -11 step 6 (issue #152): +// the presented_token the agent sent — the person token, or on a +// step-up the auth token, that it presented to the resource — must +// verify, and the resource token's presented_jti / ps / sub / +// mission_s256 / tenant must match it exactly // 3. If R3, fetch + hash-verify the document // 4. Inject mock errors / deferred response if configured // 5. Issue auth_token immediately, 200 @@ -19,6 +21,7 @@ import { calculateJwkThumbprint } from 'jose' import { ISSUER } from '../config.js' import { getConfig, mockErrorFor } from './mock.js' import { verifyResourceToken } from './verify-resource-token.js' +import { verifyPresentedToken } from './verify-presented-token.js' import { fetchR3Document, autoGrantR3 } from './r3.js' import { issueAuthToken } from './issue-auth-token.js' import { parseRequestParameters, canDriveInteraction } from './request-parameters.js' @@ -32,6 +35,9 @@ const ERROR_STATUS = { expired_agent_token: 400, invalid_resource_token: 400, expired_resource_token: 400, + invalid_presented_token: 400, + expired_presented_token: 400, + revoked_presented_token: 400, invalid_scope: 400, denied: 403, user_unreachable: 403, @@ -63,6 +69,14 @@ export const token = async (req, reply) => { if (!body.resource_token) { return problem(reply, 400, 'invalid_request', 'missing resource_token') } + // -11 issue #152: REQUIRED — the token the agent presented to the + // resource, whose jti the resource token's presented_jti names. + if (typeof body.presented_token !== 'string' || !body.presented_token) { + return problem( + reply, 400, 'invalid_request', + 'missing presented_token: the person token (or, on a step-up, the auth token) presented to the resource, named by the resource token presented_jti', + ) + } // upstream_token is call chaining — deferred fleet-wide, not implemented. if (body.upstream_token !== undefined) { @@ -116,6 +130,11 @@ export const token = async (req, reply) => { rt.error, ) } + // Step 6: the presented token against the resource token. + const presented = await verifyPresentedToken(body.presented_token, rt) + if (presented.error) { + return problem(reply, ERROR_STATUS[presented.code] || 400, presented.code, presented.error) + } let r3 = null if (rt.r3) { @@ -141,6 +160,8 @@ export const token = async (req, reply) => { mission_s256: rt.mission_s256 || undefined, tenant: rt.tenant || undefined, account: rt.account || undefined, + // §Auth Token Structure: never past the presented token's exp. + presented_exp: presented.exp, r3, } diff --git a/src/aauth/verify-presented-token.js b/src/aauth/verify-presented-token.js new file mode 100644 index 0000000..463b04e --- /dev/null +++ b/src/aauth/verify-presented-token.js @@ -0,0 +1,211 @@ +// aauth/verify-presented-token.js — step 6 of §Resource Token Verification. +// +// Protocol -11 (issue #152): the agent's auth token request carries +// `presented_token`, the token it presented to the resource that issued the +// resource token — the person token on the first challenge of a grant, or +// the auth token on a step-up / per-call challenge. The resource copied +// `ps`, `sub`, `mission_s256` and `tenant` out of it and named it by +// `presented_jti`. The PS verifies the presented token by its `typ`: +// +// aa-person+jwt — a person token this PS issued (iss = ISSUER) +// aa-auth+jwt — an auth token; issued by this PS on a three-party +// step-up, or by an Access Server in four-party. `ps` +// MUST name this PS. (mockin has no AS federation, so +// an AS-issued auth token verifies against the AS's +// JWKS only if the AS is a trusted server.) +// +// with two substitutions from the resource's own check: `aud` MUST equal +// the resource token's `iss`, and `cnf.jwk` MUST match the resource token's +// `agent_jkt`. A token that fails is `invalid_presented_token`, or +// `expired_presented_token` when only `exp` fails. Then the binding: `jti` +// equals `presented_jti`, `iss` (person) / `ps` (auth) equals the resource +// token's `ps`, and `sub`, `mission_s256`, `tenant` match exactly — any +// mismatch or omission is `invalid_resource_token`. This is what makes +// mission stripping detectable: the agent hands the PS the token the +// resource verified, under its issuer's signature, and the PS compares. +// +// No retained record is consulted. person-token-store.js keeps the +// jti/aud/exp the spec requires for revocation, nothing more. + +import * as jose from 'jose' +import { ISSUER } from '../config.js' +import { publicJwk } from './keys.js' +import { ACCEPTED_JWT_ALGS, checkJwtAlg } from './algorithms.js' +import { getEntity, PERSON_DWK } from './entity-cache.js' + +export const ACCESS_DWK = 'aauth-access.json' + +const PERSON_TYP = 'aa-person+jwt' +const AUTH_TYP = 'aa-auth+jwt' + +const fail = (code, error) => ({ code, error }) + +/** + * @param {string} presentedTokenStr the `presented_token` body parameter + * @param {object} rt the verified resource token: resource_url, + * ps, sub, mission_s256, tenant, + * presented_jti, agent_jkt + * @returns {{ code: string, error: string } | + * { kind: 'person'|'auth', jti: string, exp: number, ps: string, sub: string, + * mission_s256: string|null, tenant: string|null, payload: object }} + */ +export async function verifyPresentedToken(presentedTokenStr, rt) { + if (typeof presentedTokenStr !== 'string' || !presentedTokenStr) { + return fail('invalid_presented_token', 'presented_token must be a JWT') + } + let header, payload + try { + header = jose.decodeProtectedHeader(presentedTokenStr) + payload = jose.decodeJwt(presentedTokenStr) + } catch { + return fail('invalid_presented_token', 'malformed presented_token') + } + + let kind + if (header.typ === PERSON_TYP) kind = 'person' + else if (header.typ === AUTH_TYP) kind = 'auth' + else { + return fail( + 'invalid_presented_token', + `invalid presented_token typ: expected ${PERSON_TYP} or ${AUTH_TYP}, got ${header.typ}`, + ) + } + const algError = checkJwtAlg(header.alg) + if (algError) return fail('invalid_presented_token', `presented_token ${algError}`) + + // The PS whose person the token is for MUST be this PS. + const psClaim = kind === 'person' ? payload.iss : payload.ps + if (psClaim !== ISSUER) { + return fail( + 'invalid_presented_token', + kind === 'person' + ? `presented person token iss "${payload.iss}" was not issued by this PS` + : `presented auth token ps "${payload.ps}" does not name this PS`, + ) + } + + // Key: our own for anything we issued; the issuer's JWKS otherwise + // (an AS-issued auth token on a four-party step-up). + let keyset + if (payload.iss === ISSUER) { + if (payload.dwk !== PERSON_DWK) { + return fail( + 'invalid_presented_token', + `presented_token dwk must be ${PERSON_DWK}, got ${payload.dwk}`, + ) + } + keyset = jose.createLocalJWKSet({ keys: [publicJwk] }) + } else { + if (payload.dwk !== ACCESS_DWK) { + return fail( + 'invalid_presented_token', + `presented auth token dwk must be ${ACCESS_DWK}, got ${payload.dwk}`, + ) + } + let entity + try { + entity = await getEntity(payload.iss, ACCESS_DWK) + } catch (err) { + return fail( + 'invalid_presented_token', + `presented auth token issuer discovery failed: ${err.message}`, + ) + } + keyset = jose.createLocalJWKSet(entity.jwks) + } + + // jose verifies the signature before any claim, so JWTExpired means + // everything but exp held — the expired_presented_token case. + try { + await jose.jwtVerify(presentedTokenStr, keyset, { + algorithms: ACCEPTED_JWT_ALGS, + }) + } catch (err) { + if (err.code === 'ERR_JWT_EXPIRED') { + return fail( + 'expired_presented_token', + 'presented_token expired: obtain a fresh person token, then a fresh resource token', + ) + } + return fail( + 'invalid_presented_token', + `presented_token signature: ${err.message}`, + ) + } + + // The two substitutions. + const audOk = Array.isArray(payload.aud) + ? payload.aud.includes(rt.resource_url) + : payload.aud === rt.resource_url + if (!audOk) { + return fail( + 'invalid_presented_token', + `presented_token aud mismatch: expected the resource ${rt.resource_url}, got ${payload.aud}`, + ) + } + const cnfJwk = payload.cnf?.jwk + if (!cnfJwk) { + return fail('invalid_presented_token', 'presented_token missing cnf.jwk') + } + let jkt + try { + jkt = await jose.calculateJwkThumbprint(cnfJwk) + } catch { + return fail('invalid_presented_token', 'presented_token cnf.jwk is not a valid key') + } + if (jkt !== rt.agent_jkt) { + return fail( + 'invalid_presented_token', + `presented_token cnf.jwk thumbprint ${jkt} does not match resource_token agent_jkt ${rt.agent_jkt}`, + ) + } + if (!payload.jti) return fail('invalid_presented_token', 'presented_token missing jti') + if (!payload.sub) return fail('invalid_presented_token', 'presented_token missing sub') + + // Binding to the resource token. + if (payload.jti !== rt.presented_jti) { + return fail( + 'invalid_resource_token', + `resource_token presented_jti "${rt.presented_jti}" does not name the presented token (jti "${payload.jti}")`, + ) + } + if (rt.ps !== psClaim) { + return fail( + 'invalid_resource_token', + `resource_token ps mismatch: presented token has ${psClaim}, resource_token has ${rt.ps}`, + ) + } + if (rt.sub !== payload.sub) { + return fail( + 'invalid_resource_token', + `resource_token sub mismatch: presented token has ${payload.sub}, resource_token has ${rt.sub}`, + ) + } + // A dropped mission_s256 is exactly the stripping this check exists to + // catch, so absent-vs-present is a mismatch in both directions. + const mission = payload.mission_s256 || null + if ((rt.mission_s256 || null) !== mission) { + return fail( + 'invalid_resource_token', + `resource_token mission_s256 mismatch: presented token has ${mission || '(none)'}, resource_token has ${rt.mission_s256 || '(none)'}`, + ) + } + const tenant = payload.tenant || null + if ((rt.tenant || null) !== tenant) { + return fail( + 'invalid_resource_token', + `resource_token tenant mismatch: presented token has ${tenant || '(none)'}, resource_token has ${rt.tenant || '(none)'}`, + ) + } + + return { + kind, + jti: payload.jti, + exp: payload.exp, + ps: psClaim, + sub: payload.sub, + mission_s256: mission, + tenant, + payload, + } +} diff --git a/src/aauth/verify-resource-token.js b/src/aauth/verify-resource-token.js index ae2e7cd..8e25db9 100644 --- a/src/aauth/verify-resource-token.js +++ b/src/aauth/verify-resource-token.js @@ -11,8 +11,10 @@ // 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 +// 6. `presented_jti`, `ps` and `sub` are present — the claims the resource +// copied out of the token the request carried. Verifying them against +// the `presented_token` the agent sent is verify-presented-token.js +// (issue #152); the handler runs it after this function returns. // 7. mission active — mockin has no mission store, see the note below // // Resource tokens no longer carry an `agent` claim: `agent_jkt` binds the @@ -23,7 +25,6 @@ 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, @@ -88,11 +89,13 @@ export async function verifyResourceToken( } } - // ── Step 6 ───────────────────────────────────────────────────────── - // The claims a resource copies out of the person token it verified. - // Spec issue #95 renamed `person_token_jti` → `presented_jti` (same - // value); resources dual-emit during the transition, so accept either, - // preferring the canonical name. + // ── Step 6 (presence) ───────────────────────────────────────────── + // The claims a resource copies out of the token it verified — the + // person token, or on a step-up the auth token. Spec issue #95 renamed + // `person_token_jti` → `presented_jti` (same value); resources + // dual-emit during the transition, so accept either, preferring the + // canonical name. The values are checked against the presented token + // in verify-presented-token.js. const presentedJti = payload.presented_jti ?? payload.person_token_jti if (!presentedJti) { return { @@ -101,36 +104,6 @@ export async function verifyResourceToken( } if (!payload.ps) return { error: 'resource_token missing ps' } if (!payload.sub) return { error: 'resource_token missing sub' } - - const issued = getPersonToken(presentedJti) - if (!issued) { - return { - error: `presented_jti "${presentedJti}" 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. @@ -149,7 +122,8 @@ export async function verifyResourceToken( scope: typeof payload.scope === 'string' ? payload.scope : '', ps: payload.ps, sub: payload.sub, - person_token_jti: presentedJti, + presented_jti: presentedJti, + agent_jkt: payload.agent_jkt, mission_s256: payload.mission_s256 || null, tenant: payload.tenant || null, account: payload.account || null, diff --git a/test/aauth/helpers.js b/test/aauth/helpers.js index a6b0d31..033756a 100644 --- a/test/aauth/helpers.js +++ b/test/aauth/helpers.js @@ -120,9 +120,10 @@ export async function mintAgentToken({ .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. +// -11 §Resource Token Structure: `ps`, `sub` and `presented_jti` are +// copied from the token the request carried — the person token, or on a +// step-up the auth token (issue #152) — and `agent_jkt` binds the agent's +// key. There is no `agent` claim any more. export async function mintResourceToken({ scope = 'openid email', aud = ISSUER, @@ -136,21 +137,27 @@ export async function mintResourceToken({ r3_uri = null, r3_s256 = null, ttl = 300, + // The token the agent presented to the resource: a person token or an + // auth token. `personToken` is the older name for the same option. + presentedToken = null, personToken = null, - // Which name(s) carry the person-token jti (spec issue #95 rename): + // Which name(s) carry the presented jti (spec issue #95 rename): // 'legacy' = person_token_jti only (pre-rename resources), // 'presented' = presented_jti only (post-transition resources), - // 'both' = dual-emit (transition resources, @aauth/resource 2.1.0). - jti_claim = 'legacy', + // 'both' = dual-emit (transition resources, @aauth/resource ≥2.1.0 — + // what the fleet emits today). + jti_claim = 'both', } = {}) { - // 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) + // Given the presented 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. + const carried = presentedToken || personToken + if (carried) { + const pt = decodeJwt(carried) sub = sub ?? pt.sub person_token_jti = person_token_jti ?? pt.jti - ps = ps ?? pt.iss + // A person token names its PS as `iss`; an auth token as `ps`. + ps = ps ?? pt.ps ?? pt.iss if (mission_s256 === null && pt.mission_s256) mission_s256 = pt.mission_s256 if (tenant === null && pt.tenant) tenant = pt.tenant } @@ -214,9 +221,9 @@ export async function endpointPath(fastify, field) { // ── 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). +// Almost every auth token test now needs one first: the resource token +// names it by presented_jti and the agent presents it back as +// presented_token (§Resource Token Verification step 6, issue #152). export async function requestPersonToken(fastify, { resource = RESOURCE_SERVER_URL, @@ -266,7 +273,8 @@ export async function getPersonToken(fastify, options = {}) { /** * The common setup: get a person token, then a resource token copied from - * it. Overrides let a test corrupt exactly one copied claim. + * it. Overrides let a test corrupt exactly one copied claim. The returned + * `body` is what a conformant agent posts to the auth token endpoint. */ export async function personAndResourceToken(fastify, { person = {}, @@ -274,10 +282,17 @@ export async function personAndResourceToken(fastify, { } = {}) { const { person_token, claims, agentToken } = await getPersonToken(fastify, person) const resourceToken = await mintResourceToken({ - personToken: person_token, + presentedToken: person_token, ...resource, }) - return { agentToken, person_token, personClaims: claims, resourceToken } + return { + agentToken, + person_token, + presentedToken: person_token, + personClaims: claims, + resourceToken, + body: { resource_token: resourceToken, presented_token: person_token }, + } } // ── R3 doc helpers ───────────────────────────────────────────────────── @@ -324,11 +339,14 @@ export const PS_BODY_COMPONENTS = [ 'signature-key', ] -async function sigHeaders({ method, path, body, signatureKey, components }) { +async function sigHeaders({ + method, path, body, signatureKey, components, + signingKey = ephemeralPrivateJwk, +}) { const url = `${ISSUER}${path}` const opts = { method, - signingKey: ephemeralPrivateJwk, + signingKey, signatureKey, dryRun: true, } @@ -352,7 +370,7 @@ async function sigHeaders({ method, path, body, signatureKey, components }) { // JWT scheme — person, token, pending, permission, audit, interaction. export async function signedRequest({ - method, path, body, agentToken, components, + method, path, body, agentToken, components, signingKey, }) { const bodyStr = body === undefined ? undefined @@ -362,6 +380,7 @@ export async function signedRequest({ path, body: bodyStr, components, + signingKey, signatureKey: { type: 'jwt', jwt: agentToken }, }) return { headers, payload: bodyStr } diff --git a/test/aauth/pending.spec.js b/test/aauth/pending.spec.js index 1822754..ed29181 100644 --- a/test/aauth/pending.spec.js +++ b/test/aauth/pending.spec.js @@ -29,15 +29,12 @@ async function setRequirement(req) { } async function startTokenRequest() { - const { agentToken, resourceToken } = await personAndResourceToken(fastify, { + const { agentToken, body } = await personAndResourceToken(fastify, { resource: { scope: 'openid email' }, }) return { agentToken, - response: await postAuthToken(fastify, { - body: { resource_token: resourceToken }, - agentToken, - }), + response: await postAuthToken(fastify, { body, agentToken }), } } diff --git a/test/aauth/person.spec.js b/test/aauth/person.spec.js index 242008c..2a3829a 100644 --- a/test/aauth/person.spec.js +++ b/test/aauth/person.spec.js @@ -95,9 +95,9 @@ describe('AAuth person_token_endpoint', function () { 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 resourceToken = await mintResourceToken({ presentedToken: person_token }) const res = await postAuthToken(fastify, { - body: { resource_token: resourceToken }, + body: { resource_token: resourceToken, presented_token: person_token }, agentToken, }) expect(res.statusCode).to.equal(200) @@ -380,14 +380,20 @@ describe('AAuth person_token_endpoint', function () { // 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, + presentedToken: person_token, agent_jkt: sub.jkt, }) - // 3. The parent presents both to the auth token endpoint. + // 3. The parent presents all three to the auth token endpoint: + // the resource token, the token the sub-agent presented to + // the resource, and the sub-agent's agent token. const agentToken = await mintAgentToken() const res = await postAuthToken(fastify, { - body: { resource_token: resourceToken, subagent_token: sub.token }, + body: { + resource_token: resourceToken, + presented_token: person_token, + subagent_token: sub.token, + }, agentToken, }) expect(res.statusCode).to.equal(200) diff --git a/test/aauth/token.errors.spec.js b/test/aauth/token.errors.spec.js index 4e783e8..1940196 100644 --- a/test/aauth/token.errors.spec.js +++ b/test/aauth/token.errors.spec.js @@ -1,10 +1,13 @@ // 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. +// errors, and the -11 §Resource Token Verification step-6 binding (issue +// #152): the agent presents the token it carried to the resource as +// `presented_token`; it must verify, and the resource token's +// `presented_jti`, `ps`, `sub`, `mission_s256` and `tenant` must match it +// exactly. import { expect } from 'chai' import { randomUUID } from 'crypto' +import { SignJWT, decodeJwt } from 'jose' import Fastify from 'fastify' import api from '../../src/api.js' @@ -16,6 +19,8 @@ import { endpointPath, getPersonToken, personAndResourceToken, + resourceServer, + ephemeralJkt, } from './helpers.js' const fastify = Fastify() @@ -23,12 +28,11 @@ api(fastify) const MISSION_S256 = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' -async function postResourceToken(resourceToken, agentToken) { +async function postResourceToken(resourceToken, agentToken, presentedToken) { const token = agentToken || (await mintAgentToken()) - return postAuthToken(fastify, { - body: { resource_token: resourceToken }, - agentToken: token, - }) + const body = { resource_token: resourceToken } + if (presentedToken) body.presented_token = presentedToken + return postAuthToken(fastify, { body, agentToken: token }) } describe('AAuth auth_token_endpoint — errors', function () { @@ -60,7 +64,7 @@ describe('AAuth auth_token_endpoint — errors', function () { const resourceToken = await mintResourceToken({ scope: 'openid', sub: 'x', person_token_jti: 'y', }) - const response = await postResourceToken(resourceToken, tampered) + const response = await postResourceToken(resourceToken, tampered, 'x.y.z') // 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 @@ -75,7 +79,7 @@ describe('AAuth auth_token_endpoint — errors', function () { personToken: person_token, aud: 'https://wrong-ps.example', }) - const response = await postResourceToken(resourceToken) + const response = await postResourceToken(resourceToken, undefined, person_token) expect(response.headers['content-type']) .to.match(/^application\/problem\+json/) const body = response.json() @@ -92,9 +96,9 @@ describe('AAuth auth_token_endpoint — errors', function () { }) it('400 on upstream_token — call chaining is not implemented', async function () { - const { agentToken, resourceToken } = await personAndResourceToken(fastify) + const { agentToken, body } = await personAndResourceToken(fastify) const response = await postAuthToken(fastify, { - body: { resource_token: resourceToken, upstream_token: 'eyJ.e30.x' }, + body: { ...body, upstream_token: 'eyJ.e30.x' }, agentToken, }) expect(response.statusCode).to.equal(400) @@ -107,7 +111,7 @@ describe('AAuth auth_token_endpoint — errors', function () { personToken: person_token, aud: 'https://wrong-ps.example', }) - const response = await postResourceToken(resourceToken) + const response = await postResourceToken(resourceToken, undefined, person_token) expect(response.statusCode).to.equal(400) expect(response.json().error).to.equal('invalid_resource_token') expect(response.json().detail).to.match(/aud/) @@ -119,7 +123,7 @@ describe('AAuth auth_token_endpoint — errors', function () { personToken: person_token, agent_jkt: 'wrongthumbprint', }) - const response = await postResourceToken(resourceToken) + const response = await postResourceToken(resourceToken, undefined, person_token) expect(response.statusCode).to.equal(400) expect(response.json().detail).to.match(/agent_jkt/) }) @@ -130,73 +134,163 @@ describe('AAuth auth_token_endpoint — errors', function () { personToken: person_token, ttl: -60, }) - const response = await postResourceToken(resourceToken) + const response = await postResourceToken(resourceToken, undefined, person_token) 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 () { + describe('presented token binding (§Resource Token Verification step 6)', function () { + it('rejects a resource token with no presented_jti', async function () { const { person_token } = await getPersonToken(fastify) const resourceToken = await mintResourceToken({ - personToken: person_token, + presentedToken: person_token, person_token_jti: false, }) + const response = await postResourceToken(resourceToken, undefined, person_token) + expect(response.statusCode).to.equal(400) + expect(response.json().detail).to.match(/presented_jti/) + }) + + it('400 invalid_request when presented_token is missing', async function () { + const { person_token } = await getPersonToken(fastify) + const resourceToken = await mintResourceToken({ presentedToken: person_token }) const response = await postResourceToken(resourceToken) expect(response.statusCode).to.equal(400) - expect(response.json().detail).to.match(/person_token_jti/) + expect(response.json().error).to.equal('invalid_request') + expect(response.json().detail).to.match(/presented_token/) }) it('accepts the renamed presented_jti claim alone (spec issue #95)', async function () { const { person_token } = await getPersonToken(fastify) const resourceToken = await mintResourceToken({ - personToken: person_token, + presentedToken: person_token, jti_claim: 'presented', }) - const response = await postResourceToken(resourceToken) + const response = await postResourceToken(resourceToken, undefined, person_token) expect(response.statusCode).to.equal(200) }) - it('accepts a dual-emit token carrying both jti claim names', async function () { + it('accepts the legacy person_token_jti claim alone', async function () { const { person_token } = await getPersonToken(fastify) const resourceToken = await mintResourceToken({ - personToken: person_token, - jti_claim: 'both', + presentedToken: person_token, + jti_claim: 'legacy', }) - const response = await postResourceToken(resourceToken) + const response = await postResourceToken(resourceToken, undefined, person_token) expect(response.statusCode).to.equal(200) }) - it('rejects a person_token_jti this PS never issued', async function () { + it('rejects a presented_jti that does not name the presented token', async function () { const { person_token } = await getPersonToken(fastify) const resourceToken = await mintResourceToken({ - personToken: person_token, + presentedToken: person_token, person_token_jti: randomUUID(), }) - const response = await postResourceToken(resourceToken) + const response = await postResourceToken(resourceToken, undefined, person_token) + expect(response.statusCode).to.equal(400) + expect(response.json().error).to.equal('invalid_resource_token') + expect(response.json().detail).to.match(/does not name the presented token/) + }) + + it('rejects a presented token that is not a person or auth token', async function () { + const { person_token, agentToken } = await getPersonToken(fastify) + const resourceToken = await mintResourceToken({ presentedToken: person_token }) + const response = await postResourceToken(resourceToken, agentToken, agentToken) expect(response.statusCode).to.equal(400) - expect(response.json().detail) - .to.match(/names no person token this PS issued/) + expect(response.json().error).to.equal('invalid_presented_token') + expect(response.json().detail).to.match(/typ/) + }) + + it('rejects a presented token this PS did not sign', async function () { + const { person_token, claims } = await getPersonToken(fastify) + // Same claims, signed by the resource server's key. + const forged = await new SignJWT(claims) + .setProtectedHeader({ alg: 'Ed25519', typ: 'aa-person+jwt', kid: 'not-ours' }) + .sign(resourceServer.privateKey) + const resourceToken = await mintResourceToken({ presentedToken: person_token }) + const response = await postResourceToken(resourceToken, undefined, forged) + expect(response.statusCode).to.equal(400) + expect(response.json().error).to.equal('invalid_presented_token') + expect(response.json().detail).to.match(/signature/) + }) + + it('rejects an expired presented token with expired_presented_token', async function () { + const { person_token, claims } = await getPersonToken(fastify) + const { privateJwk } = await import('../../src/aauth/keys.js') + const { importJWK } = await import('jose') + const key = await importJWK(privateJwk, 'Ed25519') + const stale = await new SignJWT({ ...claims, iat: claims.iat - 7200, exp: claims.iat - 3600 }) + .setProtectedHeader({ alg: 'Ed25519', typ: 'aa-person+jwt', kid: privateJwk.kid }) + .sign(key) + const resourceToken = await mintResourceToken({ presentedToken: person_token }) + const response = await postResourceToken(resourceToken, undefined, stale) + expect(response.statusCode).to.equal(400) + expect(response.json().error).to.equal('expired_presented_token') + }) + + it('rejects a presented token for another resource (aud)', async function () { + const { person_token } = await getPersonToken(fastify, { + resource: 'https://other.example', + }) + // The resource token is rs.example's, but the person token + // names other.example. + const resourceToken = await mintResourceToken({ presentedToken: person_token }) + const response = await postResourceToken(resourceToken, undefined, person_token) + expect(response.statusCode).to.equal(400) + expect(response.json().error).to.equal('invalid_presented_token') + expect(response.json().detail).to.match(/aud/) + }) + + it('rejects a presented token bound to another agent key (cnf ≠ agent_jkt)', async function () { + const { generateKeyPair, exportJWK, calculateJwkThumbprint } = await import('jose') + const other = await generateKeyPair('Ed25519', { extractable: true }) + const otherJwk = await exportJWK(other.publicKey) + otherJwk.alg = 'Ed25519' + // A person token bound to a different agent key. + const otherAgentToken = await mintAgentToken({ cnf_jwk: otherJwk }) + const otherPrivate = await exportJWK(other.privateKey) + otherPrivate.alg = 'Ed25519' + const { signedRequest } = await import('./helpers.js') + const path = await endpointPath(fastify, 'person_token_endpoint') + const { headers, payload } = await signedRequest({ + method: 'POST', path, body: { resource: 'https://rs.example' }, + agentToken: otherAgentToken, signingKey: otherPrivate, + }) + const ptRes = await fastify.inject({ method: 'POST', url: path, headers, payload }) + expect(ptRes.statusCode).to.equal(200) + const otherPersonToken = ptRes.json().person_token + // The resource token binds OUR ephemeral key; the presented + // token was issued to the other key. + const resourceToken = await mintResourceToken({ + presentedToken: otherPersonToken, + agent_jkt: ephemeralJkt, + }) + const response = await postResourceToken(resourceToken, undefined, otherPersonToken) + expect(response.statusCode).to.equal(400) + expect(response.json().error).to.equal('invalid_presented_token') + expect(response.json().detail).to.match(/agent_jkt/) + void calculateJwkThumbprint }) it('rejects a mismatched sub', async function () { const { person_token } = await getPersonToken(fastify) const resourceToken = await mintResourceToken({ - personToken: person_token, + presentedToken: person_token, sub: 'some-other-subject', }) - const response = await postResourceToken(resourceToken) + const response = await postResourceToken(resourceToken, undefined, person_token) expect(response.statusCode).to.equal(400) + expect(response.json().error).to.equal('invalid_resource_token') 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, + presentedToken: person_token, ps: 'https://other-ps.example', }) - const response = await postResourceToken(resourceToken) + const response = await postResourceToken(resourceToken, undefined, person_token) expect(response.statusCode).to.equal(400) expect(response.json().detail).to.match(/ps mismatch/) }) @@ -208,10 +302,10 @@ describe('AAuth auth_token_endpoint — errors', function () { mission_s256: MISSION_S256, }) const resourceToken = await mintResourceToken({ - personToken: person_token, + presentedToken: person_token, mission_s256: false, // falsy → omitted from the token }) - const response = await postResourceToken(resourceToken) + const response = await postResourceToken(resourceToken, undefined, person_token) expect(response.statusCode).to.equal(400) expect(response.json().detail).to.match(/mission_s256 mismatch/) }) @@ -219,10 +313,10 @@ describe('AAuth auth_token_endpoint — errors', function () { it('rejects an invented mission_s256', async function () { const { person_token } = await getPersonToken(fastify) const resourceToken = await mintResourceToken({ - personToken: person_token, + presentedToken: person_token, mission_s256: MISSION_S256, }) - const response = await postResourceToken(resourceToken) + const response = await postResourceToken(resourceToken, undefined, person_token) expect(response.statusCode).to.equal(400) expect(response.json().detail).to.match(/mission_s256 mismatch/) }) @@ -230,10 +324,10 @@ describe('AAuth auth_token_endpoint — errors', function () { it('rejects a mismatched tenant', async function () { const { person_token } = await getPersonToken(fastify, { tenant: 'acme' }) const resourceToken = await mintResourceToken({ - personToken: person_token, + presentedToken: person_token, tenant: 'globex', }) - const response = await postResourceToken(resourceToken) + const response = await postResourceToken(resourceToken, undefined, person_token) expect(response.statusCode).to.equal(400) expect(response.json().detail).to.match(/tenant mismatch/) }) @@ -243,56 +337,60 @@ describe('AAuth auth_token_endpoint — errors', function () { mission_s256: MISSION_S256, tenant: 'acme', }) - const resourceToken = await mintResourceToken({ personToken: person_token }) - const response = await postResourceToken(resourceToken) + const resourceToken = await mintResourceToken({ presentedToken: person_token }) + const response = await postResourceToken(resourceToken, undefined, person_token) 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().detail).to.match(/EdDSA/) - }) - it('returns mock-injected error code', async function () { - await fastify.inject({ - method: 'PUT', - url: '/mock/aauth', - headers: { 'content-type': 'application/json' }, - payload: JSON.stringify({ error: 'denied' }), + it('reuses one person token across resource tokens', async function () { + const { person_token } = await getPersonToken(fastify) + for (let i = 0; i < 2; i++) { + const resourceToken = await mintResourceToken({ presentedToken: person_token }) + const response = await postResourceToken(resourceToken, undefined, person_token) + expect(response.statusCode).to.equal(200) + } }) - const resourceToken = await mintResourceToken({ scope: 'openid' }) - const response = await postResourceToken(resourceToken) - expect(response.statusCode).to.equal(403) - expect(response.json().error).to.equal('denied') - }) + it('step-up: accepts a resource token naming the auth token the agent carried', async function () { + const { person_token, agentToken } = await getPersonToken(fastify) + const first = await postResourceToken( + await mintResourceToken({ presentedToken: person_token }), + agentToken, person_token, + ) + expect(first.statusCode).to.equal(200) + const authToken = first.json().auth_token + const auth = decodeJwt(authToken) + expect(auth.jti).to.be.a('string') - it('scopes mock error to a specific endpoint', async function () { - const { agentToken, resourceToken } = await personAndResourceToken(fastify) - await fastify.inject({ - method: 'PUT', - url: '/mock/aauth', - headers: { 'content-type': 'application/json' }, - payload: JSON.stringify({ - error: 'denied', - error_endpoint: 'permission', - }), + // The resource challenged again on a request carrying the auth + // token: presented_jti names it, ps/sub copied from it, and the + // agent presents the auth token. + const stepUpRt = await mintResourceToken({ presentedToken: authToken }) + expect(decodeJwt(stepUpRt).presented_jti).to.equal(auth.jti) + expect(decodeJwt(stepUpRt).ps).to.equal(auth.ps) + const stepUp = await postResourceToken(stepUpRt, agentToken, authToken) + expect(stepUp.statusCode).to.equal(200) + const issued = decodeJwt(stepUp.json().auth_token) + expect(issued.sub).to.equal(auth.sub) + // §Auth Token Structure: never past the presented token's exp. + expect(issued.exp).to.be.at.most(auth.exp) }) - const response = await postResourceToken(resourceToken, agentToken) - // Token endpoint not impacted; permission endpoint would be. - expect(response.statusCode).to.equal(200) + it('caps the auth token at the presented token exp', async function () { + const { person_token, claims } = await getPersonToken(fastify) + const { privateJwk } = await import('../../src/aauth/keys.js') + const { importJWK } = await import('jose') + const key = await importJWK(privateJwk, 'Ed25519') + const now = Math.floor(Date.now() / 1000) + const shortLived = await new SignJWT({ ...claims, iat: now, exp: now + 120 }) + .setProtectedHeader({ alg: 'Ed25519', typ: 'aa-person+jwt', kid: privateJwk.kid }) + .sign(key) + const resourceToken = await mintResourceToken({ presentedToken: shortLived }) + const response = await postResourceToken(resourceToken, undefined, shortLived) + expect(response.statusCode).to.equal(200) + expect(response.json().expires_in).to.be.at.most(120) + expect(decodeJwt(response.json().auth_token).exp).to.be.at.most(now + 120) + void person_token + }) }) }) diff --git a/test/aauth/token.identity.spec.js b/test/aauth/token.identity.spec.js index faf578f..4e05835 100644 --- a/test/aauth/token.identity.spec.js +++ b/test/aauth/token.identity.spec.js @@ -12,6 +12,7 @@ import { ISSUER } from '../../src/config.js' import defaultUser from '../../src/users.js' import { installMocks, + mintAgentToken, postAuthToken, personAndResourceToken, ephemeralPublicJwk, @@ -21,13 +22,13 @@ import { const fastify = Fastify() api(fastify) -// Every token request now starts from a person token: the PS only accepts -// a resource token whose person_token_jti names one it issued. +// Every token request now starts from a person token: the resource token +// names it and the agent presents it back as presented_token. async function postToken({ person = {}, resource = {}, body = {} } = {}) { - const { agentToken, resourceToken, personClaims } = + const { agentToken, body: tokenBody, personClaims } = await personAndResourceToken(fastify, { person, resource }) const response = await postAuthToken(fastify, { - body: { resource_token: resourceToken, ...body }, + body: { ...tokenBody, ...body }, agentToken, }) return { response, personClaims } @@ -39,7 +40,10 @@ describe('AAuth auth_token_endpoint — identity flow (no R3)', function () { }) it('issues a verifiable auth_token in auto-approve mode', async function () { + // The auth token never outlives the presented person token, which + // never outlives the agent token — so give the agent a long one. const { response, personClaims } = await postToken({ + person: { agentToken: await mintAgentToken({ ttl: 7200 }) }, resource: { scope: 'openid email whoami' }, }) @@ -127,11 +131,25 @@ describe('AAuth auth_token_endpoint — identity flow (no R3)', function () { headers: { 'content-type': 'application/json' }, payload: JSON.stringify({ token_lifetime: 7200 }), }) - const { response } = await postToken({ resource: { scope: 'openid' } }) + const { response } = await postToken({ + person: { agentToken: await mintAgentToken({ ttl: 7200 }) }, + resource: { scope: 'openid' }, + }) const claims = decodeJwt(response.json().auth_token) expect(claims.exp - claims.iat).to.equal(3600) }) + it('never outlives the presented token (agent token 600s → auth token ≤ 600s)', async function () { + const { response, personClaims } = await postToken({ + person: { agentToken: await mintAgentToken({ ttl: 600 }) }, + resource: { scope: 'openid' }, + }) + expect(response.statusCode).to.equal(200) + const claims = decodeJwt(response.json().auth_token) + expect(claims.exp).to.be.at.most(personClaims.exp) + expect(response.json().expires_in).to.be.at.most(600) + }) + it('honours mock claims override', async function () { await fastify.inject({ method: 'PUT', diff --git a/test/aauth/token.r3.spec.js b/test/aauth/token.r3.spec.js index 6a262d9..1c0b543 100644 --- a/test/aauth/token.r3.spec.js +++ b/test/aauth/token.r3.spec.js @@ -29,15 +29,12 @@ const sampleR3 = { ], } -// Every request needs a person token first — the resource token has to -// name one this PS issued. +// Every request needs a person token first — the resource token names it +// and the agent presents it back. async function postToken(resource) { - const { agentToken, resourceToken } = + const { agentToken, body } = await personAndResourceToken(fastify, { resource }) - return postAuthToken(fastify, { - body: { resource_token: resourceToken }, - agentToken, - }) + return postAuthToken(fastify, { body, agentToken }) } describe('AAuth auth_token_endpoint — R3 flow', function () {