Skip to content

feat(webhooks): signed, retried webhooks built from the audit log - #580

Merged
Bccorb merged 6 commits into
mainfrom
feat/signed-webhooks
Nov 8, 2026
Merged

Bccorb merged 6 commits into
mainfrom
feat/signed-webhooks

Conversation

@Bccorb

@Bccorb Bccorb commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Closes #418. First of the Wave 4 API PRs, and it records the Wave 4 design as ADR 0005 (agreed 2026-11-07).

Builds on @seamless-auth/types 0.40.0 (fells-code/seamless-auth-types#156 and #158), published. 0.40.0 adds customClaims (default empty) and claims_mismatch to the shared OIDC account claims and sign refusal schemas, so the account claims edge here returns customClaims: {} until #585 fills it.

What it adds

  • Admin API under /admin/webhooks and /admin/webhook-deliveries: create, update, disable and delete endpoints; rotate the signing secret (the old one keeps signing beside the new one for 24 hours); send a test event; list deliveries newest first with a status=dead dead letter view; and redeliver. Every write is admin:write plus step-up and is audited. The secret is shown once.
  • Signing with the Standard Webhooks scheme, so any Standard Webhooks verifier checks a delivery. Verified in the tests by an HMAC check written from the spec rather than from our signer.
  • Delivery from committed audit rows past a cursor (ADR 0005, E2). The chain trigger holds the chain head row from insert to commit, so committed seq values are a gapless prefix and the cursor cannot skip a row. On first run the cursor starts at the newest row, so turning webhooks on replays no history. Replicas take turns on an advisory lock. Workers claim deliveries with FOR UPDATE SKIP LOCKED and a lease.
  • Retries: eight attempts over just under 42 hours, then dead. A longer Retry-After is honored up to 24 hours. A 410 disables the endpoint, and so do three days of nothing but failures. Deliveries are pruned after 30 days. A disabled endpoint's deliveries are queued on hold, and turning it back on releases them, so it catches up on what it missed (decided 2026-11-07).
  • One callout transport (src/lib/calloutTransport.ts) for this and the coming hooks and sinks. It allows https only in production and refuses userinfo and the API's own issuer host. It checks addresses inside the socket's DNS lookup, so rebinding is defeated. Link-local, multicast and unspecified addresses are never reachable. In production, private addresses are refused unless the operator lists the host in CALLOUT_PRIVATE_HOSTS. Redirects are never followed, responses are capped at 64 KiB, and there is a whole-request deadline. Failures are reported coarsely, never with the resolved address.
  • Secrets at rest: AES-256-GCM under the new CALLOUT_SECRET_ENCRYPTION_KEY. Production refuses to start without it once a webhook exists, and refuses to create one without it.

The address classification is moved out of oauthProviderPolicy.ts into outboundAddress.ts unchanged, so both transports share one list.

Found while building

  • DELETE /users/credentials wrote no audit event. Removing a passkey left no trace. Fixed in its own commit, with a reproducing test that fails on main.
  • No event recorded a completed sign-in. login_success is written when /login resolves an identifier's methods, before any factor. issueSessionAndRespond now writes session_created, which user.signed_in is built from.
  • Passkey registration now names the credential in registration_success metadata, since account registration writes the same type. organization_member_updated now records the roles after the change.
  • Audit rows record a webhook URL without its query string, because the audit log is permanent and a receiver may put a token there.

Contract changes

New admin routes (in openapi.json and adapter-manifest.json), the new audit event types session_created, webhook_endpoint_disabled and admin_webhook_*, and the two env vars. No system_config keys, so the CLI writable keys test is unaffected. Downstream: console #335, CLI #343 (webhooks half) and seamless-auth-server#269 (verifyWebhook) build on this after review.

Checks

Run locally at 7027943 on @seamless-auth/types 0.40.0 from npm, by the pre-commit hook:

  • npm run coverage: 3175 passed, 1 todo. Lines 99.27%, statements 99.08%, branches 95.75%, functions 99.4%. The new files are at or near 100%.
  • npm run lint, npm run format, npm run typecheck and npm run build pass. npm run generate:api regenerates with no further diff.
  • TEST_DB=postgres project against Postgres 18 (at ba740fc, on 0.39.0): 259 passed, including tests/postgres/webhooks.spec.ts, which runs dispatch and delivery end to end against a local receiver. Not rerun on 0.40.0; the change touches no database code.

Not run locally: the e2e project.

DELETE /users/credentials removed a passkey without writing an audit
event, so a change to what an account can sign in with left no trace.
It now writes credentials_deleted naming the passkey.
Adds webhook endpoints and deliveries behind an audited admin API with
step-up on every write. Payloads use the Standard Webhooks signature
scheme, are built from committed audit rows past a cursor, and are
retried eight times over just under 42 hours before they are dead
lettered. An endpoint that answers 410 or fails for three days is
disabled.

Every callout to an adopter URL goes through one transport that checks
the address it connects to, never reaches link-local, multicast or the
API itself, never follows redirects, and in production refuses private
addresses unless CALLOUT_PRIVATE_HOSTS lists the host. Signing secrets
are encrypted under CALLOUT_SECRET_ENCRYPTION_KEY.

Writes session_created once per completed sign-in, which user.signed_in
is built from, since login_success precedes any factor.

Records the design in ADR 0005. Closes #418.
A disabled endpoint was given no deliveries, so events that happened
while it was off were never sent, including after the API disabled it
for a 410 or three days of failures. Its deliveries are now queued on
hold, and turning it back on releases them, back as far as the 30 days
deliveries are kept. The release uses the database clock, which the due
check compares against.
@Bccorb
Bccorb marked this pull request as ready for review November 8, 2026 03:28
@Bccorb
Bccorb merged commit 312e426 into main Nov 8, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: signed, retried webhooks

1 participant