diff --git a/call-log/README.md b/call-log/README.md index 3e29a2d..fd11f3f 100644 --- a/call-log/README.md +++ b/call-log/README.md @@ -48,9 +48,12 @@ app.use('*', async (c, next) => callLogMiddleware(host(c))(c, next)) ``` One record per request, skipping `OPTIONS`, `HEAD`, `/.well-known/*`, -`/health` and `/openapi.json` (`skip` overrides). The caller is named from -`Signature-Key` without verification, so a refused call still says who -called. A person token names no agent; when your verifier resolves one, say so: +`/health` and `/openapi.json` (`skip` overrides). An unsigned request — no +`Signature` and no `Signature-Key` — is logged only when the response carries +`AAuth-Requirement`: that challenge is the first step of a call, and anything +else unsigned is a browser or a scanner. This holds under a host's own `skip` +too. The caller is named from `Signature-Key` without verification, so a +refused call still says who called. A person token names no agent; when your verifier resolves one, say so: ```ts nameAgent(verified.agent_id) diff --git a/call-log/package.json b/call-log/package.json index b74d6a7..2fd076c 100644 --- a/call-log/package.json +++ b/call-log/package.json @@ -1,6 +1,6 @@ { "name": "@aauth/call-log", - "version": "0.1.1", + "version": "0.1.2", "description": "The aauth.call record: one log record per HTTP call between AAuth roles, at each end. A pure builder, a Hono-shaped middleware for the callee side, and logged fetch wrappers for the caller side.", "type": "module", "exports": { diff --git a/call-log/src/callee.ts b/call-log/src/callee.ts index 64e0390..9e08d3a 100644 --- a/call-log/src/callee.ts +++ b/call-log/src/callee.ts @@ -40,6 +40,12 @@ const skipByDefault = (request: Request) => { return path.startsWith('/.well-known/') || path === '/health' || path === '/openapi.json' } +// An unsigned request is a browser or a scanner, not a call between roles, +// unless the response challenges it with `AAuth-Requirement`: that 401 is the +// first step of a call. Applied after `skip`, so a host's own `skip` keeps it. +const isUnsigned = (request: Request) => !request.headers.has('signature') && !request.headers.has('signature-key') +const challenged = (response: Response) => response.headers.has('aauth-requirement') + // A request body is read once by the handler. Clone before `next()` only // when it is worth logging: JSON, and small. Cloning tees the stream, and a // tee that nobody drains holds the bytes. @@ -53,19 +59,22 @@ const worthCloning = (request: Request) => { /** * `app.use('*', callLogMiddleware(host))`, before the routes. Every request - * not skipped gets one callee record. + * not skipped gets one callee record, except an unsigned one whose response + * carries no `AAuth-Requirement`. */ export function callLogMiddleware(host: CallLogHost, options: CalleeOptions = {}) { const skip = options.skip ?? skipByDefault return async (c: ContextLike, next: Next): Promise => { const request = c.req.raw if (skip(request)) return next() + const unsigned = isUnsigned(request) const started = new Date() const callId = await callIdOf(request.headers.get('signature')) const requestClone = worthCloning(request) ? request.clone() : null const context: CallContext = { callId } await runInCall(context, next) const response = c.res + if (unsigned && !challenged(response)) return const ended = Date.now() const responseClone = response.clone() const ctx = executionCtxOf(c) diff --git a/call-log/src/sides.test.ts b/call-log/src/sides.test.ts index 528b171..c755f0f 100644 --- a/call-log/src/sides.test.ts +++ b/call-log/src/sides.test.ts @@ -76,15 +76,36 @@ describe('the callee side', () => { expect(records).toHaveLength(1) }) - it('an unsigned call has a random id and no caller; its own 5xx is error level', async () => { + it('an unsigned request is logged only when the response carries AAuth-Requirement, with a random id and no caller', async () => { const { host, records, settled } = testHost() - const { c, next } = contextFor(new Request('https://encrypt.aauth.dev/send', { method: 'POST' }), async () => new Response('boom', { status: 500 })) + const mw = callLogMiddleware(host, { skip: () => false }) // a host's own skip does not turn the rule off + const answers: [Request, Response][] = [ + [new Request('https://encrypt.aauth.dev/'), Response.json({ error: 'signature_required' }, { status: 401, headers: { 'Accept-Signature': 'sig=("@method" "@authority" "@path" "signature-key")' } })], + [new Request('https://encrypt.aauth.dev/.env'), new Response('not found', { status: 404 })], + [new Request('https://encrypt.aauth.dev/send', { method: 'POST' }), new Response('boom', { status: 500 })], + [new Request('https://encrypt.aauth.dev/send', { method: 'POST' }), Response.json({ error: 'person_token_required' }, { status: 401, headers: { 'AAuth-Requirement': 'requirement=person-token' } })], + ] + for (const [request, response] of answers) { + const { c, next } = contextFor(request, async () => response) + await mw(c, next) + } + await settled() + expect(records).toHaveLength(1) + const [r] = records + expect(r).toMatchObject({ method: 'POST', path: '/send', status: 401, level: 30, response: { params: { 'AAuth-Requirement': { requirement: 'person-token' } } } }) + expect(r.call_id).toMatch(/^[0-9a-f-]{36}$/) + expect(r.from).toBeUndefined() + expect(r.signed).toBeUndefined() + }) + + it('a request carrying only Signature-Key is not unsigned; its own 5xx is error level', async () => { + const { host, records, settled } = testHost() + const request = new Request('https://encrypt.aauth.dev/send', { method: 'POST', headers: { 'signature-key': `sig=jwt; jwt="${agentJwt}"` } }) + const { c, next } = contextFor(request, async () => new Response('boom', { status: 500 })) await callLogMiddleware(host)(c, next) await settled() - expect(records[0]).toMatchObject({ status: 500, level: 50 }) + expect(records[0]).toMatchObject({ status: 500, level: 50, from: 'aauth:owl@ap.example' }) expect(records[0].call_id).toMatch(/^[0-9a-f-]{36}$/) - expect(records[0].from).toBeUndefined() - expect(records[0].signed).toBeUndefined() }) }) diff --git a/package-lock.json b/package-lock.json index e0c9023..3bcd227 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,7 +52,7 @@ }, "call-log": { "name": "@aauth/call-log", - "version": "0.1.0", + "version": "0.1.2", "license": "MIT", "devDependencies": { "@types/node": "^20.0.0",