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
11 changes: 11 additions & 0 deletions call-log/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,17 @@ const res = await send(url, { method: 'POST', body, signingKey, signatureKey })
For a fetch you cannot wrap, `failedFetch(host, { url, status | error })`
records a failure.

## Two things a host should know

- **The callee record waits on the client reading the response.** The
record reads a clone of the response, and in workerd a clone is a tee
that completes when the original body is consumed. Every real caller
reads the body; a test that checks only `res.status` never gets the
record — read the body.
- **Where a `Signature` header is fake and constant** (a test harness that
trusts `Signature-Key`), every call gets the same `call_id`. Make it
distinct per request.

## Pieces

`callIdOf`, `tokenOf`, `tokenize`, `signerOf`, `paramsOf`, `errorOf`,
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.0",
"version": "0.1.1",
"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
16 changes: 13 additions & 3 deletions call-log/src/callee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ export interface CalleeOptions {
skip?: (request: Request) => boolean
}

// Hono's `executionCtx` is a getter that throws where there is none (Node,
// `app.request` in tests); read it as such.
const executionCtxOf = (c: ContextLike): { waitUntil(p: Promise<unknown>): void } | undefined => {
try {
const ctx = c.executionCtx
return ctx && typeof ctx.waitUntil === 'function' ? ctx : undefined
} catch {
return undefined
}
}

const skipByDefault = (request: Request) => {
if (request.method === 'OPTIONS' || request.method === 'HEAD') return true
const path = new URL(request.url).pathname
Expand Down Expand Up @@ -57,9 +68,8 @@ export function callLogMiddleware(host: CallLogHost, options: CalleeOptions = {}
const response = c.res
const ended = Date.now()
const responseClone = response.clone()
const hostWithCtx: CallLogHost = c.executionCtx?.waitUntil
? { ...host, defer: host.defer ?? ((p) => c.executionCtx!.waitUntil(p)) }
: host
const ctx = executionCtxOf(c)
const hostWithCtx: CallLogHost = ctx ? { ...host, defer: host.defer ?? ((p) => ctx.waitUntil(p)) } : host
defer(
hostWithCtx,
(async () => {
Expand Down
11 changes: 9 additions & 2 deletions call-log/src/caller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,14 @@ export function loggedFetch(makeFetch: (onSigned: (sent: SentLike) => void) => F
}
}

/**
* @hellocoop/httpsig's `fetch`, loosely: it is overloaded on `dryRun` and
* `returnSent`, and the wrapper only ever calls it with `returnSent: true`.
* The options are `any` so the overloaded function is assignable as is.
*/
export interface HttpsigFetchLike {
(url: string, options: Record<string, unknown>): Promise<Response | { response: Response; sent: { headers: Headers } }>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(url: string | URL, options: any): Promise<Response | { response: Response; sent: { headers: Headers } } | { headers: Headers }>
}

/**
Expand All @@ -130,7 +136,8 @@ export function loggedHttpsigFetch(httpsigFetch: HttpsigFetchLike, host: CallLog
defer(host, record(host, call, url, init, started, undefined, { error }))
throw error
}
const { response, sent } = 'response' in result ? result : { response: result, sent: undefined }
const { response, sent } =
'response' in result ? result : result instanceof Response ? { response: result, sent: undefined } : { response: new Response(null), sent: result }
defer(host, record(host, call, url, init, started, sent, { response, clone: response.clone() }))
return response
}
Expand Down
3 changes: 2 additions & 1 deletion call-log/src/record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,8 @@ export async function partOf(
params?: Record<string, unknown>,
): Promise<Part | undefined> {
const out: Part = {}
if (params) out.params = params
// A resource token rides in AAuth-Requirement on the auth-token challenge: a token, so payload only.
if (params) out.params = tokenize(params) as Record<string, unknown>
if (body) {
const content_type = header(body.headers, 'content-type')
const length = Number(header(body.headers, 'content-length'))
Expand Down
52 changes: 52 additions & 0 deletions call-log/src/sides.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,55 @@ describe('the caller side', () => {
expect(records[0]).toMatchObject({ side: 'caller', parent: 'p', method: 'GET', path: '/.well-known/aauth-resource.json', status: 404, level: 40 })
})
})

describe('0.1.1', () => {
const resourceJwt = `${b64({ alg: 'Ed25519', typ: 'aa-resource+jwt' })}.${b64({ iss: 'https://notes.example', aud: 'https://as.example', scope: 'notes' })}.c2ln`

it('a resource token inside a parsed AAuth-Requirement is logged as payload, not as the JWT', async () => {
const { host, records, settled } = testHost()
const request = new Request('https://encrypt.aauth.dev/notes', { headers: { signature: 'sig=:BBBB:', 'signature-key': `sig=jwt; jwt="${agentJwt}"` } })
const { c, next } = contextFor(request, async () =>
Response.json({ error: 'auth_token_required' }, { status: 401, headers: { 'AAuth-Requirement': `requirement=auth-token; resource-token="${resourceJwt}"` } }),
)
await callLogMiddleware(host)(c, next)
await settled()
const [r] = records
expect(r.response?.params).toEqual({ 'AAuth-Requirement': { requirement: 'auth-token', 'resource-token': { type: 'aa-resource+jwt', payload: { iss: 'https://notes.example', aud: 'https://as.example', scope: 'notes' } } } })
expect(JSON.stringify(r)).not.toContain(resourceJwt)
expect(r.level).toBe(30) // still a challenge
})

it('a context whose executionCtx getter throws (Hono on Node) is logged, not a 500', async () => {
const { host, records, settled } = testHost()
const request = new Request('https://encrypt.aauth.dev/health-ish', { headers: { signature: 'sig=:CCCC:' } })
const c = {
req: { raw: request },
res: new Response(null, { status: 404 }),
get executionCtx(): { waitUntil(p: Promise<unknown>): void } {
throw new Error('This context has no ExecutionContext')
},
}
const next = async () => { c.res = Response.json({ ok: true }) }
await expect(callLogMiddleware(host)(c, next)).resolves.toBeUndefined()
await settled()
expect(records).toHaveLength(1)
expect(records[0]).toMatchObject({ status: 200, call_id: sha('sig=:CCCC:') })
})

it('loggedHttpsigFetch accepts a fetch typed like @hellocoop/httpsig (overloaded on returnSent)', async () => {
const { host, records, settled } = testHost('as')
// The shape of httpsig's fetch: with returnSent it answers { response, sent }.
async function httpsigLike(url: string | URL, options: { returnSent: true } & Record<string, unknown>): Promise<{ response: Response; sent: { headers: Headers } }>
async function httpsigLike(url: string | URL, options: Record<string, unknown>): Promise<Response>
async function httpsigLike(_url: string | URL, options: Record<string, unknown>): Promise<Response | { response: Response; sent: { headers: Headers } }> {
const response = new Response(null, { status: 200 })
const sent = { headers: new Headers({ signature: 'sig=:DDDD:', 'signature-key': 'sig=jwks_uri; id="https://access.aauth.dev"; dwk="aauth-access.json"; kid="k"' }) }
return options.returnSent ? { response, sent } : response
}
const send = loggedHttpsigFetch(httpsigLike, host, { to_role: 'resource' })
const res = await send('https://notes.example/aauth/revoke', { method: 'POST', body: JSON.stringify({ jti: 'x', exp: 1 }) })
expect(res.status).toBe(200)
await settled()
expect(records[0]).toMatchObject({ side: 'caller', call_id: sha('sig=:DDDD:'), to: 'https://notes.example', path: '/aauth/revoke', to_role: 'resource', signed: { scheme: 'jwks_uri', id: 'https://access.aauth.dev' } })
})
})
Loading