Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions call-log/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion call-log/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
11 changes: 10 additions & 1 deletion call-log/src/callee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<void> => {
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)
Expand Down
31 changes: 26 additions & 5 deletions call-log/src/sides.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})

Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading