Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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: 9 additions & 0 deletions .changeset/resend-delivery-webhooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'ePDS': minor
---

Resend delivery events can now be measured against the sign-in code lifetime.

**Affects:** Operators

**Operators:** Register `https://<AUTH_HOSTNAME>/webhooks/resend` for the `email.sent`, `email.delivered`, `email.delivery_delayed`, `email.bounced`, and `email.failed` events, then set `RESEND_WEBHOOK_SECRET` to the endpoint's `whsec_...` signing secret. Delivery counts and latency statistics are exposed under `resendDelivery` in the authenticated `/metrics` response; the receiver remains disabled when the secret is unset.
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,10 @@ SMTP_PASS=
SMTP_FROM=noreply@pds.example
SMTP_FROM_NAME=ePDS

# Optional Resend delivery webhook signing secret. Configure the webhook at
# https://auth.pds.example/webhooks/resend and copy its whsec_... secret here.
RESEND_WEBHOOK_SECRET=

DB_LOCATION=/data/epds.sqlite

# ============================================================================
Expand Down
21 changes: 21 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,27 @@ buttons appear on the login page.
| `AWS_SES_SMTP_PASS` | AWS SES SMTP password |
| `POSTMARK_SERVER_TOKEN` | Postmark server token |
| `EMAIL_TEMPLATE_ALLOWED_DOMAINS` | Optional comma-separated list of HTTPS hostnames from which `email_template_uri` can be fetched. If unset, any HTTPS URL is allowed. If set, templates hosted on unlisted domains are logged as a warning and ignored. |
| `RESEND_WEBHOOK_SECRET` | Optional Resend webhook signing secret (`whsec_...`). When set, enables `POST /webhooks/resend`; when unset, the receiver is not mounted. |

#### Resend delivery metrics

To capture delivery latency for email sent through Resend:

1. In the Resend dashboard, register `https://<AUTH_HOSTNAME>/webhooks/resend`.
2. Subscribe it to `email.sent`, `email.delivered`, `email.delivery_delayed`, `email.bounced`, and `email.failed`.
3. Set `RESEND_WEBHOOK_SECRET` to that endpoint's signing secret and redeploy the auth service.
4. Send a test sign-in code through the production SMTP configuration and
confirm that both `email.sent` and `email.delivered` reach the receiver.
Resend's documentation does not explicitly guarantee that SMTP-submitted
email emits webhooks, so verify this before relying on the metrics.

The receiver verifies the Svix signature against the raw request body,
deduplicates retries by `svix-id`, and stores events in the auth SQLite
database. The authenticated `/metrics` response includes `resendDelivery`
counts, average and maximum send-to-delivery latency, delayed-delivery events,
and the fraction of matched deliveries exceeding the 10-minute code lifetime.
A `deliveryOverOtpLifetimeFraction` of `0` with `matchedDeliveries: 0` means
there is not yet a matched `email.sent`/`email.delivered` pair.

### Database

Expand Down
14 changes: 10 additions & 4 deletions docs/design/testing-gaps.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ pds-core callback, full OAuth flow).
| `auth-service/lib/` | ~77% | `auto-provision.ts` at 0% (needs live PDS) |
| `auth-service/middleware/` | ~91% | `rate-limit.ts` timer cleanup at 82% |
| `auth-service/email/` | ~71% | Template conditional branches partially covered |
| `auth-service/routes/` | 0% | All seven route files — see below |
| `auth-service/routes/` | ~32% | Resend webhook is HTTP-tested; browser/OAuth routes remain gaps |
| `auth-service/better-auth.ts` | 0% | better-auth wiring — see below |
| `auth-service/context.ts` | 0% | Minimal glue class |
| `auth-service/index.ts` | 0% | Express app assembly + `main()` |
Expand Down Expand Up @@ -57,10 +57,13 @@ pds-core callback, full OAuth flow).
concerns that should be covered by end-to-end tests (see
`docs/design/e2e-testing.md`).

### 2. `auth-service/src/routes/` (0% — 7 route files, ~2300 lines total)
### 2. `auth-service/src/routes/` (low coverage)

**Files:** `login-page.ts`, `consent.ts`, `recovery.ts`, `account-login.ts`,
`account-settings.ts`, `choose-handle.ts`, `complete.ts`
The signed `resend-webhook.ts` receiver has HTTP-level integration coverage
for valid signatures, rejected signatures, supported event filtering,
persistence, and idempotent retries. The browser/OAuth route files remain the
main gap: `login-page.ts`, `consent.ts`, `recovery.ts`, `account-login.ts`,
`account-settings.ts`, `choose-handle.ts`, and `complete.ts`.

**Why they're hard:**

Expand All @@ -78,6 +81,9 @@ pds-core callback, full OAuth flow).

**What can be tested:**

- Provider webhook routes can be tested through an ephemeral Express server
with locally signed payloads, as demonstrated by
`resend-webhook.test.ts`.
- The DB-level logic used by these routes is already well-covered via
`consent.test.ts` (auth_flow operations, client login tracking,
signCallback round-trip).
Expand Down
6 changes: 6 additions & 0 deletions packages/auth-service/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,12 @@ SMTP_PASS=
SMTP_FROM=noreply@pds.example
SMTP_FROM_NAME=ePDS

# Optional Resend delivery webhook receiver. In the Resend dashboard, register
# https://<AUTH_HOSTNAME>/webhooks/resend for email.sent, email.delivered,
# email.delivery_delayed, email.bounced, and email.failed, then copy the
# endpoint's whsec_... signing secret here.
RESEND_WEBHOOK_SECRET=

# Database path (separate from PDS account.sqlite)
DB_LOCATION=/data/epds.sqlite

Expand Down
3 changes: 2 additions & 1 deletion packages/auth-service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"cookie-parser": "^1.4.6",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"nodemailer": "^6.9.8"
"nodemailer": "^6.9.8",
"svix": "^1.96.1"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.8",
Expand Down
159 changes: 159 additions & 0 deletions packages/auth-service/src/__tests__/resend-webhook.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { EpdsDb } from '@certified-app/shared'
import express from 'express'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { randomUUID } from 'node:crypto'
import { Webhook } from 'svix'
import { createResendWebhookRouter } from '../routes/resend-webhook.js'

const WEBHOOK_SECRET = `whsec_${Buffer.from('test-webhook-secret').toString(
'base64',
)}`

let db: EpdsDb
let dbPath: string

beforeEach(() => {
dbPath = path.join(os.tmpdir(), `resend-webhook-${randomUUID()}.sqlite`)
db = new EpdsDb(dbPath)
})

afterEach(() => {
db.close()
for (const suffix of ['', '-wal', '-shm']) {
try {
fs.unlinkSync(dbPath + suffix)
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
}
}
})

function makeEvent(type = 'email.sent'): Record<string, unknown> {
return {
type,
created_at: '2026-07-14T10:00:00.000Z',
data: {
email_id: 'resend-email-123',
to: ['person@example.com'],
from: 'ePDS <login@example.org>',
subject: 'Your sign-in code',
},
}
}

async function postWebhook(
event: Record<string, unknown>,
options: {
svixId?: string
validSignature?: boolean
includeHeaders?: boolean
} = {},
): Promise<{ status: number; json: Record<string, unknown> }> {
const app = express()
app.use(createResendWebhookRouter(db, WEBHOOK_SECRET))
const server = app.listen(0)

try {
server.unref()
const port = await new Promise<number>((resolve, reject) => {
server.once('error', reject)
server.once('listening', () => {
const address = server.address()
if (typeof address === 'object' && address) resolve(address.port)
else reject(new Error('Failed to resolve ephemeral port'))
})
})

const payload = JSON.stringify(event)
const svixId = options.svixId ?? 'msg_test_123'
const timestamp = new Date()
const signature =
options.validSignature === false
? 'v1,invalid'
: new Webhook(WEBHOOK_SECRET).sign(svixId, timestamp, payload)

const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (options.includeHeaders !== false) {
headers['svix-id'] = svixId
headers['svix-timestamp'] = String(Math.floor(timestamp.getTime() / 1000))
headers['svix-signature'] = signature
}

const response = await fetch(`http://127.0.0.1:${port}/webhooks/resend`, {
method: 'POST',
headers,
body: payload,
})
return {
status: response.status,
json: (await response.json()) as Record<string, unknown>,
}
} finally {
await new Promise<void>((resolve) => {
server.close(() => {
resolve()
})
})
}
}

