diff --git a/README.md b/README.md index 639d4e0..b09d123 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ The mock API at `PUT /mock/aauth` switches the simulated behaviours: 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. +Expiry is judged against mockin's clock with no tolerance (AAuth -11 §Expiry and the Refresh Margin). A signature `created`, an agent token `iat`, or a presented token `iat` more than 60 seconds ahead of mockin's clock is `clock_skew` — a `401` with `Signature-Error: error=clock_skew` for the signature or the agent token, a `400` problem for a presented token — so an agent knows to wait the difference out rather than refresh. + 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 d7751ef..ab87fe0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@fastify/cors": "^11.3.0", "@fastify/formbody": "^9.0.0", "@hellocoop/constants": "*", - "@hellocoop/httpsig": "^2.3.0", + "@hellocoop/httpsig": "^2.6.0", "fastify": "^5.12.3", "jose": "^6.2.12" }, @@ -188,9 +188,9 @@ } }, "node_modules/@hellocoop/httpsig": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@hellocoop/httpsig/-/httpsig-2.3.0.tgz", - "integrity": "sha512-hbG4StKLyZ20Y2xy+5wJpwjzyZdpQ2mcLfoE55CRH4qJtUfdwE1x6gm3o11QiZlwAMEAUhm9yltCTrQZSnmVfw==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@hellocoop/httpsig/-/httpsig-2.6.0.tgz", + "integrity": "sha512-LS/teUB0LsbMJwTlYX2MlUs/P6GrJISuvYGeiRmIROyY7MACc32RdHHd9/UOpWNBnrfhs7RnYRT5VSjfZsIF4g==", "license": "MIT", "engines": { "node": ">=18" diff --git a/package.json b/package.json index c591b10..25d75ec 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@fastify/cors": "^11.3.0", "@fastify/formbody": "^9.0.0", "@hellocoop/constants": "*", - "@hellocoop/httpsig": "^2.3.0", + "@hellocoop/httpsig": "^2.6.0", "fastify": "^5.12.3", "jose": "^6.2.12" }, diff --git a/src/aauth/person.js b/src/aauth/person.js index e18b3a7..37f7880 100644 --- a/src/aauth/person.js +++ b/src/aauth/person.js @@ -30,6 +30,7 @@ const ERROR_STATUS = { invalid_request: 400, invalid_agent_token: 400, expired_agent_token: 400, + clock_skew: 400, denied: 403, user_unreachable: 403, server_error: 500, @@ -117,7 +118,8 @@ export const person = async (req, reply) => { const sub = await verifyAgentToken(body.subagent_token) if (sub.error) { return problem( - reply, 400, 'invalid_agent_token', `subagent_token: ${sub.error}`, + reply, 400, sub.code || 'invalid_agent_token', + `subagent_token: ${sub.error}`, ) } if (sub.payload.parent_agent !== aauth.agent_id) { diff --git a/src/aauth/token.js b/src/aauth/token.js index 8a534a1..d5bae89 100644 --- a/src/aauth/token.js +++ b/src/aauth/token.js @@ -33,6 +33,7 @@ const ERROR_STATUS = { invalid_request: 400, invalid_agent_token: 400, expired_agent_token: 400, + clock_skew: 400, invalid_resource_token: 400, expired_resource_token: 400, invalid_presented_token: 400, @@ -109,7 +110,8 @@ export const token = async (req, reply) => { const sub = await verifyAgentToken(body.subagent_token) if (sub.error) { return problem( - reply, 400, 'invalid_agent_token', `subagent_token: ${sub.error}`, + reply, 400, sub.code || 'invalid_agent_token', + `subagent_token: ${sub.error}`, ) } if (sub.payload.parent_agent !== aauth.agent_id) { diff --git a/src/aauth/verify-agent-token.js b/src/aauth/verify-agent-token.js index 4242c2f..e28ecd2 100644 --- a/src/aauth/verify-agent-token.js +++ b/src/aauth/verify-agent-token.js @@ -14,10 +14,16 @@ import * as jose from 'jose' import { getEntity, AGENT_DWK } from './entity-cache.js' import { ACCEPTED_JWT_ALGS, checkJwtAlg } from './algorithms.js' +// The same 60 s window @hellocoop/httpsig applies to the signature's +// `created`; a verifier that bounds iat SHOULD use it (signature-key draft). +export const IAT_SKEW_SECONDS = 60 + /** * @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}>} + * @returns {Promise<{payload?: object, header?: object, error?: string, code?: string}>} + * `code` names a Signature-Error / token endpoint code other than the + * caller's default (today only `clock_skew`). */ export async function verifyAgentToken(raw, decoded) { let header = decoded?.header @@ -57,5 +63,17 @@ export async function verifyAgentToken(raw, decoded) { if (!payload.sub) return { error: 'agent_token missing sub' } if (!payload.cnf?.jwk) return { error: 'agent_token missing cnf.jwk' } + // `iat` is not a validity check (§Expiry and the Refresh Margin), except + // that one further ahead of our clock than the signature window is the + // issuer's clock disagreeing with ours: clock_skew, distinct from + // invalid_jwt because a fresh token would carry the same skew. + const now = Math.floor(Date.now() / 1000) + if (typeof payload.iat === 'number' && payload.iat > now + IAT_SKEW_SECONDS) { + return { + error: `agent_token iat is ${payload.iat - now}s ahead of this server's clock (window ${IAT_SKEW_SECONDS}s)`, + code: 'clock_skew', + } + } + return { header, payload, metadata: entity.metadata } } diff --git a/src/aauth/verify-presented-token.js b/src/aauth/verify-presented-token.js index 463b04e..b810400 100644 --- a/src/aauth/verify-presented-token.js +++ b/src/aauth/verify-presented-token.js @@ -32,6 +32,7 @@ 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' +import { IAT_SKEW_SECONDS } from './verify-agent-token.js' export const ACCESS_DWK = 'aauth-access.json' @@ -133,6 +134,19 @@ export async function verifyPresentedToken(presentedTokenStr, rt) { ) } + // `iat` is not a validity check, except that one further ahead of our + // clock than the signature window is the issuer's clock disagreeing with + // ours: clock_skew — the agent waits rather than refreshes. + if (typeof payload.iat === 'number') { + const now = Math.floor(Date.now() / 1000) + if (payload.iat > now + IAT_SKEW_SECONDS) { + return fail( + 'clock_skew', + `presented_token iat is ${payload.iat - now}s ahead of this server's clock (window ${IAT_SKEW_SECONDS}s)`, + ) + } + } + // The two substitutions. const audOk = Array.isArray(payload.aud) ? payload.aud.includes(rt.resource_url) diff --git a/src/aauth/verify-request.js b/src/aauth/verify-request.js index b294711..9946ba6 100644 --- a/src/aauth/verify-request.js +++ b/src/aauth/verify-request.js @@ -173,7 +173,10 @@ export async function verifyRequest(request) { const verified = await verifyAgentToken(raw, { header, payload }) if (verified.error) { - return fail(401, 'invalid_jwt', verified.error) + const code = verified.code || 'invalid_jwt' + return fail(401, code, verified.error, { + 'Signature-Error': generateSignatureErrorHeader({ error: code }), + }) } return { diff --git a/test/aauth/helpers.js b/test/aauth/helpers.js index 033756a..4af2ee5 100644 --- a/test/aauth/helpers.js +++ b/test/aauth/helpers.js @@ -99,11 +99,13 @@ export async function mintAgentToken({ cnf_jwk = ephemeralPublicJwk, parent_agent = undefined, ttl = 600, + // seconds added to iat (a positive value fakes an issuer clock ahead of ours) + iatOffset = 0, // -10 forbids the polymorphic 'EdDSA'; tests override this to prove // mockin declines it. alg = 'Ed25519', } = {}) { - const now = Math.floor(Date.now() / 1000) + const now = Math.floor(Date.now() / 1000) + iatOffset const payload = { iss: AGENT_SERVER_URL, dwk: 'aauth-agent.json', @@ -371,11 +373,19 @@ async function sigHeaders({ // JWT scheme — person, token, pending, permission, audit, interaction. export async function signedRequest({ method, path, body, agentToken, components, signingKey, + // sign as if the agent's clock were this many ms off; mockin (same + // process) verifies on the real clock because the offset is lifted + // before inject + clockOffsetMs = 0, }) { const bodyStr = body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body) - const headers = await sigHeaders({ + const realNow = Date.now + if (clockOffsetMs) Date.now = () => realNow() + clockOffsetMs + let headers + try { + headers = await sigHeaders({ method, path, body: bodyStr, @@ -383,6 +393,9 @@ export async function signedRequest({ signingKey, signatureKey: { type: 'jwt', jwt: agentToken }, }) + } finally { + Date.now = realNow + } return { headers, payload: bodyStr } } diff --git a/test/aauth/token.errors.spec.js b/test/aauth/token.errors.spec.js index 1940196..a8827d8 100644 --- a/test/aauth/token.errors.spec.js +++ b/test/aauth/token.errors.spec.js @@ -19,6 +19,7 @@ import { endpointPath, getPersonToken, personAndResourceToken, + requestPersonToken, resourceServer, ephemeralJkt, } from './helpers.js' @@ -139,6 +140,52 @@ describe('AAuth auth_token_endpoint — errors', function () { expect(response.json().error).to.equal('expired_resource_token') }) + describe('clock_skew (§Expiry and the Refresh Margin)', function () { + it('401 clock_skew when the signature created is ahead of our clock', async function () { + const { agentToken, body } = await personAndResourceToken(fastify) + const { signedRequest } = await import('./helpers.js') + const path = await endpointPath(fastify, 'auth_token_endpoint') + const { headers, payload } = await signedRequest({ + method: 'POST', path, body, agentToken, clockOffsetMs: 300_000, + }) + const response = await fastify.inject({ method: 'POST', url: path, headers, payload }) + expect(response.statusCode).to.equal(401) + expect(response.headers['signature-error']).to.equal('error=clock_skew') + }) + + it('401 clock_skew when the agent token iat is ahead of our clock', async function () { + const skewed = await mintAgentToken({ iatOffset: 7200, ttl: 3600 }) + const { person_token } = await getPersonToken(fastify) + const resourceToken = await mintResourceToken({ presentedToken: person_token }) + const response = await postResourceToken(resourceToken, skewed, person_token) + expect(response.statusCode).to.equal(401) + expect(response.json().error).to.equal('clock_skew') + expect(response.headers['signature-error']).to.equal('error=clock_skew') + }) + + it('an agent token iat inside the window is not skew', async function () { + const near = await mintAgentToken({ iatOffset: 30, ttl: 3600 }) + const res = await requestPersonToken(fastify, { agentToken: near }) + expect(res.statusCode).to.equal(200) + }) + + it('400 clock_skew when the presented token iat is ahead of our clock', 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 skewed = await new SignJWT({ ...claims, iat: now + 7200, exp: now + 10800 }) + .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, skewed) + expect(response.statusCode).to.equal(400) + expect(response.json().error).to.equal('clock_skew') + expect(response.json().detail).to.match(/ahead of this server/) + }) + }) + 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)