describe('Resend webhook receiver', () => {
it('verifies, persists, and acknowledges a delivery event', async () => {
const result = await postWebhook(makeEvent('email.delivered'))

expect(result).toEqual({
status: 200,
json: { received: true, duplicate: false },
})
expect(db.getResendEmailEvents('resend-email-123')).toMatchObject([
{
svixId: 'msg_test_123',
eventType: 'email.delivered',
recipients: ['person@example.com'],
},
])
})

it('acknowledges a Svix retry without inserting it twice', async () => {
const first = await postWebhook(makeEvent(), { svixId: 'msg_retry' })
const retry = await postWebhook(makeEvent(), { svixId: 'msg_retry' })

expect(first.json.duplicate).toBe(false)
expect(retry).toEqual({
status: 200,
json: { received: true, duplicate: true },
})
expect(db.getResendEmailEvents('resend-email-123')).toHaveLength(1)
})

it('rejects an invalid signature before persisting the payload', async () => {
const result = await postWebhook(makeEvent(), { validSignature: false })

expect(result.status).toBe(400)
expect(result.json).toEqual({ error: 'Invalid webhook signature' })
expect(db.getResendEmailEvents('resend-email-123')).toHaveLength(0)
})

it('rejects a request without the required Svix headers', async () => {
const result = await postWebhook(makeEvent(), { includeHeaders: false })

expect(result).toEqual({
status: 400,
json: { error: 'Invalid webhook request' },
})
expect(db.getResendEmailEvents('resend-email-123')).toHaveLength(0)
})

it('rejects signed event types that are not used for delivery metrics', async () => {
const result = await postWebhook(makeEvent('email.opened'))

expect(result.status).toBe(400)
expect(result.json).toEqual({ error: 'Invalid webhook payload' })
expect(db.getResendEmailEvents('resend-email-123')).toHaveLength(0)
})
})
6 changes: 3 additions & 3 deletions packages/auth-service/src/better-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* The instance is mounted at /api/auth/* alongside the existing custom routes.
*/
import type { EpdsDb } from '@certified-app/shared'
import { createLogger } from '@certified-app/shared'
import { createLogger, OTP_LIFETIME_SECONDS } from '@certified-app/shared'
import { betterAuth } from 'better-auth'
import { APIError, createAuthMiddleware } from 'better-auth/api'
import { generateRandomString } from 'better-auth/crypto'
Expand Down Expand Up @@ -204,7 +204,7 @@ export async function runBetterAuthMigrations(
plugins: [
emailOTP({
otpLength,
expiresIn: 600,
expiresIn: OTP_LIFETIME_SECONDS,
allowedAttempts: 5,
storeOTP: 'hashed',
...(otpCharset === 'alphanumeric'
Expand Down Expand Up @@ -305,7 +305,7 @@ export function createBetterAuth(
plugins: [
emailOTP({
otpLength,
expiresIn: 600,
expiresIn: OTP_LIFETIME_SECONDS,
allowedAttempts: 5,
storeOTP: 'hashed',
...(otpCharset === 'alphanumeric'
Expand Down
2 changes: 2 additions & 0 deletions packages/auth-service/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export interface AuthServiceConfig {
dbLocation: string
otpLength: number
otpCharset: 'numeric' | 'alphanumeric'
/** Resend webhook signing secret. When unset, the receiver is disabled. */
resendWebhookSecret?: string
/**
* OAuth client_id URLs trusted for branding injection. Used to gate
* CSS branding injection AND client-supplied email templates
Expand Down
9 changes: 9 additions & 0 deletions packages/auth-service/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { createChooseHandleRouter } from './routes/choose-handle.js'
import { createHeartbeatRouter } from './routes/heartbeat.js'
import { createPreviewRouter } from './routes/preview.js'
import { createPreviewEmailsRouter } from './routes/preview-emails.js'
import { createResendWebhookRouter } from './routes/resend-webhook.js'
import { createRootRouter } from './routes/root.js'
import { createTestHooksRouter } from './routes/test-hooks.js'
import { resolveAuthPort } from './lib/resolve-port.js'
Expand Down Expand Up @@ -47,6 +48,13 @@ export function createAuthService(config: AuthServiceConfig): {
)
app.all('/api/auth/*', toNodeHandler(betterAuthInstance))

// Webhook verification requires the exact request bytes, so mount this
// before any urlencoded or JSON body parser. It deliberately sits outside
// browser CSRF protection; authenticity comes from the Svix signature.
if (config.resendWebhookSecret) {
app.use(createResendWebhookRouter(ctx.db, config.resendWebhookSecret))
}
Comment thread
aspiers marked this conversation as resolved.
Comment thread
aspiers marked this conversation as resolved.

// Middleware
app.set('trust proxy', 1)
app.use(express.urlencoded({ extended: true }))
Comment thread
aspiers marked this conversation as resolved.
Expand Down Expand Up @@ -158,6 +166,7 @@ async function main() {
otpCharset: (process.env.OTP_CHARSET || 'numeric') as
| 'numeric'
| 'alphanumeric',
resendWebhookSecret: process.env.RESEND_WEBHOOK_SECRET || undefined,
trustedClients: (process.env.PDS_OAUTH_TRUSTED_CLIENTS || '')
.split(',')
.map((s) => s.trim())
Expand Down
2 changes: 1 addition & 1 deletion packages/auth-service/src/lib/auth-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const AUTH_FLOW_COOKIE = 'epds_auth_flow'
* can use them.
*
* NOT the OTP code's lifetime — better-auth enforces that separately
* in the `verification` table (`expiresIn: 600` in better-auth.ts).
* in the `verification` table (`OTP_LIFETIME_SECONDS` in the shared package).
*
* 60 minutes lets a slow user who hits OTP expiry (10 min) and clicks
* Resend still have a live auth_flow + cookie to land on /auth/complete
Expand Down
Loading
Loading